Skip to content

Reject an unknown strategy instead of silently dropping it - #134

Open
andig wants to merge 1 commit into
mainfrom
fix/reject-unknown-strategies
Open

Reject an unknown strategy instead of silently dropping it#134
andig wants to merge 1 commit into
mainfrom
fix/reject-unknown-strategies

Conversation

@andig

@andig andig commented Aug 5, 2026

Copy link
Copy Markdown
Member

Found while analysing #132.

What happens today

Optimizer matches the strategy names off the request against the branches that build the objective:

self.peak_sides = PEAK_STRATEGY_SIDES.get(strategy.charging_strategy, ())
...
if self.strategy.charging_strategy == 'charge_before_export':
if self.strategy.discharging_strategy == 'discharge_before_import':

A name that matches none of them reaches no branch, so the request is solved without any strategy. Nothing in the response says so — the schedule is cost optimal for the request, status is Optimal, objective_value is right, and only the shape of the grid profile gives it away.

That is what a request answered like #132 looks like. Replaying that request on main with the strategy string intact levels grid export to a flat 658 W plateau; replaying it with the strategy misspelled reproduces the reported export peak of 1179.2 W exactly — the raw max(ft - gt) of the request, i.e. no levelling at all, at an identical bill.

openapi.yaml has carried the enum since the strategies were added:

charging_strategy:
  enum: [none, charge_before_export, attenuate_demand_peaks, attenuate_feedin_peaks, attenuate_grid_peaks]

but the flask-restx input model declared a plain fields.String, so nothing validated it. The documented contract and the enforced one had drifted apart.

The change

Declare the accepted names on the input model, listed in optimizer.py next to the code that reads them so the list and the branches cannot drift:

CHARGING_STRATEGIES = ('none', 'charge_before_export', *PEAK_STRATEGY_SIDES)
DISCHARGING_STRATEGIES = ('none', 'discharge_before_import')

The existing @api.expect(..., validate=True) then does the rest, and names the offending value and the accepted set:

{
  "message": "Input payload validation failed",
  "details": {
    "strategy.charging_strategy": "'attenuate_fedin_peaks' is not one of ['none', 'charge_before_export', 'attenuate_demand_peaks', 'attenuate_feedin_peaks', 'attenuate_grid_peaks']"
  }
}

An absent strategy object, an absent field, and none are all unchanged. A charging strategy passed in the discharging slot is now rejected too — the two sets are disjoint and were never interchangeable.

Breaking change

A caller sending a name outside the documented enum gets a 400 where it used to get a schedule. That is the point: today it gets a schedule that quietly ignores what it asked for. Worth calling out for the release notes, since evcc is the caller.

Verification

uv run pytest green, 98 tests. uv run ruff check clean. tests/test_app.py::test_unknown_strategy_is_rejected pins both halves: three rejected names, and every documented charging × discharging combination plus an absent strategy still solving.

Not in scope

This does not change any schedule, and it is not a fix for the optimisation reported in #132 — that request levels correctly on current main. It removes the failure mode that makes a dropped strategy indistinguishable from a working one.

🤖 Generated with Claude Code

Strategy names are read straight off the request and matched against the branches in the
objective, so a name that matches none of them reaches no branch at all and the request is solved
without any strategy. Nothing in the response says so: the schedule is cost optimal, the status
says Optimal, and only the shape of the grid profile gives it away, which is exactly how #132
presents - an attenuate_feedin_peaks request answered with the raw PV surplus as its export peak.

openapi.yaml has carried the enum since the strategies were added, but the flask-restx input model
declared a plain string, so nothing validated it. Declare the accepted names on the model instead,
listed next to the code that reads them so the two cannot drift, and let the existing payload
validation return the 400 with the offending value and the accepted set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andig

andig commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@ekkea is this the correct approach for API validation?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant