Skip to content

Commit a50b33d

Browse files
authored
perf: decide strategy ties in a second solve, not with a weight (#115)
1 parent e793d25 commit a50b33d

11 files changed

Lines changed: 509 additions & 20 deletions

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ All Python commands run through `uv`. Use `uv run python`, never bare `python`,
2424

2525
- `src/optimizer/optimizer.py` builds and solves the MILP. `Optimizer` assembles the variables, objective, and constraints, then `solve()` returns the result dict.
2626
- `src/optimizer/app.py` is the Flask API. It parses the request into the dataclasses (`GridConfig`, `BatteryConfig`, `TimeSeriesData`, `OptimizationStrategy`), runs the optimizer, and marshals the response. The optimization endpoint is `POST /optimize/charge-schedule`.
27-
- `src/optimizer/settings.py` holds runtime settings (solver threads, time limit) via pydantic-settings with the `OPTIMIZER_` env prefix.
27+
- `src/optimizer/settings.py` holds runtime settings (solver threads, time limit, MIP gap, preference budget) via pydantic-settings with the `OPTIMIZER_` env prefix.
2828
- `openapi.yaml` is the source of truth for the API contract.
2929
- `cmd/client.go` is the Go CLI client. `client/client.gen.go` is generated from `openapi.yaml` and must not be edited by hand.
3030
- `tests/` holds the test suite, `test_cases/*.json` hold data driven scenarios.
@@ -37,6 +37,8 @@ The model is a maximization problem. Read these before changing it:
3737
- Energy is in Wh, power limits in W, prices per Wh. Time steps carry individual durations `dt` in seconds, so convert power to energy with `dt / 3600`.
3838
- 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.
3939
- 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 from `get_clean_objective_value()`, which recomputes the real economic value without strategy incentives or penalties.
40+
- The objective is assembled in two parts, `cost_objective` (real money: grid cost, export revenue, battery value, demand rate, penalties) and `preference_objective` (the tie breakers, peak and ramp weights, priorities), and `solve()` optimizes them in that order. A new term goes into the part it belongs to, `tests/test_objective_split.py` asserts the split stays exhaustive.
41+
- `solve()` runs two stages. The first maximizes `cost_objective` and may stop `OPTIMIZER_GAP_ABS` short of the optimum. The second maximizes `preference_objective` under 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_stage` records 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.
4042
- `get_clean_objective_value()` measures battery value as `(s[T-1] - s[0]) * p_a`, but `s[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.
4143
- Grid limits are soft: exceeding `p_max_imp` or `p_max_exp` is penalized rather than forbidden, so an over constrained request reports the violation instead of returning infeasible.
4244
- Never read `problem.status` to mean "the solver finished". pulp sets `LpStatusOptimal` whenever 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 == LpSolutionOptimal` is the only thing that means proved. `solve()` folds the two into the reported status, `Optimal` against `Feasible`.

docs/comparison_objective_terms.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,8 @@
3434
halves.
3535
- The tier 3 terms keyed off `min_import_price` invert sign when market prices go negative, the
3636
reason peak leveling uses `penalty_base` instead.
37+
- Tier 3 is not solved together with the tiers above it. `solve()` maximizes tiers 1 and 2 first,
38+
then maximizes tier 3 over the schedules that keep that value, so the distance between the tiers
39+
no longer decides whether a preference is respected. The ranges listed above are what the second
40+
stage works on, and it normalizes them off its own largest coefficient before solving.
3741

probe-pulp.mps

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
*SENSE:Maximize
2+
NAME MODEL
3+
ROWS
4+
N OBJ
5+
L C0000000
6+
COLUMNS
7+
MARK 'MARKER' 'INTORG'
8+
X0000000 C0000000 1.000000000000e+00
9+
X0000000 OBJ 1.000000000000e+00
10+
MARK 'MARKER' 'INTEND'
11+
MARK 'MARKER' 'INTORG'
12+
X0000001 C0000000 1.000000000000e+00
13+
X0000001 OBJ 1.000000000000e+00
14+
MARK 'MARKER' 'INTEND'
15+
MARK 'MARKER' 'INTORG'
16+
X0000002 C0000000 1.000000000000e+00
17+
X0000002 OBJ 1.000000000000e+00
18+
MARK 'MARKER' 'INTEND'
19+
MARK 'MARKER' 'INTORG'
20+
X0000003 C0000000 1.000000000000e+00
21+
X0000003 OBJ 1.000000000000e+00
22+
MARK 'MARKER' 'INTEND'
23+
RHS
24+
RHS C0000000 2.000000000000e+00
25+
BOUNDS
26+
BV BND X0000000
27+
BV BND X0000001
28+
BV BND X0000002
29+
BV BND X0000003
30+
ENDATA

probe-pulp.mst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Stopped on time - objective value 0
2+
0 X0000000 1.0 0
3+
1 X0000001 1.0 0
4+
2 X0000002 0.0 0
5+
3 X0000003 0.0 0

probe-pulp.sol

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Optimal - objective value 2.00000000
2+
0 C0000000 2 -0
3+
0 X0000000 1 1
4+
1 X0000001 1 1
5+
2 X0000002 0 1
6+
3 X0000003 0 1

0 commit comments

Comments
 (0)