data_show.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771
  1. """
  2. CLI commands for listing database contents and classes
  3. """
  4. from __future__ import annotations
  5. from datetime import datetime, timedelta
  6. import click
  7. from flask import current_app as app
  8. from flask.cli import with_appcontext
  9. from tabulate import tabulate
  10. from humanize import naturaldelta, naturaltime
  11. import pandas as pd
  12. import uniplot
  13. import vl_convert as vlc
  14. from string import Template
  15. import pytz
  16. import json
  17. from sqlalchemy import select, func
  18. from flexmeasures.data import db
  19. from flexmeasures.data.models.user import Account, AccountRole, User, Role
  20. from flexmeasures.data.models.data_sources import DataSource
  21. from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
  22. from flexmeasures.data.models.time_series import Sensor, TimedBelief
  23. from flexmeasures.data.schemas.generic_assets import (
  24. GenericAssetIdField,
  25. SensorsToShowSchema,
  26. )
  27. from flexmeasures.data.schemas.sensors import SensorIdField
  28. from flexmeasures.data.schemas.account import AccountIdField
  29. from flexmeasures.data.schemas.sources import DataSourceIdField
  30. from flexmeasures.data.schemas.times import AwareDateTimeField, DurationField
  31. from flexmeasures.data.services.time_series import simplify_index
  32. from flexmeasures.utils.time_utils import determine_minimum_resampling_resolution
  33. from flexmeasures.cli.utils import MsgStyle, validate_unique
  34. from flexmeasures.utils.coding_utils import delete_key_recursive
  35. from flexmeasures.utils.flexmeasures_inflection import join_words_into_a_list
  36. from flexmeasures.cli.utils import (
  37. DeprecatedOptionsCommand,
  38. DeprecatedOption,
  39. get_sensor_aliases,
  40. )
  41. @click.group("show")
  42. def fm_show_data():
  43. """FlexMeasures: Show data."""
  44. @fm_show_data.command("accounts")
  45. @with_appcontext
  46. def list_accounts():
  47. """
  48. List all accounts on this FlexMeasures instance.
  49. """
  50. accounts = db.session.scalars(select(Account).order_by(Account.name)).all()
  51. if not accounts:
  52. click.secho("No accounts created yet.", **MsgStyle.WARN)
  53. raise click.Abort()
  54. click.echo("All accounts on this FlexMeasures instance:\n ")
  55. account_data = [
  56. (
  57. account.id,
  58. account.name,
  59. db.session.scalar(
  60. select(func.count())
  61. .select_from(GenericAsset)
  62. .filter_by(account_id=account.id)
  63. ),
  64. )
  65. for account in accounts
  66. ]
  67. click.echo(tabulate(account_data, headers=["ID", "Name", "Assets"]))
  68. @fm_show_data.command("roles")
  69. @with_appcontext
  70. def list_roles():
  71. """
  72. Show available account and user roles
  73. """
  74. account_roles = db.session.scalars(
  75. select(AccountRole).order_by(AccountRole.name)
  76. ).all()
  77. if not account_roles:
  78. click.secho("No account roles created yet.", **MsgStyle.WARN)
  79. raise click.Abort()
  80. click.echo("Account roles:\n")
  81. click.echo(
  82. tabulate(
  83. [(r.id, r.name, r.description) for r in account_roles],
  84. headers=["ID", "Name", "Description"],
  85. )
  86. )
  87. click.echo()
  88. user_roles = db.session.scalars(select(Role).order_by(Role.name)).all()
  89. if not user_roles:
  90. click.secho("No user roles created yet, not even admin.", **MsgStyle.WARN)
  91. raise click.Abort()
  92. click.echo("User roles:\n")
  93. click.echo(
  94. tabulate(
  95. [(r.id, r.name, r.description) for r in user_roles],
  96. headers=["ID", "Name", "Description"],
  97. )
  98. )
  99. @fm_show_data.command("account")
  100. @with_appcontext
  101. @click.option("--id", "account", type=AccountIdField(), required=True)
  102. def show_account(account):
  103. """
  104. Show information about an account, including users and assets.
  105. """
  106. click.echo(f"========{len(account.name) * '='}========")
  107. click.echo(f"Account {account.name} (ID: {account.id})")
  108. click.echo(f"========{len(account.name) * '='}========\n")
  109. if account.account_roles:
  110. click.echo(
  111. f"Account role(s): {','.join([role.name for role in account.account_roles])}"
  112. )
  113. else:
  114. click.secho("Account has no roles.", **MsgStyle.WARN)
  115. click.echo()
  116. users = db.session.scalars(
  117. select(User).filter_by(account_id=account.id).order_by(User.username)
  118. ).all()
  119. if not users:
  120. click.secho("No users in account ...", **MsgStyle.WARN)
  121. else:
  122. click.echo("All users:\n ")
  123. user_data = [
  124. (
  125. user.id,
  126. user.username,
  127. user.email,
  128. naturaltime(user.last_login_at),
  129. naturaltime(user.last_seen_at),
  130. ",".join([role.name for role in user.roles]),
  131. )
  132. for user in users
  133. ]
  134. click.echo(
  135. tabulate(
  136. user_data,
  137. headers=["ID", "Name", "Email", "Last Login", "Last Seen", "Roles"],
  138. )
  139. )
  140. click.echo()
  141. assets = db.session.scalars(
  142. select(GenericAsset)
  143. .filter_by(account_id=account.id)
  144. .order_by(GenericAsset.name)
  145. ).all()
  146. if not assets:
  147. click.secho("No assets in account ...", **MsgStyle.WARN)
  148. else:
  149. click.echo("All assets:\n ")
  150. asset_data = [
  151. (asset.id, asset.name, asset.generic_asset_type.name, asset.location)
  152. for asset in assets
  153. ]
  154. click.echo(tabulate(asset_data, headers=["ID", "Name", "Type", "Location"]))
  155. @fm_show_data.command("asset-types")
  156. @with_appcontext
  157. def list_asset_types():
  158. """
  159. Show available asset types
  160. """
  161. asset_types = db.session.scalars(
  162. select(GenericAssetType).order_by(GenericAssetType.name)
  163. ).all()
  164. if not asset_types:
  165. click.secho("No asset types created yet.", **MsgStyle.WARN)
  166. raise click.Abort()
  167. click.echo(
  168. tabulate(
  169. [(t.id, t.name, t.description) for t in asset_types],
  170. headers=["ID", "Name", "Description"],
  171. )
  172. )
  173. @fm_show_data.command("asset")
  174. @with_appcontext
  175. @click.option("--id", "asset", type=GenericAssetIdField(), required=True)
  176. def show_generic_asset(asset):
  177. """
  178. Show asset info and list sensors
  179. """
  180. separator_num = 18 if asset.parent_asset is not None else 8
  181. click.echo(f"======{len(asset.name) * '='}{separator_num * '='}")
  182. click.echo(f"Asset {asset.name} (ID: {asset.id})")
  183. if asset.parent_asset is not None:
  184. click.echo(
  185. f"Child of asset {asset.parent_asset.name} (ID: {asset.parent_asset.id})"
  186. )
  187. click.echo(f"======{len(asset.name) * '='}{separator_num * '='}\n")
  188. standardized_sensors_to_show = SensorsToShowSchema().deserialize(
  189. asset.sensors_to_show
  190. )
  191. asset_data = [
  192. (
  193. asset.generic_asset_type.name,
  194. asset.location,
  195. "".join([f"{k}: {v}\n" for k, v in asset.flex_context.items()]),
  196. "".join(
  197. [
  198. f"{graph['title']}: {graph['sensors']} \n"
  199. for graph in standardized_sensors_to_show
  200. ]
  201. ),
  202. "".join([f"{k}: {v}\n" for k, v in asset.attributes.items()]),
  203. )
  204. ]
  205. click.echo(
  206. tabulate(
  207. asset_data,
  208. headers=[
  209. "Type",
  210. "Location",
  211. "Flex-Context",
  212. "Sensors to show",
  213. "Attributes",
  214. ],
  215. )
  216. )
  217. child_asset_data = [
  218. (
  219. child.id,
  220. child.name,
  221. child.generic_asset_type.name,
  222. )
  223. for child in asset.child_assets
  224. ]
  225. click.echo()
  226. click.echo(f"======{len(asset.name) * '='}===================")
  227. click.echo(f"Child assets of {asset.name} (ID: {asset.id})")
  228. click.echo(f"======{len(asset.name) * '='}===================\n")
  229. if child_asset_data:
  230. click.echo(tabulate(child_asset_data, headers=["Id", "Name", "Type"]))
  231. else:
  232. click.secho("No children assets ...", **MsgStyle.WARN)
  233. click.echo()
  234. sensors = db.session.scalars(
  235. select(Sensor).filter_by(generic_asset_id=asset.id).order_by(Sensor.name)
  236. ).all()
  237. if not sensors:
  238. click.secho("No sensors in asset ...", **MsgStyle.WARN)
  239. raise click.Abort()
  240. click.echo("All sensors in asset:\n ")
  241. sensor_data = [
  242. (
  243. sensor.id,
  244. sensor.name,
  245. sensor.unit,
  246. naturaldelta(sensor.event_resolution),
  247. sensor.timezone,
  248. "".join([f"{k}: {v}\n" for k, v in sensor.attributes.items()]),
  249. )
  250. for sensor in sensors
  251. ]
  252. click.echo(
  253. tabulate(
  254. sensor_data,
  255. headers=["ID", "Name", "Unit", "Resolution", "Timezone", "Attributes"],
  256. )
  257. )
  258. @fm_show_data.command("data-sources")
  259. @with_appcontext
  260. @click.option(
  261. "--id",
  262. "source",
  263. required=False,
  264. type=DataSourceIdField(),
  265. help="ID of data source.",
  266. )
  267. @click.option(
  268. "--show-attributes",
  269. "show_attributes",
  270. type=bool,
  271. help="Whether to show the attributes of the DataSource.",
  272. is_flag=True,
  273. )
  274. def list_data_sources(source: DataSource | None = None, show_attributes: bool = False):
  275. """
  276. Show available data sources
  277. """
  278. if source is None:
  279. sources = db.session.scalars(
  280. select(DataSource)
  281. .order_by(DataSource.type)
  282. .order_by(DataSource.name)
  283. .order_by(DataSource.model)
  284. .order_by(DataSource.version)
  285. ).all()
  286. else:
  287. sources = [source]
  288. if not sources:
  289. click.secho("No data sources created yet.", **MsgStyle.WARN)
  290. raise click.Abort()
  291. headers = ["ID", "Name", "User ID", "Model", "Version"]
  292. if show_attributes:
  293. headers.append("Attributes")
  294. rows = dict()
  295. for source in sources:
  296. row = [
  297. source.id,
  298. source.name,
  299. source.user_id,
  300. source.model,
  301. source.version,
  302. ]
  303. if show_attributes:
  304. row.append(json.dumps(source.attributes, indent=4))
  305. if source.type not in rows:
  306. rows[source.type] = [row]
  307. else:
  308. rows[source.type].append(row)
  309. for ds_type, row in rows.items():
  310. click.echo(f"type: {ds_type}")
  311. click.echo("=" * len(ds_type))
  312. click.echo(tabulate(row, headers=headers))
  313. click.echo("\n")
  314. @fm_show_data.command("chart")
  315. @with_appcontext
  316. @click.option(
  317. "--sensor",
  318. "sensors",
  319. required=False,
  320. multiple=True,
  321. type=SensorIdField(),
  322. help="ID of sensor(s). This argument can be given multiple times.",
  323. )
  324. @click.option(
  325. "--asset",
  326. "assets",
  327. required=False,
  328. multiple=True,
  329. type=GenericAssetIdField(),
  330. help="ID of asset(s). This argument can be given multiple times.",
  331. )
  332. @click.option(
  333. "--start",
  334. "start",
  335. type=AwareDateTimeField(),
  336. required=True,
  337. help="Plot starting at this datetime. Follow up with a timezone-aware datetime in ISO 6801 format.",
  338. )
  339. @click.option(
  340. "--end",
  341. "end",
  342. type=AwareDateTimeField(),
  343. required=True,
  344. help="Plot ending at this datetime. Follow up with a timezone-aware datetime in ISO 6801 format.",
  345. )
  346. @click.option(
  347. "--belief-time",
  348. "belief_time",
  349. type=AwareDateTimeField(),
  350. required=False,
  351. help="Time at which beliefs had been known. Follow up with a timezone-aware datetime in ISO 6801 format.",
  352. )
  353. @click.option(
  354. "--height",
  355. "height",
  356. required=False,
  357. type=int,
  358. default=200,
  359. help="Height of the image in pixels..",
  360. )
  361. @click.option(
  362. "--width",
  363. "width",
  364. required=False,
  365. type=int,
  366. default=500,
  367. help="Width of the image in pixels.",
  368. )
  369. @click.option(
  370. "--filename",
  371. "filename_template",
  372. required=False,
  373. type=str,
  374. default="chart-$now.png",
  375. help="Format of the output file. Use dollar sign ($) to interpolate values among the following ones:"
  376. " now (current time), id (id of the sensor or asset), entity_type (either 'asset' or 'sensor')"
  377. " Example: 'result_file_$entity_type_$id_$now.csv' -> 'result_file_asset_1_2023-08-24T14:47:08' ",
  378. )
  379. @click.option(
  380. "--resolution",
  381. "resolution",
  382. type=DurationField(),
  383. required=False,
  384. help="Resolution of the data in ISO 8601 format. If not set, defaults to the minimum resolution of the sensor data. Note: Nominal durations like 'P1D' are converted to absolute timedeltas.",
  385. )
  386. def chart(
  387. sensors: list[Sensor] | None = None,
  388. assets: list[GenericAsset] | None = None,
  389. start: datetime | None = None,
  390. end: datetime | None = None,
  391. belief_time: datetime | None = None,
  392. height: int | None = None,
  393. width: int | None = None,
  394. filename_template: str | None = None,
  395. resolution: timedelta | None = None,
  396. ):
  397. """
  398. Export sensor or asset charts in PNG or SVG formats. For example:
  399. flexmeasures show chart --start 2023-08-15T00:00:00+02:00 --end 2023-08-16T00:00:00+02:00 --asset 1 --sensor 3 --resolution P1D
  400. """
  401. datetime_format = "%Y-%m-%dT%H:%M:%S"
  402. if sensors is None and assets is None:
  403. click.secho(
  404. "No sensor or asset IDs provided. Please, try passing them using the options `--asset` or `--sensor`.",
  405. **MsgStyle.ERROR,
  406. )
  407. raise click.Abort()
  408. if sensors is None:
  409. sensors = []
  410. if assets is None:
  411. assets = []
  412. for entity in sensors + assets:
  413. entity_type = "sensor"
  414. if isinstance(entity, GenericAsset):
  415. entity_type = "asset"
  416. timezone = app.config["FLEXMEASURES_TIMEZONE"]
  417. now = pytz.timezone(zone=timezone).localize(datetime.now())
  418. belief_time_str = ""
  419. if belief_time is not None:
  420. belief_time_str = belief_time.strftime(datetime_format)
  421. template = Template(str(filename_template))
  422. filename = template.safe_substitute(
  423. id=entity.id,
  424. entity_type=entity_type,
  425. now=now.strftime(datetime_format),
  426. start=start.strftime(datetime_format),
  427. end=end.strftime(datetime_format),
  428. belief_time=belief_time_str,
  429. )
  430. click.echo(f"Generating a chart for `{entity}`...")
  431. # need to fetch the entities as they get detached
  432. # and we get the (in)famous detached instance error.
  433. if entity_type == "asset":
  434. entity = db.session.get(GenericAsset, entity.id)
  435. else:
  436. entity = db.session.get(Sensor, entity.id)
  437. chart_description = entity.chart(
  438. event_starts_after=start,
  439. event_ends_before=end,
  440. beliefs_before=belief_time,
  441. include_data=True,
  442. resolution=resolution,
  443. )
  444. # remove formatType as it relies on a custom JavaScript function
  445. chart_description = delete_key_recursive(chart_description, "formatType")
  446. # set width and height
  447. chart_description["width"] = width
  448. chart_description["height"] = height
  449. png_data = vlc.vegalite_to_png(vl_spec=chart_description, scale=2)
  450. with open(filename, "wb") as f:
  451. f.write(png_data)
  452. click.secho(
  453. f"Chart for `{entity}` has been saved successfully as `{filename}`.",
  454. **MsgStyle.SUCCESS,
  455. )
  456. @fm_show_data.command("beliefs", cls=DeprecatedOptionsCommand)
  457. @with_appcontext
  458. @click.option(
  459. "--sensor",
  460. "--sensor-id",
  461. "sensors",
  462. required=True,
  463. multiple=True,
  464. callback=validate_unique,
  465. type=SensorIdField(),
  466. cls=DeprecatedOption,
  467. preferred="--sensor",
  468. deprecated=["--sensor-id"],
  469. help="ID of sensor(s). This argument can be given multiple times.",
  470. )
  471. @click.option(
  472. "--start",
  473. "start",
  474. type=AwareDateTimeField(),
  475. required=True,
  476. help="Plot starting at this datetime. Follow up with a timezone-aware datetime in ISO 6801 format.",
  477. )
  478. @click.option(
  479. "--duration",
  480. "duration",
  481. type=DurationField(),
  482. required=True,
  483. help="Duration of the plot, after --start. Follow up with a duration in ISO 6801 format, e.g. PT1H (1 hour) or PT45M (45 minutes).",
  484. )
  485. @click.option(
  486. "--belief-time-before",
  487. "belief_time_before",
  488. type=AwareDateTimeField(),
  489. required=False,
  490. help="Time at which beliefs had been known. Follow up with a timezone-aware datetime in ISO 6801 format.",
  491. )
  492. @click.option(
  493. "--source",
  494. "--source-id",
  495. "source",
  496. required=False,
  497. type=DataSourceIdField(),
  498. cls=DeprecatedOption,
  499. preferred="--source",
  500. deprecated=["--source-id"],
  501. help="Source of the beliefs (an existing source id).",
  502. )
  503. @click.option(
  504. "--source-type",
  505. "source_types",
  506. required=False,
  507. type=str,
  508. help="Only show beliefs from this type of source, for example, 'user', 'forecaster' or 'scheduler'.",
  509. )
  510. @click.option(
  511. "--resolution",
  512. "resolution",
  513. type=DurationField(),
  514. required=False,
  515. help="Resolution of the data. If not set, defaults to the minimum resolution of the sensor data.",
  516. )
  517. @click.option(
  518. "--timezone",
  519. "timezone",
  520. type=str,
  521. required=False,
  522. help="Timezone of the data. If not set, defaults to the timezone of the first non-empty sensor.",
  523. )
  524. @click.option(
  525. "--to-file",
  526. "filepath",
  527. required=False,
  528. type=str,
  529. help="Set a filepath to store the beliefs as a CSV file.",
  530. )
  531. @click.option(
  532. "--include-ids/--exclude-ids",
  533. "include_ids",
  534. default=False,
  535. type=bool,
  536. help="Include sensor IDs in the plot's legend labels and the file's column headers. "
  537. "NB non-unique sensor names will always show an ID.",
  538. )
  539. @click.option(
  540. "--reduced-paths/--full-paths",
  541. "reduce_paths",
  542. default=True,
  543. type=bool,
  544. help="Whether to include the full path to the asset that the sensor belongs to"
  545. "which shows any parent assets and their account, "
  546. "or a reduced version of the path, which shows as much detail as is needed to distinguish the sensors.",
  547. )
  548. def plot_beliefs(
  549. sensors: list[Sensor],
  550. start: datetime,
  551. duration: timedelta,
  552. resolution: timedelta | None,
  553. timezone: str | None,
  554. belief_time_before: datetime | None,
  555. source: DataSource | None,
  556. filepath: str | None,
  557. source_types: list[str] = None,
  558. include_ids: bool = False,
  559. reduce_paths: bool = True,
  560. ):
  561. """
  562. Show a simple plot of belief data directly in the terminal, and optionally, save the data to a CSV file.
  563. """
  564. sensors = list(sensors)
  565. if resolution is None:
  566. resolution = determine_minimum_resampling_resolution(
  567. [sensor.event_resolution for sensor in sensors]
  568. )
  569. # query data
  570. beliefs_by_sensor = TimedBelief.search(
  571. sensors=sensors,
  572. event_starts_after=start,
  573. event_ends_before=start + duration,
  574. beliefs_before=belief_time_before,
  575. source=source,
  576. source_types=source_types,
  577. one_deterministic_belief_per_event=True,
  578. resolution=resolution,
  579. sum_multiple=False,
  580. )
  581. # Only keep non-empty (and abort in case of no data)
  582. for s in sensors:
  583. if beliefs_by_sensor[s].empty:
  584. click.secho(f"No data found for sensor {s.id} ({s.name})", **MsgStyle.WARN)
  585. beliefs_by_sensor.pop(s)
  586. if len(beliefs_by_sensor) == 0:
  587. click.secho("No data found!", **MsgStyle.WARN)
  588. raise click.Abort()
  589. sensors = list(beliefs_by_sensor.keys())
  590. # Concatenate data
  591. df = pd.concat([simplify_index(df) for df in beliefs_by_sensor.values()], axis=1)
  592. # Find out whether the Y-axis should show a shared unit
  593. if all(sensor.unit == sensors[0].unit for sensor in sensors):
  594. shared_unit = sensors[0].unit
  595. else:
  596. shared_unit = ""
  597. click.secho(
  598. "The y-axis shows no unit, because the selected sensors do not share the same unit.",
  599. **MsgStyle.WARN,
  600. )
  601. # Decide whether to include sensor IDs
  602. if include_ids:
  603. df.columns = [f"{s.name} (ID {s.id})" for s in sensors]
  604. else:
  605. # In case of non-unique sensor names, show more of the sensor's ancestry
  606. duplicates = find_duplicates(sensors, "name")
  607. if duplicates:
  608. message = "The following sensor name"
  609. message += "s are " if len(duplicates) > 1 else " is "
  610. message += (
  611. f"duplicated: {join_words_into_a_list(duplicates)}. "
  612. f"To distinguish the sensors, their plot labels will include more parent assets and their account, as needed. "
  613. f"To show the full path for each sensor, use the --full-path flag. "
  614. f"Or to uniquely label them by their ID instead, use the --include-ids flag."
  615. )
  616. click.secho(message, **MsgStyle.WARN)
  617. sensor_aliases = get_sensor_aliases(sensors, reduce_paths=reduce_paths)
  618. df.columns = [sensor_aliases.get(s.id, s.name) for s in sensors]
  619. # Convert to the requested or default timezone
  620. if timezone is not None:
  621. timezone = sensors[0].timezone
  622. df.index = df.index.tz_convert(timezone)
  623. # Build title
  624. if len(sensors) == 1:
  625. title = f"Beliefs for Sensor '{sensors[0].name}' (ID {sensors[0].id}).\n"
  626. else:
  627. title = f"Beliefs for Sensors {join_words_into_a_list([s.name + ' (ID ' + str(s.id) + ')' for s in sensors])}.\n"
  628. title += f"Data spans {naturaldelta(duration)} and starts at {start}.\n"
  629. title += f"The time resolution (x-axis) is {naturaldelta(resolution)}.\n"
  630. if belief_time_before:
  631. title += f"\nOnly beliefs made before: {belief_time_before}."
  632. if source:
  633. title += f"\nSource: {source.description}"
  634. uniplot.plot(
  635. ys=[df[col] for col in df.columns],
  636. xs=[df.index for _ in df.columns],
  637. title=title,
  638. color=True,
  639. lines=True,
  640. y_unit=shared_unit,
  641. legend_labels=(
  642. df.columns if shared_unit else [f"{col} in {s.unit}" for col in df.columns]
  643. ),
  644. )
  645. if filepath is not None:
  646. df.columns = pd.MultiIndex.from_arrays(
  647. [df.columns, [df.sensor.unit for df in beliefs_by_sensor.values()]]
  648. )
  649. df.to_csv(filepath)
  650. click.secho("Data saved to file.", **MsgStyle.SUCCESS)
  651. def find_duplicates(_list: list, attr: str | None = None) -> list:
  652. """Find duplicates in a list, optionally based on a specified attribute.
  653. :param _list: The input list to search for duplicates.
  654. :param attr: The attribute name to consider when identifying duplicates.
  655. If None, the function will check for duplicates based on the elements themselves.
  656. :returns: A list containing the duplicate elements found in the input list.
  657. """
  658. if attr:
  659. _list = [getattr(item, attr) for item in _list]
  660. return [item for item in set(_list) if _list.count(item) > 1]
  661. def list_items(item_type):
  662. """
  663. Show available items of a specific type.
  664. """
  665. click.echo(f"{item_type.capitalize()}:\n")
  666. click.echo(
  667. tabulate(
  668. [
  669. (
  670. item_name,
  671. item_class.__version__,
  672. item_class.__author__,
  673. item_class.__module__,
  674. )
  675. for item_name, item_class in getattr(app, item_type).items()
  676. ],
  677. headers=["name", "version", "author", "module"],
  678. )
  679. )
  680. @fm_show_data.command("reporters")
  681. @with_appcontext
  682. def list_reporters():
  683. """
  684. Show available reporters.
  685. """
  686. with app.app_context():
  687. list_items("reporters")
  688. @fm_show_data.command("schedulers")
  689. @with_appcontext
  690. def list_schedulers():
  691. """
  692. Show available schedulers.
  693. """
  694. with app.app_context():
  695. list_items("schedulers")
  696. app.cli.add_command(fm_show_data)