Skip to content

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.v5vehicle.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):

    return energy_model.battery_capacity_wh * _usable_capacity_fraction_at_soc(
        energy_model, soc=1.0
    )

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.v5v6 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_wh was introduced to fix, and it must not reappear.
  • Update bvlos_sim/schemas/vehicle_energy.py field descriptions and docs/missions.md, which currently lists energy.usable_capacity_curve among 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.v5 cannot change shape in place; either option that alters the field bumps the schema version and ships a migrate path.
  • 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

  1. 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).
  2. No code path presents a curve while consuming a scalar: the interpolator and the ordering validator are either both exercised or both gone.
  3. The mission budget, the reserve threshold, and the RTH/divert budgets agree on the same derating.
  4. docs/missions.md describes 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.