Battery dispatch — the charge window is a wall, not a setpoint
April 19, 2019, Surprise, Arizona: a rack at the APS McMicken battery facility went into thermal runaway; when firefighters opened the door the accumulated gases exploded and put four of them in the hospital. Lithium cells driven outside their charge window — overcharged at the top, or dragged below empty by an aggressive arbitrage optimizer — plate lithium, grow dendrites, and eventually burn. Dispatch software optimizes revenue and will happily command one more charging interval at the top of the window unless something refuses. Here the refusal is the memory itself: state-of-charge lives in a slot whose invariant is 0..100. The write that would cross the bound is rejected BY THE SLOT, even if every guard in the handler is deleted. A 20%-per-interval ramp limit rides on top in the handler.
A Soma program from the energy part of the verified corpus: 83 lines, cells Battery, SocTests. It passes soma check with soma 3.2.4.
How it is built
The cell Battery declares 3 signals in its face: charge_step(id: String, pct: Int) -> Map, discharge_step(id: String, pct: Int) -> Map and soc_of(id: String) -> Int; keeps 1 memory slot: soc (Map<String, Int>, persistent and consistent) under the invariant soc >= 0 && soc <= 100. Handlers: soc_of (line 33), charge_step (line 37) and discharge_step (line 49).
The file also holds 1 test cell (SocTests) with 12 assertions, run by soma test.
The source
battery_soc.cell
// ============================================================================
// BATTERY DISPATCH — the charge window is a wall, not a setpoint
// ============================================================================
//
// April 19, 2019, Surprise, Arizona: a rack at the APS McMicken battery
// facility went into thermal runaway; when firefighters opened the door
// the accumulated gases exploded and put four of them in the hospital.
// Lithium cells driven outside their charge window — overcharged at the
// top, or dragged below empty by an aggressive arbitrage optimizer —
// plate lithium, grow dendrites, and eventually burn.
//
// Dispatch software optimizes revenue and will happily command one more
// charging interval at the top of the window unless something refuses.
// Here the refusal is the memory itself: state-of-charge lives in a slot
// whose invariant is 0..100. The write that would cross the bound is
// rejected BY THE SLOT, even if every guard in the handler is deleted.
// A 20%-per-interval ramp limit rides on top in the handler.
// ============================================================================
cell Battery {
face {
signal charge_step(id: String, pct: Int) -> Map
signal discharge_step(id: String, pct: Int) -> Map
signal soc_of(id: String) -> Int
}
memory {
// % state of charge per battery — THE wall
soc: Map<String, Int> [persistent, consistent]
invariant soc >= 0 && soc <= 100
}
on soc_of(id: String) {
return soc.get(id) ?? 50 // units ship at 50%
}
on charge_step(id: String, pct: Int) {
if pct <= 0 || pct > 20 {
return map("ok", false, "reason", "ramp limit is 1..20% per interval")
}
let cur = soc_of(id)
let w = try { soc.set(id, cur + pct) }
if w.error != () {
return map("ok", false, "reason", "SoC wall refused: {w.error}")
}
return map("ok", true, "soc", cur + pct)
}
on discharge_step(id: String, pct: Int) {
if pct <= 0 || pct > 20 {
return map("ok", false, "reason", "ramp limit is 1..20% per interval")
}
let cur = soc_of(id)
let w = try { soc.set(id, cur - pct) }
if w.error != () {
return map("ok", false, "reason", "SoC wall refused: {w.error}")
}
return map("ok", true, "soc", cur - pct)
}
}
cell test SocTests {
rules {
// normal arbitrage cycling proceeds
assert charge_step("b1", 20).ok == true // 50 -> 70
assert charge_step("b1", 20).ok == true // 70 -> 90
// the interval that would overcharge is refused; SoC unchanged
assert charge_step("b1", 20).ok == false // 90 + 20 > 100
assert soc_of("b1") == 90
// ramp limit holds
assert charge_step("b1", 30).ok == false
// drain toward the floor, then one step too far — refused
assert discharge_step("b2", 20).ok == true // 50 -> 30
assert discharge_step("b2", 20).ok == true // 30 -> 10
assert discharge_step("b2", 20).ok == false // 10 - 20 < 0
assert soc_of("b2") == 10
// THE WALL: a raw out-of-window write raises at the memory
assert_fails soc.set("b1", 130)
assert_fails soc.set("b1", -5)
assert soc_of("b1") == 90
}
}What the toolchain says
$ soma check battery_soc.cell
✓ All checks passed.$ soma test battery_soc.cell
test SocTests ...
✓ assert charge_step("b1", 20).ok == true
✓ assert charge_step("b1", 20).ok == true
✓ assert charge_step("b1", 20).ok == false
✓ assert soc_of("b1") == 90
✓ assert charge_step("b1", 30).ok == false
✓ assert discharge_step("b2", 20).ok == true
✓ assert discharge_step("b2", 20).ok == true
✓ assert discharge_step("b2", 20).ok == false
✓ assert soc_of("b2") == 10
✓ assert_fails soc.set("b1", 130) — raised memory invariant violated on 'soc': soc >= 0 && soc <= 100 — rejected write of 130 (key "b1"); the slot is unchanged
✓ assert_fails soc.set("b1", -5) — raised memory invariant violated on 'soc': soc >= 0 && soc <= 100 — rejected write of -5 (key "b1"); the slot is unchanged
✓ assert soc_of("b1") == 90
12 tests: 12 passed, 0 failed$ soma verify battery_soc.cell
Verifying battery_soc.cell...
Battery/invariants:
✓ invariant soc >= 0 — writer 'charge_step' proven by induction (writes cur + pct)
✓ invariant soc <= 100 — writer 'discharge_step' proven by induction (writes cur - pct)
⚠ invariant soc >= 0 && soc <= 100 — runtime-checked (computed values): charge_step → soc [soc <= 100] because `cur + pct` is only known to lie in [1, 120] — narrow it: `require (cur + pct) <= 100 else …`, discharge_step → soc [soc >= 0] because `cur - pct` is only known to lie in [-20, 99] — narrow it: `require (cur - pct) >= 0 else …`
2 passed, 1 warnings, 0 failures
Temporal: 0 passed, 0 failed
VERIFY OK