data_edit.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. """
  2. CLI commands for editing data
  3. """
  4. from __future__ import annotations
  5. from datetime import timedelta
  6. import click
  7. import pandas as pd
  8. from flask import current_app as app
  9. from flask.cli import with_appcontext
  10. import json
  11. from flexmeasures.data.models.user import Account
  12. from flexmeasures.data.schemas.account import AccountIdField
  13. from sqlalchemy import delete
  14. from flexmeasures import Sensor, Asset
  15. from flexmeasures.data import db
  16. from flexmeasures.data.schemas.attributes import validate_special_attributes
  17. from flexmeasures.data.schemas.generic_assets import GenericAssetIdField
  18. from flexmeasures.data.schemas.sensors import SensorIdField
  19. from flexmeasures.data.models.generic_assets import GenericAsset
  20. from flexmeasures.data.models.audit_log import AssetAuditLog
  21. from flexmeasures.data.models.time_series import TimedBelief
  22. from flexmeasures.data.utils import save_to_db
  23. from flexmeasures.cli.utils import MsgStyle, DeprecatedOption, DeprecatedOptionsCommand
  24. @click.group("edit")
  25. def fm_edit_data():
  26. """FlexMeasures: Edit data."""
  27. @fm_edit_data.command("attribute", cls=DeprecatedOptionsCommand)
  28. @with_appcontext
  29. @click.option(
  30. "--asset",
  31. "--asset-id",
  32. "assets",
  33. required=False,
  34. multiple=True,
  35. type=GenericAssetIdField(),
  36. cls=DeprecatedOption,
  37. deprecated=["--asset-id"],
  38. preferred="--asset",
  39. help="Add/edit attribute to this asset. Follow up with the asset's ID.",
  40. )
  41. @click.option(
  42. "--sensor",
  43. "--sensor-id",
  44. "sensors",
  45. required=False,
  46. multiple=True,
  47. type=SensorIdField(),
  48. cls=DeprecatedOption,
  49. deprecated=["--sensor-id"],
  50. preferred="--sensor",
  51. help="Add/edit attribute to this sensor. Follow up with the sensor's ID.",
  52. )
  53. @click.option(
  54. "--attribute",
  55. "attribute_key",
  56. required=True,
  57. help="Add/edit this attribute. Follow up with the name of the attribute.",
  58. )
  59. @click.option(
  60. "--float",
  61. "attribute_float_value",
  62. required=False,
  63. type=float,
  64. help="Set the attribute to this float value.",
  65. )
  66. @click.option(
  67. "--bool",
  68. "attribute_bool_value",
  69. required=False,
  70. type=bool,
  71. help="Set the attribute to this bool value.",
  72. )
  73. @click.option(
  74. "--str",
  75. "attribute_str_value",
  76. required=False,
  77. type=str,
  78. help="Set the attribute to this string value.",
  79. )
  80. @click.option(
  81. "--int",
  82. "attribute_int_value",
  83. required=False,
  84. type=int,
  85. help="Set the attribute to this integer value.",
  86. )
  87. @click.option(
  88. "--list",
  89. "attribute_list_value",
  90. required=False,
  91. type=str,
  92. help="Set the attribute to this list value. Pass a string with a JSON-parse-able list representation, e.g. '[1,\"a\"]'.",
  93. )
  94. @click.option(
  95. "--dict",
  96. "attribute_dict_value",
  97. required=False,
  98. type=str,
  99. help="Set the attribute to this dict value. Pass a string with a JSON-parse-able dict representation, e.g. '{1:\"a\"}'.",
  100. )
  101. @click.option(
  102. "--null",
  103. "attribute_null_value",
  104. required=False,
  105. is_flag=True,
  106. default=False,
  107. help="Set the attribute to a null value.",
  108. )
  109. def edit_attribute(
  110. attribute_key: str,
  111. assets: list[GenericAsset],
  112. sensors: list[Sensor],
  113. attribute_null_value: bool,
  114. attribute_float_value: float | None = None,
  115. attribute_bool_value: bool | None = None,
  116. attribute_str_value: str | None = None,
  117. attribute_int_value: int | None = None,
  118. attribute_list_value: str | None = None,
  119. attribute_dict_value: str | None = None,
  120. ):
  121. """Edit (or add) an asset attribute or sensor attribute."""
  122. if not assets and not sensors:
  123. raise ValueError("Missing flag: pass at least one --asset-id or --sensor-id.")
  124. # Parse attribute value
  125. attribute_value = parse_attribute_value(
  126. attribute_float_value=attribute_float_value,
  127. attribute_bool_value=attribute_bool_value,
  128. attribute_str_value=attribute_str_value,
  129. attribute_int_value=attribute_int_value,
  130. attribute_list_value=attribute_list_value,
  131. attribute_dict_value=attribute_dict_value,
  132. attribute_null_value=attribute_null_value,
  133. )
  134. # Some attributes with special in meaning in FlexMeasures must pass validation
  135. validate_special_attributes(attribute_key, attribute_value)
  136. # Set attribute
  137. for asset in assets:
  138. AssetAuditLog.add_record_for_attribute_update(
  139. attribute_key, attribute_value, "asset", asset
  140. )
  141. asset.attributes[attribute_key] = attribute_value
  142. db.session.add(asset)
  143. for sensor in sensors:
  144. AssetAuditLog.add_record_for_attribute_update(
  145. attribute_key, attribute_value, "sensor", sensor
  146. )
  147. sensor.attributes[attribute_key] = attribute_value
  148. db.session.add(sensor)
  149. db.session.commit()
  150. click.secho("Successfully edited/added attribute.", **MsgStyle.SUCCESS)
  151. @fm_edit_data.command("resample-data", cls=DeprecatedOptionsCommand)
  152. @with_appcontext
  153. @click.option(
  154. "--sensor",
  155. "--sensor-id",
  156. "sensor_ids",
  157. multiple=True,
  158. required=True,
  159. cls=DeprecatedOption,
  160. deprecated=["--sensor-id"],
  161. preferred="--sensor",
  162. help="Resample data for this sensor. Follow up with the sensor's ID. This argument can be given multiple times.",
  163. )
  164. @click.option(
  165. "--event-resolution",
  166. "event_resolution_in_minutes",
  167. type=int,
  168. required=True,
  169. help="New event resolution as an integer number of minutes.",
  170. )
  171. @click.option(
  172. "--from",
  173. "start_str",
  174. required=False,
  175. help="Resample only data from this datetime onwards. Follow up with a timezone-aware datetime in ISO 6801 format.",
  176. )
  177. @click.option(
  178. "--until",
  179. "end_str",
  180. required=False,
  181. help="Resample only data until this datetime. Follow up with a timezone-aware datetime in ISO 6801 format.",
  182. )
  183. @click.option(
  184. "--skip-integrity-check",
  185. is_flag=True,
  186. help="Whether to skip checking the resampled time series data for each sensor."
  187. " By default, an excerpt and the mean value of the original"
  188. " and resampled data will be shown for manual approval.",
  189. )
  190. def resample_sensor_data(
  191. sensor_ids: list[int],
  192. event_resolution_in_minutes: int,
  193. start_str: str | None = None,
  194. end_str: str | None = None,
  195. skip_integrity_check: bool = False,
  196. ):
  197. """Assign a new event resolution to an existing sensor and resample its data accordingly."""
  198. event_resolution = timedelta(minutes=event_resolution_in_minutes)
  199. event_starts_after = pd.Timestamp(start_str) # note that "" or None becomes NaT
  200. event_ends_before = pd.Timestamp(end_str)
  201. for sensor_id in sensor_ids:
  202. sensor = db.session.get(Sensor, sensor_id)
  203. if sensor.event_resolution == event_resolution:
  204. click.echo(f"{sensor} already has the desired event resolution.")
  205. continue
  206. df_original = sensor.search_beliefs(
  207. most_recent_beliefs_only=False,
  208. event_starts_after=event_starts_after,
  209. event_ends_before=event_ends_before,
  210. ).sort_values("event_start")
  211. df_resampled = df_original.resample_events(event_resolution).sort_values(
  212. "event_start"
  213. )
  214. if not skip_integrity_check:
  215. message = ""
  216. if sensor.event_resolution < event_resolution:
  217. message += f"Downsampling {sensor} to {event_resolution} will result in a loss of data. "
  218. click.confirm(
  219. message
  220. + f"Data before:\n{df_original}\nData after:\n{df_resampled}\nMean before: {df_original['event_value'].mean()}\nMean after: {df_resampled['event_value'].mean()}\nContinue?",
  221. abort=True,
  222. )
  223. AssetAuditLog.add_record(
  224. sensor.generic_asset,
  225. f"Resampled sensor data for sensor '{sensor.name}': {sensor.id} to {event_resolution} from {sensor.event_resolution}",
  226. )
  227. # Update sensor
  228. sensor.event_resolution = event_resolution
  229. db.session.add(sensor)
  230. # Update sensor data
  231. query = delete(TimedBelief).filter_by(sensor=sensor)
  232. if not pd.isnull(event_starts_after):
  233. query = query.filter(TimedBelief.event_start >= event_starts_after)
  234. if not pd.isnull(event_ends_before):
  235. query = query.filter(
  236. TimedBelief.event_start + sensor.event_resolution <= event_ends_before
  237. )
  238. db.session.execute(query)
  239. save_to_db(df_resampled, bulk_save_objects=True)
  240. db.session.commit()
  241. click.secho("Successfully resampled sensor data.", **MsgStyle.SUCCESS)
  242. @fm_edit_data.command("transfer-ownership")
  243. @with_appcontext
  244. @click.option(
  245. "--asset",
  246. "asset",
  247. type=GenericAssetIdField(),
  248. required=True,
  249. help="Change the ownership of this asset and its children. Follow up with the asset's ID.",
  250. )
  251. @click.option(
  252. "--new-owner",
  253. "new_owner",
  254. type=AccountIdField(),
  255. required=True,
  256. help="New owner of the asset and its children.",
  257. )
  258. def transfer_ownership(asset: Asset, new_owner: Account):
  259. """
  260. Transfer the ownership of and asset and its children to an account.
  261. """
  262. def transfer_ownership_recursive(asset: Asset, account: Account):
  263. AssetAuditLog.add_record(
  264. asset,
  265. (
  266. f"Transferred ownership for asset '{asset.name}': {asset.id} from '{asset.owner.name}': {asset.owner.id} to '{account.name}': {account.id}"
  267. if asset.owner is not None
  268. else f"Assign ownership to public asset '{asset.name}': {asset.id} to '{account.name}': {account.id}"
  269. ),
  270. )
  271. asset.owner = account
  272. for child in asset.child_assets:
  273. transfer_ownership_recursive(child, account)
  274. transfer_ownership_recursive(asset, new_owner)
  275. click.secho(
  276. f"Success! Asset `{asset}` ownership was transferred to account `{new_owner}`.",
  277. **MsgStyle.SUCCESS,
  278. )
  279. db.session.commit()
  280. app.cli.add_command(fm_edit_data)
  281. def parse_attribute_value( # noqa: C901
  282. attribute_null_value: bool,
  283. attribute_float_value: float | None = None,
  284. attribute_bool_value: bool | None = None,
  285. attribute_str_value: str | None = None,
  286. attribute_int_value: int | None = None,
  287. attribute_list_value: str | None = None,
  288. attribute_dict_value: str | None = None,
  289. ) -> float | int | bool | str | list | dict | None:
  290. """Parse attribute value."""
  291. if not single_true(
  292. [attribute_null_value]
  293. + [
  294. v is not None
  295. for v in [
  296. attribute_float_value,
  297. attribute_bool_value,
  298. attribute_str_value,
  299. attribute_int_value,
  300. attribute_list_value,
  301. attribute_dict_value,
  302. ]
  303. ]
  304. ):
  305. raise ValueError("Cannot set multiple values simultaneously.")
  306. if attribute_null_value:
  307. return None
  308. elif attribute_float_value is not None:
  309. return float(attribute_float_value)
  310. elif attribute_bool_value is not None:
  311. return bool(attribute_bool_value)
  312. elif attribute_int_value is not None:
  313. return int(attribute_int_value)
  314. elif attribute_list_value is not None:
  315. try:
  316. val = json.loads(attribute_list_value)
  317. except json.decoder.JSONDecodeError as jde:
  318. raise ValueError(f"Error parsing list value: {jde}")
  319. if not isinstance(val, list):
  320. raise ValueError(f"{val} is not a list.")
  321. return val
  322. elif attribute_dict_value is not None:
  323. try:
  324. val = json.loads(attribute_dict_value)
  325. except json.decoder.JSONDecodeError as jde:
  326. raise ValueError(f"Error parsing dict value: {jde}")
  327. if not isinstance(val, dict):
  328. raise ValueError(f"{val} is not a dict.")
  329. return val
  330. return attribute_str_value
  331. def single_true(iterable) -> bool:
  332. i = iter(iterable)
  333. return any(i) and not any(i)