Skip to content

cost function math

github-actions edited this page Oct 1, 2026 · 6 revisions

HSEM Cost Function — Mathematical Reference

This document provides the complete mathematical formulation of the HSEM cost function (planner/cost_function.py). It is the source of truth for all cost calculations.


Two-aggregate architecture

The cost function returns two distinct aggregates for every plan:

Aggregate Symbol Contents Used for
total_cost $C_{total}$ Real money terms only Auditing, bill comparison
score $S$ $C_{total}$ + synthetic penalties + terminal SoC value Candidate selection

The selector picks the plan with the lowest score, not the lowest money cost.


Total cost (money terms)

$$ C_{total} = C_{import} - R_{export} + C_{cycle} + C_{loss} + C_{tariff} $$

Grid import cost

$$ C_{import} = \sum_{t \in slots} gi[t] \cdot p_{imp}[t] $$

Where $gi[t]$ is the actual grid import in kWh and $p_{imp}[t]$ is the import price in currency/kWh.

The cost function prices actual grid energy drawn, not stored energy. If the battery stores $x$ kWh and charge efficiency is $e$, the grid import is $x/e$, which includes conversion losses implicitly.

Export revenue

$$ R_{export} = \sum_{t \in slots} ge[t] \cdot p_{exp}[t] $$

Where $ge[t]$ is the grid export in kWh and $p_{exp}[t]$ is the export price.

Revenue is subtracted from total cost. Negative export prices (curtailment penalties) increase total cost.

Battery cycle cost (depreciation)

$$ C_{cycle} = \sum_{t \in slots} \max(charge[t], discharge[t]) \cdot c_{cycle} $$

Where $c_{cycle}$ is the cycle cost per kWh throughput.

The cycle cost counts the maximum of charge and discharge per slot, not their sum. This matches the MILP formulation where $m[t] = \max(ec[t], ed[t])$ and the 2× denominator in the cycle cost formula:

$$ c_{cycle} = \frac{purchase\_price}{2 \cdot usable\_kwh \cdot expected\_cycles} $$

The 2× denominator accounts for one full round-trip (charge + discharge = 2 × usable_kwh throughput per cycle). With this factor, charging $x$ kWh and discharging $x$ kWh costs $c_{cycle} \cdot \max(x, x) = c_{cycle} \cdot x$, which equals $\frac{purchase\_price \cdot x}{2 \cdot usable \cdot cycles}$ — matching the expected wear for moving $x$ kWh through the battery in one direction.

Conversion-loss compatibility field

$$ C_{loss} = 0 $$

For battery-side charge and discharge energy, the physical AC flows are:

$$ charge_{AC}[t] = \frac{charge[t]}{\eta_{chg}} $$

$$ discharge_{AC}[t] = discharge[t] \cdot \eta_{dis} $$

The first quantity increases import or consumes otherwise-exportable PV. The second reduces import or creates AC export. Import cost and export revenue thus price efficiency exactly once; another loss-price term would double-count it.

Tariff cost

$$ C_{tariff} = \sum_{t \in slots} tariff[t] $$

An optional per-slot fixed tariff cost, typically zero unless the user configures grid tariff fees.


Score (selector objective)

$$ S = C_{total} + P_{soc} + P_{grid} + V_{terminal} + B $$

$B$ is the battery target penalty, zero unless that opt-in feature is active.

SoC penalties (quadratic guard)

$$ P_{soc} = \sum_{t \in slots} \begin{cases} w_{low} \cdot (soc_{min} - soc[t])^2 & \mathrm{if } soc[t] < soc_{min} \\ w_{high} \cdot (soc[t] - soc_{max})^2 & \mathrm{if } soc[t] > soc_{max} \\ 0 & \mathrm{otherwise} \end{cases} $$

These are soft guards — the SoC simulation already hard-clamps at hardware limits, so violations are rare. The quadratic form heavily penalises large deviations while tolerating tiny numerical rounding errors.

Past-slot exclusion: Slots with time_passed recommendation are excluded from SoC penalty calculation. The SoC simulator writes estimated_battery_soc = 0.0 as a sentinel on past slots, which would otherwise generate a false penalty of $w_{low} \cdot soc_{min}^2$ per past slot — identical across all candidates but log-misleading.

Grid limit penalty

$$ P_{grid} = \sum_{t \in slots} \max(0, \frac{|gi[t] - ge[t]|}{\Delta t} - L_{grid}) \cdot \Delta t \cdot w_{grid} $$

Where $\Delta t$ is slot duration in hours, $L_{grid}$ is the configured grid power limit in kW, and $w_{grid}$ is the penalty weight per excess kWh.

Terminal SoC value (opportunity cost)

$$ V_{terminal} = (E_{initial} - E_{final}) \cdot p_{replacement} $$

Where:

  • $E_{initial}$ = stored battery energy above the discharge floor at the start of the horizon (kWh)
  • $E_{final}$ = stored battery energy above the discharge floor at the end of the horizon (kWh)
  • $p_{replacement}$ = the end value $V$ of one stored kWh after the horizon (issue #1138)

Sign convention:

$$ \begin{aligned} \Delta E &< 0 \mathrm{ (more energy at end)} \rightarrow V_{terminal} < 0 \mathrm{ (credit)} \\ \Delta E &> 0 \mathrm{ (less energy at end)} \rightarrow V_{terminal} > 0 \mathrm{ (penalty)} \end{aligned} $$

The term depends only on $E_{final} - E_{initial}$, so a charge/discharge cycle inside the horizon that leaves the end energy unchanged adds zero. The MILP objective applies the same value as $-V$ on every charge variable and $+V$ on every discharge variable, through the shared helper cost_helpers.terminal_soc_value.

$V$ is estimated from the last known day of prices, standing in for the unknown day after the horizon:

$$ V = \max\left(0, \min\left(0.9 \cdot (\eta_{dis} \cdot p_{peak} - c), \frac{p_{night}}{\eta_{chg}} + c\right)\right) $$

  • $p_{peak}$ = mean of that day's top-N import prices, N = ceil(usable / max_discharge_per_slot)
  • $p_{night}$ = mean import price of that day from 00:00 to 06:00
  • $c$ = cycle cost per kWh

A leftover kWh is worth the lower of its discounted use at the next peak and the cost of storing it again overnight. See docs/planner-spec.md § Terminal SoC for why it is not derived from any price inside the horizon.

Battery target penalty (issue #1109)

Zero unless the opt-in house-battery target SoC is active. It prices the shortfall against the target at the next target occurrence, for every candidate, undiscounted:

$$ B = P \cdot \max(E_{target} - E[T],\ 0) $$

  • $E[T]$ = estimated_battery_capacity_kwh at the target slot $T$
  • $E_{target}$ = the configured target in model kWh (above the discharge floor)
  • $P$ = the shortfall price the MILP stage-2 slack uses: $P = \min\left(\max_{t \le T} \frac{p_{exp}[t]}{\eta_{chg}} + c + \varepsilon,\ P_{ev} - \varepsilon\right)$

$B$ enters the score only, never the total cost. See docs/planner-spec.md § House-battery target SoC by deadline.


Past-slot exclusion rules

The cost function skips any slot whose recommendation is time_passed:

  • All energy-flow fields (grid_import_kwh, batteries_charged, etc.) are zero on past slots
  • Including them would only affect the SoC penalty (bogus $w_{low} \cdot soc_{min}^2$)
  • Skipping past slots does not change the winner (the bogus penalty is identical across candidates) but keeps the reported cost clean

Cost invariants (test assertions)

For every planner run:

  1. $C_{total} = C_{import} - R_{export} + C_{cycle} + C_{loss}$ (exact)
  2. No synthetic penalty enters $C_{total}$
  3. $S = C_{total} + P_{soc} + P_{grid} + P_{override} + V_{terminal}$ (exact)
  4. When all penalties = 0 and terminal-SoC is disabled: $S = C_{total}$
  5. Selector picks minimum $S$, not minimum $C_{total}$
  6. $score_{winner} = score_{final\_output}$ (no post-selection mutation)
  7. Two identical plans, one ending with more stored energy → lower $V_{terminal}$ → lower $S$

HSEM Documentation

Quick Reference

Architecture Decision Records

Clone this wiki locally