Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Bugfixes
* The "module not installed" error for an unresolved ``FLEXMEASURES_PLUGINS`` entry now hints at the expected comma-separated format, which helps people who accidentally use an incorrect format like a JSON-array [see `PR #2473 <https://www.github.com/FlexMeasures/flexmeasures/pull/2473>`_]
* KPIs on the asset page counted one day more than the selected time range [see `PR #2434 <https://www.github.com/FlexMeasures/flexmeasures/pull/2434>`_]
* KPIs on the asset page now total the values the chart beside them draws, counting each event under the day it starts in: a sensor reported by several sources counted only one of them, and a revised value was counted on top of the value it revised [see `PR #2434 <https://www.github.com/FlexMeasures/flexmeasures/pull/2434>`_]
* Fix chart interpolation flipping to linear for a row that mixes a real sensor with fixed-value flex-model/flex-context sensors, even when the real sensor itself is not instantaneous [see `PR #2356 <https://www.github.com/FlexMeasures/flexmeasures/pull/2356>`_ and `issue #2253 <https://github.com/FlexMeasures/flexmeasures/issues/2253>`_]
* 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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2482>`_]
* ``max-training-period`` said the same thing as ``train-period``, so the two are now one setting, and the former is a deprecated alias that hosts should stop using [see `PR #2482 <https://www.github.com/FlexMeasures/flexmeasures/pull/2482>`_]

Expand Down
7 changes: 5 additions & 2 deletions flexmeasures/data/models/charts/belief_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -951,8 +951,11 @@ def create_line_layer(
if shared_unit != "a.u.":
scale_values = True

# Use linear interpolation if any of the sensors shown within one row is instantaneous; otherwise, use step-after
if any(sensor.event_resolution == timedelta(0) for sensor in sensors):
# Use linear interpolation if any of the sensors shown within one row is instantaneous; otherwise, use step-after.
# Only real sensors determine interpolation; fixed-value/constant sensors (negative id)
# are always event_resolution=0 by construction and must not force a mixed row to linear (#2253).
real_sensors = [s for s in sensors if getattr(s, "id", None) is None or s.id >= 0]
if any(sensor.event_resolution == timedelta(0) for sensor in real_sensors):
interpolate = "linear"
else:
interpolate = "step-after"
Expand Down
112 changes: 112 additions & 0 deletions flexmeasures/data/tests/test_belief_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import pytz

from flexmeasures import Sensor
from flexmeasures.data.models.charts.belief_charts import create_line_layer
from flexmeasures.data.models.charts.utils import source_legend_label_transformation
from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
from flexmeasures.data.models.data_sources import DataSource
Expand Down Expand Up @@ -942,3 +943,114 @@ def test_validate_sensors_to_show_omits_y_axis_by_default(
rows = battery.validate_sensors_to_show()
assert len(rows) == 1
assert "y-axis" not in rows[0]


# ---------------------------------------------------------------------------
# Tests for create_line_layer interpolation (#2253)
# ---------------------------------------------------------------------------


@pytest.fixture(scope="function")
def battery_with_interval_sensor_and_soc_min(app, fresh_db):
"""Battery asset (public, no owner) with:

* A real, non-instantaneous sensor (unit ``kWh``, 15-min resolution).
* ``soc-min: "20 kWh"`` in the flex_model, i.e. a fixed-value/constant
sensor, which always has ``event_resolution == timedelta(0)`` by
construction (see ``GenericAsset._create_fixed_value_sensors``).
* ``sensors_to_show`` configured so both appear together in **one row**
(same Vega-Lite layer), reproducing the row shape that triggers #2253.
"""
battery_type = GenericAssetType(name="test_battery_type_for_charts_interval")
fresh_db.session.add(battery_type)
fresh_db.session.flush()

battery = GenericAsset(
name="Test Battery (interval sensor chart tests)",
generic_asset_type=battery_type,
flex_model={
"soc-min": "20 kWh",
},
)
fresh_db.session.add(battery)
fresh_db.session.flush()

interval_sensor = Sensor(
name="energy per interval",
unit="kWh",
event_resolution=timedelta(minutes=15), # real, non-instantaneous sensor
generic_asset=battery,
)
fresh_db.session.add(interval_sensor)
fresh_db.session.flush()

battery.sensors_to_show = [
{
"title": None,
"plots": [
{"sensor": interval_sensor.id},
{"asset": battery.id, "flex-model": "soc-min"},
],
}
]
fresh_db.session.flush()

return battery, interval_sensor


def test_mixed_row_with_fixed_value_sensor_keeps_step_after(
battery_with_interval_sensor_and_soc_min,
):
"""A row mixing a real, non-instantaneous sensor with a fixed-value/constant
sensor must keep step-after interpolation.

Regression test for #2253: fixed-value sensors (always
``event_resolution == timedelta(0)``) used to force the whole row to
linear interpolation, even when the real sensor in that row was not
instantaneous.
"""
battery, interval_sensor = battery_with_interval_sensor_and_soc_min

rows = battery.validate_sensors_to_show()
all_sensors = rows[0]["plots"][0]["sensors"]

# Sanity-check the fixture: one real (non-instantaneous) sensor plus one
# fixed-value (instantaneous-by-construction) sensor, in the same row.
assert interval_sensor.event_resolution == timedelta(minutes=15)
fixed_value_sensors = [s for s in all_sensors if s.id < 0]
assert len(fixed_value_sensors) == 1
assert fixed_value_sensors[0].event_resolution == timedelta(0)

layer = create_line_layer(
all_sensors,
event_start_field_definition={},
event_value_field_definition={},
sensor_field_definition={},
combine_legend=True,
)

assert layer["mark"]["interpolate"] == "step-after"


def test_real_instantaneous_sensor_still_uses_linear(battery_with_soc_flex_model):
"""Guard case: a row containing only a real, instantaneous sensor must
still use linear interpolation (proves the #2253 fix didn't change
behavior for the original, non-mixed case)."""
battery, soc_sensor = battery_with_soc_flex_model

rows = battery.validate_sensors_to_show()
all_sensors = rows[0]["plots"][0]["sensors"]
real_sensors = [s for s in all_sensors if s.id >= 0]

assert real_sensors == [soc_sensor]
assert soc_sensor.event_resolution == timedelta(0)

layer = create_line_layer(
real_sensors,
event_start_field_definition={},
event_value_field_definition={},
sensor_field_definition={},
combine_legend=True,
)

assert layer["mark"]["interpolate"] == "linear"
Loading