belief_charts.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795
  1. from __future__ import annotations
  2. from datetime import datetime, timedelta
  3. from flexmeasures.data.models.charts.defaults import FIELD_DEFINITIONS, REPLAY_RULER
  4. from flexmeasures.utils.flexmeasures_inflection import (
  5. capitalize,
  6. )
  7. from flexmeasures.utils.coding_utils import flatten_unique
  8. from flexmeasures.utils.unit_utils import get_unit_dimension
  9. def create_bar_chart_or_histogram_specs(
  10. sensor: "Sensor", # noqa F821
  11. event_starts_after: datetime | None = None,
  12. event_ends_before: datetime | None = None,
  13. chart_type: str = "bar_chart",
  14. **override_chart_specs: dict,
  15. ):
  16. """
  17. This function generates the specifications required to visualize sensor data either as a bar chart or a histogram.
  18. The chart type (bar_chart or histogram) can be specified, and various field definitions are set up based on the sensor attributes and
  19. event time range. The resulting specifications can be customized further through additional keyword arguments.
  20. The function handles the following:
  21. - Determines unit and formats for the sensor data.
  22. - Configures event value and event start field definitions.
  23. - Sets the appropriate mark type and interpolation based on sensor attributes.
  24. - Defines chart specifications for both bar charts and histograms, including titles, axis configurations, and tooltips.
  25. - Merges any additional specifications provided through keyword arguments into the final chart specifications.
  26. """
  27. unit = sensor.unit if sensor.unit else "a.u."
  28. event_value_field_definition = dict(
  29. title=f"{capitalize(sensor.sensor_type)} ({unit})",
  30. format=[".3~r", unit],
  31. formatType="quantityWithUnitFormat",
  32. stack=None,
  33. **FIELD_DEFINITIONS["event_value"],
  34. )
  35. if unit == "%":
  36. event_value_field_definition["scale"] = dict(
  37. domain={"unionWith": [0, 105]}, nice=False
  38. )
  39. event_start_field_definition = FIELD_DEFINITIONS["event_start"].copy()
  40. event_start_field_definition["timeUnit"] = {
  41. "unit": "yearmonthdatehoursminutesseconds",
  42. "step": sensor.event_resolution.total_seconds(),
  43. }
  44. if event_starts_after and event_ends_before:
  45. event_start_field_definition["scale"] = {
  46. "domain": [
  47. event_starts_after.timestamp() * 10**3,
  48. event_ends_before.timestamp() * 10**3,
  49. ]
  50. }
  51. mark_type = "bar"
  52. mark_interpolate = None
  53. if sensor.event_resolution == timedelta(0) and sensor.has_attribute("interpolate"):
  54. mark_type = "area"
  55. mark_interpolate = sensor.get_attribute("interpolate")
  56. replay_ruler = REPLAY_RULER.copy()
  57. if chart_type == "histogram":
  58. description = "A histogram showing the distribution of sensor data."
  59. x = {
  60. **event_value_field_definition,
  61. "bin": True,
  62. }
  63. y = {
  64. "aggregate": "count",
  65. "title": "Count",
  66. }
  67. replay_ruler["encoding"] = {
  68. "detail": {
  69. "field": "belief_time",
  70. "type": "temporal",
  71. "title": None,
  72. },
  73. }
  74. else:
  75. description = (f"A simple {mark_type} chart showing sensor data.",)
  76. x = event_start_field_definition
  77. y = event_value_field_definition
  78. chart_specs = {
  79. "description": description,
  80. "title": capitalize(sensor.name) if sensor.name != sensor.sensor_type else None,
  81. "layer": [
  82. {
  83. "mark": {
  84. "type": mark_type,
  85. "interpolate": mark_interpolate,
  86. "clip": True,
  87. "width": {"band": 0.999},
  88. },
  89. "encoding": {
  90. "x": x,
  91. "y": y,
  92. "color": FIELD_DEFINITIONS["source_name"],
  93. "detail": FIELD_DEFINITIONS["source"],
  94. "opacity": {"value": 0.7},
  95. "tooltip": [
  96. (
  97. FIELD_DEFINITIONS["full_date"]
  98. if chart_type != "histogram"
  99. else None
  100. ),
  101. {
  102. **event_value_field_definition,
  103. **dict(title=f"{capitalize(sensor.sensor_type)}"),
  104. },
  105. FIELD_DEFINITIONS["source_name_and_id"],
  106. FIELD_DEFINITIONS["source_model"],
  107. ],
  108. },
  109. "transform": [
  110. {
  111. "calculate": "datum.source.name + ' (ID: ' + datum.source.id + ')'",
  112. "as": "source_name_and_id",
  113. },
  114. ],
  115. "selection": {
  116. "scroll": {"type": "interval", "bind": "scales", "encodings": ["x"]}
  117. },
  118. },
  119. replay_ruler,
  120. ],
  121. }
  122. for k, v in override_chart_specs.items():
  123. chart_specs[k] = v
  124. return chart_specs
  125. def histogram(
  126. sensor: "Sensor", # noqa F821
  127. event_starts_after: datetime | None = None,
  128. event_ends_before: datetime | None = None,
  129. **override_chart_specs: dict,
  130. ):
  131. """
  132. Generates specifications for a histogram chart using sensor data. This function leverages
  133. the `create_bar_chart_or_histogram_specs` helper function, specifying `chart_type` as 'histogram'.
  134. """
  135. chart_type = "histogram"
  136. chart_specs = create_bar_chart_or_histogram_specs(
  137. sensor,
  138. event_starts_after,
  139. event_ends_before,
  140. chart_type,
  141. **override_chart_specs,
  142. )
  143. return chart_specs
  144. def bar_chart(
  145. sensor: "Sensor", # noqa F821
  146. event_starts_after: datetime | None = None,
  147. event_ends_before: datetime | None = None,
  148. **override_chart_specs: dict,
  149. ):
  150. """
  151. Generates specifications for a bar chart using sensor data. This function leverages
  152. the `create_bar_chart_or_histogram_specs` helper function to create the specifications.
  153. """
  154. chart_specs = create_bar_chart_or_histogram_specs(
  155. sensor,
  156. event_starts_after,
  157. event_ends_before,
  158. **override_chart_specs,
  159. )
  160. return chart_specs
  161. def daily_heatmap(
  162. sensor: "Sensor", # noqa F821
  163. event_starts_after: datetime | None = None,
  164. event_ends_before: datetime | None = None,
  165. **override_chart_specs: dict,
  166. ):
  167. return heatmap(
  168. sensor,
  169. event_starts_after,
  170. event_ends_before,
  171. split="daily",
  172. **override_chart_specs,
  173. )
  174. def weekly_heatmap(
  175. sensor: "Sensor", # noqa F821
  176. event_starts_after: datetime | None = None,
  177. event_ends_before: datetime | None = None,
  178. **override_chart_specs: dict,
  179. ):
  180. return heatmap(
  181. sensor,
  182. event_starts_after,
  183. event_ends_before,
  184. split="weekly",
  185. **override_chart_specs,
  186. )
  187. def heatmap(
  188. sensor: "Sensor", # noqa F821
  189. event_starts_after: datetime | None = None,
  190. event_ends_before: datetime | None = None,
  191. split: str = "weekly",
  192. **override_chart_specs: dict,
  193. ):
  194. unit = sensor.unit if sensor.unit else "a.u."
  195. if split == "daily":
  196. x_time_unit = "hoursminutesseconds"
  197. y_time_unit = "yearmonthdate"
  198. x_domain_max = 24
  199. x_axis_label_expression = "timeFormat(datum.value, '%H:%M')"
  200. x_axis_label_offset = None
  201. y_axis_label_offset_expression = (
  202. "(scale('y', 24 * 60 * 60 * 1000) - scale('y', 0)) / 2"
  203. )
  204. x_axis_tick_count = None
  205. y_axis_tick_count = "day"
  206. ruler_y_axis_label_offset_expression = (
  207. "(scale('y', 24 * 60 * 60 * 1000) - scale('y', 0))"
  208. )
  209. x_axis_label_bound = False
  210. elif split == "weekly":
  211. x_time_unit = "dayhoursminutesseconds"
  212. y_time_unit = "yearweek"
  213. x_domain_max = 7 * 24
  214. x_axis_tick_count = "day"
  215. y_axis_tick_count = "week"
  216. x_axis_label_expression = "timeFormat(datum.value, '%A')"
  217. x_axis_label_offset = {
  218. "expr": "(scale('x', 24 * 60 * 60 * 1000) - scale('x', 0)) / 2",
  219. }
  220. y_axis_label_offset_expression = (
  221. "(scale('y', 7 * 24 * 60 * 60 * 1000) - scale('y', 0)) / 2"
  222. )
  223. ruler_y_axis_label_offset_expression = (
  224. "(scale('y', 7 * 24 * 60 * 60 * 1000) - scale('y', 0))"
  225. )
  226. x_axis_label_bound = True
  227. else:
  228. raise NotImplementedError(f"Split '{split}' is not implemented.")
  229. event_value_field_definition = dict(
  230. title=f"{capitalize(sensor.sensor_type)} ({unit})",
  231. format=[".3~r", unit],
  232. formatType="quantityWithUnitFormat",
  233. stack=None,
  234. **FIELD_DEFINITIONS["event_value"],
  235. scale={"scheme": "blueorange", "domainMid": 0, "domain": {"unionWith": [0]}},
  236. )
  237. event_start_field_definition = dict(
  238. field="event_start",
  239. type="temporal",
  240. title=None,
  241. timeUnit={
  242. "unit": x_time_unit,
  243. "step": sensor.event_resolution.total_seconds(),
  244. },
  245. axis={
  246. "tickCount": x_axis_tick_count,
  247. "labelBound": x_axis_label_bound,
  248. "labelExpr": x_axis_label_expression,
  249. "labelFlush": False,
  250. "labelOffset": x_axis_label_offset,
  251. "labelOverlap": True,
  252. "labelSeparation": 1,
  253. },
  254. scale={
  255. "domain": [
  256. {"hours": 0},
  257. {"hours": x_domain_max},
  258. ]
  259. },
  260. )
  261. event_start_date_field_definition = dict(
  262. field="event_start",
  263. type="temporal",
  264. title=None,
  265. timeUnit={
  266. "unit": y_time_unit,
  267. },
  268. axis={
  269. "tickCount": y_axis_tick_count,
  270. # Center align the date labels
  271. "labelOffset": {
  272. "expr": y_axis_label_offset_expression,
  273. },
  274. "labelFlush": False,
  275. "labelBound": True,
  276. },
  277. )
  278. if event_starts_after and event_ends_before:
  279. event_start_date_field_definition["scale"] = {
  280. "domain": [
  281. event_starts_after.timestamp() * 10**3,
  282. event_ends_before.timestamp() * 10**3,
  283. ],
  284. }
  285. mark = {"type": "rect", "clip": True, "opacity": 0.7}
  286. tooltip = [
  287. FIELD_DEFINITIONS["full_date"],
  288. {
  289. **event_value_field_definition,
  290. **dict(title=f"{capitalize(sensor.sensor_type)}"),
  291. },
  292. FIELD_DEFINITIONS["source_name_and_id"],
  293. FIELD_DEFINITIONS["source_model"],
  294. ]
  295. chart_specs = {
  296. "description": f"A {split} heatmap showing sensor data.",
  297. # the sensor type is already shown as the y-axis title (avoid redundant info)
  298. "title": capitalize(sensor.name) if sensor.name != sensor.sensor_type else None,
  299. "layer": [
  300. {
  301. "mark": mark,
  302. "encoding": {
  303. "x": event_start_field_definition,
  304. "y": event_start_date_field_definition,
  305. "color": event_value_field_definition,
  306. "detail": FIELD_DEFINITIONS["source"],
  307. "tooltip": tooltip,
  308. },
  309. "transform": [
  310. {
  311. # Mask overlapping data during the fall DST transition, which we show later with a special layer
  312. "filter": "timezoneoffset(datum.event_start) >= timezoneoffset(datum.event_start + 60 * 60 * 1000) && timezoneoffset(datum.event_start) <= timezoneoffset(datum.event_start - 60 * 60 * 1000)"
  313. },
  314. {
  315. "calculate": "datum.source.name + ' (ID: ' + datum.source.id + ')'",
  316. "as": "source_name_and_id",
  317. },
  318. # In case of multiple sources, show the one with the most visible data
  319. {
  320. "joinaggregate": [{"op": "count", "as": "source_count"}],
  321. "groupby": ["source.id"],
  322. },
  323. {
  324. "window": [
  325. {"op": "rank", "field": "source_count", "as": "source_rank"}
  326. ],
  327. "sort": [{"field": "source_count", "order": "descending"}],
  328. "frame": [None, None],
  329. },
  330. {"filter": "datum.source_rank == 1"},
  331. # In case of a tied rank, arbitrarily choose the first one occurring in the data
  332. {
  333. "window": [
  334. {
  335. "op": "first_value",
  336. "field": "source.id",
  337. "as": "first_source_id",
  338. }
  339. ],
  340. },
  341. {"filter": "datum.source.id == datum.first_source_id"},
  342. ],
  343. },
  344. {
  345. "data": {"name": "replay"},
  346. "mark": {
  347. "type": "rule",
  348. },
  349. "encoding": {
  350. "x": {
  351. "field": "belief_time",
  352. "type": "temporal",
  353. "timeUnit": x_time_unit,
  354. },
  355. "y": {
  356. "field": "belief_time",
  357. "type": "temporal",
  358. "timeUnit": y_time_unit,
  359. },
  360. "yOffset": {
  361. "value": {
  362. "expr": ruler_y_axis_label_offset_expression,
  363. }
  364. },
  365. },
  366. },
  367. create_fall_dst_transition_layer(
  368. sensor.timezone,
  369. mark,
  370. event_value_field_definition,
  371. event_start_field_definition,
  372. tooltip,
  373. split=split,
  374. ),
  375. ],
  376. }
  377. for k, v in override_chart_specs.items():
  378. chart_specs[k] = v
  379. chart_specs["config"] = {
  380. "legend": {"orient": "right"},
  381. # "legend": {"direction": "horizontal"},
  382. }
  383. return chart_specs
  384. def create_fall_dst_transition_layer(
  385. timezone,
  386. mark,
  387. event_value_field_definition,
  388. event_start_field_definition,
  389. tooltip,
  390. split: str,
  391. ) -> dict:
  392. """Special layer for showing data during the daylight savings time transition in fall."""
  393. if split == "daily":
  394. step = 12
  395. calculate_second_bin = "timezoneoffset(datum.event_start + 60 * 60 * 1000) > timezoneoffset(datum.event_start) ? datum.event_start : datum.event_start + 12 * 60 * 60 * 1000"
  396. calculate_next_bin = (
  397. "datum.dst_transition_event_start + 12 * 60 * 60 * 1000 - 60 * 60 * 1000"
  398. )
  399. elif split == "weekly":
  400. step = 7 * 12
  401. calculate_second_bin = "timezoneoffset(datum.event_start + 60 * 60 * 1000) > timezoneoffset(datum.event_start) ? datum.event_start : datum.event_start + 7 * 12 * 60 * 60 * 1000"
  402. calculate_next_bin = "datum.dst_transition_event_start + 7 * 12 * 60 * 60 * 1000 - 60 * 60 * 1000"
  403. else:
  404. raise NotImplementedError(f"Split '{split}' is not implemented.")
  405. return {
  406. "mark": mark,
  407. "encoding": {
  408. "x": event_start_field_definition,
  409. "y": {
  410. "field": "dst_transition_event_start",
  411. "type": "temporal",
  412. "title": None,
  413. "timeUnit": {"unit": "yearmonthdatehours", "step": step},
  414. },
  415. "y2": {
  416. "field": "dst_transition_event_start_next",
  417. "timeUnit": {"unit": "yearmonthdatehours", "step": step},
  418. },
  419. "color": event_value_field_definition,
  420. "detail": FIELD_DEFINITIONS["source"],
  421. "tooltip": [
  422. {
  423. "field": "event_start",
  424. "type": "temporal",
  425. "title": "Timezone",
  426. "timeUnit": "utc",
  427. "format": [timezone],
  428. "formatType": "timezoneFormat",
  429. },
  430. *tooltip,
  431. ],
  432. },
  433. "transform": [
  434. {
  435. "filter": "timezoneoffset(datum.event_start) < timezoneoffset(datum.event_start + 60 * 60 * 1000) || timezoneoffset(datum.event_start) > timezoneoffset(datum.event_start - 60 * 60 * 1000)",
  436. },
  437. {
  438. # Push the more recent hour into the second 12-hour bin
  439. "calculate": calculate_second_bin,
  440. "as": "dst_transition_event_start",
  441. },
  442. {
  443. # Calculate a time point in the next 12-hour bin
  444. "calculate": calculate_next_bin,
  445. "as": "dst_transition_event_start_next",
  446. },
  447. {
  448. "calculate": "datum.source.name + ' (ID: ' + datum.source.id + ')'",
  449. "as": "source_name_and_id",
  450. },
  451. ],
  452. }
  453. def chart_for_multiple_sensors(
  454. sensors_to_show: list["Sensor" | list["Sensor"] | dict[str, "Sensor"]], # noqa F821
  455. event_starts_after: datetime | None = None,
  456. event_ends_before: datetime | None = None,
  457. combine_legend: bool = True,
  458. **override_chart_specs: dict,
  459. ):
  460. # Determine the shared data resolution
  461. all_shown_sensors = flatten_unique(sensors_to_show)
  462. condition = list(
  463. sensor.event_resolution
  464. for sensor in all_shown_sensors
  465. if sensor.event_resolution > timedelta(0)
  466. )
  467. minimum_non_zero_resolution = min(condition) if any(condition) else timedelta(0)
  468. # Set up field definition for event starts
  469. event_start_field_definition = FIELD_DEFINITIONS["event_start"].copy()
  470. event_start_field_definition["timeUnit"] = {
  471. "unit": "yearmonthdatehoursminutesseconds",
  472. "step": minimum_non_zero_resolution.total_seconds(),
  473. }
  474. # If a time window was set explicitly, adjust the domain to show the full window regardless of available data
  475. if event_starts_after and event_ends_before:
  476. event_start_field_definition["scale"] = {
  477. "domain": [
  478. event_starts_after.timestamp() * 10**3,
  479. event_ends_before.timestamp() * 10**3,
  480. ]
  481. }
  482. sensors_specs = []
  483. for entry in sensors_to_show:
  484. title = entry.get("title")
  485. sensors = entry.get("sensors")
  486. # List the sensors that go into one row
  487. row_sensors: list["Sensor"] = sensors # noqa F821
  488. # Set up field definition for sensor descriptions
  489. sensor_field_definition = FIELD_DEFINITIONS["sensor_description"].copy()
  490. sensor_field_definition["scale"] = dict(
  491. domain=[sensor.to_dict()["description"] for sensor in row_sensors]
  492. )
  493. # Derive the unit that should be shown
  494. unit = determine_shared_unit(row_sensors)
  495. sensor_type = determine_shared_sensor_type(row_sensors)
  496. # Set up field definition for event values
  497. event_value_field_definition = dict(
  498. title=f"{capitalize(sensor_type)} ({unit})",
  499. format=[".3~r", unit],
  500. formatType="quantityWithUnitFormat",
  501. stack=None,
  502. **FIELD_DEFINITIONS["event_value"],
  503. )
  504. if unit == "%":
  505. event_value_field_definition["scale"] = dict(
  506. domain={"unionWith": [0, 105]}, nice=False
  507. )
  508. # Set up shared tooltip
  509. shared_tooltip = [
  510. dict(
  511. field="sensor.description",
  512. type="nominal",
  513. title="Sensor",
  514. ),
  515. {
  516. **event_value_field_definition,
  517. **dict(title=f"{capitalize(sensor_type)}"),
  518. },
  519. FIELD_DEFINITIONS["full_date"],
  520. dict(
  521. field="belief_horizon",
  522. type="quantitative",
  523. title="Horizon",
  524. format=["d", 4],
  525. formatType="timedeltaFormat",
  526. ),
  527. {
  528. **event_value_field_definition,
  529. **dict(title=f"{capitalize(sensor_type)}"),
  530. },
  531. FIELD_DEFINITIONS["source_name_and_id"],
  532. FIELD_DEFINITIONS["source_type"],
  533. FIELD_DEFINITIONS["source_model"],
  534. ]
  535. # Draw a line for each sensor (and each source)
  536. layers = [
  537. create_line_layer(
  538. row_sensors,
  539. event_start_field_definition,
  540. event_value_field_definition,
  541. sensor_field_definition,
  542. combine_legend=combine_legend,
  543. )
  544. ]
  545. # Optionally, draw transparent full-height rectangles that activate the tooltip anywhere in the graph
  546. # (to be precise, only at points on the x-axis where there is data)
  547. if len(row_sensors) == 1:
  548. # With multiple sensors, we cannot do this, because it is ambiguous which tooltip to activate (instead, we use a different brush in the circle layer)
  549. layers.append(
  550. create_rect_layer(
  551. event_start_field_definition,
  552. event_value_field_definition,
  553. shared_tooltip,
  554. )
  555. )
  556. # Draw circle markers that are shown on hover
  557. layers.append(
  558. create_circle_layer(
  559. row_sensors,
  560. event_start_field_definition,
  561. event_value_field_definition,
  562. sensor_field_definition,
  563. shared_tooltip,
  564. )
  565. )
  566. layers.append(REPLAY_RULER)
  567. # Layer the lines, rectangles and circles within one row, and filter by which sensors are represented in the row
  568. sensor_specs = {
  569. "title": f"{capitalize(title)}" if title else None,
  570. "transform": [
  571. {
  572. "filter": {
  573. "field": "sensor.id",
  574. "oneOf": [sensor.id for sensor in row_sensors],
  575. }
  576. }
  577. ],
  578. "layer": layers,
  579. "width": "container",
  580. }
  581. sensors_specs.append(sensor_specs)
  582. # Vertically concatenate the rows
  583. chart_specs = dict(
  584. description="A vertically concatenated chart showing sensor data.",
  585. vconcat=[*sensors_specs],
  586. transform=[
  587. {
  588. "calculate": "datum.source.name + ' (ID: ' + datum.source.id + ')'",
  589. "as": "source_name_and_id",
  590. },
  591. ],
  592. )
  593. chart_specs["config"] = {
  594. "view": {"continuousWidth": 800, "continuousHeight": 150},
  595. "autosize": {"type": "fit-x", "contains": "padding"},
  596. }
  597. if combine_legend is True:
  598. chart_specs["resolve"] = {"scale": {"x": "shared"}}
  599. else:
  600. chart_specs["resolve"] = {"scale": {"color": "independent"}}
  601. for k, v in override_chart_specs.items():
  602. chart_specs[k] = v
  603. return chart_specs
  604. def determine_shared_unit(sensors: list["Sensor"]) -> str: # noqa F821
  605. units = list(set([sensor.unit for sensor in sensors if sensor.unit]))
  606. # Replace with 'a.u.' in case of mixing units
  607. shared_unit = units[0] if len(units) == 1 else "a.u."
  608. # Replace with 'dimensionless' in case of empty unit
  609. return shared_unit if shared_unit else "dimensionless"
  610. def determine_shared_sensor_type(sensors: list["Sensor"]) -> str: # noqa F821
  611. sensor_types = list(set([sensor.sensor_type for sensor in sensors]))
  612. # Return the sole sensor type
  613. if len(sensor_types) == 1:
  614. return sensor_types[0]
  615. # Check the units for common cases
  616. shared_unit = determine_shared_unit(sensors)
  617. return get_unit_dimension(shared_unit)
  618. def create_line_layer(
  619. sensors: list["Sensor"], # noqa F821
  620. event_start_field_definition: dict,
  621. event_value_field_definition: dict,
  622. sensor_field_definition: dict,
  623. combine_legend: bool,
  624. ):
  625. # Use linear interpolation if any of the sensors shown within one row is instantaneous; otherwise, use step-after
  626. if any(sensor.event_resolution == timedelta(0) for sensor in sensors):
  627. interpolate = "linear"
  628. else:
  629. interpolate = "step-after"
  630. line_layer = {
  631. "mark": {
  632. "type": "line",
  633. "interpolate": interpolate,
  634. "clip": True,
  635. },
  636. "encoding": {
  637. "x": event_start_field_definition,
  638. "y": event_value_field_definition,
  639. "color": (
  640. sensor_field_definition
  641. if combine_legend
  642. else {
  643. **sensor_field_definition,
  644. "legend": {
  645. "orient": "right",
  646. "columns": 1,
  647. "direction": "vertical",
  648. },
  649. }
  650. ),
  651. "strokeDash": {
  652. "scale": {
  653. # Distinguish forecasters and schedulers by line stroke
  654. "domain": ["forecaster", "scheduler", "other"],
  655. # Schedulers get a dashed line, forecasters get a dotted line, the rest gets a solid line
  656. "range": [[2, 2], [4, 4], [1, 0]],
  657. },
  658. "field": "source.type",
  659. "legend": {
  660. "title": "Source",
  661. },
  662. },
  663. "detail": [FIELD_DEFINITIONS["source"]],
  664. },
  665. "selection": {
  666. "scroll": {"type": "interval", "bind": "scales", "encodings": ["x"]}
  667. },
  668. }
  669. return line_layer
  670. def create_circle_layer(
  671. sensors: list["Sensor"], # noqa F821
  672. event_start_field_definition: dict,
  673. event_value_field_definition: dict,
  674. sensor_field_definition: dict,
  675. shared_tooltip: list,
  676. ):
  677. params = [
  678. {
  679. "name": "hover_x_brush",
  680. "select": {
  681. "type": "point",
  682. "encodings": ["x"],
  683. "on": "mouseover",
  684. "nearest": False,
  685. "clear": "mouseout",
  686. },
  687. }
  688. ]
  689. if len(sensors) > 1:
  690. # extra brush for showing the tooltip of the closest sensor
  691. params.append(
  692. {
  693. "name": "hover_nearest_brush",
  694. "select": {
  695. "type": "point",
  696. "on": "mouseover",
  697. "nearest": True,
  698. "clear": "mouseout",
  699. },
  700. }
  701. )
  702. or_conditions = [{"param": "hover_x_brush", "empty": False}]
  703. if len(sensors) > 1:
  704. or_conditions.append({"param": "hover_nearest_brush", "empty": False})
  705. circle_layer = {
  706. "mark": {
  707. "type": "circle",
  708. "opacity": 1,
  709. "clip": True,
  710. },
  711. "encoding": {
  712. "x": event_start_field_definition,
  713. "y": event_value_field_definition,
  714. "color": sensor_field_definition,
  715. "size": {
  716. "condition": {"value": "200", "test": {"or": or_conditions}},
  717. "value": "0",
  718. },
  719. "tooltip": shared_tooltip,
  720. },
  721. "params": params,
  722. }
  723. return circle_layer
  724. def create_rect_layer(
  725. event_start_field_definition: dict,
  726. event_value_field_definition: dict,
  727. shared_tooltip: list,
  728. ):
  729. rect_layer = {
  730. "mark": {
  731. "type": "rect",
  732. "y2": "height",
  733. "opacity": 0,
  734. },
  735. "encoding": {
  736. "x": event_start_field_definition,
  737. "y": {
  738. "condition": {
  739. "test": "isNaN(datum['event_value'])",
  740. **event_value_field_definition,
  741. },
  742. "value": 0,
  743. },
  744. "tooltip": shared_tooltip,
  745. },
  746. }
  747. return rect_layer