-
Notifications
You must be signed in to change notification settings - Fork 56
Count an event once in a KPI, when several sources report it #2472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b4d0b30
d9423d1
097070f
da9ebb9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1910,6 +1910,155 @@ def test_kpi_window_honours_the_offset_it_is_given( | |
| assert total != shifted, "the assertion above only means something if these differ" | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) | ||
| def test_kpi_counts_an_event_once_when_two_sources_report_it( | ||
| db, client, setup_api_test_data, setup_sources, requesting_user | ||
| ): | ||
| """Two sources reporting one event are two claims about it, not two contributions to it. | ||
|
|
||
| Summing them produced a number no source ever reported, and that no point on the chart showed. | ||
| The KPI now reduces one value per event, and these two sources are of the same version, | ||
| so the one that believed the event more recently is the one it counts. | ||
| """ | ||
| asset_type = ( | ||
| db.session.query(GenericAssetType).filter_by(name="battery").one_or_none() | ||
| ) | ||
| asset = GenericAsset( | ||
| name="kpi with two sources on one event", | ||
| generic_asset_type=asset_type, | ||
| account_id=requesting_user.account_id, | ||
| ) | ||
| db.session.add(asset) | ||
| db.session.flush() | ||
| sensor = Sensor( | ||
| name="kpi with 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()) | ||
| reported, corrected = sources[0], sources[-1] | ||
| assert reported.id != corrected.id, "this test needs two distinct sources" | ||
|
|
||
|
Comment on lines
+1942
to
+1945
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right — The docstring now says what the test does: these two sources are of the same version, so the one And a new test covers the version half, with two versions of one reporter, the newer one speaking # The newer version spoke first, and the older version spoke later.
...
assert total == pytest.approx(42.0), "the newer version's value, despite the older belief time"It reports 141.0 without the fix — 42 plus 99 — so it covers both the summing and the ordering. |
||
| window_start = datetime(2030, 3, 15, tzinfo=utc) | ||
| db.session.bulk_insert_mappings( | ||
| TimedBelief, | ||
| [ | ||
| # One event, claimed by two sources, the second more recently than the first. | ||
| dict( | ||
| event_start=window_start, | ||
| belief_horizon=timedelta(days=2), | ||
| event_value=100.0, | ||
| sensor_id=sensor.id, | ||
| source_id=reported.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=corrected.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(), | ||
| ) | ||
| assert total == pytest.approx( | ||
| 80.0 | ||
| ), "the more recent belief about the event, rather than 180.0, which neither source reported" | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) | ||
| def test_kpi_prefers_the_latest_source_version_over_the_most_recent_belief( | ||
| db, client, setup_api_test_data, requesting_user | ||
| ): | ||
| """A newer version of a source wins the event, even when an older version believed it more recently. | ||
|
|
||
| Version comes first because it says which code produced the value, | ||
| where the belief time only says when it was said. | ||
| """ | ||
| from flexmeasures.data.models.data_sources import DataSource | ||
|
|
||
| asset_type = ( | ||
| db.session.query(GenericAssetType).filter_by(name="battery").one_or_none() | ||
| ) | ||
| asset = GenericAsset( | ||
| name="kpi with two source versions", | ||
| generic_asset_type=asset_type, | ||
| account_id=requesting_user.account_id, | ||
| ) | ||
| db.session.add(asset) | ||
| db.session.flush() | ||
| sensor = Sensor( | ||
| name="kpi with two source versions sensor", | ||
| generic_asset=asset, | ||
| event_resolution=timedelta(days=1), | ||
| unit="EUR", | ||
| ) | ||
| db.session.add(sensor) | ||
| # Two versions of one reporter, which is what a release upgrade leaves behind. | ||
| older_version = DataSource( | ||
| name="Reporter", type="reporter", model="Rep", version="1" | ||
| ) | ||
| newer_version = DataSource( | ||
| name="Reporter", type="reporter", model="Rep", version="2" | ||
| ) | ||
| db.session.add_all([older_version, newer_version]) | ||
| db.session.flush() | ||
|
|
||
| window_start = datetime(2030, 4, 15, tzinfo=utc) | ||
| db.session.bulk_insert_mappings( | ||
| TimedBelief, | ||
| [ | ||
| # The newer version spoke first, and the older version spoke later. | ||
| dict( | ||
| event_start=window_start, | ||
| belief_horizon=timedelta(days=2), | ||
| event_value=42.0, | ||
| sensor_id=sensor.id, | ||
| source_id=newer_version.id, | ||
| cumulative_probability=0.5, | ||
| ), | ||
| dict( | ||
| event_start=window_start, | ||
| belief_horizon=timedelta(days=1), | ||
| event_value=99.0, | ||
| sensor_id=sensor.id, | ||
| source_id=older_version.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(), | ||
| ) | ||
| assert total == pytest.approx( | ||
| 42.0 | ||
| ), "the newer version's value, despite the older belief time" | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) | ||
| def test_kpi_reports_what_the_chart_draws( | ||
| db, client, setup_api_test_data, setup_sources, requesting_user | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -902,6 +902,10 @@ def _select_latest_version_and_belief_per_event( | |
| """Keep, per event, the single belief with the latest source version, | ||
| breaking version ties by most recent belief time. | ||
|
|
||
| Beliefs that tie on both keep the order they came in, | ||
| which is what lets a caller express its own precedence by the order in which it passes its sources. | ||
|
Comment on lines
+905
to
+906
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This suggests that the user order is inferior to the belief time order. Was that the desired behaviour when the user order feature was introduced? Just a question at this point, no need to go off and make changes yet.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is worse than inferior: the caller's order is not consulted at all. I probed it rather than reasoned about it. The list order changes nothing. Running Same answer both ways. And What actually decides is the source's name. def __lt__(self, other):
"""Set a rule for ordering."""
return self.__str__() < other.__str__()A probe with the id order and the name order deliberately disagreeing settles it: So in Which means the question was never faced. Every belief in that fixture has Worth its own issue, I think — both the misleading docstring and the question you are asking, which is a real decision nobody has taken.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Researched. Short version: the array-order precedence was specified in review and never implemented, and you were the one who asked for it to be pinned down. The PR is #819, Make source optional in the input field of the The use case is a data source transition on one sensor. Victor's words:
and yours:
That produced two things: The intention came from your own question, on the very line we are discussing:
The docstring we have today is the answer to it — "the first source defined in the sources array is prioritized" — so array order is what was meant. Nothing was written to make it so. And you had already noticed the gap earlier in the same review:
That is still true. Your uncertainty back then ("I'm not 100% sure whether that would lead to taking the oldest or newest belief") also never got resolved, because the fixture gives every belief The tests are all in What that means for the use case. It works today, but on an accident: at the overlapping event the winner is the alphabetically first source name. Name a scheduler's source something that sorts before the measurement source and the transition silently resolves the wrong way — the exact mix-up the Shall I write this up as an issue? It has three parts worth separating: the docstring promising precedence that is not implemented, the alphabetical rule nobody chose, and your original question of whether a caller's order should outrank a more recent belief.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3) You are right, and my sentence was wrong. Same event and same belief horizon does mean the same belief time, so the two sources are very much in competition — that is precisely why something has to break the tie. What I meant to say is that the belief-time criterion never gets to decide between them: they tie on it, so the comparison falls through to whatever orders the sources themselves. Your 2023 question of oldest-versus-newest is what the fixture never puts to the test, not the competition itself. 2) Right on both counts, and it explains the drift. Version preference came later: That matters here. In August 2023 there was no version step at all, and FM did not do this selection itself: 1) No — the record says the opposite, though the code now does what you describe. Three things were said, and none of them is "freshest wins":
What runs today is none of those. Two sources, no versions, one event, the alphabetically first source holding the older belief: So the precedence today is version, then belief time, then source name, and the listing is never consulted. Freshness even beats the name rule, which is why this only shows up as alphabetical when the belief times tie — as they do in that fixture. One thing I have not verified: whether the 2023 timely-beliefs implementation took the oldest belief, as you suspected at the time. If it did, the cross-source rule flipped from oldest to newest when FM took the selection over, silently. That is checkable by running the old library, and I would want it in the issue if we write one, since it decides whether "freshest wins" was ever a decision or just a side effect.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dug into it, and the answer is the opposite of "silent": the flip was deliberate and written down, just not where the docstring is. 2023 took the oldest belief, exactly as you suspected. The implementation was FlexMeasures' own, not timely-beliefs' — bdf = (
bdf.for_each_belief(get_median_belief)
.groupby(level=["event_start"], group_keys=False)
.apply(lambda x: x.head(1))
)
It changed in #1306 (2025-01-28), whose description says so as its first bullet:
So freshest-wins was a decision, taken with a reason. What went unnoticed is that the docstring one file away still promised the array order — #1306 even fixed a typo in that sentence while leaving the claim standing. One more thing the dig turned up, which I had not expected: the rules differ depending on whether the sources share a name. Two sources of one family are collapsed by Written up as #2476, with the history, the use cases you asked about — KPIs, reporters at a source transition, a bumped scheduler version, two forecasting models side by side, and the config-fragmented scheduler sources from #2464 — and a recommendation. |
||
| See `test_source_transition`, where the first source in the list wins the events both sources report. | ||
|
|
||
| Assumes deterministic beliefs (probabilistic depth 1) and a belief_time index level. | ||
| """ | ||
| source_codes, unique_sources = pd.factorize(bdf.index.get_level_values("source")) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch on the ambiguity, but the fix you suggest would break a documented feature — so I have
left the tie alone and documented why.
I did implement it first: a last tie-break by highest source id, matching
source_priorities, whichranks by version and then by id. Two existing tests then failed, and the second one is the answer:
test_source_transition(flexmeasures/data/models/reporting/tests/test_aggregator.py) statesAn
AggregatorReportergivensources=[ds1, ds2]expectsds1to win the events both report. Thatworks because the remaining tie keeps the order the rows arrive in. Breaking the tie by source id
made
ds2win, since it has the higher id, and the reporter silently stopped honouring the caller'sorder.
test_select_latest_version_and_belief_per_event_equivalencefailed too, since its referenceimplementation keeps the incumbent on an exact tie.
So the order is not arbitrary noise to be tidied away — it is how precedence is expressed. What was
missing is that this is nowhere written down, so
_select_latest_version_and_belief_per_eventnowsays it:
For the KPI endpoint, which passes no sources of its own, an exact tie — same version, same belief
time, two sources — does leave the value up to the order the rows come back in. That is a real
limit, and a narrow one: it needs two sources to claim one event at the very same moment under the
same version. Worth its own issue if we want KPIs to be deterministic even then; it is not something
this PR can settle without taking the reporter's precedence away.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But if no user preference is given, we could still make it deterministic by selecting the latest ID, right? It seems to me that this might solve the issue for KPIs without breaking the feature of users selecting a specific source list? Or am I missing some caveats?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are not missing a caveat — I was. The caveat I gave earlier in this thread is wrong, and so is the premise it was answering.
There is no user-order feature to break. As shown on the other thread, reversing the sources list changes nothing; ties are settled by the source's name, through
sort_index()andBeliefSource.__lt__comparing string representations.test_source_transitionpasses becausesource1sorts beforesource2. So breaking ties by id would not take a feature away — it would replace one arbitrary rule with another, and flip that test.But the thing we were fixing is not broken either. Because that ordering comes from a deterministic sort rather than from however the rows arrive, a KPI over tied sources already answers the same way every time. Copilot's "effectively non-deterministic, depending on row ordering from the DB/pandas" is not what happens. My probe returns the same winner across repeats, and it is the alphabetically first source name.
So the choice is narrower than it looked: not "deterministic or not", but which arbitrary source should win — alphabetically first, or most recently added. Latest id is more defensible than alphabetical, since it at least tracks something real. Two things I would weigh:
test_source_transition, whose expectation encodes the alphabetical outcome.My suggestion: leave this PR as it is, since it does not touch tie behaviour, and open an issue covering all of it — the docstring that promises a precedence the code does not implement, the alphabetical rule nobody chose, and your question about whether a caller's order should outrank a more recent belief. Happy to write that up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right, and it is not only my proposal that has it wrong — the code does this today. Your example, near enough verbatim: a scheduler at v1 and a forecaster at v9 on one sensor, one event, the scheduler holding the belief that is 12 hours fresher.
The forecaster wins on the number alone, against a fresher belief and against a caller who listed the scheduler first.
The cause is that the two mechanisms disagree about what a version means.
keep_latest_versioncompares versions only within a(name, type, model)family, which is the only place the number means anything. The selection that runs after it ranks every source in the frame together:Two teams' independent numbering treated as one series. A plugin scheduler that starts at v10 outranks a built-in one at v3 permanently, on any sensor they share.
So the recommendation in #2476 now reads in two steps rather than one chain:
sourcesorder if given, else freshest belief, else highest id.Version never crosses the family line. Your example then resolves the way you would expect — the scheduler, because it was listed first, or because it is fresher when nothing was listed.
Issue updated.