Gas turbine light-off — purge before every ignition attempt, no exceptions
February 7, 2010, Middletown, Connecticut: the Kleen Energy power plant, weeks from commissioning, exploded during a natural-gas pipe-cleaning operation. Six workers died. The blast led NFPA 56 to ban flammable-gas blows outright — but the same physics lives inside every gas turbine and HRSG: admit fuel without flame, and the exhaust path fills with a combustible cloud waiting for one spark. That is why NFPA 85 requires a forced-air purge of several full volume changes before EVERY ignition attempt — including the retry after a failed light-off, which is exactly when an impatient operator (or a buggy auto-restart) wants to skip it. Here the purge is unskippable: `firing` is reachable only from `purge_complete`, and the ONLY exit from `failed_light` is back into `purging`. soma verify proves no path admits fuel into an unpurged machine. The purge-volume ledger refuses credit for less than the mandated 5 volume changes.
A Soma program from the energy part of the verified corpus: 134 lines, cells Combustor, PurgeTests. It passes soma check with soma 3.2.4.
How it is built
The cell Combustor declares 7 signals in its face: start_purge(id: String) -> Map, purge_credit(id: String, volumes: Int) -> Map, ignite(id: String) -> Map, flame_on(id: String) -> Map, flame_out(id: String) -> Map, stop_unit(id: String) -> Map and unit_state(id: String) -> String; keeps 1 memory slot: purged (Map<String, Int>, persistent and consistent) under the invariant purged >= 0 && purged <= 50. Its state machine lightoff starts in shutdown and allows 7 transitions: shutdown -> purging, purging -> purge_complete, purge_complete -> firing, firing -> running, firing -> failed_light, failed_light -> purging and running -> shutdown. Handlers: start_purge (line 51), purge_credit (line 58), ignite (line 71), flame_on (line 78), flame_out (line 84), stop_unit (line 90) and unit_state (line 96).
The file also holds 1 test cell (PurgeTests) with 19 assertions, run by soma test.
The source
gas_purge.cell
// ============================================================================
// GAS TURBINE LIGHT-OFF — purge before every ignition attempt, no exceptions
// ============================================================================
//
// February 7, 2010, Middletown, Connecticut: the Kleen Energy power plant,
// weeks from commissioning, exploded during a natural-gas pipe-cleaning
// operation. Six workers died. The blast led NFPA 56 to ban flammable-gas
// blows outright — but the same physics lives inside every gas turbine and
// HRSG: admit fuel without flame, and the exhaust path fills with a
// combustible cloud waiting for one spark. That is why NFPA 85 requires a
// forced-air purge of several full volume changes before EVERY ignition
// attempt — including the retry after a failed light-off, which is exactly
// when an impatient operator (or a buggy auto-restart) wants to skip it.
//
// Here the purge is unskippable: `firing` is reachable only from
// `purge_complete`, and the ONLY exit from `failed_light` is back into
// `purging`. soma verify proves no path admits fuel into an unpurged
// machine. The purge-volume ledger refuses credit for less than the
// mandated 5 volume changes.
// ============================================================================
cell Combustor {
face {
signal start_purge(id: String) -> Map
signal purge_credit(id: String, volumes: Int) -> Map
signal ignite(id: String) -> Map
signal flame_on(id: String) -> Map
signal flame_out(id: String) -> Map
signal stop_unit(id: String) -> Map
signal unit_state(id: String) -> String
}
memory {
// volume changes pushed through since the last fuel admission;
// 5 are required, more than 50 in one credit is a sensor fault
purged: Map<String, Int> [persistent, consistent]
invariant purged >= 0 && purged <= 50
}
state lightoff {
initial: shutdown
shutdown -> purging // forced-draft air through the whole path
purging -> purge_complete // >= 5 volume changes verified
purge_complete -> firing // igniters live, fuel admitted
firing -> running // flame proven
firing -> failed_light // no flame — unburned fuel in the exhaust
failed_light -> purging // the ONLY exit: purge it out first
running -> shutdown
}
on start_purge(id: String) {
let r = try { transition(id, "purging") }
if r.error != () { return map("ok", false, "reason", "{r.error}") }
purged.set(id, 0)
return map("ok", true, "state", get_status(id))
}
on purge_credit(id: String, volumes: Int) {
if get_status(id) != "purging" {
return map("ok", false, "reason", "no purge running (state: {get_status(id)})")
}
let w = try { purged.set(id, (purged.get(id) ?? 0) + volumes) }
if w.error != () { return map("ok", false, "reason", "{w.error}") }
if (purged.get(id) ?? 0) < 5 {
return map("ok", true, "state", "purging", "volumes", purged.get(id))
}
transition(id, "purge_complete")
return map("ok", true, "state", get_status(id), "volumes", purged.get(id))
}
on ignite(id: String) {
let r = try { transition(id, "firing") }
if r.error != () { return map("ok", false, "reason", "purge interlock: {r.error}") }
purged.set(id, 0) // fuel is in — the purge credit is SPENT
return map("ok", true, "state", get_status(id))
}
on flame_on(id: String) {
let r = try { transition(id, "running") }
if r.error != () { return map("ok", false, "reason", "{r.error}") }
return map("ok", true, "state", get_status(id))
}
on flame_out(id: String) {
let r = try { transition(id, "failed_light") }
if r.error != () { return map("ok", false, "reason", "{r.error}") }
return map("ok", true, "state", get_status(id))
}
on stop_unit(id: String) {
let r = try { transition(id, "shutdown") }
if r.error != () { return map("ok", false, "reason", "{r.error}") }
return map("ok", true, "state", get_status(id))
}
on unit_state(id: String) { return get_status(id) }
}
cell test PurgeTests {
rules {
// fuel into a cold, unpurged machine: the transition is absent
assert_fails transition("u1", "firing")
assert ignite("u1").ok == false
// purge, but 3 volume changes is not 5 — still purging
assert start_purge("u1").ok == true
assert purge_credit("u1", 3).ok == true
assert unit_state("u1") == "purging"
assert_fails transition("u1", "firing")
// full credit, then light-off — and the flame fails
assert purge_credit("u1", 2).ok == true
assert unit_state("u1") == "purge_complete"
assert ignite("u1").ok == true
assert flame_out("u1").ok == true
// THE KLEEN ENERGY MOMENT: retry ignition with raw gas still in
// the exhaust path. The only exit from failed_light is a purge.
assert_fails transition("u1", "firing")
assert ignite("u1").ok == false
// re-purge, then the retry is legal
assert start_purge("u1").ok == true
assert purge_credit("u1", 5).ok == true
assert ignite("u1").ok == true
assert flame_on("u1").ok == true
assert unit_state("u1") == "running"
// the purge ledger rejects nonsense credits
assert_fails purged.set("u1", -1)
assert_fails purged.set("u1", 99)
}
}What the toolchain says
$ soma check gas_purge.cell
✓ All checks passed.$ soma test gas_purge.cell
test PurgeTests ...
✓ assert_fails transition("u1", "firing") — raised invalid transition: shutdown → firing. Current state: 'shutdown'. Valid targets: [purging]
✓ assert ignite("u1").ok == false
✓ assert start_purge("u1").ok == true
✓ assert purge_credit("u1", 3).ok == true
✓ assert unit_state("u1") == "purging"
✓ assert_fails transition("u1", "firing") — raised invalid transition: purging → firing. Current state: 'purging'. Valid targets: [purge_complete]
✓ assert purge_credit("u1", 2).ok == true
✓ assert unit_state("u1") == "purge_complete"
✓ assert ignite("u1").ok == true
✓ assert flame_out("u1").ok == true
✓ assert_fails transition("u1", "firing") — raised invalid transition: failed_light → firing. Current state: 'failed_light'. Valid targets: [purging]
✓ assert ignite("u1").ok == false
✓ assert start_purge("u1").ok == true
✓ assert purge_credit("u1", 5).ok == true
✓ assert ignite("u1").ok == true
✓ assert flame_on("u1").ok == true
✓ assert unit_state("u1") == "running"
✓ assert_fails purged.set("u1", -1) — raised memory invariant violated on 'purged': purged >= 0 && purged <= 50 — rejected write of -1 (key "u1"); the slot is unchanged
✓ assert_fails purged.set("u1", 99) — raised memory invariant violated on 'purged': purged >= 0 && purged <= 50 — rejected write of 99 (key "u1"); the slot is unchanged
19 tests: 19 passed, 0 failed$ soma verify gas_purge.cell
Verifying gas_purge.cell...
State machine 'lightoff': 6 states, initial 'shutdown'
States: [failed_light, firing, purge_complete, purging, running, shutdown]
✓ 6 states, 7 transitions
✓ all states reachable from 'shutdown'
✓ reactive machine: no terminal state, and every reachable state can return to 'shutdown'
✓ no deadlocks
✓ liveness: every reachable state can return to 'shutdown' (reactive machine)
✓ think-isolated: CTL safety properties hold regardless of LLM output (6 handlers, 6 literal transitions, 0 dynamic)
✓ termination: all 7 handlers structurally terminate
✓ refinement: handler `start_purge` ⟶ {purging}
✓ refinement: handler `purge_credit` ⟶ {purging → purge_complete}
✓ refinement: handler `ignite` ⟶ {firing}
✓ refinement: handler `flame_on` ⟶ {running}
✓ refinement: handler `flame_out` ⟶ {failed_light}
✓ refinement: handler `stop_unit` ⟶ {shutdown}
· transition sources: `start_purge`, `ignite`, `flame_on`, `flame_out`, `stop_unit` call transition(id, target) without declaring the source — the runtime refuses a move the graph forbids, but the proof does not see where the handler starts; `transition(id, from, to)` names the edge (checked by `soma check`, refused at run time from any other state)
Combustor/invariants:
✓ invariant purged >= 0 && purged <= 50 — writer 'ignite' proven (writes 0)
✓ invariant purged >= 0 && purged <= 50 — writer 'start_purge' proven (writes 0)
⚠ invariant purged >= 0 && purged <= 50 — runtime-checked (computed values): purge_credit → purged because `purged` has no known range; `volumes` is a parameter (narrow it: `require ((purged.get(id) ?? 0) + volumes) >= 0 && ((purged.get(id) ?? 0) + volumes) <= 50 else …`)
15 passed, 1 warnings, 0 failures
Temporal properties for 'lightoff':
✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK