feat(pvforecast): keep outages out of the local provider's calibration

A battery or inverter failure limits PV to local demand for days. The
calibration read that as the plant's true output and learned it as a permanent
model loss, so one outage degraded the forecast long after the hardware was
fixed.

Calibration now estimates the healthy plant ratio over
`calibration_reference_days`, excludes days below `calibration_outage_threshold`
of it, and falls back to the most recent `calibration_min_healthy_days` when the
normal window is contaminated. `calibration_outage_filter_enabled` turns this
off for plants where measured curtailment, not available potential, is the
prediction target.

The fit also uses native 15-minute meter readings when every configured PV meter
supplies them - never interpolating hourly counters into an invented
quarter-hour profile - interpolates azimuth factors smoothly between bin centres
instead of stepping the EMS input curve, and normalizes the shape per forecast
day so it redistributes energy without changing that day's kWh correction. The
default azimuth bin widens from 15 to 45 degrees, which is what a typical
window actually supports.

Fixes the calibration window itself: it was derived from the measurement store
as a whole rather than from the configured PV production meters. A load meter
reaching further than the PV meter placed the window where no PV reading exists,
so calibration silently fell back to hourly fitting or skipped itself.

Also fixes `Measurement.load()`, which discarded every stored record. It
validated the file into a "temporary" Measurement, but Measurement is a
singleton, so that instance was the live one and the parsed records were
dropped.
This commit is contained in:
Andreas
2026-09-09 07:56:55 +02:00
parent a2f4ef6f54
commit 6dc58c33e2
8 changed files with 700 additions and 77 deletions
+29
View File
@@ -1,3 +1,5 @@
import json
import numpy as np
import pytest
from pendulum import datetime, duration
@@ -430,3 +432,30 @@ class TestMeasurement:
result = measurement_eos.load_total_kwh(start_datetime=start_datetime, end_datetime=end_datetime, interval=interval)
expected = np.array([100]) # Only one complete interval covered
np.testing.assert_array_equal(result, expected)
def test_load_restores_file_records_into_singleton(self, measurement_eos, config_eos, tmp_path):
"""File loading must not lose records when validating the Measurement singleton."""
# ConfigEOS and Measurement are singletons that outlive this module, so
# every global this test touches has to be put back; otherwise later
# modules read their measurements from a deleted tmp_path.
previous_folder = config_eos.general.data_folder_path
previous_keys = config_eos.measurement.load_emr_keys
previous_records = measurement_eos.records
config_eos.general.data_folder_path = tmp_path
config_eos.measurement.load_emr_keys = ["load0_mr"]
record = MeasurementDataRecord(date_time=to_datetime("2026-08-01T12:00:00Z"))
record["load0_mr"] = 123.5
payload = {"records": [record.model_dump(mode="json")]}
(tmp_path / "measurement.json").write_text(
json.dumps(payload), encoding="utf-8", newline="\n"
)
try:
measurement_eos.records = []
assert measurement_eos.load() is True
assert len(measurement_eos.records) == 1
assert measurement_eos.records[0]["load0_mr"] == pytest.approx(123.5)
finally:
measurement_eos.records = previous_records
config_eos.measurement.load_emr_keys = previous_keys
config_eos.general.data_folder_path = previous_folder