From 1d4e193857681f373c1335d8db8ba4bcb89985f7 Mon Sep 17 00:00:00 2001 From: Taran Mamidala Date: Mon, 7 Sep 2026 12:07:29 -0400 Subject: [PATCH] fix(assets): select one deterministic belief per event in KPI endpoint (#2471) Signed-off-by: Taran Mamidala --- documentation/changelog.rst | 1 + flexmeasures/api/v3_0/assets.py | 1 + .../api/v3_0/tests/test_assets_api.py | 63 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 72921d0e39..ddc4d2ebdf 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -39,6 +39,7 @@ Infrastructure / Support Bugfixes ----------- +* In the asset KPI endpoint, select a single deterministic belief per event (``one_deterministic_belief_per_event=True``) so multiple sources reporting beliefs for the same event are not summed together [see `PR #2488 `_] * ``flexmeasures add schedule --dry-run`` no longer saves a schedule when it is combined with ``--as-job``, where the flag used to be dropped without a word and the queued job stored its schedule anyway; that combination is now rejected, and a dry run says how many beliefs it would have saved and which events they cover [see `PR #2483 `_] * Upgrading a database old enough to still carry the pre-``GenericAsset``/``Sensor`` tables now works, where the v0.18.0 migration that removes them crashed twice over: once while checking whether those tables hold data, as soon as one of them held more than a single row, and once while dropping them, because it dropped each table before the ones referencing it [see `PR #2475 `_] * A forecaster that is told both where to start training and how much history to train on now trains on whichever of the two asks for less data, rather than training back to the start date: ``train-start`` says where training may begin, and ``train-period`` says how much history to use [see `PR #2482 `_] diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 28c84dfd79..e2de597974 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -2345,6 +2345,7 @@ def get_kpis(self, id: int, asset: GenericAsset, start, end): event_starts_after=start, event_ends_before=end, most_recent_beliefs_only=True, + one_deterministic_belief_per_event=True, ) # Count each event once, under the window it starts in. # The search also returns events that merely overlap the window, which the chart draws, diff --git a/flexmeasures/api/v3_0/tests/test_assets_api.py b/flexmeasures/api/v3_0/tests/test_assets_api.py index 4bd78a2cc3..dce10bb3af 100644 --- a/flexmeasures/api/v3_0/tests/test_assets_api.py +++ b/flexmeasures/api/v3_0/tests/test_assets_api.py @@ -2070,3 +2070,66 @@ def local_day(day: int) -> str: assert sum(totals.values()) == pytest.approx( 222.0 ), "each event counts once across neighbouring days, not twice" + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +def test_kpi_two_sources_same_event( + db, client, setup_api_test_data, setup_sources, requesting_user +): + """When multiple sources report beliefs for the same event, a KPI should not sum across sources.""" + asset_type = ( + db.session.query(GenericAssetType).filter_by(name="battery").one_or_none() + ) + asset = GenericAsset( + name="kpi two sources same event", + generic_asset_type=asset_type, + account_id=requesting_user.account_id, + ) + db.session.add(asset) + db.session.flush() + sensor = Sensor( + name="kpi two sources sensor", + generic_asset=asset, + event_resolution=timedelta(days=1), + unit="EUR", + ) + db.session.add(sensor) + db.session.flush() + sources = list(setup_sources.values()) + a, b = sources[0], sources[-1] + assert a.id != b.id, "this test needs two distinct sources" + window_start = datetime(2030, 3, 15, tzinfo=utc) + db.session.bulk_insert_mappings( + TimedBelief, + [ + dict( + event_start=window_start, + belief_horizon=timedelta(days=2), + event_value=100.0, + sensor_id=sensor.id, + source_id=a.id, + cumulative_probability=0.5, + ), + dict( + event_start=window_start, + belief_horizon=timedelta(days=1), + event_value=80.0, + sensor_id=sensor.id, + source_id=b.id, + cumulative_probability=0.5, + ), + ], + ) + asset.sensors_to_show_as_kpis = [ + {"title": "Daily costs", "sensor": sensor.id, "function": "sum"} + ] + db.session.flush() + total = _kpi_total( + client, + asset, + window_start.isoformat(), + (window_start + timedelta(days=1)).isoformat(), + ) + # The KPI selects one deterministic belief per event rather than double-counting across sources + assert total in (100.0, 80.0) + assert total != 180.0