Ticket 110: usable_capacity_curve Behaves as a Scalar¶
Status¶
Implemented as Option B.
energy.usable_capacity_curve is gone. energy.usable_capacity_fraction — a
single float in (0, 1], default 1.0 — replaces it, and it is applied to the
mission budget, the reserve threshold, _deliverable_capacity_wh, and the RTH
and divert budgets alike, which is what the curve never was. Declaring 0.7
now moves usable_energy_wh from 675.0 to 405.0 on the reference pack; the
curve left it at 675.0 for every shape the schema accepted.
Option B over Option A because the ticket's own reasoning holds: A makes energy
path-dependent and requires reversing the ordering validator, and neither buys
anything until the model can fit a discharge curve from a log — which
calibrate does not do and which this ticket puts out of scope. A constant
derating that is honestly described beats a curve that is silently ignored.
A vehicle file still carrying usable_capacity_curve is rejected at load with
a message naming the replacement field, rather than the generic "extra inputs
are not permitted" that extra="forbid" would emit. Vehicle files carry no
schema_version key, so the vehicle.v5 → vehicle.v6 bump is a label in the
output envelopes and schema-versions; there is no migration function to write
and none is registered (migration/registry.py is mission-only).
Still true, and now the only remaining gap here: the field is operator-declared.
calibrate does not fit it, so its provenance story stays "declared", exactly
as the Integration Requirements below anticipated.
Goal¶
Make vehicle.energy.usable_capacity_curve either do what its name, schema
description, interpolation code, and ordering validator all promise, or stop
promising it. Today four separate pieces of the codebase present it as a curve
and it behaves as one number.
Why This Matters¶
usable_capacity_curve is the energy model's only representation of
voltage sag and Peukert-style capacity loss — the effects that make a pack
deliver less than its nameplate energy exactly when a contingency needs it. An
operator modelling those effects will write the physically natural curve, get a
result identical to writing nothing at all, and reasonably conclude the model
accounted for them.
That is worse than the field not existing. A silently ignored derating is the same class of defect as a silently clamped wind query: the tool reports a margin it did not verify.
Current gap¶
_usable_capacity_fraction_at_soc
(bvlos_sim/estimator/execution/energy.py:714-735) is a full piecewise-linear
interpolator over the curve. Its only caller is _deliverable_capacity_wh
(energy.py:693-701):
soc=1.0 is the only argument the function is ever given
(grep -rn "_usable_capacity_fraction_at_soc" bvlos_sim/ returns the
definition and that one call). And EnergyModel.validate_usable_capacity_curve
(bvlos_sim/schemas/vehicle_energy.py:106-124) requires usable_fraction to be
non-decreasing in soc, so the value at soc=1.0 is by construction the curve's
maximum. The model therefore always reads the single most optimistic point on
a curve the operator supplied to be pessimistic.
Measured¶
Same mission and vehicle, varying only energy.usable_capacity_curve:
| Curve | usable_energy_wh |
|---|---|
| absent | 675.0 |
0.0→0.85, 0.5→0.93, 1.0→1.00 (natural sag) |
675.0 |
0.0→0.50, 0.9→0.99, 1.0→1.00 (severe sag) |
675.0 |
0.0→0.90, 1.0→0.90 (flat) |
585.0 |
1.0→0.90 (single point) |
585.0 |
A sag curve is a no-op. A flat curve and a one-point curve are identical. The field is a scalar with an interpolator attached.
The validator also rejects the shape an operator would reach for first:
$ EnergyModel(..., usable_capacity_curve=[{'soc': 0.0, 'usable_fraction': 1.0},
{'soc': 1.0, 'usable_fraction': 0.8}])
ValueError: usable_capacity_curve usable_fraction must be non-decreasing
So the only curves the schema accepts are the ones the estimator ignores.
Decision required¶
Pick one, and make the schema, the docs, and the code say the same thing.
Option A — integrate along the mission SoC trajectory. Track state of charge leg by leg, evaluate the curve at each leg's SoC, and derate the energy actually delivered over that leg. This is what the field's shape implies and what makes a sag curve mean something. Costs: energy becomes path-dependent (numbers move for every vehicle that declares a curve), reserve and RTH budgets need the same treatment or they diverge from the mission budget, and the ordering validator needs revisiting — a decreasing fraction toward empty is the physically natural direction and is currently rejected.
Option B — collapse to a scalar. Replace the field with
energy.usable_capacity_fraction: float, delete _usable_capacity_fraction_at_soc
and the ordering validator, and state plainly that the model applies a constant
pack derating and does not model voltage sag as a function of SoC. Costs: a
vehicle.v5 → v6 bump with a migration, and the roadmap gains an honest
known gap where a fictional capability used to be.
Option B is smaller and truthful. Option A is the modelling the field was designed for. Either is better than the present state, in which the schema documents A and the code does B.
Scope¶
- Record the decision in this ticket before any code changes.
- Apply it consistently to the mission energy budget, the reserve threshold,
_deliverable_capacity_wh, and the RTH and divert energy budgets — a derating that tightens the mission gate while leaving contingency margins untouched is the bug_deliverable_capacity_whwas introduced to fix, and it must not reappear. - Update
bvlos_sim/schemas/vehicle_energy.pyfield descriptions anddocs/missions.md, which currently listsenergy.usable_capacity_curveamong the "optional fidelity fields" that "add deterministic mass, ISA-density, and usable-state-of-charge scaling". - Add tests that fail on the current behaviour: for Option A, a sag curve must
change
usable_energy_wh; for Option B, the scalar must be the only shape the schema accepts.
Integration Requirements¶
vehicle.v5cannot change shape in place; either option that alters the field bumps the schema version and ships amigratepath.- Golden fixtures covering vehicles with a curve change in the same commit.
- Calibration (
calibrate) does not fit this parameter today; whichever option wins, the field's provenance story stays "operator-declared", not fitted.
Acceptance Criteria¶
- A physically natural sag curve either changes the estimate (Option A) or is rejected at schema load with a message naming the replacement field (Option B).
- No code path presents a curve while consuming a scalar: the interpolator and the ordering validator are either both exercised or both gone.
- The mission budget, the reserve threshold, and the RTH/divert budgets agree on the same derating.
docs/missions.mddescribes the field's actual behaviour, and the roadmap records the modelling limit if Option B is chosen.
Out of Scope¶
- Temperature, age, or C-rate dependent capacity models.
- Fitting a discharge curve from flight logs.
- Cell-level or per-pack battery modelling.