Power bus: a consumer its thermostat has closed is not billed

The bus billed every active IPowerConsumer, but a behaviour group stops at its
Thermotoggle: a heater above its target never runs its EnergyDraw, yet its
full draw (1000-2500/s) was charged every tick. On an idle station that kept
the reactor in permanent deficit, overloading at overload efficiency. The bus
now skips a consumer whose group has a closed thermostat ahead of it, the same
rule it already applies to inactive items; Thermotoggle.Open is the one
statement of when a thermostat is open, and Execute returns it.

Tests (Soul's F2; S17 survived before): from a hull at one uniform temperature,
one tick below the heater's target raises its cells and one above does not, so
the heater's own contribution is measured with no reactor heat in it; and the
bus bills the heater's draw below its target and not above.

This does not keep an idle station warm (Soul's F1, reported separately): the
reactor's throttled idle heat alone still drives it past its plateau.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 20:46:30 +02:00
commit 74d2cf8fc4
3 changed files with 65 additions and 5 deletions

View file

@ -47,8 +47,8 @@ public class Thermotoggle : Behavior
TargetTemperature = data.TargetTemperature;
}
public override bool Execute(float dt)
{
return Temperature < TargetTemperature ^ _data.HighPass;
}
// Whether the rest of this behaviour's group runs this tick: below the target, or above it for a high-pass one.
public bool Open => Temperature < TargetTemperature ^ _data.HighPass;
public override bool Execute(float dt) => Open;
}

View file

@ -92,6 +92,16 @@ public class PowerBus
public Draw(EquippedItem item, int tier, float request) { Item = item; Tier = tier; Request = request; }
}
private static bool ClosedByThermostat(EquippedItem item, Behavior consumer)
{
foreach (var behavior in item.BehaviorGroups[consumer.Data.Group].Behaviors)
{
if (behavior == consumer) return false;
if (behavior is Thermotoggle { Open: false }) return true;
}
return false;
}
public void Step(float dt)
{
var reactors = new List<Reactor>();
@ -119,7 +129,9 @@ public class PowerBus
// The other direction of the same rule: a consumer switched off or shut down (Active.Value false)
// never runs its Execute, so it must never be billed either -- otherwise a disabled item's demand
// starves every live consumer and drains the capacitors for energy nobody spends.
if (behavior is IPowerConsumer consumer && item.Active.Value)
// And a consumer whose group a thermostat has closed ahead of it does not run this tick either, so it is
// not billed: a heater above its target draws nothing.
if (behavior is IPowerConsumer consumer && item.Active.Value && !ClosedByThermostat(item, behavior))
{
// Cut 5 (docs/stats-and-power-cut.md §1.3): the item's own stored choice wins once it has one;
// PowerTiers.Unassigned only survives past EquippedItem's constructor for a consumer added to

View file

@ -438,6 +438,54 @@ public sealed class RunStartTests : IDisposable
Assert.True(coldest > Freezing, $"the heater's cells fell to {coldest} K (station now {station.MinTemp}..{station.MaxTemp} K)");
}
// The heater itself warms the station: from a hull at one uniform temperature (so nothing conducts and interior
// cells radiate nothing), one tick below the heater's target raises its cells, and one tick above it does not.
[Fact]
public void AStationHeaterHeatsBelowItsTargetAndNotAbove()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var heater = station.Equipment.Single(IsHeater);
var target = heater.Behaviors.OfType<Thermotoggle>().Single().TargetTemperature;
for (var tick = 0; tick < 10; tick++) zone.Update(.1f); // the power bus is running
float Step(float start)
{
foreach (var v in station.HullData.Shape.Coordinates) station.Temperature[v.x, v.y] = start;
zone.Update(.1f);
return heater.Temperature - start;
}
var below = Step(target - 20);
var above = Step(target + 20);
Assert.True(below > 0, $"below its target the heater's cells moved {below} K");
Assert.True(above <= 0, $"above its target the heater's cells moved {above} K");
}
// A consumer runs only when its group's thermostat is open, and is billed only then: a heater above its target
// puts no demand on the power bus, below it the bus bills its draw.
[Fact]
public void AHeaterAboveItsTargetDrawsNoPower()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var heater = station.Equipment.Single(IsHeater);
var target = heater.Behaviors.OfType<Thermotoggle>().Single().TargetTemperature;
var request = heater.Behaviors.OfType<EnergyDraw>().Single().PowerRequest(.1f);
Assert.True(request > 0, "the heater asks for power");
for (var tick = 0; tick < 10; tick++) zone.Update(.1f); // the heater is online
Assert.True(heater.Active.Value, "the heater is active");
float Demand(float temperature)
{
foreach (var v in station.HullData.Shape.Coordinates) station.Temperature[v.x, v.y] = temperature;
station.PowerBus.Step(.1f);
return station.PowerBus.TotalDemand;
}
var cold = Demand(target - 20);
var warm = Demand(target + 20);
Assert.True(cold - warm >= request * .99f, $"demand {cold} below the target, {warm} above; the heater asks {request}");
}
// A station hull with no room left for a heater fails its generation by name, rather than spawning a station
// that will freeze. The only station hull on offer is one whose interior takes the smallest docking bay and no more.
[Fact]