Skip to content

Commit 9588cb3

Browse files
Ahmad-WahidFlix6x
andauthored
Fix Charge Point sessions chart loading, plus zoom-sync, epoch-bar, and alignment improvements (#2259)
* fix: render charge point sessions chart and sync x-axis zoom The charge point sessions chart crashed and, once rendering, did not share x-axis pan/zoom with the other subcharts. - Read sensors via _extract_sensors_from_entry to handle the standardized {title, plots:[{sensors}]} format (fixes NoneType/KeyError on 'sensors'). - Filter the 'Power flow by type' entry through its plots. - Always set resolve.scale.x = shared (only mark color independent when legends are not combined) so subcharts share the x scale. - Remove pinned x/x2 scale domains from the sessions layers. - Add an invisible zoom layer bound to the same event_start field the other charts use, so dragging the sessions chart drives the same shared-scale signal and all subcharts pan together. * fix: hide incomplete charge point session pairs (no epoch bars) A session bar was drawn from 1970-01-01 when one endpoint of a pair was missing (e.g. a 'stop charging' value with no matching 'start charging'). The pivot transform fills the missing endpoint with 0, which Vega renders as epoch 0 (1970); isValid() treats 0 as valid, so it must be excluded too. Filter each paired session layer (start/stop charging, arrival/departure, plug in/plug out) requiring both endpoints to be > 0, so a pair missing an endpoint is skipped while the asset's other complete pairs still render. * fix: align charge point session lines per asset The thick start/stop charging line did not sit on the thin plug-in/out and arrival/departure lines. Each layer derived its yOffset from a session_id built as asset_id + event_start, but the charging beliefs carry a different event_start than the plug/arrival beliefs (and may even fall on another day), so the charging layer landed in a different yOffset sub-band. Remove the per-session yOffset from the session layers so all three pairs of the same asset share one row (y = asset_id) and line up exactly. * docs: reassign changelog entry for PR #2237 from v1.0.0 to v0.33.1 Signed-off-by: F.N. Claessen <claessen@seita.nl> * docs: changelog entry Signed-off-by: F.N. Claessen <claessen@seita.nl> --------- Signed-off-by: F.N. Claessen <claessen@seita.nl> Co-authored-by: F.N. Claessen <claessen@seita.nl>
1 parent cbb67be commit 9588cb3

2 files changed

Lines changed: 104 additions & 77 deletions

File tree

documentation/changelog.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,16 @@ Infrastructure / Support
2929

3030
Bugfixes
3131
-----------
32-
* Allow flex-model and flex-context to be missing from scheduling requests, because by now the whole flex-config can be defined on assets (in the db) instead [see `PR #2237 <https://www.github.com/FlexMeasures/flexmeasures/pull/2237>`_]
3332
* Let storage scheduling treat missing constant SoC bounds as unconstrained lower or upper bounds [see `PR #2221 <https://www.github.com/FlexMeasures/flexmeasures/pull/2221>`_]
3433
* Allow root assets belonging to different accounts to share the same name, while keeping asset names unique among root assets within the same account and among children of the same parent [see `PR #2226 <https://www.github.com/FlexMeasures/flexmeasures/pull/2226>`_]
3534

3635

36+
v0.33.1 | July XX, 2026
37+
============================
38+
* Allow flex-model and flex-context to be missing from scheduling requests, because by now the whole flex-config can be defined on assets (in the db) instead [see `PR #2237 <https://www.github.com/FlexMeasures/flexmeasures/pull/2237>`_]
39+
* Fix Chart Point sessions chart [see `PR #2259 <https://www.github.com/FlexMeasures/flexmeasures/pull/2259>`_]
40+
41+
3742
v0.33.0 | June 1, 2026
3843
============================
3944

flexmeasures/data/models/charts/belief_charts.py

Lines changed: 98 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -656,10 +656,12 @@ def _build_chart_specs(
656656
"view": {"continuousWidth": 800, "continuousHeight": 150},
657657
"autosize": {"type": "fit-x", "contains": "padding"},
658658
}
659-
if combine_legend is True:
660-
chart_specs["resolve"] = {"scale": {"x": "shared"}}
661-
else:
662-
chart_specs["resolve"] = {"scale": {"color": "independent"}}
659+
# Always share the x-axis so panning/zooming stays synchronized across the
660+
# vertically concatenated subcharts. When legends are not combined, keep the
661+
# color scale independent per subchart.
662+
chart_specs["resolve"] = {"scale": {"x": "shared"}}
663+
if combine_legend is not True:
664+
chart_specs["resolve"]["scale"]["color"] = "independent"
663665
for k, v in override_chart_specs.items():
664666
chart_specs[k] = v
665667
return chart_specs
@@ -1132,7 +1134,7 @@ def chart_for_chargepoint_sessions(
11321134
all_sensors = []
11331135
sensors_to_show_copy = sensors_to_show.copy()
11341136
for entry in sensors_to_show_copy:
1135-
sensors = entry.get("sensors")
1137+
sensors = _extract_sensors_from_entry(entry)
11361138
all_sensors.extend(
11371139
[
11381140
s
@@ -1181,6 +1183,7 @@ def chart_for_chargepoint_sessions(
11811183
"value": "event_value",
11821184
"groupby": ["session_id", "asset", "asset_id"],
11831185
},
1186+
{"filter": "datum['arrival'] > 0 && datum['departure'] > 0"},
11841187
{"filter": {"selection": "arr_dep"}},
11851188
],
11861189
"selection": {
@@ -1206,23 +1209,11 @@ def chart_for_chargepoint_sessions(
12061209
"x": {
12071210
"field": "arrival",
12081211
"type": "temporal",
1209-
"scale": {
1210-
"domain": [
1211-
event_starts_after.timestamp() * 1000,
1212-
event_ends_before.timestamp() * 1000,
1213-
]
1214-
},
12151212
},
12161213
"x2": {
12171214
"field": "departure",
12181215
"type": "temporal",
12191216
"title": None,
1220-
"scale": {
1221-
"domain": [
1222-
event_starts_after.timestamp() * 1000,
1223-
event_ends_before.timestamp() * 1000,
1224-
]
1225-
},
12261217
},
12271218
"y": {
12281219
"field": "asset_id",
@@ -1233,14 +1224,6 @@ def chart_for_chargepoint_sessions(
12331224
"title": "Sessions",
12341225
"axis": {"labels": False, "ticks": False, "domain": False},
12351226
},
1236-
"yOffset": {
1237-
"field": "session_id",
1238-
"type": "nominal",
1239-
"bandPosition": 0.5,
1240-
"scale": {
1241-
"domain": {"selection": "arr_dep", "field": "session_id"}
1242-
},
1243-
},
12441227
"color": {
12451228
"field": "asset",
12461229
"type": "nominal",
@@ -1291,6 +1274,7 @@ def chart_for_chargepoint_sessions(
12911274
"asset_id",
12921275
],
12931276
},
1277+
{"filter": "datum['plug in'] > 0 && datum['plug out'] > 0"},
12941278
{"filter": {"selection": "plugin_plugout"}},
12951279
],
12961280
"selection": {
@@ -1310,22 +1294,10 @@ def chart_for_chargepoint_sessions(
13101294
"x": {
13111295
"field": "plug in",
13121296
"type": "temporal",
1313-
"scale": {
1314-
"domain": [
1315-
event_starts_after.timestamp() * 1000,
1316-
event_ends_before.timestamp() * 1000,
1317-
]
1318-
},
13191297
},
13201298
"x2": {
13211299
"field": "plug out",
13221300
"type": "temporal",
1323-
"scale": {
1324-
"domain": [
1325-
event_starts_after.timestamp() * 1000,
1326-
event_ends_before.timestamp() * 1000,
1327-
]
1328-
},
13291301
},
13301302
"y": {
13311303
"field": "asset_id",
@@ -1339,17 +1311,6 @@ def chart_for_chargepoint_sessions(
13391311
"title": "Sessions",
13401312
"axis": {"labels": False, "ticks": False, "domain": False},
13411313
},
1342-
"yOffset": {
1343-
"field": "session_id",
1344-
"type": "nominal",
1345-
"bandPosition": 0.5,
1346-
"scale": {
1347-
"domain": {
1348-
"selection": "plugin_plugout",
1349-
"field": "session_id",
1350-
}
1351-
},
1352-
},
13531314
"color": {
13541315
"field": "asset",
13551316
"type": "nominal",
@@ -1397,6 +1358,9 @@ def chart_for_chargepoint_sessions(
13971358
"value": "event_value",
13981359
"groupby": ["session_id", "asset", "asset_id"],
13991360
},
1361+
{
1362+
"filter": "datum['start charging'] > 0 && datum['stop charging'] > 0"
1363+
},
14001364
{"filter": {"selection": "start_stop_charging"}},
14011365
],
14021366
"selection": {
@@ -1413,22 +1377,10 @@ def chart_for_chargepoint_sessions(
14131377
"x": {
14141378
"field": "start charging",
14151379
"type": "temporal",
1416-
"scale": {
1417-
"domain": [
1418-
event_starts_after.timestamp() * 1000,
1419-
event_ends_before.timestamp() * 1000,
1420-
]
1421-
},
14221380
},
14231381
"x2": {
14241382
"field": "stop charging",
14251383
"type": "temporal",
1426-
"scale": {
1427-
"domain": [
1428-
event_starts_after.timestamp() * 1000,
1429-
event_ends_before.timestamp() * 1000,
1430-
]
1431-
},
14321384
},
14331385
"y": {
14341386
"field": "asset_id",
@@ -1442,17 +1394,6 @@ def chart_for_chargepoint_sessions(
14421394
"title": "Sessions",
14431395
"axis": {"labels": False, "ticks": False, "domain": False},
14441396
},
1445-
"yOffset": {
1446-
"field": "session_id",
1447-
"type": "nominal",
1448-
"bandPosition": 0.5,
1449-
"scale": {
1450-
"domain": {
1451-
"selection": "start_stop_charging",
1452-
"field": "session_id",
1453-
}
1454-
},
1455-
},
14561397
"color": {
14571398
"field": "asset",
14581399
"type": "nominal",
@@ -1489,11 +1430,13 @@ def chart_for_chargepoint_sessions(
14891430
for idx, entry in enumerate(sensors_to_show_copy):
14901431
title = entry.get("title")
14911432
if title == "Power flow by type":
1492-
sensors_to_show_copy[idx]["sensors"] = [
1493-
sensor
1494-
for sensor in entry["sensors"]
1495-
if sensor.name == "charge points power"
1496-
]
1433+
for plot in entry.get("plots", []):
1434+
if "sensors" in plot:
1435+
plot["sensors"] = [
1436+
sensor
1437+
for sensor in plot["sensors"]
1438+
if sensor.name == "charge points power"
1439+
]
14971440
chart_specs = chart_for_multiple_sensors(
14981441
sensors_to_show_copy,
14991442
event_starts_after,
@@ -1506,5 +1449,84 @@ def chart_for_chargepoint_sessions(
15061449
for chart in chart_specs["vconcat"]
15071450
if chart["title"] in ["Prices", "Power flow by type"]
15081451
]
1452+
1453+
# Synchronize x-axis panning/zooming across all stacked subcharts.
1454+
#
1455+
# The other subcharts bind their `scroll` interval selection to the shared x
1456+
# scale via the `event_start` field, so they all drive the same scale-domain
1457+
# signal. The Charge Point sessions chart, however, plots `arrival`/`departure`
1458+
# (etc.), so its own scale-bound selection writes a *different* signal that the
1459+
# shared scale never reads. As a result, dragging the other charts moved the
1460+
# sessions chart, but dragging the sessions chart moved nothing.
1461+
#
1462+
# Fix: drop the sessions chart's own scale-bound selections and add an invisible
1463+
# layer that binds the zoom to the *same* `event_start` field definition the
1464+
# other charts use, so dragging it drives the same shared-scale signal.
1465+
event_start_x = _find_event_start_x_encoding(chart_specs["vconcat"])
1466+
if event_start_x is not None:
1467+
_remove_scroll_selections(cp_chart)
1468+
zoom_x = deepcopy(event_start_x)
1469+
zoom_x.pop("scale", None) # let the shared scale govern the domain
1470+
cp_chart["layer"].append(
1471+
{
1472+
"mark": {"type": "rule", "opacity": 0},
1473+
"encoding": {"x": zoom_x},
1474+
"selection": {
1475+
"scroll": {
1476+
"type": "interval",
1477+
"bind": "scales",
1478+
"encodings": ["x"],
1479+
}
1480+
},
1481+
}
1482+
)
1483+
15091484
chart_specs["vconcat"].insert(0, cp_chart)
15101485
return chart_specs
1486+
1487+
1488+
def _find_event_start_x_encoding(spec) -> dict | None:
1489+
"""Return a copy of the first ``x`` encoding bound to the ``event_start`` field.
1490+
1491+
Used to reuse the exact same field definition (notably its ``timeUnit``) so that
1492+
a scale-bound interval selection writes the same shared-scale signal as the other
1493+
subcharts.
1494+
"""
1495+
if isinstance(spec, dict):
1496+
encoding = spec.get("encoding")
1497+
if isinstance(encoding, dict):
1498+
x = encoding.get("x")
1499+
if isinstance(x, dict) and x.get("field") == "event_start":
1500+
return deepcopy(x)
1501+
for key in ("layer", "vconcat", "hconcat", "concat"):
1502+
for child in spec.get(key, []) or []:
1503+
found = _find_event_start_x_encoding(child)
1504+
if found is not None:
1505+
return found
1506+
elif isinstance(spec, list):
1507+
for child in spec:
1508+
found = _find_event_start_x_encoding(child)
1509+
if found is not None:
1510+
return found
1511+
return None
1512+
1513+
1514+
def _remove_scroll_selections(spec: dict) -> None:
1515+
"""Recursively remove any ``scroll`` (scale-bound interval) selections/params."""
1516+
if not isinstance(spec, dict):
1517+
return
1518+
selection = spec.get("selection")
1519+
if isinstance(selection, dict):
1520+
selection.pop("scroll", None)
1521+
if not selection:
1522+
spec.pop("selection", None)
1523+
params = spec.get("params")
1524+
if isinstance(params, list):
1525+
spec["params"] = [
1526+
p for p in params if not (isinstance(p, dict) and p.get("name") == "scroll")
1527+
]
1528+
if not spec["params"]:
1529+
spec.pop("params", None)
1530+
for key in ("layer", "vconcat", "hconcat", "concat"):
1531+
for child in spec.get(key, []) or []:
1532+
_remove_scroll_selections(child)

0 commit comments

Comments
 (0)