Conversation
Establish the forecast validity contract agreed in davidusb-geek#1135. Numerical validity: every externally supplied forecast value (pv_power_forecast, pv_power_forecast_p10, load_power_forecast, load_cost_forecast, prod_price_forecast, outdoor_temperature_forecast) must be a finite real number. NaN, +/-Inf, booleans and non-numeric values are rejected with one diagnostic (key, reason, list position or source timestamp, value). Timestamp-mapping values are checked before pandas aggregation. A rejected key is passed as None with the list method selected, so the cycle fails instead of falling back to the configured method or the weather-forecast temperature. Signed prices and temperatures remain valid. The external PV P50/P10 pair now rejects booleans too; its other semantics are unchanged. Physical-domain validity: Forecast.get_load_forecast() enforces the optimizer-facing load contract once for every method, after the optional mix: a non-finite value fails the cycle; a finite negative value is clipped to 0 W with one summarized warning. A minimal pre-mix check prevents +/-Inf from crashing the blend. Raw MLForecaster predictions (forecast-model-predict) stay raw. Rejected inputs now stop cleanly before the solver: None weather frames and None price lists fail instead of raising, and /action answers 400 when an optimization action returns False. Docs: canonical Forecast input contract in docs/passing_data.md, with corrections and cross-links in forecasts.md, good_practices.md, mlforecaster.md, config.md, the cookbook template and Node-RED recipe, AGENTS.md and develop_ai_coders.md. Fixes davidusb-geek#1135 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideEnforces a single fail-closed finite-real forecast contract at runtime ingestion and optimizer boundaries, clips only finite negative household loads after mixing, preserves signed prices/temperatures and raw ML outputs, handles failures cleanly through CLI/web paths, and documents and tests the resulting behavior. Sequence diagram for fail-closed forecast handlingsequenceDiagram
participant Client
participant Runtime as treat_runtimeparams
participant Forecast
participant Action
participant Solver
Client->>Runtime: Submit forecast values
Runtime->>Runtime: describe_invalid_forecast_value
alt invalid value
Runtime-->>Action: passed_data=None, method=list
Action->>Forecast: Build forecast
Forecast-->>Action: False
Action-->>Client: HTTP 400
else valid values
Runtime->>Runtime: Align timestamp mapping
Runtime-->>Action: Valid forecast
Action->>Forecast: get_load_forecast
Forecast->>Forecast: Validate final P_Load after mixing
alt finite negative load
Forecast->>Forecast: clip(lower=0)
else non-finite load
Forecast-->>Action: False
Action-->>Client: HTTP 400
end
Forecast-->>Action: Valid optimizer-facing load
Action->>Solver: Perform optimization
Solver-->>Client: Optimization result
end
Flow diagram for forecast validation to optimizationflowchart LR
A[External forecast input] --> B[describe_invalid_forecast_value]
B -->|invalid finite-real value| C[Reject and set passed_data to None]
C --> D[Fail action before solver]
B -->|valid| E[Align or aggregate timestamp mapping]
E --> F[Forecast generation and optional mixing]
F --> G[get_load_forecast]
G -->|non-finite P_Load| D
G -->|finite negative P_Load| H[Clip load to 0 W]
G -->|finite non-negative P_Load| I[Optimizer]
H --> I
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've reviewed your changes and they look great!
Sourcery assessment
Needs a human reviewer. Incorrect validation, clipping, or timestamp alignment could reject an optimization cycle or produce a wrong schedule that is persisted or acted on before the issue is noticed. Reverting restores the prior behavior, but schedules already generated or applied would need to be rerun or corrected.
Sourcery withdrew this approval because the latest commits introduced blocking findings.
There was a problem hiding this comment.
Hey - I've found 2 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="src/emhass/utils.py" line_range="2141" />
<code_context>
+ passed_outdoor_temp is None
+ and input_data_dict["fcst"].optim_conf.get("outdoor_temperature_forecast_method") == "list"
+ ):
+ logger.error(
+ "outdoor_temperature_forecast was supplied but rejected; "
+ "not falling back to the weather forecast temperature."
</code_context>
<issue_to_address>
**issue (broader_impact):** A forecast supplied as a stringified list is rejected before the existing `ast.literal_eval` conversion runs: the new list/type branch logs an error and leaves `passed_data[forecast_key]` unset, so the later conversion cannot make the forecast usable.
**Triggers:** When callers use the pre-existing string representation of a list, such as `"[1, 2, 3]"`, for a forecast runtime parameter.
**Suggested fix:** Parse a stringified list before applying the list-length and value validation, then validate the parsed values while still rejecting strings as individual forecast entries.
</issue_to_address>
### Comment 2
<location path="src/emhass/command_line.py" line_range="2644-2647" />
<code_context>
+ # treat_runtimeparams selects the "list" method but passes no data when it
+ # rejects a supplied outdoor_temperature_forecast (#1135). Fail the cycle
+ # instead of silently substituting the weather-forecast temperature.
+ if (
+ passed_outdoor_temp is None
+ and input_data_dict["fcst"].optim_conf.get("outdoor_temperature_forecast_method") == "list"
+ ):
+ logger.error(
+ "outdoor_temperature_forecast was supplied but rejected; "
</code_context>
<issue_to_address>
**issue (bug_risk):** The new guard treats every missing `outdoor_temperature_forecast` as a rejected runtime forecast whenever the configured method is `list`, so a legitimate configuration that selects the list method without supplying runtime outdoor-temperature data fails instead of using the existing weather-temperature fallback.
**Triggers:** When `outdoor_temperature_forecast_method` is configured as `list` and the runtime payload omits `outdoor_temperature_forecast` rather than supplying an invalid value.
**Suggested fix:** Distinguish an absent runtime key from a rejected supplied key, and only fail for the latter.
</issue_to_address>Sourcery assessment
Needs a human reviewer. 2 findings to address first, and invalid or negatively clipped forecasts can change the optimizer's dispatch plan, while rejected inputs can stop an optimization cycle and leave automated controls without a new plan. Reverting restores the prior behavior for future cycles, but any control decision already made or missed during the bad cycle cannot be undone.
Blocking findings: src/emhass/utils.py:2141, src/emhass/command_line.py:2647
Fixes #1135
Problem
EMHASS had no single enforced validity contract for forecast values before they reach the optimizer:
isinstance(x, int | float), which acceptsNaN,±Infand booleans (boolsubclassesint), and only warned before continuing;mlforecaster, …) reached the optimizer unchanged;P_Loadafter the optional mix/feedback step;Maintainer-approved contract
As agreed on #1135:
NaN,±Inf, booleans and non-numeric values are rejected, and the optimization cycle fails before the solver. Signed prices and temperatures stay valid.load_power_forecastis the canonical non-negative household consumption (zero load is valid). The optimizer-facing household load must be finite and>= 0 W. A finalmax(P_Load, 0)correction for finite negative loads is applied once at theForecast.get_load_forecast()boundary, with one summarized warning. A non-finiteP_Loadfails the cycle.Current-master delta: external PV P10 (#1128)
pv_power_forecast_p10landed after the original #1135 audit. It is an external forecast input, so it is part of the same finite-real-number contract._validate_and_align_external_pv_pair()already rejected some non-numeric and non-finite values, butnp.asarray(..., dtype=float)ran first and silently coerced other invalid inputs:True/Falsebecame1.0/0.0, and numeric-looking strings such as"2.5"were accepted as floats.This PR closes that gap by reusing the canonical
describe_invalid_forecast_value()helper — the same one used for every other external forecast key — and calling it on the original P50 and P10 source values, for both lists and mappings, before any NumPy/pandas coercion runs. It rejectsbool,np.bool_, numeric-looking strings (e.g."2.5"), ordinary strings,None/null,NaN,+Infand-Inffor both P50 and P10. The diagnostic names the forecast key, the list position or source timestamp, and the offending value.A second, independent gap was found and fixed on this same head: individually finite source values can still produce a non-finite result after alignment/aggregation (e.g. an overflowed mean when averaging large finite floats into one time-step bucket). Timestamped P50/P10 pairs are therefore validated a second time, after
_align_runtime_forecast_mapping(), with the same helper, so a non-finite aggregate is rejected before it reaches the optimizer.Nothing else about P10 changes: the list/mapping representation contract, timeline matching, pair-length checks, existing error messages, the P10 bias formula, and the final PV-domain (non-negative) treatment all stay as they were.
Implementation
utils.describe_invalid_forecast_value()reprof the value), orNone. It does not check sign.utils.treat_runtimeparams()"[1,2,3]") is normalized withast.literal_evalbefore length and value validation, so existing callers keep working and the parsed members are still subject to the finite-real contract (a parsed[1, "2.5", 3]is rejected). Mappings: source values are validated before_align_runtime_forecast_mapping(), so pandas cannot coercebool → 1.0or crash on strings, and the error names the source timestamp. The aligned list is validated again. Lists: values are validated in the existinglen >= horizonbranch. Invalid: onelogger.error,passed_data[key] = None, and the forecast method is forced to"list". This is the same invalid-sentinel pattern #1128 uses for P10, so the cycle fails instead of falling back to the configured method. A rejectedoutdoor_temperature_forecastadditionally sets an internal_outdoor_temperature_forecast_rejectedprovenance marker (initializedFalseon every call), so rejection can be distinguished from omission. The old per-valuenon digitswarning loop is removed. The alignment helper itself is untouched.utils._validate_and_align_external_pv_pair()describe_invalid_forecast_value()finite-real validator is applied to the original P50 and P10 source values (list positions or source timestamps) beforenp.asarray(..., dtype=float)coercion, sobool/np.bool_, numeric-looking strings, ordinary strings,None,NaNand±Infare all rejected on the same terms as every other external forecast key, with source provenance preserved in the diagnostic. For timestamped mappings, the aligned P50/P10 output is validated a second time after_align_runtime_forecast_mapping(), to catch a non-finite value produced during bucket aggregation.Forecast.get_load_forecast()False. Finite negative → one warning (count, minimum, first timestamp, "clipped to 0 W before optimization") andSeries.clip(lower=0), which keeps index, name and shape. Valid → unchanged. A minimal pre-mix numeric check runs only when mixing is enabled, because±Infin the first step would otherwise crashint(round(...))insideget_mix_forecast()before the final check.get_mix_forecast()itself is unchanged.Forecast.get_load_cost_forecast()/get_prod_price_forecast()data_list is None(the rejected sentinel) as a cleanFalseinstead oflen(None)→TypeError.command_line._get_dayahead_pv_forecast()/_get_naive_mpc_pv_forecast()Noneweather frame as failure. Before, the P10/P50 invalid sentinel reachedget_power_from_weather(None)and raised.command_line.prepare_forecast_and_weather_data()outdoor_temperature_forecastkeeps the existing weathertemp_airfallback; a supplied valid one is used; a supplied but rejected one fails the cycle instead of silently using the weather-forecast temperature.web_server._handle_action_dispatch()Falsenow answers400with the action log, like a failedset_input_data_dict. Before, it reachedget_injection_dict(False)and crashed with a 500. The solver was never reached in either case.The raw ML model is not clamped:
MLForecaster.predict()andforecast-model-predictare unchanged.load_negativeandset_zero_minare not repurposed, andRetrieveHass.prepare_data()is untouched.get_power_from_weather()and itsp_pv_forecast[p_pv_forecast < 0] = 0are untouched.Backwards compatibility
_align_runtime_forecast_mapping) and valid P50/P10 pairs (identical bias result)."[1,2,3]") keep working: they are normalized to a list before validation. Strings inside the resulting list remain invalid.outdoor_temperature_forecastkeeps the existing weather-temperature fallback, including whenoutdoor_temperature_forecast_methodis configured aslist. Only a supplied-and-rejected value fails closed.NaN/±Inf/null/bool/strings now fails the action. Before, it was accepted, or it crashed later;naivehistory with negative samples whenset_zero_min=false) is now clipped to 0 W with one warning;NaNleft in history or CSV) now fails cleanly instead of reaching the solver;/action/*-optimanswers400instead of a 500 crash.Regression matrix
New:
tests/test_forecast_validity_contract.py(28 tests with subtests). Extended:tests/test_external_pv_p10.py(+2 tests),tests/test_command_line_utils.py(test_prepare_forecast_and_weather_data: omitted-vs-rejected outdoor temperature),tests/test_utils.py(test_treat_runtimeparams_failed: stringified lists now asserted as passed through).listtest_valid_plain_lists_are_passed_unchangedTrue,np.bool_, string,Nonefor all 5 keyspassed_data=None; method forcedlist; exactly one error with key,position N,repr(value)test_invalid_plain_list_values_fail_closedtest_long_list_is_accepted_and_short_list_keeps_existing_invalid_behaviorrepr([0.0, 1.0, …])listtest_legacy_stringified_list_is_normalized_before_validation"2.5"passed_data=None; one errornon-numeric value '2.5' at position 5test_stringified_list_with_string_member_is_rejectedpassed_datatest_treat_runtimeparams_failed(extended)list, runtime key omittedtest_prepare_forecast_and_weather_data(Test 3)False, "was supplied but rejected"); no weather fallbacktest_prepare_forecast_and_weather_data(Test 3b)test_invalid_mapping_source_values_fail_closed_with_timestamp_align_runtime_forecast_mappingtest_mapping_alignment_semanticstest_valid_non_negative_load_is_unchangedtest_finite_negative_load_is_clipped_with_one_summary_warningFalse, one error with timestamptest_non_finite_or_non_numeric_load_failstest_every_load_method_is_routed_through_the_boundarynan,inf,-inf,abc,TrueFalseTestCsvLoadMLForecaster.predict, stub estimator)FalseTestNativeMlLoadforecast-model-predictwith negative predictiontest_negative_ml_load_is_clipped_but_raw_prediction_stays_rawtest_negative_mix_result_is_clipped_after_mixingFalsetest_non_finite_mix_result_cannot_reach_optimizationFalse, noOverflowErrortest_non_finite_forecast_does_not_crash_the_mixload_negative=Truetest_external_positive_load_is_not_inverted_by_load_negativeload_negative=Truetest_load_negative_still_normalizes_retrieved_historytest_negative_pv_is_still_clipped_by_existing_pv_correctionperform_*/perform_optimizationnever calledtest_invalid_external_forecast_never_reaches_the_solvertest_invalid_external_pv_pair_never_reaches_the_solvertest_valid_signed_inputs_reach_the_solverP_Loadtest_negative_external_load_reaches_the_solver_clipped400, solver never calledtest_rejected_forecast_returns_400_without_solvingtest_boolean_values_are_rejected_in_p50_and_p10"2.5"), ordinary strings,None/null rejected in list P50, list P10, mapping P50, mapping P10test_strings_and_null_are_rejected_in_p50_and_p10_before_coercionnp.bool_; ordinary strings;None;NaN/±Infvia direct validator calltest_direct_pair_validation_rejects_numpy_bool_and_non_finitetest_timestamp_alignment_rejects_non_finite_aggregated_pairtest_external_pv_p10.pyFinal compatibility findings (Sourcery review of
7344170)A later Sourcery review found two compatibility issues. Both were fixed in
c12fbd5(code + tests) andc7b57fb(docs), and Sourcery subsequently marked both review threads resolved.1. Legacy stringified forecast-list compatibility
Existing EMHASS accepts a whole forecast list supplied as a string, such as
"[1,2,3]". The earlier head validated before that conversion ran, so such payloads were rejected. The final implementation normalizes a valid stringified list withast.literal_evalbefore forecast length/value validation. The parsed members are still subject to the #1135 finite-real contract:"[1,2,3]"is normalized to a numeric list;[1, "2.5", 3], remains invalid;null/None,NaNand±Infremain invalid forecast members;2. Outdoor-temperature omission versus rejection
The previous guard conflated
outdoor_temperature_forecastbeing omitted from runtime input with it being supplied but rejected: withoutdoor_temperature_forecast_method: listconfigured, omission also failed the cycle. The final implementation records explicit rejection provenance (_outdoor_temperature_forecast_rejected), giving this contract:outdoor_temperature_forecast→ existing weathertemp_airfallback remains available;outdoor_temperature_forecast→ runtime forecast is used;outdoor_temperature_forecast→ fail closed; no silent fallback to weather.Both cases are covered in the regression matrix above.
RED evidence (unmodified base
237d0fe)The RED and mutation evidence below was produced during the implementation pass, before the two compatibility fixes above; the compatibility regressions are covered by the final-head test runs under Validation.
The implementation-pass test files were copied into a temporary detached worktree at the exact base SHA, with no source changes, and run there. The worktree was then deleted.
The failures are behavioural, not missing names:
no logs of level ERROR(79)AssertionError: 1 != 0solver calls (45)np.float64(-10.0) != 0.0,no logs of level WARNINGTypeError: agg function failed [how->mean,dtype->object]OverflowError: cannot convert float infinity to integer[1.0, np.True_] is not NoneTests that pass on the base by design, because they guard unchanged behaviour: valid lists, long/short list, mapping alignment semantics, valid CSV, positive ML,
load_negative(both directions), negative PV correction, valid signed inputs reaching the solver, and all pre-existing P10 tests.Mutation / adversarial evidence
Each mutant was applied to the implementation and the focused suites were run. The exact original bytes were then restored, verified by SHA-256 after every mutant. No mutant was committed.
P_Loadclip removedtest_negative_mix_result_is_clipped_after_mixing,test_non_finite_mix_result_cannot_reach_optimization>= 0applied (signed prices/temps rejected)MLForecaster.predict()Falsehandling removedNoneweather frame not treated as failureDocumentation audit
One canonical contract lives in
docs/passing_data.md§ Forecast input contract. Other pages link to it and only correct the context-specific claims.docs/passing_data.mdload_negative/set_zero_min= history prep), the final load check, and a fallback philosophy with no blanket zero.outdoor_temperature_forecastadded to the key list. Final compatibility semantics: absence is different from rejection foroutdoor_temperature_forecast(omitted → weather fallback available; supplied-but-invalid → cycle fails); existing whole-list string compatibility is normalized before validation; strings inside the resulting forecast list remain invalid; the canonical finite-real forecast-value contract is otherwise unchanged. The P10 paragraph covers the complete non-numeric/boolean/non-finite rejection contract: bool,np.bool_, numeric-looking strings, ordinary strings,None/null,NaNand±Infare all rejected before coercion, for both P50 and P10, in lists and mappings, with a second post-alignment check for timestamped pairs. Corrected the claim thatruntime_params.jsonis "consumed by the generated OpenAPI spec":scripts/generate_openapi.pydoes not read it and/action/{action_name}has a generic-object request body.docs/forecasts.mdpd.to_datetime(..., utc=True)), which is a silent-shift risk. Cross-links the contract. Price-template| float(0)on current Nordpool/Amber prices changed to| float, so an unavailable sensor fails the call instead of sending price 0.docs/study_cases/good_practices.mddefault(0)troubleshooting advice with a domain-appropriate fallback or fail-closed approach (0 W PV at night given as a legitimate example).docs/mlforecaster.mdP_Load. Clipping does not improve raw accuracy.docs/config.mdload_negative/set_zero_minare retrieved-history preparation only, with a link to the contract.docs/cookbook/_template.mddocs/cookbook/transport_nodered_mpc_orchestration.md0.30/0.08arrays, and allows negative prices. The caveat is rewritten with a link.docs/cookbook/battery_aware_runtime_params.mdsoc_init(SOC), which is not a forecast input covered by #1135.docs/cookbook/ev_evcc_executor.md| float(0)usages feedsoc_init,def_total_hoursanddef_current_power, not forecast inputs, and the recipe documents that choice explicitly.docs/cookbook/forecast_victoriametrics_long_history.mddocs/cookbook/tariff_demand_charge.mddocs/cookbook/index.mddocs/study_cases/*(others:mpc,ev,basic_*,dhw,heat_pump_walkthrough,chance_constrained_mpc,reference_configs,legacy_cli)heat_pump_walkthroughpasses a numeric outdoor-temperature list, andreference_configsonly names*_method: list.docs/thermal_model.md0.0PV/load lists are a deliberate, known-zero example, which is valid under the contract.docs/thermal_battery.md,docs/heat_topology.mddocs/advanced_math_model.md,README.md,docs/usage_guide.md,docs/quick_start.md,docs/differences.md,docs/battery_identification.mdbattery_identificationmentionsset_zero_minfor unrelated battery sensors.scripts/load_negative/set_zero_minfrom config into history preparation (unchanged semantics) or build numeric literal lists.src/emhass/static/script.js, templates)pv_power_forecastplaceholder key name.param_definitions.jsondescriptions ofload_negative/set_zero_minopenapi.json, sodocs/config.mdcarries the clarification instead.CHANGELOG.mdHuman contributor guidance
CONTRIBUTING.mddevelop.md,AGENTS.mdanddevelop_ai_coders.md, and this PR adds no new contributor workflow step.docs/develop.mdAI guidance
AGENTS.mdutils.describe_invalid_forecast_value), legacy whole-list string normalization preserved but strings inside the parsed list invalid, an omittedoutdoor_temperature_forecastmay use the weather fallback while invalid supplied outdoor-temperature data fails closed, load/PV physical, prices signed, temperature signed,load_negative/set_zero_min= history-only preparation controls, external load is the canonical non-negative household consumption, raw ML stays raw, and timestamp shifts are never silent (naive timestamps are UTC), with a link to the canonical contract. Includespv_power_forecast_p10. TheLast verified against upstream/mastermarker is updated to237d0fe, 2026-09-30.docs/develop_ai_coders.mdtests/test_forecast_validity_contract.py(signed-domain and final-load tests) passing.Machine-readable / API scope (non-goals)
src/emhass/static/data/runtime_params.json: unchanged. No forecast payload keys were added.src/emhass/static/openapi.jsonandscripts/generate_openapi.py: unchanged. There is no/actionschema redesign or new schema composition.runtime_params.jsonfeeding the OpenAPI spec is corrected. A complete machine-readable/actionforecast payload schema remains a separate follow-up, as noted in Forecast validity contract: non-finite forecast values and negative P_Load can reach optimisation #1135.Validation
All fork-owned workflows below ran on
MMicieli/emhassActions against the exact final head079f6a9cdd708581a0ff7b94492c46c5519ff702. Upstreamdavidusb-geek/emhassActions are not used as the validation authority for this table.Validation topology:
MMicieli/emhassforkmasterwas safely fast-forwarded to the exact upstream base237d0fe4089f76ff5f8bbcae24876e64008e60ad(no force push);masterhad no unique fork commits and was simply 72 commits behind;ruff checkruff format --check --diffDocumentation validation (from the implementation pass; not separately rebuilt by the fork workflow above, since the later remediation commits changed validator code, tests, one
AGENTS.mdbullet, and added two plain prose paragraphs to an existingdocs/passing_data.mdsection — no new Sphinx headings, directives or links):git diff --checksphinx-build -b html docs docs/_buildSphinx baseline: the unmodified base build reports 19 warnings, so upstream is not
-Wclean and no warnings-as-errors success is claimed. With a clean feature build, the normalized warning set (paths and.mdline numbers stripped) is identical to the base set: 19 warnings on both, with none added and none removed. This PR introduces no new Sphinx warning or error, including in the autodoc'dutils.describe_invalid_forecast_valuedocstring.docs/_buildand test-createddata/last_run.json/data/plan_latest.jsonwere removed and are not committed.Base / head
davidusb-geek/emhass@237d0fe4089f76ff5f8bbcae24876e64008e60ad(currentmasterat the time of writing)079f6a9cdd708581a0ff7b94492c46c5519ff702ee0bb7b— initial Forecast validity contract: non-finite forecast values and negative P_Load can reach optimisation #1135 implementation (numerical validity contract, optimizer-facing load contract, docs);ab688c5— paired-PV contract completion (pre-coercion validation reusing the canonical helper, post-alignment validation for timestamped pairs);7344170— test-only null-diagnostic expectation alignment;c12fbd5— forecast compatibility semantics (legacy stringified-list normalization before validation; outdoor-temperature omission vs rejection provenance) with regressions;c7b57fb— docs:docs/passing_data.mdandAGENTS.mdclarify absence vs rejection and whole-list string compatibility;079f6a9— style: ruff formatting only.Out of scope
/actionOpenAPI request-body schema or forecast keys inruntime_params.jsonsoc_init/ deferrable-load template fallbacks in cookbook recipes (not forecast inputs)MLForecaster,get_mix_forecast(),get_power_from_weather(),RetrieveHass.prepare_data(), P10 bias/alignment semantics, the optimizer formulation, tariffs, batteries or SOCmasteronly)Summary by Sourcery
Enforce a consistent fail-closed validity contract for external forecasts and optimizer-facing household load values.
Bug Fixes:
Enhancements:
Documentation:
Tests: