Imported from evcc-io/optimizer (
AGENTS.md). Install upstream withnpx skills add evcc-io/optimizer. Copyright stays with the author.
Agent Rules for the evcc optimizer
This file provides guidance to AI coding agents working in this repository.
Project Overview
- The optimizer computes cost optimal home energy schedules for evcc: battery charging and discharging, grid import and export, given price, solar, and demand forecasts.
- The core is a Mixed Integer Linear Program (MILP) built with PuLP and solved with CBC, exposed over an HTTP API (Flask plus flask-restx).
- A small Go CLI client (
cmd/, generatedclient/) calls the API and renders the result as tables and charts. - Python 3.13 managed with
uv. Go 1.24.
Essential Commands
All Python commands run through uv. Use uv run python, never bare python, it is not on PATH.
make testruns the Python test suite (uv run pytest)make lintformats and lints (autopep8thenruff check --fix)make runstarts the API locally on port 7050 (uv run python -m optimizer.app)make buildregenerates the Go client from the OpenAPI spec (go generate ./...)make installsyncs dependencies (uv sync)make loadtestruns Locust load tests against a local instance
Architecture
src/optimizer/optimizer.pybuilds and solves the MILP.Optimizerassembles the variables, objective, and constraints, thensolve()returns the result dict.src/optimizer/app.pyis the Flask API. It parses the request into the dataclasses (GridConfig,BatteryConfig,TimeSeriesData,OptimizationStrategy), runs the optimizer, and marshals the response. The optimization endpoint isPOST /optimize/charge-schedule.src/optimizer/settings.pyholds runtime settings (solver threads, time limit, MIP gap, preference budget) via pydantic-settings with theOPTIMIZER_env prefix.openapi.yamlis the source of truth for the API contract.cmd/client.gois the Go CLI client.client/client.gen.gois generated fromopenapi.yamland must not be edited by hand.tests/holds the test suite,test_cases/*.jsonhold data driven scenarios.
Domain Knowledge
The model is a maximization problem. Read these before changing it:
- The objective is maximized. Costs and penalties enter as negative terms, so every penalty coefficient must be strictly positive to actually penalize.
- Energy is in Wh, power limits in W, prices per Wh. Time steps carry individual durations
dtin seconds, so convert power to energy withdt / 3600. - Penalty coefficients scale from a positive floor,
np.max([max_import_price, 0.1e-3]). This keeps penalties positive even when market prices are zero or negative. - Charging and discharging strategies are cost-neutral tie-breakers. They add tiny soft terms (coefficient around
min_import_price * 1e-6) that only decide between economically equal solutions. They are intentionally excluded fromget_clean_objective_value(), which recomputes the real economic value without strategy incentives or penalties. - The objective is assembled in two parts,
cost_objective(real money: grid cost, export revenue, battery value, demand rate, penalties) andpreference_objective(the tie breakers, peak and ramp weights, priorities), andsolve()optimizes them in that order. A new term goes into the part it belongs to,tests/test_objective_split.pyasserts the split stays exhaustive. solve()runs two stages. The first maximizescost_objectiveand may stopOPTIMIZER_GAP_ABSshort of the optimum. The second maximizespreference_objectiveunder a constraint that keeps the money the first found, so the strategies decide the tie instead of being swallowed by a gap that is orders larger than they are. A second stage that times out or comes back infeasible falls back to the first stage schedule whole,preference_stagerecords how it ended. The tiny slack the bound needs,COST_BOUND_SLACK, is spent on every request: the second stage is indifferent to money and drops straight to the bound.get_clean_objective_value()measures battery value as(s[T-1] - s[0]) * p_a, buts[0]already includes the first time step's charging, so energy charged in the first step is not counted as a gain. The optimization objective itself uses the absolute final state of charge,s[-1] * p_a. Two solutions that are equal in the real objective can therefore report different clean values. Keep optional charging off the first time step when designing cost-neutrality scenarios.- Grid limits are soft: exceeding
p_max_imporp_max_expis penalized rather than forbidden, so an over constrained request reports the violation instead of returning infeasible. - Never read
problem.statusto mean "the solver finished". pulp setsLpStatusOptimalwhenever CBC returned any feasible solution, including one it stopped on at the time limit: on one captured request a 2 s and a 30 s run both reported Optimal, with objective values of -682466848 and 59714881.problem.sol_status == LpSolutionOptimalis the only thing that means proved.solve()folds the two into the reported status,OptimalagainstFeasible. - CBC's
-secis not a wall clock on the request. It is only tested between branch and bound nodes, so a model that finishes in presolve or the root relaxation ignores it entirely: every stored case still proves itself at a limit of 0.01 s, and020-weird-charging-at-nighttakes 0.98 s regardless. Do not write tests that expect a small time limit to truncate a solve. - The assembled objective is scaled before it goes to the solver. Prices per Wh put the raw coefficients close to CBC's absolute tolerances, where real improvements are discarded as numerical noise. The factor is derived per model by
objective_scale(), which puts the largest coefficient atOBJECTIVE_TARGET;OBJECTIVE_SCALEoverrides it with a fixed factor and exists for the tests. Anchor on the largest coefficient, never on the smallest: the smallest is a strategy tie breaker and is deliberately tiny, so aiming it at a floor drags the objective below the constraint matrix and costs solutions. Scaling does not change the argmax. Keep new objective terms in the same unit and let the scaling do its work, do not compensate for it in individual coefficients.
Python Coding Standards
- Line length is 160 (
ruffandautopep8are configured to match). Runmake lintbefore committing. - ruff rule sets in use:
F,I,E,W,PL. Respect import ordering (I). - Reuse the NumPy helpers already present in the model rather than hand rolling loops.
- Type hint new public functions and dataclass fields.
API and Code Generation
- Change the API by editing
openapi.yamland the matching flask-restx models inapp.pytogether, then runmake buildto regenerate the Go client. - Never edit
client/client.gen.goby hand. The generator config istools/cfg.yaml, invoked throughtools/generate.go.
Testing
- The suite runs with
uv run pytest. test_cases/*.jsonare loaded bytests/test_app.py. Each file holds arequestand an optionalexpected_response, and the test asserts the optimizer status and, withnumpy.isclose, the objective value. Add a scenario by dropping in a JSON file.- A test case with
"strict": trueadditionally compares grid import, grid export, and the battery schedules withnumpy.allclose. Use it for features that only pick between cost neutral alternatives, where the objective value barely moves. Do not set it on scenarios with several equally optimal schedules, the comparison would be arbitrary. - Prefer a data driven case over a dedicated test module, so a failure points at the scenario rather than at a feature specific script.
- Cover both the success and the limit violation paths.
Writing Style
- No em dashes in comments, commit messages, or docs. Use periods, commas, or colons.
- Project name is
evcc, always lowercase. - Uppercase acronyms in prose: MILP, API, HTTP, JWT, SoC.
- Commit subjects follow conventional commits:
feat:,fix:,docs:,chore:,infra:, then a short description with no trailing period. Reference the issue when relevant, for examplefix: use max() for penalty scaling floor (closes #75).
Comment Style
- Prefer self documenting code. Comment the why, not the what.
- Default to no comment. Add one only for a non obvious constraint, invariant, or surprising behavior, and keep it to one or two lines.
- Do not reference the current task, PR, or issue in code comments. Git history covers that.
Pull Request Descriptions
Structure PR descriptions in this order. No headlines. Be concise.
- References first line: link the related issue or PR (
closes #71,fixes #123,pairs with evcc-io/evcc#456). A PR should almost always reference an issue, only skip for trivial fixes. - Intro: one or a few sentences framing what the PR does and why. The full problem belongs in the linked issue.
- Bullet list: the most significant changes or user facing implications, most significant first.
- TODO section only if open points remain.
Avoid file paths, line numbers, or code listings copied from the diff. Include a code snippet only when it conveys a contract more clearly than prose. No testing checklists, no co-author footers, no generator footers.