Skip to content

fix: value stored energy against s_initial, not the first step's SOC - #129

Draft
andig wants to merge 1 commit into
mainfrom
worktree-fix-clean-objective-initial-soc
Draft

fix: value stored energy against s_initial, not the first step's SOC#129
andig wants to merge 1 commit into
mainfrom
worktree-fix-clean-objective-initial-soc

Conversation

@andig

@andig andig commented Aug 1, 2026

Copy link
Copy Markdown
Member

Problem

get_clean_objective_value subtracts s[i][0] from the final state of charge:

clean_objective += (pulp.value(self.variables['s'][i][self.T-1])
                    - pulp.value(self.variables['s'][i][0])) * bat.p_a

But s[0] is the state after the first time step — it already carries that step's charging:

self.problem += (self.variables['s'][i][0]
                 == bat.s_initial
                 + self.eta_c * self.variables['c'][i][0]
                 - (1 / self.eta_d) * self.variables['d'][i][0])

So the reported objective_value is short by the first step's net charge × p_a. The objective the solver maximizes uses s[i][-1] outright and is unaffected, so no schedule changes — this is purely the reported number.

Why it matters

15 of the 19 stored test cases move, from 0.01 % (013) to 154 % (010). Any comparison of two runs by reported objective value can come out backwards when they differ in what the battery does in the first step.

Concretely: constant 4 kW PV, no load, a battery whose stored energy is worth exactly what exporting pays (p_a = p_E/η), so storing and exporting are an economic tie. Without a strategy the run reports 3.20; with attenuate_feedin_peaks, which stores everything, it reports 2.80 — a 12.5 % loss that does not exist.

Change

  • Reference bat.s_initial instead of s[0].
  • Update the expectations of the 15 affected test cases by the exact amount the formula was off for their recorded schedule, Σ (s[0] − s_initial)·p_a. Nothing else in those files changes.
  • Add tests/test_clean_objective.py, covering a charging and a discharging first step. Both fail against the old formula (-0.02 vs 0.36, -0.19 vs -0.61) and pass with the fix.

Verification

uv is not available on the machine this was written on and the CBC binary shipped with pulp is x86-64 with no Rosetta here, so the suite was run against HiGHS through a thin shim instead of make test. 18 of the 19 stored cases reproduce their updated objective value exactly. 013 differs by 0.06 % because HiGHS picks a different optimum among the ties in that case, which is unrelated to this change — with HiGHS's own schedule both the old and the new formula give the same value, since no battery charges in its first step there. Please confirm with make test on CBC before merging.

🤖 Generated with Claude Code

get_clean_objective_value subtracted s[i][0] from the final state of charge, but
s[0] is the state after the first time step and already carries that step's
charging (constraint at optimizer.py:555). The reported objective value was
therefore short by the first step's net charge times p_a, while the objective
the solver actually maximizes was unaffected.

The distortion is not marginal: 15 of the 19 stored test cases move, by 0.01 %
on 013 up to 154 % on 010, so any comparison of two strategies by their reported
objective value could come out backwards. Case in point, a request where storing
PV and exporting it are worth exactly the same reported 3.20 without a strategy
and 2.80 with attenuate_feedin_peaks, suggesting the strategy costs 12.5 % where
it is in fact economically neutral.

The expectations of the affected test cases are updated by the exact amount the
formula was off for the recorded schedule; no schedule changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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