From f290d5d419982460f323db9af907c3f697316e25 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:49:35 -0700 Subject: [PATCH 01/34] Add component-based energy system design spec Design for replacing the scattered energy balance logic in the simulator engine with a modular, component-based energy system. Components (GridMeter, PVSource, BESSUnit, LoadGroup) resolve on a PanelBus with role-based ordering and conservation enforcement. --- ...26-03-28-component-energy-system-design.md | 668 ++++++++++++++++++ 1 file changed, 668 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-28-component-energy-system-design.md diff --git a/docs/superpowers/specs/2026-03-28-component-energy-system-design.md b/docs/superpowers/specs/2026-03-28-component-energy-system-design.md new file mode 100644 index 0000000..74a8158 --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-component-energy-system-design.md @@ -0,0 +1,668 @@ +# Component-Based Energy System Design + +## Problem Statement + +The simulator's energy balance logic is scattered across three inconsistent calculation +paths in an 1837-line god class (`DynamicSimulationEngine`). Each path uses different +sign conventions, different battery handling, and different grid power formulas. This +produces bugs that are symptomatic of poor system design: + +- BESS discharge is not throttled to actual load demand (GFE constraint violated) +- Grid-offline mode force-discharges the battery, ignoring charge mode entirely + (hybrid inverter + solar-excess cannot charge during islanding) +- Dashboard summary bypasses BSEE and SOE bounds (`battery = consumption - pv`) +- Non-hybrid vs hybrid PV behavior during islanding is not properly modeled +- The three-way interdependency between loads, PV production, and BESS + charge/discharge is not modeled as a closed energy balance +- Adding or changing BESS configuration can produce stale or inconsistent state + +These issues compound when the system needs to run modeling passes (before/after +comparison) or future optimization iterations, because each requires cloning engine +internals and reconstructing the energy balance from scattered state. + +## Design Goals + +1. **Physical fidelity**: Components represent physical devices. Power flows obey + conservation of energy. The BESS as GFE never over-discharges. Hybrid vs + non-hybrid inverter behavior is correct during islanding. + +2. **Single source of truth**: One energy balance calculation, used by real-time + snapshots, dashboard summaries, and modeling passes alike. + +3. **Instantiable as a value**: An `EnergySystem` can be constructed from a + configuration, ticked forward over timestamps, and discarded. No singletons, + no shared mutable state, no coupling to the engine or dashboard. The modeling + pass and optimizer construct their own independent instances. + +4. **Testable in isolation**: The energy system is fully testable without the engine, + dashboard, MQTT, or any transport layer. + +5. **Uniform sign convention**: Non-negative magnitudes internally. Direction + expressed by role (demand vs supply). eBus native convention applied once at + the snapshot output boundary. + +## Architecture + +### Separation of Concerns + +The behavior engine decides what each device *wants* to do (power intent). +The energy system decides what physically *happens* (power resolution). + +``` +BehaviorEngine ──> PowerInputs ──> EnergySystem.tick() ──> SystemState + │ + ┌───────────────────────┤ + v v + Snapshot Assembly Modeling Output +``` + +### Component Roles and Bus Resolution + +Each physical device is a `Component` with a role that determines evaluation order. +Components attach to a `PanelBus`. Each tick, the bus resolves components in role +order and enforces conservation of energy as a postcondition. + +| Role | Evaluation Order | Description | Examples | +|-------------|-----------------|------------------------------------------------|----------------| +| **LOAD** | First | Declares demand | Consumer circuits, EVSE | +| **SOURCE** | Second | Declares supply up to available capacity | PV inverter | +| **STORAGE** | Third | Charges from excess or discharges to meet deficit | BESS | +| **SLACK** | Last | Absorbs residual (whatever the bus can't balance) | Grid meter (or BESS when islanded) | + +Resolution proceeds: + +1. **LOAD**: Accumulates total demand on the bus. +2. **SOURCE**: PV declares available supply. Bus now knows demand and supply. +3. **STORAGE**: BESS sees the deficit (demand - supply) or excess (supply - demand) + and acts accordingly, constrained by GFE rules, SOE bounds, and charge mode. +4. **SLACK**: Grid absorbs whatever remains. If disconnected, contributes nothing + (BESS as GFE already covered the deficit in step 3). + +Conservation assertion: after all roles resolve, `total_demand = total_supply + grid`. +BESS discharge is already included in `total_supply`; BESS charge in `total_demand`. +If the residual exceeds floating-point tolerance, it is a bug. + +## Core Types + +### PowerContribution + +What a component returns from `resolve()`. Single sign convention: all values +are non-negative magnitudes. Direction is expressed by which field is populated. + +```python +@dataclass +class PowerContribution: + demand_w: float = 0.0 # power consumed (always >= 0) + supply_w: float = 0.0 # power produced (always >= 0) +``` + +### BusState + +Accumulates the energy balance as components resolve. Each downstream component +sees the cumulative state from all upstream roles. + +Note: while components return non-negative `PowerContribution` values, the bus +accumulates them into `total_demand_w` and `total_supply_w` directly. BESS discharge +adds to supply; BESS charge adds to demand. `storage_contribution_w` is a signed +convenience field for downstream components to see the net storage effect without +inspecting both demand and supply deltas. + +```python +@dataclass +class BusState: + total_demand_w: float = 0.0 # includes BESS charging + total_supply_w: float = 0.0 # includes BESS discharging + storage_contribution_w: float = 0.0 # net: positive = discharge, negative = charge + # (derived from supply/demand deltas, not + # a separate sign convention) + grid_power_w: float = 0.0 + + @property + def net_deficit_w(self) -> float: + """Remaining demand after all supply and storage contributions.""" + return self.total_demand_w - self.total_supply_w + + def is_balanced(self) -> bool: + """Conservation check: grid absorbs exactly what remains.""" + residual = self.total_demand_w - self.total_supply_w - self.grid_power_w + return abs(residual) < 0.01 +``` + +### PowerInputs + +External inputs that drive each tick. Provided by the behavior engine (or recorder +replay, or synthetic generation). The energy system does not generate power values; +it resolves how they flow. + +```python +@dataclass +class PowerInputs: + pv_available_w: float = 0.0 + bess_requested_w: float = 0.0 + bess_scheduled_state: str = "idle" # "charging" | "discharging" | "idle" + load_demand_w: float = 0.0 + grid_connected: bool = True +``` + +### SystemState + +The resolved output of a tick. Single source of truth consumed by snapshot +assembly, dashboard, and modeling output. + +```python +@dataclass +class SystemState: + grid_power_w: float + pv_power_w: float + bess_power_w: float + bess_state: str + load_power_w: float + soe_kwh: float + soe_percentage: float + balanced: bool +``` + +### Configuration Dataclasses + +```python +@dataclass(frozen=True) +class GridConfig: + connected: bool = True + +@dataclass(frozen=True) +class PVConfig: + nameplate_w: float = 0.0 + inverter_type: str = "ac_coupled" # "ac_coupled" | "hybrid" + +@dataclass(frozen=True) +class BESSConfig: + nameplate_kwh: float = 13.5 + max_charge_w: float = 3500.0 + max_discharge_w: float = 3500.0 + charge_efficiency: float = 0.95 + discharge_efficiency: float = 0.95 + backup_reserve_pct: float = 20.0 + hard_min_pct: float = 5.0 + hybrid: bool = False + initial_soe_kwh: float | None = None # defaults to 50% of nameplate + +@dataclass(frozen=True) +class LoadConfig: + demand_w: float = 0.0 + +@dataclass(frozen=True) +class EnergySystemConfig: + grid: GridConfig + pv: PVConfig | None = None + bess: BESSConfig | None = None + loads: list[LoadConfig] = field(default_factory=list) +``` + +## Concrete Components + +### GridMeter (Role: SLACK) + +Absorbs whatever the bus cannot balance. When disconnected, contributes nothing. + +```python +class GridMeter(Component): + role = SLACK + connected: bool + + def resolve(self, bus_state: BusState) -> PowerContribution: + if not self.connected: + return PowerContribution() + deficit = bus_state.net_deficit_w + if deficit > 0: + return PowerContribution(supply_w=deficit) # importing + elif deficit < 0: + return PowerContribution(demand_w=-deficit) # absorbing excess + return PowerContribution() +``` + +### PVSource (Role: SOURCE) + +Declares available production. Does not make decisions. The `online` flag is +controlled by the system topology: if AC-coupled and grid disconnected, PV goes +offline unless the co-located BESS is hybrid. + +```python +class PVSource(Component): + role = SOURCE + available_power_w: float + online: bool + + def resolve(self, bus_state: BusState) -> PowerContribution: + if not self.online: + return PowerContribution() + return PowerContribution(supply_w=self.available_power_w) +``` + +### BESSUnit (Role: STORAGE) + +The most complex component. Encapsulates SOE tracking, GFE behavior, hybrid +inverter control, and charge/discharge constraints. + +**GFE constraint**: When discharging, the BESS only sources what the bus actually +demands. If PV already covers all loads, discharge power is zero. The BESS as GFE +never pushes excess power back through the grid meter. + +**Hybrid inverter**: Not a separate component. It is a configuration property of +`BESSUnit`. When `hybrid=True`, the BESS keeps its co-located PV online during +islanding. When `hybrid=False`, PV goes offline when grid disconnects. + +**Islanding behavior**: When grid is disconnected, the BESS becomes the de facto +slack. If hybrid, PV continues producing and the BESS only covers the gap. If +solar-excess mode and PV exceeds loads, the BESS charges from the excess. If +non-hybrid, PV is offline and the BESS covers all load demand. + +```python +class BESSUnit(Component): + role = STORAGE + + # Configuration + nameplate_capacity_kwh: float + max_charge_w: float + max_discharge_w: float + charge_efficiency: float + discharge_efficiency: float + backup_reserve_pct: float + hard_min_pct: float + hybrid: bool + pv_source: PVSource | None # co-located PV reference (if hybrid) + + # State + soe_kwh: float + scheduled_state: str + requested_power_w: float + + # Output (set after resolve) + effective_power_w: float + effective_state: str + + def resolve(self, bus_state: BusState) -> PowerContribution: + if self.scheduled_state == "idle": + return PowerContribution() + if self.scheduled_state == "discharging": + return self._resolve_discharge(bus_state) + if self.scheduled_state == "charging": + return self._resolve_charge(bus_state) + + def _resolve_discharge(self, bus_state: BusState) -> PowerContribution: + deficit = bus_state.net_deficit_w + if deficit <= 0: + self.effective_power_w = 0.0 + self.effective_state = "idle" + return PowerContribution() + power = min(self.requested_power_w, deficit, self.max_discharge_w, + self._max_discharge_for_soe()) + self.effective_power_w = power + self.effective_state = "discharging" + return PowerContribution(supply_w=power) + + def _resolve_charge(self, bus_state: BusState) -> PowerContribution: + power = min(self.requested_power_w, self.max_charge_w, + self._max_charge_for_soe()) + self.effective_power_w = power + self.effective_state = "charging" + return PowerContribution(demand_w=power) + + def _max_discharge_for_soe(self) -> float: + """Max instantaneous discharge (W) before hitting SOE floor. + + Computed from available energy above the reserve threshold and + the discharge efficiency. Prevents over-drain within a single tick. + """ + ... + + def _max_charge_for_soe(self) -> float: + """Max instantaneous charge (W) before hitting SOE ceiling. + + Computed from remaining capacity below 100% and the charge + efficiency. Prevents overcharge within a single tick. + """ + ... + + def integrate_energy(self, delta_s: float) -> None: + """Integrate effective power over elapsed time to update SOE. + + Applies charge/discharge efficiency: + - Charging: soe += (power / 1000) * hours * charge_efficiency + - Discharging: soe -= (power / 1000) * hours / discharge_efficiency + + Clamps to [hard_min, nameplate] bounds after integration. + """ + ... + + def update_pv_online_status(self, grid_connected: bool) -> None: + """If hybrid, keep co-located PV online even when grid disconnected.""" + if self.pv_source is not None: + if self.hybrid: + self.pv_source.online = True + elif not grid_connected: + self.pv_source.online = False +``` + +### LoadGroup (Role: LOAD) + +Declares demand. Could be individual circuits or aggregate. + +```python +class LoadGroup(Component): + role = LOAD + demand_w: float + + def resolve(self, bus_state: BusState) -> PowerContribution: + return PowerContribution(demand_w=self.demand_w) +``` + +### EVSE + +Modeled as a `LoadGroup` — pure consumer. Positive demand, no sign flip. EVSE +special properties (status, advertised current, lock state) live on the circuit +and device layer, not in the energy system. If V2H is added in the future, EVSE +promotes to STORAGE role, but it would present as a BESS to the panel. + +## EnergySystem + +### Construction + +```python +class EnergySystem: + bus: PanelBus + grid: GridMeter + pv: PVSource | None + bess: BESSUnit | None + + @staticmethod + def from_config(config: EnergySystemConfig) -> EnergySystem: + """Pure factory. No side effects, no external dependencies.""" + ... +``` + +### Tick + +```python +def tick(self, ts: float, inputs: PowerInputs) -> SystemState: + # 1. Apply topology: grid connected, PV online status + self.grid.connected = inputs.grid_connected + if self.bess is not None: + self.bess.update_pv_online_status(inputs.grid_connected) + elif self.pv is not None and not inputs.grid_connected: + self.pv.online = False # no BESS to keep PV alive + + # 2. Set component inputs from PowerInputs + if self.pv is not None: + self.pv.available_power_w = inputs.pv_available_w + if self.bess is not None: + self.bess.scheduled_state = inputs.bess_scheduled_state + self.bess.requested_power_w = inputs.bess_requested_w + + # 3. Resolve bus (components already configured above) + bus_state = self.bus.resolve() + + # 4. Integrate BESS energy over time delta + if self.bess is not None: + self.bess.integrate_energy(delta_s) + + # 5. Return resolved state + return SystemState( + grid_power_w=bus_state.grid_power_w, + pv_power_w=self.pv.available_power_w if self.pv and self.pv.online else 0.0, + bess_power_w=self.bess.effective_power_w if self.bess else 0.0, + bess_state=self.bess.effective_state if self.bess else "idle", + load_power_w=bus_state.total_demand_w, + soe_kwh=self.bess.soe_kwh if self.bess else 0.0, + soe_percentage=self.bess.soe_percentage if self.bess else 0.0, + balanced=bus_state.is_balanced(), + ) +``` + +### Usage Patterns + +```python +# Live simulation — one instance, ticked by engine clock +energy_system = EnergySystem.from_config(current_config) +state = energy_system.tick(current_time, inputs) + +# Modeling — two independent instances +system_before = EnergySystem.from_config(config_before) +system_after = EnergySystem.from_config(config_after) +for ts in timestamps: + state_b = system_before.tick(ts, inputs_b) + state_a = system_after.tick(ts, inputs_a) + +# Optimization — N independent instances +for config in candidates: + system = EnergySystem.from_config(config) + cost = simulate_horizon(system, behavior, timestamps) +``` + +## Sign Convention + +### Internal Convention + +All power values inside the energy system are **non-negative magnitudes**. Direction +is expressed by which field they populate (`demand_w` vs `supply_w`). There is no +sign flipping inside the energy system. + +### Output Boundary: eBus Native Convention + +The snapshot assembly layer translates `SystemState` to SPAN eBus native convention: + +| SystemState field | eBus field | eBus convention | +|-------------------|-----------|-----------------| +| `grid_power_w` | `instant_grid_power_w` | positive = importing | +| `grid_power_w` | `power_flow_grid` | positive = importing | +| `pv_power_w` | `power_flow_pv` | positive = producing | +| `bess_power_w` + `bess_state` | `power_flow_battery` | positive = charging, negative = discharging | +| `load_power_w` | `power_flow_site` | positive = consuming | + +### HA Sensor Layer (unchanged) + +The HA integration's existing `value_fn` lambdas apply user-facing sign flips: + +| Sensor | eBus Native | User-Facing | Flip | +|--------|-------------|-------------|------| +| Battery Power | + = charging | negated: + = discharging | Yes | +| PV Power | + = producing | negated | Yes | +| Grid Power Flow | + = importing | negated: + = exporting | Yes | +| Site Power | + = consuming | as-is | No | +| Circuit Power (PV) | + = producing | negated | Yes | +| Circuit Power (EVSE/other) | + = consuming | as-is | No | + +The energy system does not concern itself with user-facing conventions. It outputs +eBus native; the HA integration handles the rest. + +## Engine Integration + +### What the Engine Retains + +- Clock management (`SimulationClock`) +- Circuit management and ticking (behavior engine generates power values) +- Behavior engine (`RealisticBehaviorEngine`) — generates power intent +- Dynamic overrides and tab synchronization +- Snapshot assembly (converts `SystemState` to `SpanPanelSnapshot`) +- Load shedding decisions (which circuits to shed based on priority) + +### What the Engine Sheds + +- All three grid power calculations (replaced by `SystemState.grid_power_w`) +- Battery power clamping / GFE logic (replaced by `BESSUnit._resolve_discharge`) +- Solar-excess two-pass aggregation (bus role ordering handles this naturally) +- Sign convention juggling in snapshot assembly +- `get_power_summary()` inline aggregation (reads from `SystemState`) +- BSEE class (`bsee.py` deleted, absorbed into `BESSUnit`) + +### Engine After Integration + +```python +class DynamicSimulationEngine: + _clock: SimulationClock + _behavior_engine: RealisticBehaviorEngine + _circuits: dict[str, SimulatedCircuit] + _energy_system: EnergySystem + + async def get_snapshot(self) -> SpanPanelSnapshot: + # 1. Tick circuits (behavior engine generates power values) + self._tick_circuits(current_time) + + # 2. Collect power inputs from circuit state + inputs = self._collect_power_inputs() + + # 3. Resolve energy balance + system_state = self._energy_system.tick(current_time, inputs) + + # 4. Reflect effective battery power back to circuit + self._apply_system_state_to_circuits(system_state) + + # 5. Assemble snapshot + return self._build_snapshot(system_state, circuit_snapshots) + + async def compute_modeling_data(self, horizon_hours: int) -> dict: + config_before = self._build_energy_config(baseline=True) + config_after = self._build_energy_config(baseline=False) + + system_before = EnergySystem.from_config(config_before) + system_after = EnergySystem.from_config(config_after) + + for ts in timestamps: + inputs_b = self._modeling_inputs_at(ts, baseline=True) + inputs_a = self._modeling_inputs_at(ts, baseline=False) + state_b = system_before.tick(ts, inputs_b) + state_a = system_after.tick(ts, inputs_a) + results.append(state_b, state_a) + + return self._format_modeling_response(results) +``` + +## Testing Strategy + +### Layer 1: Component Unit Tests (`test_components.py`) + +Each component resolves correctly given a `BusState`. Tests are pure — +instantiate component, call `resolve()`, assert result. + +- GridMeter absorbs exact deficit +- GridMeter returns zero when disconnected +- PVSource returns zero when offline +- PVSource returns available power when online +- BESSUnit discharge throttled to deficit (GFE constraint) +- BESSUnit idles when solar exceeds demand +- BESSUnit stops discharge at backup reserve +- BESSUnit stops charge at max SOE +- BESSUnit charge limited by max charge rate +- LoadGroup returns configured demand + +### Layer 2: Bus Integration Tests (`test_bus.py`) + +Full resolution cycle. Conservation enforced as assertion. + +- Conservation: load + PV + BESS + grid, power in = power out +- Conservation holds when BESS is throttled +- Conservation holds when grid is disconnected +- Conservation holds with no BESS +- Conservation holds with no PV +- Charging increases grid import +- Discharging decreases grid import +- Grid never goes negative from BESS discharge alone +- Grid exactly zero when BESS covers full deficit + +### Layer 3: Topology / Scenario Tests (`test_scenarios.py`) + +Physical behavior under configuration and state changes. Every issue identified +in the original conversation is covered. + +**GFE and discharge throttling (Issues 1, 2):** +- BESS discharge clamped to actual load deficit +- Grid never negative from battery discharge +- Grid exactly zero when BESS matches deficit + +**Hybrid vs non-hybrid islanding (Issues 3, 5, 8):** +- Non-hybrid island: PV offline, BESS covers all loads +- Hybrid island: PV online, BESS covers only the gap +- Hybrid island + solar-excess: excess PV charges BESS +- Non-hybrid island ignores solar-excess (PV offline, no excess) + +**Single calculation path (Issues 4, 6, 7):** +- All consumers (snapshot, dashboard, modeling) read from same SystemState +- Architectural: single `tick()` method, not three aggregation functions + +**Modeling reflects BESS changes (Issue 9):** +- Adding BESS to "after" config reduces grid trace +- Changing nameplate affects discharge duration across horizon + +**No stale state (Issue 10):** +- Two EnergySystem instances share no state +- Configuration change produces correct results immediately + +**EVSE:** +- EVSE behaves as pure consumer load, no sign flip + +**SOE integration:** +- SOE tracks correctly over multi-tick discharge +- SOE tracks correctly over multi-tick charge +- Efficiency losses applied correctly (charge and discharge) + +## File Structure + +``` +src/span_panel_simulator/ +├── energy/ +│ ├── __init__.py # Public API re-exports +│ ├── types.py # ComponentRole, PowerContribution, BusState, +│ │ # SystemState, PowerInputs, config dataclasses +│ ├── components.py # Component base, GridMeter, PVSource, BESSUnit, LoadGroup +│ ├── bus.py # PanelBus — role-ordered resolution, conservation check +│ └── system.py # EnergySystem — from_config factory, tick entry point +│ +├── engine.py # Slimmed: delegates energy math to EnergySystem +├── circuit.py # Circuit energy counters remain; battery direction from SystemState +├── bsee.py # DELETED — absorbed into BESSUnit +└── dashboard/ + └── routes.py # Reads from SystemState, no separate aggregation + +tests/ +├── test_energy/ +│ ├── test_components.py # Layer 1: component unit tests +│ ├── test_bus.py # Layer 2: bus integration / conservation +│ └── test_scenarios.py # Layer 3: topology / scenario tests +└── ... # Existing tests unchanged +``` + +Approximate sizes: +- `energy/types.py`: ~120 lines +- `energy/components.py`: ~250 lines +- `energy/bus.py`: ~60 lines +- `energy/system.py`: ~100 lines +- Total new code: ~540 lines (replaces ~400 lines of scattered logic) + +## Migration Path + +### Phase 1: Build energy system in isolation + +- Create `energy/` package with all components, bus, system, types +- Write all three test layers +- Tests pass against new code only. Engine untouched. Nothing breaks. + +### Phase 2: Wire into engine (dual path) + +- Engine constructs `EnergySystem` alongside existing logic +- `get_snapshot()` uses `SystemState` for grid/battery/pv values +- Old calculation kept behind a flag for comparison during development +- Existing 193 tests still pass + +### Phase 3: Eliminate old paths + +- Remove three separate grid power calculations from engine.py +- Remove `get_power_summary()` inline aggregation +- Remove modeling pass inline energy balance +- Delete `bsee.py` +- Update `circuit.py` battery direction to read from `SystemState` + +### Phase 4: Clean up + +- Remove GFE throttling from old BSEE (absorbed into BESSUnit) +- Remove grid-offline force-discharge override from behavior engine +- Remove dashboard `battery = consumption - pv` shortcut +- Remove sign convention juggling from snapshot assembly From 42b2e712650fb745d5ba7286773a5a0d4d34b1f0 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:59:40 -0700 Subject: [PATCH 02/34] Add component-based energy system implementation plan 12-task phased plan: build energy system in isolation (Phase 1), wire into engine (Phase 2), eliminate old paths (Phase 3), and remove dead code (Phase 4). --- .../2026-03-28-component-energy-system.md | 1999 +++++++++++++++++ 1 file changed, 1999 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-28-component-energy-system.md diff --git a/docs/superpowers/plans/2026-03-28-component-energy-system.md b/docs/superpowers/plans/2026-03-28-component-energy-system.md new file mode 100644 index 0000000..f713185 --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-component-energy-system.md @@ -0,0 +1,1999 @@ +# Component-Based Energy System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the scattered energy balance logic in the simulator engine with a modular, component-based energy system that enforces conservation of energy and correctly models GFE, hybrid inverter, and islanding behavior. + +**Architecture:** Physical components (GridMeter, PVSource, BESSUnit, LoadGroup) resolve on a PanelBus in role order (LOAD → SOURCE → STORAGE → SLACK). The EnergySystem is a pure value object — constructed from config, ticked with PowerInputs, returns SystemState. The engine delegates all energy math to it. + +**Tech Stack:** Python 3.14, dataclasses, pytest. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-03-28-component-energy-system-design.md` + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|---------------| +| `src/span_panel_simulator/energy/__init__.py` | Create | Public API re-exports | +| `src/span_panel_simulator/energy/types.py` | Create | ComponentRole enum, PowerContribution, BusState, SystemState, PowerInputs, config dataclasses | +| `src/span_panel_simulator/energy/components.py` | Create | Component base class, GridMeter, PVSource, BESSUnit, LoadGroup | +| `src/span_panel_simulator/energy/bus.py` | Create | PanelBus — role-ordered resolution, conservation check | +| `src/span_panel_simulator/energy/system.py` | Create | EnergySystem — from_config factory, tick entry point | +| `tests/test_energy/__init__.py` | Create | Test package init | +| `tests/test_energy/test_components.py` | Create | Layer 1: component unit tests | +| `tests/test_energy/test_bus.py` | Create | Layer 2: bus integration / conservation tests | +| `tests/test_energy/test_scenarios.py` | Create | Layer 3: topology / scenario tests | +| `src/span_panel_simulator/engine.py` | Modify | Wire EnergySystem into snapshot, modeling, and dashboard paths; remove old energy balance code | +| `src/span_panel_simulator/circuit.py` | Modify | Battery direction reads from SystemState instead of behavior engine | +| `src/span_panel_simulator/bsee.py` | Delete | Absorbed into BESSUnit | + +--- + +## Phase 1: Build Energy System in Isolation + +### Task 1: Types Module + +**Files:** +- Create: `src/span_panel_simulator/energy/__init__.py` +- Create: `src/span_panel_simulator/energy/types.py` +- Test: `tests/test_energy/__init__.py` +- Test: `tests/test_energy/test_components.py` + +- [ ] **Step 1: Create package structure** + +```bash +mkdir -p src/span_panel_simulator/energy tests/test_energy +``` + +- [ ] **Step 2: Write types module** + +Create `src/span_panel_simulator/energy/__init__.py`: + +```python +"""Component-based energy system for the SPAN panel simulator.""" +``` + +Create `tests/test_energy/__init__.py`: + +```python +"""Energy system tests.""" +``` + +Create `src/span_panel_simulator/energy/types.py` with all core types from the spec: +- `ComponentRole` enum: `LOAD`, `SOURCE`, `STORAGE`, `SLACK` +- `PowerContribution` dataclass: `demand_w`, `supply_w` (both non-negative) +- `BusState` dataclass: `total_demand_w`, `total_supply_w`, `storage_contribution_w`, `grid_power_w`, `net_deficit_w` property, `is_balanced()` method +- `PowerInputs` dataclass: `pv_available_w`, `bess_requested_w`, `bess_scheduled_state`, `load_demand_w`, `grid_connected` +- `SystemState` dataclass: `grid_power_w`, `pv_power_w`, `bess_power_w`, `bess_state`, `load_power_w`, `soe_kwh`, `soe_percentage`, `balanced` +- Config dataclasses (all frozen): `GridConfig`, `PVConfig`, `BESSConfig`, `LoadConfig`, `EnergySystemConfig` + +Implementation details from spec: +- `BusState.net_deficit_w` = `total_demand_w - total_supply_w` +- `BusState.is_balanced()` checks `abs(total_demand_w - total_supply_w - grid_power_w) < 0.01` +- `BESSConfig.initial_soe_kwh` defaults to `None` (factory computes 50% of nameplate) +- `EnergySystemConfig.loads` uses `field(default_factory=list)` + +- [ ] **Step 3: Write type smoke tests** + +Create `tests/test_energy/test_components.py` with initial type tests: + +```python +"""Layer 1: Component unit tests for the energy system.""" + +from __future__ import annotations + +from span_panel_simulator.energy.types import ( + BESSConfig, + BusState, + ComponentRole, + EnergySystemConfig, + GridConfig, + LoadConfig, + PowerContribution, + PowerInputs, + PVConfig, + SystemState, +) + + +class TestPowerContribution: + def test_default_zero(self) -> None: + pc = PowerContribution() + assert pc.demand_w == 0.0 + assert pc.supply_w == 0.0 + + def test_demand_only(self) -> None: + pc = PowerContribution(demand_w=5000.0) + assert pc.demand_w == 5000.0 + assert pc.supply_w == 0.0 + + +class TestBusState: + def test_net_deficit_positive(self) -> None: + bs = BusState(total_demand_w=5000.0, total_supply_w=3000.0) + assert bs.net_deficit_w == 2000.0 + + def test_net_deficit_negative_means_excess(self) -> None: + bs = BusState(total_demand_w=2000.0, total_supply_w=5000.0) + assert bs.net_deficit_w == -3000.0 + + def test_balanced_when_grid_absorbs_residual(self) -> None: + bs = BusState( + total_demand_w=5000.0, + total_supply_w=3000.0, + grid_power_w=2000.0, + ) + assert bs.is_balanced() + + def test_not_balanced_when_residual_exists(self) -> None: + bs = BusState( + total_demand_w=5000.0, + total_supply_w=3000.0, + grid_power_w=1000.0, + ) + assert not bs.is_balanced() + + +class TestComponentRole: + def test_role_ordering(self) -> None: + roles = [ComponentRole.SLACK, ComponentRole.LOAD, ComponentRole.STORAGE, ComponentRole.SOURCE] + sorted_roles = sorted(roles, key=lambda r: r.value) + assert sorted_roles == [ + ComponentRole.LOAD, + ComponentRole.SOURCE, + ComponentRole.STORAGE, + ComponentRole.SLACK, + ] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python -m pytest tests/test_energy/test_components.py -v` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/energy/ tests/test_energy/ +git commit -m "Add energy system types module with core dataclasses" +``` + +--- + +### Task 2: GridMeter and LoadGroup Components + +**Files:** +- Create: `src/span_panel_simulator/energy/components.py` +- Test: `tests/test_energy/test_components.py` + +- [ ] **Step 1: Write failing tests for GridMeter and LoadGroup** + +Append to `tests/test_energy/test_components.py`: + +```python +from span_panel_simulator.energy.components import GridMeter, LoadGroup + + +class TestLoadGroup: + def test_returns_demand(self) -> None: + load = LoadGroup(demand_w=5000.0) + contribution = load.resolve(BusState()) + assert contribution.demand_w == 5000.0 + assert contribution.supply_w == 0.0 + + def test_zero_demand(self) -> None: + load = LoadGroup(demand_w=0.0) + contribution = load.resolve(BusState()) + assert contribution.demand_w == 0.0 + + +class TestGridMeter: + def test_absorbs_deficit(self) -> None: + grid = GridMeter(connected=True) + bus = BusState(total_demand_w=5000.0, total_supply_w=3000.0) + contribution = grid.resolve(bus) + assert contribution.supply_w == 2000.0 + assert contribution.demand_w == 0.0 + + def test_absorbs_excess(self) -> None: + grid = GridMeter(connected=True) + bus = BusState(total_demand_w=2000.0, total_supply_w=5000.0) + contribution = grid.resolve(bus) + assert contribution.demand_w == 3000.0 + assert contribution.supply_w == 0.0 + + def test_zero_when_balanced(self) -> None: + grid = GridMeter(connected=True) + bus = BusState(total_demand_w=3000.0, total_supply_w=3000.0) + contribution = grid.resolve(bus) + assert contribution.demand_w == 0.0 + assert contribution.supply_w == 0.0 + + def test_zero_when_disconnected(self) -> None: + grid = GridMeter(connected=False) + bus = BusState(total_demand_w=5000.0, total_supply_w=0.0) + contribution = grid.resolve(bus) + assert contribution.demand_w == 0.0 + assert contribution.supply_w == 0.0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python -m pytest tests/test_energy/test_components.py::TestGridMeter -v` +Expected: FAIL with `ImportError` + +- [ ] **Step 3: Implement GridMeter and LoadGroup** + +Create `src/span_panel_simulator/energy/components.py`: + +```python +"""Physical energy components for the panel bus. + +Each component has a role (LOAD, SOURCE, STORAGE, SLACK) and implements +``resolve()`` which returns a ``PowerContribution`` given the current +``BusState``. All power values are non-negative magnitudes; direction +is expressed by which field (``demand_w`` vs ``supply_w``) is populated. +""" + +from __future__ import annotations + +from span_panel_simulator.energy.types import ( + BusState, + ComponentRole, + PowerContribution, +) + + +class Component: + """Base class for all bus components.""" + + role: ComponentRole + + def resolve(self, bus_state: BusState) -> PowerContribution: + raise NotImplementedError + + +class LoadGroup(Component): + """Consumer load — declares demand on the bus.""" + + role = ComponentRole.LOAD + + def __init__(self, demand_w: float = 0.0) -> None: + self.demand_w = demand_w + + def resolve(self, bus_state: BusState) -> PowerContribution: + return PowerContribution(demand_w=self.demand_w) + + +class GridMeter(Component): + """Utility grid connection — slack bus that absorbs residual.""" + + role = ComponentRole.SLACK + + def __init__(self, connected: bool = True) -> None: + self.connected = connected + + def resolve(self, bus_state: BusState) -> PowerContribution: + if not self.connected: + return PowerContribution() + deficit = bus_state.net_deficit_w + if deficit > 0: + return PowerContribution(supply_w=deficit) + elif deficit < 0: + return PowerContribution(demand_w=-deficit) + return PowerContribution() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python -m pytest tests/test_energy/test_components.py -v` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/energy/components.py tests/test_energy/test_components.py +git commit -m "Add GridMeter and LoadGroup energy components" +``` + +--- + +### Task 3: PVSource Component + +**Files:** +- Modify: `src/span_panel_simulator/energy/components.py` +- Test: `tests/test_energy/test_components.py` + +- [ ] **Step 1: Write failing tests for PVSource** + +Append to `tests/test_energy/test_components.py`: + +```python +from span_panel_simulator.energy.components import GridMeter, LoadGroup, PVSource + + +class TestPVSource: + def test_returns_available_power_when_online(self) -> None: + pv = PVSource(available_power_w=4000.0, online=True) + contribution = pv.resolve(BusState()) + assert contribution.supply_w == 4000.0 + assert contribution.demand_w == 0.0 + + def test_returns_zero_when_offline(self) -> None: + pv = PVSource(available_power_w=4000.0, online=False) + contribution = pv.resolve(BusState()) + assert contribution.supply_w == 0.0 + assert contribution.demand_w == 0.0 + + def test_zero_production(self) -> None: + pv = PVSource(available_power_w=0.0, online=True) + contribution = pv.resolve(BusState()) + assert contribution.supply_w == 0.0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python -m pytest tests/test_energy/test_components.py::TestPVSource -v` +Expected: FAIL with `ImportError` + +- [ ] **Step 3: Implement PVSource** + +Add to `src/span_panel_simulator/energy/components.py`: + +```python +class PVSource(Component): + """Solar PV inverter — declares available production.""" + + role = ComponentRole.SOURCE + + def __init__(self, available_power_w: float = 0.0, online: bool = True) -> None: + self.available_power_w = available_power_w + self.online = online + + def resolve(self, bus_state: BusState) -> PowerContribution: + if not self.online: + return PowerContribution() + return PowerContribution(supply_w=self.available_power_w) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python -m pytest tests/test_energy/test_components.py -v` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/energy/components.py tests/test_energy/test_components.py +git commit -m "Add PVSource energy component" +``` + +--- + +### Task 4: BESSUnit Component — Discharge and GFE + +**Files:** +- Modify: `src/span_panel_simulator/energy/components.py` +- Test: `tests/test_energy/test_components.py` + +- [ ] **Step 1: Write failing tests for BESSUnit discharge behavior** + +Append to `tests/test_energy/test_components.py`: + +```python +from span_panel_simulator.energy.components import BESSUnit, GridMeter, LoadGroup, PVSource + + +class TestBESSUnitDischarge: + def _make_bess( + self, + *, + nameplate_kwh: float = 13.5, + max_discharge_w: float = 5000.0, + max_charge_w: float = 3500.0, + soe_kwh: float = 10.0, + backup_reserve_pct: float = 20.0, + hard_min_pct: float = 5.0, + scheduled_state: str = "discharging", + requested_power_w: float = 5000.0, + ) -> BESSUnit: + return BESSUnit( + nameplate_capacity_kwh=nameplate_kwh, + max_charge_w=max_charge_w, + max_discharge_w=max_discharge_w, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=backup_reserve_pct, + hard_min_pct=hard_min_pct, + hybrid=False, + pv_source=None, + soe_kwh=soe_kwh, + scheduled_state=scheduled_state, + requested_power_w=requested_power_w, + ) + + def test_discharge_throttled_to_deficit(self) -> None: + """GFE: only source what loads demand.""" + bess = self._make_bess(requested_power_w=5000.0) + bus = BusState(total_demand_w=2000.0, total_supply_w=0.0) + contribution = bess.resolve(bus) + assert contribution.supply_w == 2000.0 + assert bess.effective_state == "discharging" + + def test_discharge_idle_when_no_deficit(self) -> None: + """Solar covers all loads — no discharge needed.""" + bess = self._make_bess(requested_power_w=5000.0) + bus = BusState(total_demand_w=3000.0, total_supply_w=4000.0) + contribution = bess.resolve(bus) + assert contribution.supply_w == 0.0 + assert bess.effective_state == "idle" + assert bess.effective_power_w == 0.0 + + def test_discharge_limited_by_max_rate(self) -> None: + bess = self._make_bess(max_discharge_w=2000.0, requested_power_w=5000.0) + bus = BusState(total_demand_w=8000.0, total_supply_w=0.0) + contribution = bess.resolve(bus) + assert contribution.supply_w == 2000.0 + + def test_discharge_limited_by_requested(self) -> None: + bess = self._make_bess(requested_power_w=1500.0) + bus = BusState(total_demand_w=5000.0, total_supply_w=0.0) + contribution = bess.resolve(bus) + assert contribution.supply_w == 1500.0 + + def test_discharge_stops_at_backup_reserve(self) -> None: + """SOE at backup reserve — cannot discharge.""" + bess = self._make_bess( + nameplate_kwh=10.0, + soe_kwh=2.0, # exactly 20% of 10 kWh + backup_reserve_pct=20.0, + ) + bus = BusState(total_demand_w=5000.0, total_supply_w=0.0) + contribution = bess.resolve(bus) + assert contribution.supply_w == 0.0 + assert bess.effective_state == "idle" + + def test_idle_state_returns_zero(self) -> None: + bess = self._make_bess(scheduled_state="idle") + bus = BusState(total_demand_w=5000.0, total_supply_w=0.0) + contribution = bess.resolve(bus) + assert contribution.supply_w == 0.0 + assert contribution.demand_w == 0.0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python -m pytest tests/test_energy/test_components.py::TestBESSUnitDischarge -v` +Expected: FAIL with `ImportError` + +- [ ] **Step 3: Implement BESSUnit** + +Add to `src/span_panel_simulator/energy/components.py`: + +```python +_SOE_MAX_PCT = 100.0 +_MAX_INTEGRATION_DELTA_S = 300.0 + + +class BESSUnit(Component): + """Battery Energy Storage System — GFE-aware storage component. + + When discharging, the BESS only sources what the bus actually demands + (GFE constraint). Enforces SOE bounds, max charge/discharge rates, + and efficiency losses. Controls co-located PV online status when + configured as a hybrid inverter. + """ + + role = ComponentRole.STORAGE + + def __init__( + self, + *, + nameplate_capacity_kwh: float, + max_charge_w: float, + max_discharge_w: float, + charge_efficiency: float, + discharge_efficiency: float, + backup_reserve_pct: float, + hard_min_pct: float, + hybrid: bool, + pv_source: PVSource | None, + soe_kwh: float, + scheduled_state: str = "idle", + requested_power_w: float = 0.0, + ) -> None: + self.nameplate_capacity_kwh = nameplate_capacity_kwh + self.max_charge_w = max_charge_w + self.max_discharge_w = max_discharge_w + self.charge_efficiency = charge_efficiency + self.discharge_efficiency = discharge_efficiency + self.backup_reserve_pct = backup_reserve_pct + self.hard_min_pct = hard_min_pct + self.hybrid = hybrid + self.pv_source = pv_source + self.soe_kwh = soe_kwh + self.scheduled_state = scheduled_state + self.requested_power_w = requested_power_w + + # Output — set by resolve() + self.effective_power_w: float = 0.0 + self.effective_state: str = "idle" + + # Timestamp tracking for energy integration + self._last_ts: float | None = None + + @property + def soe_percentage(self) -> float: + if self.nameplate_capacity_kwh <= 0: + return 0.0 + return self.soe_kwh / self.nameplate_capacity_kwh * 100.0 + + def resolve(self, bus_state: BusState) -> PowerContribution: + if self.scheduled_state == "idle": + self.effective_power_w = 0.0 + self.effective_state = "idle" + return PowerContribution() + if self.scheduled_state == "discharging": + return self._resolve_discharge(bus_state) + if self.scheduled_state == "charging": + return self._resolve_charge(bus_state) + self.effective_power_w = 0.0 + self.effective_state = "idle" + return PowerContribution() + + def _resolve_discharge(self, bus_state: BusState) -> PowerContribution: + deficit = bus_state.net_deficit_w + if deficit <= 0: + self.effective_power_w = 0.0 + self.effective_state = "idle" + return PowerContribution() + max_for_soe = self._max_discharge_for_soe() + if max_for_soe <= 0: + self.effective_power_w = 0.0 + self.effective_state = "idle" + return PowerContribution() + power = min(self.requested_power_w, deficit, self.max_discharge_w, max_for_soe) + self.effective_power_w = power + self.effective_state = "discharging" + return PowerContribution(supply_w=power) + + def _resolve_charge(self, bus_state: BusState) -> PowerContribution: + max_for_soe = self._max_charge_for_soe() + if max_for_soe <= 0: + self.effective_power_w = 0.0 + self.effective_state = "idle" + return PowerContribution() + power = min(self.requested_power_w, self.max_charge_w, max_for_soe) + self.effective_power_w = power + self.effective_state = "charging" + return PowerContribution(demand_w=power) + + def _max_discharge_for_soe(self) -> float: + """Max discharge power before hitting SOE floor (backup reserve).""" + min_kwh = self.nameplate_capacity_kwh * self.backup_reserve_pct / 100.0 + available_kwh = self.soe_kwh - min_kwh + if available_kwh <= 0: + return 0.0 + # Convert to instantaneous watts (assuming 1-second resolution is + # conservative; actual integration uses real delta). + return available_kwh * 1000.0 * 3600.0 # effectively unlimited for a single tick + + def _max_charge_for_soe(self) -> float: + """Max charge power before hitting SOE ceiling (100%).""" + max_kwh = self.nameplate_capacity_kwh * _SOE_MAX_PCT / 100.0 + headroom_kwh = max_kwh - self.soe_kwh + if headroom_kwh <= 0: + return 0.0 + return headroom_kwh * 1000.0 * 3600.0 # effectively unlimited for a single tick + + def integrate_energy(self, ts: float) -> None: + """Integrate effective power over elapsed time to update SOE.""" + if self._last_ts is None: + self._last_ts = ts + return + + delta_s = ts - self._last_ts + self._last_ts = ts + if delta_s <= 0: + return + delta_s = min(delta_s, _MAX_INTEGRATION_DELTA_S) + delta_hours = delta_s / 3600.0 + + mag = abs(self.effective_power_w) + if self.effective_state == "charging" and mag > 0: + energy_kwh = (mag / 1000.0) * delta_hours * self.charge_efficiency + self.soe_kwh += energy_kwh + elif self.effective_state == "discharging" and mag > 0: + energy_kwh = (mag / 1000.0) * delta_hours / self.discharge_efficiency + self.soe_kwh -= energy_kwh + + max_kwh = self.nameplate_capacity_kwh * _SOE_MAX_PCT / 100.0 + min_kwh = self.nameplate_capacity_kwh * self.hard_min_pct / 100.0 + self.soe_kwh = max(min_kwh, min(max_kwh, self.soe_kwh)) + + def update_pv_online_status(self, grid_connected: bool) -> None: + """Control co-located PV based on hybrid inverter capability.""" + if self.pv_source is None: + return + if self.hybrid: + self.pv_source.online = True + elif not grid_connected: + self.pv_source.online = False + else: + self.pv_source.online = True +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python -m pytest tests/test_energy/test_components.py -v` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/energy/components.py tests/test_energy/test_components.py +git commit -m "Add BESSUnit component with GFE discharge throttling" +``` + +--- + +### Task 5: BESSUnit — Charging, SOE Integration, and Hybrid PV Control + +**Files:** +- Test: `tests/test_energy/test_components.py` + +- [ ] **Step 1: Write failing tests for charging, SOE, and hybrid PV** + +Append to `tests/test_energy/test_components.py`: + +```python +class TestBESSUnitCharge: + def _make_bess(self, **kwargs: object) -> BESSUnit: + defaults: dict[str, object] = dict( + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=5000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=20.0, + hard_min_pct=5.0, + hybrid=False, + pv_source=None, + soe_kwh=6.75, + scheduled_state="charging", + requested_power_w=3000.0, + ) + defaults.update(kwargs) + return BESSUnit(**defaults) + + def test_charge_at_requested_rate(self) -> None: + bess = self._make_bess(requested_power_w=2000.0) + bus = BusState(total_demand_w=1000.0, total_supply_w=5000.0) + contribution = bess.resolve(bus) + assert contribution.demand_w == 2000.0 + assert bess.effective_state == "charging" + + def test_charge_limited_by_max_rate(self) -> None: + bess = self._make_bess(max_charge_w=1500.0, requested_power_w=3000.0) + bus = BusState() + contribution = bess.resolve(bus) + assert contribution.demand_w == 1500.0 + + def test_charge_stops_at_full_soe(self) -> None: + bess = self._make_bess(nameplate_capacity_kwh=10.0, soe_kwh=10.0) + bus = BusState() + contribution = bess.resolve(bus) + assert contribution.demand_w == 0.0 + assert bess.effective_state == "idle" + + +class TestBESSUnitSOEIntegration: + def _make_bess(self, **kwargs: object) -> BESSUnit: + defaults: dict[str, object] = dict( + nameplate_capacity_kwh=10.0, + max_charge_w=3500.0, + max_discharge_w=5000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=20.0, + hard_min_pct=5.0, + hybrid=False, + pv_source=None, + soe_kwh=5.0, + scheduled_state="idle", + requested_power_w=0.0, + ) + defaults.update(kwargs) + return BESSUnit(**defaults) + + def test_discharge_decreases_soe(self) -> None: + bess = self._make_bess( + soe_kwh=5.0, + scheduled_state="discharging", + requested_power_w=2000.0, + ) + bus = BusState(total_demand_w=5000.0, total_supply_w=0.0) + bess.resolve(bus) + # First tick establishes timestamp + bess.integrate_energy(1000.0) + # Second tick integrates over 1 hour + bess.integrate_energy(4600.0) # 3600 seconds later + # 2000W for 1 hour / 0.95 efficiency = ~2.105 kWh consumed + assert bess.soe_kwh < 5.0 + expected = 5.0 - (2.0 / 0.95) + assert abs(bess.soe_kwh - expected) < 0.01 + + def test_charge_increases_soe(self) -> None: + bess = self._make_bess( + soe_kwh=3.0, + scheduled_state="charging", + requested_power_w=2000.0, + ) + bus = BusState() + bess.resolve(bus) + bess.integrate_energy(1000.0) + bess.integrate_energy(4600.0) + # 2000W for 1 hour * 0.95 efficiency = 1.9 kWh stored + expected = 3.0 + (2.0 * 0.95) + assert abs(bess.soe_kwh - expected) < 0.01 + + def test_soe_clamped_to_bounds(self) -> None: + bess = self._make_bess(nameplate_capacity_kwh=10.0, soe_kwh=9.9) + bess.effective_state = "charging" + bess.effective_power_w = 50000.0 # absurdly high + bess.integrate_energy(0.0) + bess.integrate_energy(3600.0) + assert bess.soe_kwh <= 10.0 + + +class TestBESSUnitHybridPV: + def test_hybrid_keeps_pv_online_when_grid_disconnected(self) -> None: + pv = PVSource(available_power_w=4000.0, online=True) + bess = BESSUnit( + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=5000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=20.0, + hard_min_pct=5.0, + hybrid=True, + pv_source=pv, + soe_kwh=6.75, + ) + bess.update_pv_online_status(grid_connected=False) + assert pv.online is True + + def test_non_hybrid_sheds_pv_when_grid_disconnected(self) -> None: + pv = PVSource(available_power_w=4000.0, online=True) + bess = BESSUnit( + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=5000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=20.0, + hard_min_pct=5.0, + hybrid=False, + pv_source=pv, + soe_kwh=6.75, + ) + bess.update_pv_online_status(grid_connected=False) + assert pv.online is False + + def test_non_hybrid_pv_online_when_grid_connected(self) -> None: + pv = PVSource(available_power_w=4000.0, online=False) + bess = BESSUnit( + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=5000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=20.0, + hard_min_pct=5.0, + hybrid=False, + pv_source=pv, + soe_kwh=6.75, + ) + bess.update_pv_online_status(grid_connected=True) + assert pv.online is True +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `.venv/bin/python -m pytest tests/test_energy/test_components.py -v` +Expected: All pass (implementation already done in Task 4) + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_energy/test_components.py +git commit -m "Add BESSUnit charge, SOE integration, and hybrid PV tests" +``` + +--- + +### Task 6: PanelBus + +**Files:** +- Create: `src/span_panel_simulator/energy/bus.py` +- Test: `tests/test_energy/test_bus.py` + +- [ ] **Step 1: Write failing bus integration tests** + +Create `tests/test_energy/test_bus.py`: + +```python +"""Layer 2: Bus integration tests — conservation enforcement.""" + +from __future__ import annotations + +from span_panel_simulator.energy.bus import PanelBus +from span_panel_simulator.energy.components import BESSUnit, GridMeter, LoadGroup, PVSource + + +def _make_bess(**kwargs: object) -> BESSUnit: + defaults: dict[str, object] = dict( + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=5000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=20.0, + hard_min_pct=5.0, + hybrid=False, + pv_source=None, + soe_kwh=10.0, + scheduled_state="idle", + requested_power_w=0.0, + ) + defaults.update(kwargs) + return BESSUnit(**defaults) + + +class TestBusConservation: + def test_load_only_grid_covers(self) -> None: + bus = PanelBus( + components=[LoadGroup(demand_w=5000.0), GridMeter(connected=True)] + ) + state = bus.resolve() + assert state.is_balanced() + assert state.grid_power_w == 5000.0 + + def test_load_and_pv_grid_covers_deficit(self) -> None: + bus = PanelBus( + components=[ + LoadGroup(demand_w=5000.0), + PVSource(available_power_w=3000.0, online=True), + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() + assert abs(state.grid_power_w - 2000.0) < 0.01 + + def test_pv_exceeds_load_grid_absorbs(self) -> None: + bus = PanelBus( + components=[ + LoadGroup(demand_w=2000.0), + PVSource(available_power_w=5000.0, online=True), + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() + # Grid absorbs 3kW excess (negative = exporting/absorbing) + assert abs(state.grid_power_w - (-3000.0)) < 0.01 + # Demand/supply only reflect non-SLACK components + assert abs(state.total_demand_w - 2000.0) < 0.01 + assert abs(state.total_supply_w - 5000.0) < 0.01 + + def test_bess_discharge_reduces_grid(self) -> None: + bess = _make_bess(scheduled_state="discharging", requested_power_w=3000.0) + bus = PanelBus( + components=[ + LoadGroup(demand_w=5000.0), + bess, + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() + assert abs(state.grid_power_w - 2000.0) < 0.01 + + def test_bess_charge_increases_grid(self) -> None: + bess = _make_bess(scheduled_state="charging", requested_power_w=3000.0, soe_kwh=5.0) + bus = PanelBus( + components=[ + LoadGroup(demand_w=2000.0), + bess, + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() + assert abs(state.grid_power_w - 5000.0) < 0.01 + + def test_grid_never_negative_from_bess_discharge(self) -> None: + bess = _make_bess(scheduled_state="discharging", requested_power_w=5000.0) + bus = PanelBus( + components=[ + LoadGroup(demand_w=2000.0), + PVSource(available_power_w=1000.0, online=True), + bess, + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() + assert state.grid_power_w >= -0.01 # never negative + + def test_conservation_grid_disconnected(self) -> None: + bess = _make_bess(scheduled_state="discharging", requested_power_w=5000.0) + bus = PanelBus( + components=[ + LoadGroup(demand_w=3000.0), + bess, + GridMeter(connected=False), + ] + ) + state = bus.resolve() + assert state.is_balanced() + assert abs(state.grid_power_w) < 0.01 + + def test_conservation_no_bess(self) -> None: + bus = PanelBus( + components=[ + LoadGroup(demand_w=5000.0), + PVSource(available_power_w=2000.0, online=True), + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() + + def test_conservation_no_pv(self) -> None: + bus = PanelBus( + components=[ + LoadGroup(demand_w=5000.0), + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python -m pytest tests/test_energy/test_bus.py -v` +Expected: FAIL with `ImportError` + +- [ ] **Step 3: Implement PanelBus** + +Create `src/span_panel_simulator/energy/bus.py`: + +```python +"""PanelBus — role-ordered power flow resolution with conservation enforcement.""" + +from __future__ import annotations + +from span_panel_simulator.energy.components import Component +from span_panel_simulator.energy.types import BusState, ComponentRole + + +class PanelBus: + """Resolves components in role order and enforces conservation of energy. + + Components are evaluated in order: LOAD -> SOURCE -> STORAGE -> SLACK. + SLACK (grid) contributions are tracked in ``grid_power_w`` only — they + are NOT folded into ``total_demand_w``/``total_supply_w`` so that the + conservation check ``total_demand = total_supply + grid_power`` holds + without double-counting. + """ + + def __init__(self, components: list[Component]) -> None: + self._components = components + + def resolve(self) -> BusState: + state = BusState() + for role in (ComponentRole.LOAD, ComponentRole.SOURCE, ComponentRole.STORAGE, ComponentRole.SLACK): + for component in self._components: + if component.role != role: + continue + contribution = component.resolve(state) + if role == ComponentRole.SLACK: + # Grid tracked separately — not in demand/supply totals + state.grid_power_w += contribution.supply_w - contribution.demand_w + else: + state.total_demand_w += contribution.demand_w + state.total_supply_w += contribution.supply_w + if role == ComponentRole.STORAGE: + state.storage_contribution_w += contribution.supply_w - contribution.demand_w + return state +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python -m pytest tests/test_energy/test_bus.py -v` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/energy/bus.py tests/test_energy/test_bus.py +git commit -m "Add PanelBus with role-ordered resolution and conservation checks" +``` + +--- + +### Task 7: EnergySystem + +**Files:** +- Create: `src/span_panel_simulator/energy/system.py` +- Modify: `src/span_panel_simulator/energy/__init__.py` +- Test: `tests/test_energy/test_scenarios.py` + +- [ ] **Step 1: Write failing scenario tests** + +Create `tests/test_energy/test_scenarios.py`: + +```python +"""Layer 3: Topology and scenario tests covering all identified issues.""" + +from __future__ import annotations + +from span_panel_simulator.energy.system import EnergySystem +from span_panel_simulator.energy.types import ( + BESSConfig, + EnergySystemConfig, + GridConfig, + LoadConfig, + PowerInputs, + PVConfig, +) + + +def _grid_online() -> GridConfig: + return GridConfig(connected=True) + + +def _grid_offline() -> GridConfig: + return GridConfig(connected=False) + + +def _pv(nameplate_w: float = 6000.0, inverter_type: str = "ac_coupled") -> PVConfig: + return PVConfig(nameplate_w=nameplate_w, inverter_type=inverter_type) + + +def _bess( + *, + nameplate_kwh: float = 13.5, + max_discharge_w: float = 5000.0, + max_charge_w: float = 3500.0, + hybrid: bool = False, + backup_reserve_pct: float = 20.0, + initial_soe_kwh: float | None = None, +) -> BESSConfig: + return BESSConfig( + nameplate_kwh=nameplate_kwh, + max_discharge_w=max_discharge_w, + max_charge_w=max_charge_w, + hybrid=hybrid, + backup_reserve_pct=backup_reserve_pct, + initial_soe_kwh=initial_soe_kwh, + ) + + +# === GFE and Discharge Throttling (Issues 1, 2) === + + +class TestGFEThrottling: + def test_grid_never_negative_from_bess_discharge(self) -> None: + system = EnergySystem.from_config(EnergySystemConfig( + grid=_grid_online(), + pv=_pv(), + bess=_bess(), + loads=[LoadConfig(demand_w=3000.0)], + )) + state = system.tick(1000.0, PowerInputs( + pv_available_w=2000.0, + bess_scheduled_state="discharging", + bess_requested_w=5000.0, + load_demand_w=3000.0, + grid_connected=True, + )) + assert state.balanced + assert state.grid_power_w >= -0.01 + assert state.bess_power_w == 1000.0 + + def test_bess_covers_exact_deficit(self) -> None: + system = EnergySystem.from_config(EnergySystemConfig( + grid=_grid_online(), + bess=_bess(), + loads=[LoadConfig(demand_w=3000.0)], + )) + state = system.tick(1000.0, PowerInputs( + bess_scheduled_state="discharging", + bess_requested_w=5000.0, + load_demand_w=3000.0, + grid_connected=True, + )) + assert state.balanced + assert abs(state.grid_power_w) < 0.01 + assert abs(state.bess_power_w - 3000.0) < 0.01 + + def test_bess_idle_when_pv_exceeds_load(self) -> None: + system = EnergySystem.from_config(EnergySystemConfig( + grid=_grid_online(), + pv=_pv(), + bess=_bess(), + loads=[LoadConfig(demand_w=2000.0)], + )) + state = system.tick(1000.0, PowerInputs( + pv_available_w=5000.0, + bess_scheduled_state="discharging", + bess_requested_w=5000.0, + load_demand_w=2000.0, + grid_connected=True, + )) + assert state.balanced + assert state.bess_power_w == 0.0 + assert state.bess_state == "idle" + + +# === Hybrid vs Non-Hybrid Islanding (Issues 3, 5, 8) === + + +class TestIslanding: + def test_non_hybrid_pv_offline_bess_covers_all(self) -> None: + system = EnergySystem.from_config(EnergySystemConfig( + grid=_grid_offline(), + pv=_pv(inverter_type="ac_coupled"), + bess=_bess(hybrid=False), + loads=[LoadConfig(demand_w=3000.0)], + )) + state = system.tick(1000.0, PowerInputs( + pv_available_w=4000.0, + bess_scheduled_state="discharging", + bess_requested_w=5000.0, + load_demand_w=3000.0, + grid_connected=False, + )) + assert state.balanced + assert state.pv_power_w == 0.0 + assert state.bess_power_w == 3000.0 + assert state.grid_power_w == 0.0 + + def test_hybrid_pv_online_bess_covers_gap(self) -> None: + system = EnergySystem.from_config(EnergySystemConfig( + grid=_grid_offline(), + pv=_pv(inverter_type="hybrid"), + bess=_bess(hybrid=True), + loads=[LoadConfig(demand_w=5000.0)], + )) + state = system.tick(1000.0, PowerInputs( + pv_available_w=3000.0, + bess_scheduled_state="discharging", + bess_requested_w=5000.0, + load_demand_w=5000.0, + grid_connected=False, + )) + assert state.balanced + assert state.pv_power_w == 3000.0 + assert state.bess_power_w == 2000.0 + assert state.grid_power_w == 0.0 + + def test_hybrid_island_solar_excess_charges_bess(self) -> None: + system = EnergySystem.from_config(EnergySystemConfig( + grid=_grid_offline(), + pv=_pv(inverter_type="hybrid"), + bess=_bess(hybrid=True, max_charge_w=3000.0, initial_soe_kwh=5.0), + loads=[LoadConfig(demand_w=2000.0)], + )) + state = system.tick(1000.0, PowerInputs( + pv_available_w=5000.0, + bess_scheduled_state="charging", + bess_requested_w=3000.0, + load_demand_w=2000.0, + grid_connected=False, + )) + assert state.balanced + assert state.pv_power_w == 5000.0 + assert state.bess_state == "charging" + assert state.bess_power_w == 3000.0 + assert state.grid_power_w == 0.0 + + def test_non_hybrid_island_ignores_solar_excess(self) -> None: + system = EnergySystem.from_config(EnergySystemConfig( + grid=_grid_offline(), + pv=_pv(inverter_type="ac_coupled"), + bess=_bess(hybrid=False), + loads=[LoadConfig(demand_w=3000.0)], + )) + state = system.tick(1000.0, PowerInputs( + pv_available_w=5000.0, + bess_scheduled_state="charging", + bess_requested_w=3000.0, + load_demand_w=3000.0, + grid_connected=False, + )) + assert state.balanced + assert state.pv_power_w == 0.0 + # Must discharge to cover load since PV is offline + assert state.bess_power_w == 3000.0 + assert state.bess_state == "discharging" + + +# === Charging/Discharging Grid Impact (Issues 6, 7, 9) === + + +class TestGridImpact: + def test_charging_increases_grid_import(self) -> None: + system = EnergySystem.from_config(EnergySystemConfig( + grid=_grid_online(), + bess=_bess(max_charge_w=3000.0), + loads=[LoadConfig(demand_w=2000.0)], + )) + state = system.tick(1000.0, PowerInputs( + bess_scheduled_state="charging", + bess_requested_w=3000.0, + load_demand_w=2000.0, + grid_connected=True, + )) + assert state.balanced + assert abs(state.grid_power_w - 5000.0) < 0.01 + + def test_discharging_decreases_grid_import(self) -> None: + system = EnergySystem.from_config(EnergySystemConfig( + grid=_grid_online(), + bess=_bess(max_discharge_w=3000.0), + loads=[LoadConfig(demand_w=5000.0)], + )) + state = system.tick(1000.0, PowerInputs( + bess_scheduled_state="discharging", + bess_requested_w=3000.0, + load_demand_w=5000.0, + grid_connected=True, + )) + assert state.balanced + assert abs(state.grid_power_w - 2000.0) < 0.01 + + def test_add_bess_reduces_grid_in_modeling(self) -> None: + config_no_bess = EnergySystemConfig( + grid=_grid_online(), + loads=[LoadConfig(demand_w=5000.0)], + ) + config_with_bess = EnergySystemConfig( + grid=_grid_online(), + bess=_bess(max_discharge_w=3000.0), + loads=[LoadConfig(demand_w=5000.0)], + ) + sys_b = EnergySystem.from_config(config_no_bess) + sys_a = EnergySystem.from_config(config_with_bess) + + state_b = sys_b.tick(1000.0, PowerInputs(load_demand_w=5000.0)) + state_a = sys_a.tick(1000.0, PowerInputs( + load_demand_w=5000.0, + bess_scheduled_state="discharging", + bess_requested_w=3000.0, + )) + assert state_b.balanced and state_a.balanced + assert abs(state_b.grid_power_w - 5000.0) < 0.01 + assert abs(state_a.grid_power_w - 2000.0) < 0.01 + + +# === No Stale State (Issue 10) === + + +class TestIndependentInstances: + def test_two_instances_share_no_state(self) -> None: + config = EnergySystemConfig( + grid=_grid_online(), + bess=_bess(initial_soe_kwh=10.0), + loads=[LoadConfig(demand_w=3000.0)], + ) + sys1 = EnergySystem.from_config(config) + sys2 = EnergySystem.from_config(config) + + # Discharge sys1 for a tick + sys1.tick(1000.0, PowerInputs( + bess_scheduled_state="discharging", + bess_requested_w=3000.0, + load_demand_w=3000.0, + )) + sys1.tick(4600.0, PowerInputs( + bess_scheduled_state="discharging", + bess_requested_w=3000.0, + load_demand_w=3000.0, + )) + + # sys2 should still have initial SOE + state2 = sys2.tick(1000.0, PowerInputs( + bess_scheduled_state="discharging", + bess_requested_w=3000.0, + load_demand_w=3000.0, + )) + assert state2.soe_kwh == 10.0 + + +# === EVSE as Consumer === + + +class TestEVSE: + def test_evse_is_pure_load(self) -> None: + system = EnergySystem.from_config(EnergySystemConfig( + grid=_grid_online(), + loads=[ + LoadConfig(demand_w=3000.0), + LoadConfig(demand_w=7200.0), # EVSE at 30A + ], + )) + state = system.tick(1000.0, PowerInputs( + load_demand_w=10200.0, + grid_connected=True, + )) + assert state.balanced + assert abs(state.grid_power_w - 10200.0) < 0.01 + assert abs(state.load_power_w - 10200.0) < 0.01 + + +# === SOE Duration (Nameplate) === + + +class TestNameplateDuration: + def test_larger_nameplate_sustains_longer(self) -> None: + small_config = EnergySystemConfig( + grid=_grid_online(), + bess=_bess(nameplate_kwh=5.0, max_discharge_w=2500.0, initial_soe_kwh=2.5), + loads=[LoadConfig(demand_w=2500.0)], + ) + large_config = EnergySystemConfig( + grid=_grid_online(), + bess=_bess(nameplate_kwh=20.0, max_discharge_w=2500.0, initial_soe_kwh=10.0), + loads=[LoadConfig(demand_w=2500.0)], + ) + small = EnergySystem.from_config(small_config) + large = EnergySystem.from_config(large_config) + + inputs = PowerInputs( + bess_scheduled_state="discharging", + bess_requested_w=2500.0, + load_demand_w=2500.0, + grid_connected=True, + ) + + # Simulate 2 hours in 60-second ticks + ts = 0.0 + for _ in range(120): + ts += 60.0 + s_small = small.tick(ts, inputs) + s_large = large.tick(ts, inputs) + + # Small BESS (5kWh, started at 50%, reserve=20%=1kWh, usable=1.5kWh) + # should be depleted; large should still be going + assert s_small.bess_state == "idle" + assert s_large.bess_state == "discharging" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python -m pytest tests/test_energy/test_scenarios.py -v` +Expected: FAIL with `ImportError` + +- [ ] **Step 3: Implement EnergySystem** + +Create `src/span_panel_simulator/energy/system.py`: + +```python +"""EnergySystem — the top-level energy balance resolver. + +Constructed from an ``EnergySystemConfig``, ticked with ``PowerInputs``, +and returns a ``SystemState``. Pure value object — no external dependencies, +no shared mutable state. +""" + +from __future__ import annotations + +from span_panel_simulator.energy.bus import PanelBus +from span_panel_simulator.energy.components import BESSUnit, GridMeter, LoadGroup, PVSource +from span_panel_simulator.energy.types import ( + BESSConfig, + EnergySystemConfig, + PowerInputs, + SystemState, +) + + +class EnergySystem: + """Component-based energy balance resolver. + + Instantiate via ``from_config()``. Call ``tick()`` each simulation + step to resolve power flows across the bus. + """ + + def __init__( + self, + bus: PanelBus, + grid: GridMeter, + pv: PVSource | None, + bess: BESSUnit | None, + load: LoadGroup, + ) -> None: + self.bus = bus + self.grid = grid + self.pv = pv + self.bess = bess + self.load = load + + @staticmethod + def from_config(config: EnergySystemConfig) -> EnergySystem: + grid = GridMeter(connected=config.grid.connected) + + pv: PVSource | None = None + if config.pv is not None: + pv = PVSource(available_power_w=0.0, online=True) + + bess: BESSUnit | None = None + if config.bess is not None: + bc: BESSConfig = config.bess + initial_soe = bc.initial_soe_kwh + if initial_soe is None: + initial_soe = bc.nameplate_kwh * 0.5 + bess = BESSUnit( + nameplate_capacity_kwh=bc.nameplate_kwh, + max_charge_w=bc.max_charge_w, + max_discharge_w=bc.max_discharge_w, + charge_efficiency=bc.charge_efficiency, + discharge_efficiency=bc.discharge_efficiency, + backup_reserve_pct=bc.backup_reserve_pct, + hard_min_pct=bc.hard_min_pct, + hybrid=bc.hybrid, + pv_source=pv, + soe_kwh=initial_soe, + ) + + total_demand = sum(lc.demand_w for lc in config.loads) + load = LoadGroup(demand_w=total_demand) + + components: list = [load] + if pv is not None: + components.append(pv) + if bess is not None: + components.append(bess) + components.append(grid) + + bus = PanelBus(components=components) + return EnergySystem(bus=bus, grid=grid, pv=pv, bess=bess, load=load) + + def tick(self, ts: float, inputs: PowerInputs) -> SystemState: + # 1. Apply topology + self.grid.connected = inputs.grid_connected + if self.bess is not None: + self.bess.update_pv_online_status(inputs.grid_connected) + elif self.pv is not None and not inputs.grid_connected: + self.pv.online = False + + # 2. Set component inputs + self.load.demand_w = inputs.load_demand_w + if self.pv is not None: + self.pv.available_power_w = inputs.pv_available_w + if self.bess is not None: + self.bess.scheduled_state = inputs.bess_scheduled_state + self.bess.requested_power_w = inputs.bess_requested_w + + # Non-hybrid islanding override: if grid disconnected and PV + # is offline, BESS must discharge regardless of schedule + if not inputs.grid_connected and not self.bess.hybrid: + self.bess.scheduled_state = "discharging" + self.bess.requested_power_w = self.bess.max_discharge_w + + # 3. Resolve bus + bus_state = self.bus.resolve() + + # 4. Integrate BESS energy + if self.bess is not None: + self.bess.integrate_energy(ts) + + # 5. Return resolved state + pv_power = 0.0 + if self.pv is not None and self.pv.online: + pv_power = self.pv.available_power_w + + bess_power = 0.0 + bess_state = "idle" + soe_kwh = 0.0 + soe_pct = 0.0 + if self.bess is not None: + bess_power = self.bess.effective_power_w + bess_state = self.bess.effective_state + soe_kwh = self.bess.soe_kwh + soe_pct = self.bess.soe_percentage + + return SystemState( + grid_power_w=bus_state.grid_power_w, + pv_power_w=pv_power, + bess_power_w=bess_power, + bess_state=bess_state, + load_power_w=inputs.load_demand_w, + soe_kwh=soe_kwh, + soe_percentage=soe_pct, + balanced=bus_state.is_balanced(), + ) +``` + +- [ ] **Step 4: Update `__init__.py` with public API exports** + +Update `src/span_panel_simulator/energy/__init__.py`: + +```python +"""Component-based energy system for the SPAN panel simulator. + +Public API: + EnergySystem — top-level resolver (construct via from_config) + SystemState — resolved output of a tick + PowerInputs — external inputs that drive each tick + EnergySystemConfig, GridConfig, PVConfig, BESSConfig, LoadConfig — configuration +""" + +from span_panel_simulator.energy.system import EnergySystem +from span_panel_simulator.energy.types import ( + BESSConfig, + EnergySystemConfig, + GridConfig, + LoadConfig, + PowerInputs, + PVConfig, + SystemState, +) + +__all__ = [ + "BESSConfig", + "EnergySystem", + "EnergySystemConfig", + "GridConfig", + "LoadConfig", + "PowerInputs", + "PVConfig", + "SystemState", +] +``` + +- [ ] **Step 5: Run all energy tests** + +Run: `.venv/bin/python -m pytest tests/test_energy/ -v` +Expected: All pass + +- [ ] **Step 6: Run full test suite to confirm nothing is broken** + +Run: `.venv/bin/python -m pytest tests/ -v` +Expected: All 193+ tests pass + +- [ ] **Step 7: Commit** + +```bash +git add src/span_panel_simulator/energy/ tests/test_energy/ +git commit -m "Add EnergySystem with from_config factory and full scenario tests" +``` + +--- + +## Phase 2: Wire Into Engine + +### Task 8: Add EnergySystem to Engine Initialization + +**Files:** +- Modify: `src/span_panel_simulator/engine.py` + +The engine needs to construct an `EnergySystem` from its configuration. This task adds the construction without yet using it for power flow — that comes in Task 9. + +- [ ] **Step 1: Add import and instance variable** + +In `src/span_panel_simulator/engine.py`, add import after existing imports (after line 30): + +```python +from span_panel_simulator.energy import ( + BESSConfig, + EnergySystem, + EnergySystemConfig, + GridConfig, + LoadConfig, + PowerInputs, + PVConfig, + SystemState, +) +``` + +In `DynamicSimulationEngine.__init__()` (after line 843 `self._bsee` assignment), add: + +```python +self._energy_system: EnergySystem | None = None +``` + +- [ ] **Step 2: Add `_build_energy_system()` helper** + +Add method to `DynamicSimulationEngine` (after `_create_bsee()`, after line 1839): + +```python +def _build_energy_system(self) -> EnergySystem | None: + """Construct an EnergySystem from current circuit configuration.""" + if not self._config: + return None + + # Grid config + grid_config = GridConfig(connected=not self._forced_grid_offline) + + # PV config — find producer circuits + pv_config: PVConfig | None = None + for circuit in self._circuits.values(): + if circuit.energy_mode == "producer": + nameplate = float(circuit.template.get("typical_power", 0)) + inverter_type = circuit.template.get("inverter_type", "ac_coupled") + pv_config = PVConfig(nameplate_w=abs(nameplate), inverter_type=inverter_type) + break + + # BESS config — from battery circuit template + bess_config: BESSConfig | None = None + battery_circuit = self._find_battery_circuit() + if battery_circuit is not None: + battery_cfg = battery_circuit.template.get("battery_behavior", {}) + if isinstance(battery_cfg, dict) and battery_cfg.get("enabled", False): + nameplate = float(battery_cfg.get("nameplate_capacity_kwh", 13.5)) + hybrid = battery_cfg.get("inverter_type") == "hybrid" + bess_config = BESSConfig( + nameplate_kwh=nameplate, + max_charge_w=abs(float(battery_cfg.get("max_charge_power", 3500.0))), + max_discharge_w=abs(float(battery_cfg.get("max_discharge_power", 3500.0))), + charge_efficiency=float(battery_cfg.get("charge_efficiency", 0.95)), + discharge_efficiency=float(battery_cfg.get("discharge_efficiency", 0.95)), + backup_reserve_pct=float(battery_cfg.get("backup_reserve_pct", 20.0)), + hybrid=hybrid, + initial_soe_kwh=self._bsee.soe_kwh if self._bsee is not None else None, + ) + + # Load configs — all consumer circuits + loads = [LoadConfig() for c in self._circuits.values() if c.energy_mode == "consumer"] + + config = EnergySystemConfig( + grid=grid_config, + pv=pv_config, + bess=bess_config, + loads=loads, + ) + return EnergySystem.from_config(config) +``` + +- [ ] **Step 3: Construct energy system during initialization** + +In `initialize_async()` (after `_create_bsee()` call, after line 906), add: + +```python +self._energy_system = self._build_energy_system() +``` + +- [ ] **Step 4: Run full test suite** + +Run: `.venv/bin/python -m pytest tests/ -v` +Expected: All tests pass (energy system constructed but not yet used for output) + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/energy/ src/span_panel_simulator/engine.py +git commit -m "Wire EnergySystem construction into engine initialization" +``` + +--- + +### Task 9: Use EnergySystem in `get_snapshot()` + +**Files:** +- Modify: `src/span_panel_simulator/engine.py` + +Replace the inline energy aggregation (lines ~1250-1296) with `EnergySystem.tick()`. The engine still ticks circuits via the behavior engine, but reads grid/battery/pv power from `SystemState`. + +- [ ] **Step 1: Add `_collect_power_inputs()` helper** + +Add method to `DynamicSimulationEngine`: + +```python +def _collect_power_inputs(self) -> PowerInputs: + """Collect current circuit state into PowerInputs for the energy system.""" + pv_power = 0.0 + load_power = 0.0 + bess_power = 0.0 + bess_state = "idle" + + for circuit in self._circuits.values(): + power = circuit.instant_power_w + if circuit.energy_mode == "producer": + pv_power += power + elif circuit.energy_mode == "bidirectional": + bess_power = power + if self._bsee is not None: + bess_state = self._bsee.battery_state + elif self._behavior_engine is not None: + bess_state = self._behavior_engine.last_battery_direction + else: + load_power += power + + return PowerInputs( + pv_available_w=pv_power, + bess_requested_w=bess_power, + bess_scheduled_state=bess_state, + load_demand_w=load_power, + grid_connected=not self._forced_grid_offline, + ) +``` + +- [ ] **Step 2: Replace energy aggregation in `get_snapshot()`** + +In `get_snapshot()`, after the circuit ticking and global overrides (after line ~1232, before the old aggregation), replace the aggregation block (lines ~1249-1295) with: + +```python +# 5. Resolve energy balance via component system +system_state: SystemState | None = None +if self._energy_system is not None: + inputs = self._collect_power_inputs() + system_state = self._energy_system.tick(current_time, inputs) + + # Reflect effective battery power back to circuit + battery_circuit = self._find_battery_circuit() + if battery_circuit is not None and self._energy_system.bess is not None: + battery_circuit._instant_power_w = self._energy_system.bess.effective_power_w + + grid_power = system_state.grid_power_w + site_power = system_state.load_power_w - system_state.pv_power_w + battery_power_w = system_state.bess_power_w +``` + +Keep the old code path as a fallback for when `_energy_system` is None (during initialization before config is loaded). + +- [ ] **Step 3: Update BSEE interaction** + +After the energy system resolves, the old BSEE update call (line ~1286) should be skipped when the energy system is active (BESSUnit handles SOE tracking). Guard it: + +```python +if self._bsee is not None and self._energy_system is None: + self._bsee.update(current_time, battery_power_w, site_power_w=site_power) + # ... existing BSEE reflection code +``` + +- [ ] **Step 4: Run full test suite** + +Run: `.venv/bin/python -m pytest tests/ -v` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/engine.py +git commit -m "Use EnergySystem for power flow resolution in get_snapshot" +``` + +--- + +### Task 10: Use EnergySystem in `get_power_summary()` and `compute_modeling_data()` + +**Files:** +- Modify: `src/span_panel_simulator/engine.py` + +- [ ] **Step 1: Update `get_power_summary()` (lines ~1067-1154)** + +Replace the inline aggregation with a read from the energy system's last state. Add a `_last_system_state` field to cache the most recent tick result: + +In `__init__()` add: + +```python +self._last_system_state: SystemState | None = None +``` + +In `get_snapshot()` after `system_state = self._energy_system.tick(...)`: + +```python +self._last_system_state = system_state +``` + +Then simplify `get_power_summary()` to read from `_last_system_state` when available, falling back to the old logic only when the energy system isn't initialized. + +- [ ] **Step 2: Update `compute_modeling_data()` (lines ~1507-1682)** + +Replace the inline grid/battery calculation (lines ~1627-1650) with independent `EnergySystem` instances: + +In `compute_modeling_data()`, before the timestamp loop, construct two energy systems: + +```python +config_before = self._build_energy_config(baseline=True) +config_after = self._build_energy_config(baseline=False) +system_before = EnergySystem.from_config(config_before) if config_before else None +system_after = EnergySystem.from_config(config_after) if config_after else None +``` + +Add `_build_energy_config()` helper that creates `EnergySystemConfig` from the current or baseline configuration. + +Inside the timestamp loop, replace the manual grid calculation with: + +```python +if system_after is not None: + inputs_a = PowerInputs( + pv_available_w=prod_a, + bess_requested_w=raw_batt_a, + bess_scheduled_state=cloned_bsee_state, + load_demand_w=site_a + prod_a, # consumption component + grid_connected=True, + ) + state_a = system_after.tick(ts, inputs_a) + grid_after = state_a.grid_power_w + signed_battery_after = -state_a.bess_power_w if state_a.bess_state == "discharging" else state_a.bess_power_w +``` + +- [ ] **Step 3: Run full test suite** + +Run: `.venv/bin/python -m pytest tests/ -v` +Expected: All tests pass + +- [ ] **Step 4: Commit** + +```bash +git add src/span_panel_simulator/engine.py +git commit -m "Use EnergySystem in power summary and modeling data" +``` + +--- + +## Phase 3: Eliminate Old Paths + +### Task 11: Remove Old Energy Balance Code and BSEE + +**Files:** +- Modify: `src/span_panel_simulator/engine.py` +- Delete: `src/span_panel_simulator/bsee.py` +- Modify: `src/span_panel_simulator/circuit.py` + +- [ ] **Step 1: Remove old inline energy aggregation from `get_snapshot()`** + +Remove the fallback path that was guarded by `if self._energy_system is None`. At this point the energy system is always present. Remove the old aggregation code (the `total_consumption`, `total_production`, `battery_circuit_power` accumulation loop and the `site_power = total_consumption - total_production` / `grid_power = site_power - battery_circuit_power` formulas). + +- [ ] **Step 2: Remove old `get_power_summary()` inline aggregation** + +Remove the `battery_state` sign-flipping block (lines ~1093-1110) and the `max(0.0, total_consumption - total_production)` formula. Replace with reads from `_last_system_state`. + +- [ ] **Step 3: Remove old BSEE interaction from engine** + +Remove: +- `from span_panel_simulator.bsee import BatteryStorageEquipment` import (line 26) +- `self._bsee` field from `__init__()` (line 843) +- `_create_bsee()` method (lines 1809-1839) +- `_bsee` assignment in `initialize_async()` (line 906) +- The `self._bsee.update()` call and reflection block in `get_snapshot()` (lines ~1285-1295) +- `self._bsee.set_forced_offline()` in `set_grid_online()` (line 982) +- All reads of `self._bsee` properties (soe_percentage, battery_state, etc.) — replace with reads from `SystemState` or `self._energy_system.bess` +- The cloned BSEE construction in `compute_modeling_data()` (lines ~1579-1591) + +- [ ] **Step 4: Remove grid-offline force-discharge override from behavior engine** + +In `_apply_battery_behavior()` (line ~478), remove: + +```python +if self._grid_offline: + self._last_battery_direction = "discharging" + return self._get_discharge_power(battery_config, current_hour) +``` + +The `EnergySystem.tick()` now handles islanding behavior in its non-hybrid override logic. + +- [ ] **Step 5: Remove dashboard `battery = consumption - pv` shortcut** + +In `get_power_summary()` (line ~1110), remove: + +```python +if self.has_battery: + battery = total_consumption - pv +``` + +This is now handled by `SystemState`. + +- [ ] **Step 6: Update circuit.py battery direction** + +In `circuit.py` `_resolve_battery_direction()` (lines 297-320): this method should read from the energy system's `bess.effective_state` rather than querying the behavior engine's `last_battery_direction`. The engine should pass this through after each tick. + +- [ ] **Step 7: Delete `bsee.py`** + +```bash +git rm src/span_panel_simulator/bsee.py +``` + +- [ ] **Step 8: Run full test suite** + +Run: `.venv/bin/python -m pytest tests/ -v` +Expected: All tests pass + +- [ ] **Step 9: Run type checker** + +Run: `.venv/bin/python -m mypy src/span_panel_simulator/ --no-error-summary` +Expected: No errors + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "Remove old energy balance paths and delete bsee.py" +``` + +--- + +## Phase 4: Dead Code Removal + +### Task 12: Remove All Dead Code + +**Files:** +- Modify: `src/span_panel_simulator/engine.py` +- Modify: `src/span_panel_simulator/circuit.py` +- Possibly modify: `src/span_panel_simulator/dashboard/routes.py` + +- [ ] **Step 1: Search for dead references** + +Search for all references to removed code: + +```bash +# References to old BSEE +.venv/bin/python -m pytest tests/ -v # ensure clean first +``` + +Then grep for orphaned references: + +- `BatteryStorageEquipment` — should have zero hits outside of git history +- `_bsee` — should have zero hits in engine.py +- `_create_bsee` — should be gone +- `battery_circuit_power` variable in get_snapshot — should be gone +- `signed_battery_before`, `signed_battery_after` — should be gone from modeling if replaced +- `cloned_bsee` — should be gone +- `_forced_grid_offline` checks that duplicate what EnergySystem handles +- `set_solar_excess` on behavior engine — if the energy system handles solar-excess through bus ordering, this may become dead +- `last_battery_direction` on behavior engine — check if still needed after circuit.py update + +- [ ] **Step 2: Remove orphaned imports and variables** + +Remove any imports, variables, or methods that are no longer referenced after the migration. Check: + +- `engine.py`: unused imports, orphaned helper methods, variables only written but never read +- `circuit.py`: `_resolve_battery_direction` if replaced by energy system state +- `behavior_mutable_state.py`: fields that were only used by cloned BSEE + +- [ ] **Step 3: Remove the GFE throttling we added to bsee.py earlier in this conversation** + +This was the `site_power_w` parameter we added to `BatteryStorageEquipment.update()`. Since `bsee.py` is deleted in Task 11, verify it is indeed gone. If any vestiges remain (e.g., the engine passing `site_power_w`), remove them. + +- [ ] **Step 4: Run full test suite** + +Run: `.venv/bin/python -m pytest tests/ -v` +Expected: All tests pass + +- [ ] **Step 5: Run type checker** + +Run: `.venv/bin/python -m mypy src/span_panel_simulator/ --no-error-summary` +Expected: No errors + +- [ ] **Step 6: Run linter** + +Run: `.venv/bin/python -m ruff check src/span_panel_simulator/` +Expected: No errors (or only pre-existing ones) + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "Remove dead code from energy system migration" +``` + +--- + +## Summary + +| Phase | Tasks | What it achieves | +|-------|-------|-----------------| +| **Phase 1** | Tasks 1-7 | Energy system built and tested in isolation. Engine untouched. | +| **Phase 2** | Tasks 8-10 | Engine wired to EnergySystem for snapshot, dashboard, and modeling. | +| **Phase 3** | Task 11 | Old energy balance code and bsee.py removed. Single source of truth. | +| **Phase 4** | Task 12 | Dead code sweep. No orphaned references. Clean codebase. | From 56c3f659fe8bbcd30b3df22e6aa935bca91ab769 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:06:13 -0700 Subject: [PATCH 03/34] Add energy system types module with core dataclasses --- src/span_panel_simulator/energy/__init__.py | 1 + src/span_panel_simulator/energy/types.py | 118 ++++++++++++++++++ tests/test_energy/__init__.py | 1 + tests/test_energy/test_components.py | 127 ++++++++++++++++++++ 4 files changed, 247 insertions(+) create mode 100644 src/span_panel_simulator/energy/__init__.py create mode 100644 src/span_panel_simulator/energy/types.py create mode 100644 tests/test_energy/__init__.py create mode 100644 tests/test_energy/test_components.py diff --git a/src/span_panel_simulator/energy/__init__.py b/src/span_panel_simulator/energy/__init__.py new file mode 100644 index 0000000..6ab73da --- /dev/null +++ b/src/span_panel_simulator/energy/__init__.py @@ -0,0 +1 @@ +"""Component-based energy system for the SPAN panel simulator.""" diff --git a/src/span_panel_simulator/energy/types.py b/src/span_panel_simulator/energy/types.py new file mode 100644 index 0000000..0a7e733 --- /dev/null +++ b/src/span_panel_simulator/energy/types.py @@ -0,0 +1,118 @@ +"""Core types for the component-based energy system.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import IntEnum + + +class ComponentRole(IntEnum): + """Role of a component on the bus, ordered by resolution priority.""" + + LOAD = 1 + SOURCE = 2 + STORAGE = 3 + SLACK = 4 + + +@dataclass +class PowerContribution: + """Power contribution from a single component on the bus. + + All values are non-negative magnitudes; direction is expressed by + which field is populated (demand_w vs supply_w). + """ + + demand_w: float = 0.0 + supply_w: float = 0.0 + + +@dataclass +class BusState: + """Aggregate state of the energy bus at a point in time.""" + + total_demand_w: float = 0.0 + total_supply_w: float = 0.0 + storage_contribution_w: float = 0.0 + grid_power_w: float = 0.0 + + @property + def net_deficit_w(self) -> float: + """Positive means demand exceeds supply; negative means surplus.""" + return self.total_demand_w - self.total_supply_w + + def is_balanced(self) -> bool: + """Return True when grid power accounts for any residual imbalance.""" + return abs(self.total_demand_w - self.total_supply_w - self.grid_power_w) < 0.01 + + +@dataclass +class PowerInputs: + """External inputs fed into the energy resolution pipeline.""" + + pv_available_w: float = 0.0 + bess_requested_w: float = 0.0 + bess_scheduled_state: str = "idle" + load_demand_w: float = 0.0 + grid_connected: bool = True + + +@dataclass +class SystemState: + """Resolved system state after energy dispatch.""" + + grid_power_w: float + pv_power_w: float + bess_power_w: float + bess_state: str + load_power_w: float + soe_kwh: float + soe_percentage: float + balanced: bool + + +@dataclass(frozen=True) +class GridConfig: + """Configuration for the utility grid connection.""" + + connected: bool = True + + +@dataclass(frozen=True) +class PVConfig: + """Configuration for a solar PV inverter.""" + + nameplate_w: float = 0.0 + inverter_type: str = "ac_coupled" + + +@dataclass(frozen=True) +class BESSConfig: + """Configuration for a battery energy storage system.""" + + nameplate_kwh: float = 13.5 + max_charge_w: float = 3500.0 + max_discharge_w: float = 3500.0 + charge_efficiency: float = 0.95 + discharge_efficiency: float = 0.95 + backup_reserve_pct: float = 20.0 + hard_min_pct: float = 5.0 + hybrid: bool = False + initial_soe_kwh: float | None = None + + +@dataclass(frozen=True) +class LoadConfig: + """Configuration for a load group.""" + + demand_w: float = 0.0 + + +@dataclass(frozen=True) +class EnergySystemConfig: + """Top-level configuration for the entire energy system.""" + + grid: GridConfig + pv: PVConfig | None = None + bess: BESSConfig | None = None + loads: list[LoadConfig] = field(default_factory=list) diff --git a/tests/test_energy/__init__.py b/tests/test_energy/__init__.py new file mode 100644 index 0000000..6f2a264 --- /dev/null +++ b/tests/test_energy/__init__.py @@ -0,0 +1 @@ +"""Energy system tests.""" diff --git a/tests/test_energy/test_components.py b/tests/test_energy/test_components.py new file mode 100644 index 0000000..e634e05 --- /dev/null +++ b/tests/test_energy/test_components.py @@ -0,0 +1,127 @@ +"""Layer 1: Component unit tests for the energy system.""" + +from __future__ import annotations + +from span_panel_simulator.energy.components import GridMeter, LoadGroup, PVSource +from span_panel_simulator.energy.types import ( + BusState, + ComponentRole, + PowerContribution, +) + + +class TestPowerContribution: + def test_default_zero(self) -> None: + pc = PowerContribution() + assert pc.demand_w == 0.0 + assert pc.supply_w == 0.0 + + def test_demand_only(self) -> None: + pc = PowerContribution(demand_w=5000.0) + assert pc.demand_w == 5000.0 + assert pc.supply_w == 0.0 + + +class TestBusState: + def test_net_deficit_positive(self) -> None: + bs = BusState(total_demand_w=5000.0, total_supply_w=3000.0) + assert bs.net_deficit_w == 2000.0 + + def test_net_deficit_negative_means_excess(self) -> None: + bs = BusState(total_demand_w=2000.0, total_supply_w=5000.0) + assert bs.net_deficit_w == -3000.0 + + def test_balanced_when_grid_absorbs_residual(self) -> None: + bs = BusState( + total_demand_w=5000.0, + total_supply_w=3000.0, + grid_power_w=2000.0, + ) + assert bs.is_balanced() + + def test_not_balanced_when_residual_exists(self) -> None: + bs = BusState( + total_demand_w=5000.0, + total_supply_w=3000.0, + grid_power_w=1000.0, + ) + assert not bs.is_balanced() + + +class TestComponentRole: + def test_role_ordering(self) -> None: + roles = [ + ComponentRole.SLACK, + ComponentRole.LOAD, + ComponentRole.STORAGE, + ComponentRole.SOURCE, + ] + sorted_roles = sorted(roles, key=lambda r: r.value) + assert sorted_roles == [ + ComponentRole.LOAD, + ComponentRole.SOURCE, + ComponentRole.STORAGE, + ComponentRole.SLACK, + ] + + +class TestLoadGroup: + def test_returns_demand(self) -> None: + load = LoadGroup(demand_w=5000.0) + contribution = load.resolve(BusState()) + assert contribution.demand_w == 5000.0 + assert contribution.supply_w == 0.0 + + def test_zero_demand(self) -> None: + load = LoadGroup(demand_w=0.0) + contribution = load.resolve(BusState()) + assert contribution.demand_w == 0.0 + + +class TestGridMeter: + def test_absorbs_deficit(self) -> None: + grid = GridMeter(connected=True) + bus = BusState(total_demand_w=5000.0, total_supply_w=3000.0) + contribution = grid.resolve(bus) + assert contribution.supply_w == 2000.0 + assert contribution.demand_w == 0.0 + + def test_absorbs_excess(self) -> None: + grid = GridMeter(connected=True) + bus = BusState(total_demand_w=2000.0, total_supply_w=5000.0) + contribution = grid.resolve(bus) + assert contribution.demand_w == 3000.0 + assert contribution.supply_w == 0.0 + + def test_zero_when_balanced(self) -> None: + grid = GridMeter(connected=True) + bus = BusState(total_demand_w=3000.0, total_supply_w=3000.0) + contribution = grid.resolve(bus) + assert contribution.demand_w == 0.0 + assert contribution.supply_w == 0.0 + + def test_zero_when_disconnected(self) -> None: + grid = GridMeter(connected=False) + bus = BusState(total_demand_w=5000.0, total_supply_w=0.0) + contribution = grid.resolve(bus) + assert contribution.demand_w == 0.0 + assert contribution.supply_w == 0.0 + + +class TestPVSource: + def test_returns_available_power_when_online(self) -> None: + pv = PVSource(available_power_w=4000.0, online=True) + contribution = pv.resolve(BusState()) + assert contribution.supply_w == 4000.0 + assert contribution.demand_w == 0.0 + + def test_returns_zero_when_offline(self) -> None: + pv = PVSource(available_power_w=4000.0, online=False) + contribution = pv.resolve(BusState()) + assert contribution.supply_w == 0.0 + assert contribution.demand_w == 0.0 + + def test_zero_production(self) -> None: + pv = PVSource(available_power_w=0.0, online=True) + contribution = pv.resolve(BusState()) + assert contribution.supply_w == 0.0 From 644689e8ccbb93940b57c0223fbb7ae586e56a5a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:06:16 -0700 Subject: [PATCH 04/34] Add GridMeter and LoadGroup energy components --- src/span_panel_simulator/energy/components.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/span_panel_simulator/energy/components.py diff --git a/src/span_panel_simulator/energy/components.py b/src/span_panel_simulator/energy/components.py new file mode 100644 index 0000000..b445920 --- /dev/null +++ b/src/span_panel_simulator/energy/components.py @@ -0,0 +1,70 @@ +"""Physical energy components for the panel bus. + +Each component has a role (LOAD, SOURCE, STORAGE, SLACK) and implements +``resolve()`` which returns a ``PowerContribution`` given the current +``BusState``. All power values are non-negative magnitudes; direction +is expressed by which field (``demand_w`` vs ``supply_w``) is populated. +""" + +from __future__ import annotations + +from span_panel_simulator.energy.types import ( + BusState, + ComponentRole, + PowerContribution, +) + + +class Component: + """Base class for all bus components.""" + + role: ComponentRole + + def resolve(self, bus_state: BusState) -> PowerContribution: + raise NotImplementedError + + +class LoadGroup(Component): + """Consumer load — declares demand on the bus.""" + + role = ComponentRole.LOAD + + def __init__(self, demand_w: float = 0.0) -> None: + self.demand_w = demand_w + + def resolve(self, bus_state: BusState) -> PowerContribution: + return PowerContribution(demand_w=self.demand_w) + + +class GridMeter(Component): + """Utility grid connection — slack bus that absorbs residual.""" + + role = ComponentRole.SLACK + + def __init__(self, connected: bool = True) -> None: + self.connected = connected + + def resolve(self, bus_state: BusState) -> PowerContribution: + if not self.connected: + return PowerContribution() + deficit = bus_state.net_deficit_w + if deficit > 0: + return PowerContribution(supply_w=deficit) + elif deficit < 0: + return PowerContribution(demand_w=-deficit) + return PowerContribution() + + +class PVSource(Component): + """Solar PV inverter — declares available production.""" + + role = ComponentRole.SOURCE + + def __init__(self, available_power_w: float = 0.0, online: bool = True) -> None: + self.available_power_w = available_power_w + self.online = online + + def resolve(self, bus_state: BusState) -> PowerContribution: + if not self.online: + return PowerContribution() + return PowerContribution(supply_w=self.available_power_w) From 2b45210460a4dc1091d7d152db6225720c24d1b5 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:10:34 -0700 Subject: [PATCH 05/34] Add BESSUnit component with GFE discharge throttling --- src/span_panel_simulator/energy/components.py | 147 ++++++++++++++++++ tests/test_energy/test_components.py | 79 +++++++++- 2 files changed, 225 insertions(+), 1 deletion(-) diff --git a/src/span_panel_simulator/energy/components.py b/src/span_panel_simulator/energy/components.py index b445920..3ffb961 100644 --- a/src/span_panel_simulator/energy/components.py +++ b/src/span_panel_simulator/energy/components.py @@ -68,3 +68,150 @@ def resolve(self, bus_state: BusState) -> PowerContribution: if not self.online: return PowerContribution() return PowerContribution(supply_w=self.available_power_w) + + +_SOE_MAX_PCT = 100.0 +_MAX_INTEGRATION_DELTA_S = 300.0 + + +class BESSUnit(Component): + """Battery Energy Storage System — GFE-aware storage component. + + When discharging, the BESS only sources what the bus actually demands + (GFE constraint). Enforces SOE bounds, max charge/discharge rates, + and efficiency losses. Controls co-located PV online status when + configured as a hybrid inverter. + """ + + role = ComponentRole.STORAGE + + def __init__( + self, + *, + nameplate_capacity_kwh: float, + max_charge_w: float, + max_discharge_w: float, + charge_efficiency: float, + discharge_efficiency: float, + backup_reserve_pct: float, + hard_min_pct: float, + hybrid: bool, + pv_source: PVSource | None, + soe_kwh: float, + scheduled_state: str = "idle", + requested_power_w: float = 0.0, + ) -> None: + self.nameplate_capacity_kwh = nameplate_capacity_kwh + self.max_charge_w = max_charge_w + self.max_discharge_w = max_discharge_w + self.charge_efficiency = charge_efficiency + self.discharge_efficiency = discharge_efficiency + self.backup_reserve_pct = backup_reserve_pct + self.hard_min_pct = hard_min_pct + self.hybrid = hybrid + self.pv_source = pv_source + self.soe_kwh = soe_kwh + self.scheduled_state = scheduled_state + self.requested_power_w = requested_power_w + + # Output — set by resolve() + self.effective_power_w: float = 0.0 + self.effective_state: str = "idle" + + # Timestamp tracking for energy integration + self._last_ts: float | None = None + + @property + def soe_percentage(self) -> float: + if self.nameplate_capacity_kwh <= 0: + return 0.0 + return self.soe_kwh / self.nameplate_capacity_kwh * 100.0 + + def resolve(self, bus_state: BusState) -> PowerContribution: + if self.scheduled_state == "idle": + self.effective_power_w = 0.0 + self.effective_state = "idle" + return PowerContribution() + if self.scheduled_state == "discharging": + return self._resolve_discharge(bus_state) + if self.scheduled_state == "charging": + return self._resolve_charge(bus_state) + self.effective_power_w = 0.0 + self.effective_state = "idle" + return PowerContribution() + + def _resolve_discharge(self, bus_state: BusState) -> PowerContribution: + deficit = bus_state.net_deficit_w + if deficit <= 0: + self.effective_power_w = 0.0 + self.effective_state = "idle" + return PowerContribution() + max_for_soe = self._max_discharge_for_soe() + if max_for_soe <= 0: + self.effective_power_w = 0.0 + self.effective_state = "idle" + return PowerContribution() + power = min(self.requested_power_w, deficit, self.max_discharge_w, max_for_soe) + self.effective_power_w = power + self.effective_state = "discharging" + return PowerContribution(supply_w=power) + + def _resolve_charge(self, bus_state: BusState) -> PowerContribution: + max_for_soe = self._max_charge_for_soe() + if max_for_soe <= 0: + self.effective_power_w = 0.0 + self.effective_state = "idle" + return PowerContribution() + power = min(self.requested_power_w, self.max_charge_w, max_for_soe) + self.effective_power_w = power + self.effective_state = "charging" + return PowerContribution(demand_w=power) + + def _max_discharge_for_soe(self) -> float: + """Max discharge power before hitting SOE floor (backup reserve).""" + min_kwh = self.nameplate_capacity_kwh * self.backup_reserve_pct / 100.0 + available_kwh = self.soe_kwh - min_kwh + if available_kwh <= 0: + return 0.0 + return available_kwh * 1000.0 * 3600.0 + + def _max_charge_for_soe(self) -> float: + """Max charge power before hitting SOE ceiling (100%).""" + max_kwh = self.nameplate_capacity_kwh * _SOE_MAX_PCT / 100.0 + headroom_kwh = max_kwh - self.soe_kwh + if headroom_kwh <= 0: + return 0.0 + return headroom_kwh * 1000.0 * 3600.0 + + def integrate_energy(self, ts: float) -> None: + """Integrate effective power over elapsed time to update SOE.""" + if self._last_ts is None: + self._last_ts = ts + return + delta_s = ts - self._last_ts + self._last_ts = ts + if delta_s <= 0: + return + delta_s = min(delta_s, _MAX_INTEGRATION_DELTA_S) + delta_hours = delta_s / 3600.0 + mag = abs(self.effective_power_w) + if self.effective_state == "charging" and mag > 0: + energy_kwh = (mag / 1000.0) * delta_hours * self.charge_efficiency + self.soe_kwh += energy_kwh + elif self.effective_state == "discharging" and mag > 0: + energy_kwh = (mag / 1000.0) * delta_hours / self.discharge_efficiency + self.soe_kwh -= energy_kwh + max_kwh = self.nameplate_capacity_kwh * _SOE_MAX_PCT / 100.0 + min_kwh = self.nameplate_capacity_kwh * self.hard_min_pct / 100.0 + self.soe_kwh = max(min_kwh, min(max_kwh, self.soe_kwh)) + + def update_pv_online_status(self, grid_connected: bool) -> None: + """Control co-located PV based on hybrid inverter capability.""" + if self.pv_source is None: + return + if self.hybrid: + self.pv_source.online = True + elif not grid_connected: + self.pv_source.online = False + else: + self.pv_source.online = True diff --git a/tests/test_energy/test_components.py b/tests/test_energy/test_components.py index e634e05..b757472 100644 --- a/tests/test_energy/test_components.py +++ b/tests/test_energy/test_components.py @@ -2,7 +2,7 @@ from __future__ import annotations -from span_panel_simulator.energy.components import GridMeter, LoadGroup, PVSource +from span_panel_simulator.energy.components import BESSUnit, GridMeter, LoadGroup, PVSource from span_panel_simulator.energy.types import ( BusState, ComponentRole, @@ -125,3 +125,80 @@ def test_zero_production(self) -> None: pv = PVSource(available_power_w=0.0, online=True) contribution = pv.resolve(BusState()) assert contribution.supply_w == 0.0 + + +class TestBESSUnitDischarge: + def _make_bess( + self, + *, + nameplate_kwh: float = 13.5, + max_discharge_w: float = 5000.0, + max_charge_w: float = 3500.0, + soe_kwh: float = 10.0, + backup_reserve_pct: float = 20.0, + hard_min_pct: float = 5.0, + scheduled_state: str = "discharging", + requested_power_w: float = 5000.0, + ) -> BESSUnit: + return BESSUnit( + nameplate_capacity_kwh=nameplate_kwh, + max_charge_w=max_charge_w, + max_discharge_w=max_discharge_w, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=backup_reserve_pct, + hard_min_pct=hard_min_pct, + hybrid=False, + pv_source=None, + soe_kwh=soe_kwh, + scheduled_state=scheduled_state, + requested_power_w=requested_power_w, + ) + + def test_discharge_throttled_to_deficit(self) -> None: + """GFE: only source what loads demand.""" + bess = self._make_bess(requested_power_w=5000.0) + bus = BusState(total_demand_w=2000.0, total_supply_w=0.0) + contribution = bess.resolve(bus) + assert contribution.supply_w == 2000.0 + assert bess.effective_state == "discharging" + + def test_discharge_idle_when_no_deficit(self) -> None: + """Solar covers all loads — no discharge needed.""" + bess = self._make_bess(requested_power_w=5000.0) + bus = BusState(total_demand_w=3000.0, total_supply_w=4000.0) + contribution = bess.resolve(bus) + assert contribution.supply_w == 0.0 + assert bess.effective_state == "idle" + assert bess.effective_power_w == 0.0 + + def test_discharge_limited_by_max_rate(self) -> None: + bess = self._make_bess(max_discharge_w=2000.0, requested_power_w=5000.0) + bus = BusState(total_demand_w=8000.0, total_supply_w=0.0) + contribution = bess.resolve(bus) + assert contribution.supply_w == 2000.0 + + def test_discharge_limited_by_requested(self) -> None: + bess = self._make_bess(requested_power_w=1500.0) + bus = BusState(total_demand_w=5000.0, total_supply_w=0.0) + contribution = bess.resolve(bus) + assert contribution.supply_w == 1500.0 + + def test_discharge_stops_at_backup_reserve(self) -> None: + """SOE at backup reserve — cannot discharge.""" + bess = self._make_bess( + nameplate_kwh=10.0, + soe_kwh=2.0, # exactly 20% of 10 kWh + backup_reserve_pct=20.0, + ) + bus = BusState(total_demand_w=5000.0, total_supply_w=0.0) + contribution = bess.resolve(bus) + assert contribution.supply_w == 0.0 + assert bess.effective_state == "idle" + + def test_idle_state_returns_zero(self) -> None: + bess = self._make_bess(scheduled_state="idle") + bus = BusState(total_demand_w=5000.0, total_supply_w=0.0) + contribution = bess.resolve(bus) + assert contribution.supply_w == 0.0 + assert contribution.demand_w == 0.0 From 62da60a21356bcf9afcc5437c1d97bc91ebe9607 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:11:59 -0700 Subject: [PATCH 06/34] Add BESSUnit charge, SOE integration, and hybrid PV tests --- tests/test_energy/test_components.py | 161 +++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/tests/test_energy/test_components.py b/tests/test_energy/test_components.py index b757472..793f0eb 100644 --- a/tests/test_energy/test_components.py +++ b/tests/test_energy/test_components.py @@ -202,3 +202,164 @@ def test_idle_state_returns_zero(self) -> None: contribution = bess.resolve(bus) assert contribution.supply_w == 0.0 assert contribution.demand_w == 0.0 + + +class TestBESSUnitCharge: + def _make_bess(self, **kwargs: object) -> BESSUnit: + defaults: dict[str, object] = dict( + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=5000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=20.0, + hard_min_pct=5.0, + hybrid=False, + pv_source=None, + soe_kwh=6.75, + scheduled_state="charging", + requested_power_w=3000.0, + ) + defaults.update(kwargs) + return BESSUnit(**defaults) + + def test_charge_at_requested_rate(self) -> None: + bess = self._make_bess(requested_power_w=2000.0) + bus = BusState(total_demand_w=1000.0, total_supply_w=5000.0) + contribution = bess.resolve(bus) + assert contribution.demand_w == 2000.0 + assert bess.effective_state == "charging" + + def test_charge_limited_by_max_rate(self) -> None: + bess = self._make_bess(max_charge_w=1500.0, requested_power_w=3000.0) + bus = BusState() + contribution = bess.resolve(bus) + assert contribution.demand_w == 1500.0 + + def test_charge_stops_at_full_soe(self) -> None: + bess = self._make_bess(nameplate_capacity_kwh=10.0, soe_kwh=10.0) + bus = BusState() + contribution = bess.resolve(bus) + assert contribution.demand_w == 0.0 + assert bess.effective_state == "idle" + + +class TestBESSUnitSOEIntegration: + def _make_bess(self, **kwargs: object) -> BESSUnit: + defaults: dict[str, object] = dict( + nameplate_capacity_kwh=10.0, + max_charge_w=3500.0, + max_discharge_w=5000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=20.0, + hard_min_pct=5.0, + hybrid=False, + pv_source=None, + soe_kwh=5.0, + scheduled_state="idle", + requested_power_w=0.0, + ) + defaults.update(kwargs) + return BESSUnit(**defaults) + + def test_discharge_decreases_soe(self) -> None: + bess = self._make_bess( + soe_kwh=5.0, + scheduled_state="discharging", + requested_power_w=2000.0, + ) + bus = BusState(total_demand_w=5000.0, total_supply_w=0.0) + bess.resolve(bus) + # Seed the timestamp, then drive 12 x 300 s steps = 3600 s (1 hour) + # respecting _MAX_INTEGRATION_DELTA_S. The first call seeds _last_ts + # without integrating; subsequent calls each integrate 300 s. + bess.integrate_energy(0.0) + ts = 300.0 + for _ in range(12): + bess.integrate_energy(ts) + ts += 300.0 + # 2000W for 1 hour / 0.95 efficiency = ~2.105 kWh consumed + assert bess.soe_kwh < 5.0 + expected = 5.0 - (2.0 / 0.95) + assert abs(bess.soe_kwh - expected) < 0.01 + + def test_charge_increases_soe(self) -> None: + bess = self._make_bess( + soe_kwh=3.0, + scheduled_state="charging", + requested_power_w=2000.0, + ) + bus = BusState() + bess.resolve(bus) + # Seed the timestamp, then drive 12 x 300 s steps = 3600 s (1 hour) + # respecting _MAX_INTEGRATION_DELTA_S. + bess.integrate_energy(0.0) + ts = 300.0 + for _ in range(12): + bess.integrate_energy(ts) + ts += 300.0 + # 2000W for 1 hour * 0.95 efficiency = 1.9 kWh stored + expected = 3.0 + (2.0 * 0.95) + assert abs(bess.soe_kwh - expected) < 0.01 + + def test_soe_clamped_to_bounds(self) -> None: + bess = self._make_bess(nameplate_capacity_kwh=10.0, soe_kwh=9.9) + bess.effective_state = "charging" + bess.effective_power_w = 50000.0 + bess.integrate_energy(0.0) + bess.integrate_energy(3600.0) + assert bess.soe_kwh <= 10.0 + + +class TestBESSUnitHybridPV: + def test_hybrid_keeps_pv_online_when_grid_disconnected(self) -> None: + pv = PVSource(available_power_w=4000.0, online=True) + bess = BESSUnit( + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=5000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=20.0, + hard_min_pct=5.0, + hybrid=True, + pv_source=pv, + soe_kwh=6.75, + ) + bess.update_pv_online_status(grid_connected=False) + assert pv.online is True + + def test_non_hybrid_sheds_pv_when_grid_disconnected(self) -> None: + pv = PVSource(available_power_w=4000.0, online=True) + bess = BESSUnit( + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=5000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=20.0, + hard_min_pct=5.0, + hybrid=False, + pv_source=pv, + soe_kwh=6.75, + ) + bess.update_pv_online_status(grid_connected=False) + assert pv.online is False + + def test_non_hybrid_pv_online_when_grid_connected(self) -> None: + pv = PVSource(available_power_w=4000.0, online=False) + bess = BESSUnit( + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=5000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=20.0, + hard_min_pct=5.0, + hybrid=False, + pv_source=pv, + soe_kwh=6.75, + ) + bess.update_pv_online_status(grid_connected=True) + assert pv.online is True From cf07d64dd8520dcac3e9dffecd99369bd78e4079 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:14:45 -0700 Subject: [PATCH 07/34] Add PanelBus with role-ordered resolution and conservation checks --- src/span_panel_simulator/energy/bus.py | 48 +++++++++ tests/test_energy/test_bus.py | 133 +++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/span_panel_simulator/energy/bus.py create mode 100644 tests/test_energy/test_bus.py diff --git a/src/span_panel_simulator/energy/bus.py b/src/span_panel_simulator/energy/bus.py new file mode 100644 index 0000000..bc383e2 --- /dev/null +++ b/src/span_panel_simulator/energy/bus.py @@ -0,0 +1,48 @@ +"""PanelBus — role-ordered power flow resolution with conservation enforcement.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from span_panel_simulator.energy.types import BusState, ComponentRole + +if TYPE_CHECKING: + from span_panel_simulator.energy.components import Component + + +class PanelBus: + """Resolves components in role order and enforces conservation of energy. + + Components are evaluated in order: LOAD -> SOURCE -> STORAGE -> SLACK. + SLACK (grid) contributions are tracked in ``grid_power_w`` only — they + are NOT folded into ``total_demand_w``/``total_supply_w`` so that the + conservation check ``total_demand = total_supply + grid_power`` holds + without double-counting. + """ + + def __init__(self, components: list[Component]) -> None: + self._components = components + + def resolve(self) -> BusState: + state = BusState() + for role in ( + ComponentRole.LOAD, + ComponentRole.SOURCE, + ComponentRole.STORAGE, + ComponentRole.SLACK, + ): + for component in self._components: + if component.role != role: + continue + contribution = component.resolve(state) + if role == ComponentRole.SLACK: + # Grid tracked separately — not in demand/supply totals + state.grid_power_w += contribution.supply_w - contribution.demand_w + else: + state.total_demand_w += contribution.demand_w + state.total_supply_w += contribution.supply_w + if role == ComponentRole.STORAGE: + state.storage_contribution_w += ( + contribution.supply_w - contribution.demand_w + ) + return state diff --git a/tests/test_energy/test_bus.py b/tests/test_energy/test_bus.py new file mode 100644 index 0000000..7ea7244 --- /dev/null +++ b/tests/test_energy/test_bus.py @@ -0,0 +1,133 @@ +"""Layer 2: Bus integration tests — conservation enforcement.""" + +from __future__ import annotations + +from span_panel_simulator.energy.bus import PanelBus +from span_panel_simulator.energy.components import BESSUnit, GridMeter, LoadGroup, PVSource + + +def _make_bess(**kwargs: object) -> BESSUnit: + defaults: dict[str, object] = dict( + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=5000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + backup_reserve_pct=20.0, + hard_min_pct=5.0, + hybrid=False, + pv_source=None, + soe_kwh=10.0, + scheduled_state="idle", + requested_power_w=0.0, + ) + defaults.update(kwargs) + return BESSUnit(**defaults) + + +class TestBusConservation: + def test_load_only_grid_covers(self) -> None: + bus = PanelBus(components=[LoadGroup(demand_w=5000.0), GridMeter(connected=True)]) + state = bus.resolve() + assert state.is_balanced() + assert state.grid_power_w == 5000.0 + + def test_load_and_pv_grid_covers_deficit(self) -> None: + bus = PanelBus( + components=[ + LoadGroup(demand_w=5000.0), + PVSource(available_power_w=3000.0, online=True), + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() + assert abs(state.grid_power_w - 2000.0) < 0.01 + + def test_pv_exceeds_load_grid_absorbs(self) -> None: + bus = PanelBus( + components=[ + LoadGroup(demand_w=2000.0), + PVSource(available_power_w=5000.0, online=True), + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() + assert abs(state.grid_power_w - (-3000.0)) < 0.01 + assert abs(state.total_demand_w - 2000.0) < 0.01 + assert abs(state.total_supply_w - 5000.0) < 0.01 + + def test_bess_discharge_reduces_grid(self) -> None: + bess = _make_bess(scheduled_state="discharging", requested_power_w=3000.0) + bus = PanelBus( + components=[ + LoadGroup(demand_w=5000.0), + bess, + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() + assert abs(state.grid_power_w - 2000.0) < 0.01 + + def test_bess_charge_increases_grid(self) -> None: + bess = _make_bess(scheduled_state="charging", requested_power_w=3000.0, soe_kwh=5.0) + bus = PanelBus( + components=[ + LoadGroup(demand_w=2000.0), + bess, + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() + assert abs(state.grid_power_w - 5000.0) < 0.01 + + def test_grid_never_negative_from_bess_discharge(self) -> None: + bess = _make_bess(scheduled_state="discharging", requested_power_w=5000.0) + bus = PanelBus( + components=[ + LoadGroup(demand_w=2000.0), + PVSource(available_power_w=1000.0, online=True), + bess, + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() + assert state.grid_power_w >= -0.01 + + def test_conservation_grid_disconnected(self) -> None: + bess = _make_bess(scheduled_state="discharging", requested_power_w=5000.0) + bus = PanelBus( + components=[ + LoadGroup(demand_w=3000.0), + bess, + GridMeter(connected=False), + ] + ) + state = bus.resolve() + assert state.is_balanced() + assert abs(state.grid_power_w) < 0.01 + + def test_conservation_no_bess(self) -> None: + bus = PanelBus( + components=[ + LoadGroup(demand_w=5000.0), + PVSource(available_power_w=2000.0, online=True), + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() + + def test_conservation_no_pv(self) -> None: + bus = PanelBus( + components=[ + LoadGroup(demand_w=5000.0), + GridMeter(connected=True), + ] + ) + state = bus.resolve() + assert state.is_balanced() From f1aaa786f31d5c71dcda2f48a36b883ddbb92f5f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:28:43 -0700 Subject: [PATCH 08/34] Add EnergySystem with from_config factory and full scenario tests --- src/span_panel_simulator/energy/__init__.py | 31 +- src/span_panel_simulator/energy/system.py | 139 ++++++++ tests/test_energy/test_scenarios.py | 377 ++++++++++++++++++++ 3 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 src/span_panel_simulator/energy/system.py create mode 100644 tests/test_energy/test_scenarios.py diff --git a/src/span_panel_simulator/energy/__init__.py b/src/span_panel_simulator/energy/__init__.py index 6ab73da..70f1966 100644 --- a/src/span_panel_simulator/energy/__init__.py +++ b/src/span_panel_simulator/energy/__init__.py @@ -1 +1,30 @@ -"""Component-based energy system for the SPAN panel simulator.""" +"""Component-based energy system for the SPAN panel simulator. + +Public API: + EnergySystem — top-level resolver (construct via from_config) + SystemState — resolved output of a tick + PowerInputs — external inputs that drive each tick + EnergySystemConfig, GridConfig, PVConfig, BESSConfig, LoadConfig — configuration +""" + +from span_panel_simulator.energy.system import EnergySystem +from span_panel_simulator.energy.types import ( + BESSConfig, + EnergySystemConfig, + GridConfig, + LoadConfig, + PowerInputs, + PVConfig, + SystemState, +) + +__all__ = [ + "BESSConfig", + "EnergySystem", + "EnergySystemConfig", + "GridConfig", + "LoadConfig", + "PVConfig", + "PowerInputs", + "SystemState", +] diff --git a/src/span_panel_simulator/energy/system.py b/src/span_panel_simulator/energy/system.py new file mode 100644 index 0000000..a59647b --- /dev/null +++ b/src/span_panel_simulator/energy/system.py @@ -0,0 +1,139 @@ +"""EnergySystem — the top-level energy balance resolver. + +Constructed from an ``EnergySystemConfig``, ticked with ``PowerInputs``, +and returns a ``SystemState``. Pure value object — no external dependencies, +no shared mutable state. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from span_panel_simulator.energy.bus import PanelBus +from span_panel_simulator.energy.components import BESSUnit, GridMeter, LoadGroup, PVSource +from span_panel_simulator.energy.types import ( + EnergySystemConfig, + PowerInputs, + SystemState, +) + +if TYPE_CHECKING: + from span_panel_simulator.energy.components import Component + from span_panel_simulator.energy.types import BESSConfig + + +class EnergySystem: + """Component-based energy balance resolver. + + Instantiate via ``from_config()``. Call ``tick()`` each simulation + step to resolve power flows across the bus. + """ + + def __init__( + self, + bus: PanelBus, + grid: GridMeter, + pv: PVSource | None, + bess: BESSUnit | None, + load: LoadGroup, + ) -> None: + self.bus = bus + self.grid = grid + self.pv = pv + self.bess = bess + self.load = load + + @staticmethod + def from_config(config: EnergySystemConfig) -> EnergySystem: + grid = GridMeter(connected=config.grid.connected) + + pv: PVSource | None = None + if config.pv is not None: + pv = PVSource(available_power_w=0.0, online=True) + + bess: BESSUnit | None = None + if config.bess is not None: + bc: BESSConfig = config.bess + initial_soe = bc.initial_soe_kwh + if initial_soe is None: + initial_soe = bc.nameplate_kwh * 0.5 + bess = BESSUnit( + nameplate_capacity_kwh=bc.nameplate_kwh, + max_charge_w=bc.max_charge_w, + max_discharge_w=bc.max_discharge_w, + charge_efficiency=bc.charge_efficiency, + discharge_efficiency=bc.discharge_efficiency, + backup_reserve_pct=bc.backup_reserve_pct, + hard_min_pct=bc.hard_min_pct, + hybrid=bc.hybrid, + pv_source=pv, + soe_kwh=initial_soe, + ) + + total_demand = sum(lc.demand_w for lc in config.loads) + load = LoadGroup(demand_w=total_demand) + + components: list[Component] = [load] + if pv is not None: + components.append(pv) + if bess is not None: + components.append(bess) + components.append(grid) + + bus = PanelBus(components=components) + return EnergySystem(bus=bus, grid=grid, pv=pv, bess=bess, load=load) + + def tick(self, ts: float, inputs: PowerInputs) -> SystemState: + # 1. Apply topology + self.grid.connected = inputs.grid_connected + if self.bess is not None: + self.bess.update_pv_online_status(inputs.grid_connected) + elif self.pv is not None and not inputs.grid_connected: + self.pv.online = False + + # 2. Set component inputs + self.load.demand_w = inputs.load_demand_w + if self.pv is not None: + self.pv.available_power_w = inputs.pv_available_w + if self.bess is not None: + self.bess.scheduled_state = inputs.bess_scheduled_state + self.bess.requested_power_w = inputs.bess_requested_w + + # Non-hybrid islanding override: if grid disconnected and PV + # is offline, BESS must discharge regardless of schedule + if not inputs.grid_connected and not self.bess.hybrid: + self.bess.scheduled_state = "discharging" + self.bess.requested_power_w = self.bess.max_discharge_w + + # 3. Resolve bus + bus_state = self.bus.resolve() + + # 4. Integrate BESS energy + if self.bess is not None: + self.bess.integrate_energy(ts) + + # 5. Return resolved state + pv_power = 0.0 + if self.pv is not None and self.pv.online: + pv_power = self.pv.available_power_w + + bess_power = 0.0 + bess_state = "idle" + soe_kwh = 0.0 + soe_pct = 0.0 + if self.bess is not None: + bess_power = self.bess.effective_power_w + bess_state = self.bess.effective_state + soe_kwh = self.bess.soe_kwh + soe_pct = self.bess.soe_percentage + + return SystemState( + grid_power_w=bus_state.grid_power_w, + pv_power_w=pv_power, + bess_power_w=bess_power, + bess_state=bess_state, + load_power_w=inputs.load_demand_w, + soe_kwh=soe_kwh, + soe_percentage=soe_pct, + balanced=bus_state.is_balanced(), + ) diff --git a/tests/test_energy/test_scenarios.py b/tests/test_energy/test_scenarios.py new file mode 100644 index 0000000..48902cd --- /dev/null +++ b/tests/test_energy/test_scenarios.py @@ -0,0 +1,377 @@ +"""Layer 3: Topology and scenario tests covering all identified issues.""" + +from __future__ import annotations + +from span_panel_simulator.energy.system import EnergySystem +from span_panel_simulator.energy.types import ( + BESSConfig, + EnergySystemConfig, + GridConfig, + LoadConfig, + PowerInputs, + PVConfig, +) + + +def _grid_online() -> GridConfig: + return GridConfig(connected=True) + + +def _grid_offline() -> GridConfig: + return GridConfig(connected=False) + + +def _pv(nameplate_w: float = 6000.0, inverter_type: str = "ac_coupled") -> PVConfig: + return PVConfig(nameplate_w=nameplate_w, inverter_type=inverter_type) + + +def _bess( + *, + nameplate_kwh: float = 13.5, + max_discharge_w: float = 5000.0, + max_charge_w: float = 3500.0, + hybrid: bool = False, + backup_reserve_pct: float = 20.0, + initial_soe_kwh: float | None = None, +) -> BESSConfig: + return BESSConfig( + nameplate_kwh=nameplate_kwh, + max_discharge_w=max_discharge_w, + max_charge_w=max_charge_w, + hybrid=hybrid, + backup_reserve_pct=backup_reserve_pct, + initial_soe_kwh=initial_soe_kwh, + ) + + +class TestGFEThrottling: + def test_grid_never_negative_from_bess_discharge(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_online(), + pv=_pv(), + bess=_bess(), + loads=[LoadConfig(demand_w=3000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + pv_available_w=2000.0, + bess_scheduled_state="discharging", + bess_requested_w=5000.0, + load_demand_w=3000.0, + grid_connected=True, + ), + ) + assert state.balanced + assert state.grid_power_w >= -0.01 + assert state.bess_power_w == 1000.0 + + def test_bess_covers_exact_deficit(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_online(), + bess=_bess(), + loads=[LoadConfig(demand_w=3000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + bess_scheduled_state="discharging", + bess_requested_w=5000.0, + load_demand_w=3000.0, + grid_connected=True, + ), + ) + assert state.balanced + assert abs(state.grid_power_w) < 0.01 + assert abs(state.bess_power_w - 3000.0) < 0.01 + + def test_bess_idle_when_pv_exceeds_load(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_online(), + pv=_pv(), + bess=_bess(), + loads=[LoadConfig(demand_w=2000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + pv_available_w=5000.0, + bess_scheduled_state="discharging", + bess_requested_w=5000.0, + load_demand_w=2000.0, + grid_connected=True, + ), + ) + assert state.balanced + assert state.bess_power_w == 0.0 + assert state.bess_state == "idle" + + +class TestIslanding: + def test_non_hybrid_pv_offline_bess_covers_all(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_offline(), + pv=_pv(inverter_type="ac_coupled"), + bess=_bess(hybrid=False), + loads=[LoadConfig(demand_w=3000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + pv_available_w=4000.0, + bess_scheduled_state="discharging", + bess_requested_w=5000.0, + load_demand_w=3000.0, + grid_connected=False, + ), + ) + assert state.balanced + assert state.pv_power_w == 0.0 + assert state.bess_power_w == 3000.0 + assert state.grid_power_w == 0.0 + + def test_hybrid_pv_online_bess_covers_gap(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_offline(), + pv=_pv(inverter_type="hybrid"), + bess=_bess(hybrid=True), + loads=[LoadConfig(demand_w=5000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + pv_available_w=3000.0, + bess_scheduled_state="discharging", + bess_requested_w=5000.0, + load_demand_w=5000.0, + grid_connected=False, + ), + ) + assert state.balanced + assert state.pv_power_w == 3000.0 + assert state.bess_power_w == 2000.0 + assert state.grid_power_w == 0.0 + + def test_hybrid_island_solar_excess_charges_bess(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_offline(), + pv=_pv(inverter_type="hybrid"), + bess=_bess(hybrid=True, max_charge_w=3000.0, initial_soe_kwh=5.0), + loads=[LoadConfig(demand_w=2000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + pv_available_w=5000.0, + bess_scheduled_state="charging", + bess_requested_w=3000.0, + load_demand_w=2000.0, + grid_connected=False, + ), + ) + assert state.balanced + assert state.pv_power_w == 5000.0 + assert state.bess_state == "charging" + assert state.bess_power_w == 3000.0 + assert state.grid_power_w == 0.0 + + def test_non_hybrid_island_ignores_solar_excess(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_offline(), + pv=_pv(inverter_type="ac_coupled"), + bess=_bess(hybrid=False), + loads=[LoadConfig(demand_w=3000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + pv_available_w=5000.0, + bess_scheduled_state="charging", + bess_requested_w=3000.0, + load_demand_w=3000.0, + grid_connected=False, + ), + ) + assert state.balanced + assert state.pv_power_w == 0.0 + assert state.bess_power_w == 3000.0 + assert state.bess_state == "discharging" + + +class TestGridImpact: + def test_charging_increases_grid_import(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_online(), + bess=_bess(max_charge_w=3000.0), + loads=[LoadConfig(demand_w=2000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + bess_scheduled_state="charging", + bess_requested_w=3000.0, + load_demand_w=2000.0, + grid_connected=True, + ), + ) + assert state.balanced + assert abs(state.grid_power_w - 5000.0) < 0.01 + + def test_discharging_decreases_grid_import(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_online(), + bess=_bess(max_discharge_w=3000.0), + loads=[LoadConfig(demand_w=5000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + bess_scheduled_state="discharging", + bess_requested_w=3000.0, + load_demand_w=5000.0, + grid_connected=True, + ), + ) + assert state.balanced + assert abs(state.grid_power_w - 2000.0) < 0.01 + + def test_add_bess_reduces_grid_in_modeling(self) -> None: + config_no_bess = EnergySystemConfig( + grid=_grid_online(), + loads=[LoadConfig(demand_w=5000.0)], + ) + config_with_bess = EnergySystemConfig( + grid=_grid_online(), + bess=_bess(max_discharge_w=3000.0), + loads=[LoadConfig(demand_w=5000.0)], + ) + sys_b = EnergySystem.from_config(config_no_bess) + sys_a = EnergySystem.from_config(config_with_bess) + + state_b = sys_b.tick(1000.0, PowerInputs(load_demand_w=5000.0)) + state_a = sys_a.tick( + 1000.0, + PowerInputs( + load_demand_w=5000.0, + bess_scheduled_state="discharging", + bess_requested_w=3000.0, + ), + ) + assert state_b.balanced and state_a.balanced + assert abs(state_b.grid_power_w - 5000.0) < 0.01 + assert abs(state_a.grid_power_w - 2000.0) < 0.01 + + +class TestIndependentInstances: + def test_two_instances_share_no_state(self) -> None: + config = EnergySystemConfig( + grid=_grid_online(), + bess=_bess(initial_soe_kwh=10.0), + loads=[LoadConfig(demand_w=3000.0)], + ) + sys1 = EnergySystem.from_config(config) + sys2 = EnergySystem.from_config(config) + + sys1.tick( + 1000.0, + PowerInputs( + bess_scheduled_state="discharging", + bess_requested_w=3000.0, + load_demand_w=3000.0, + ), + ) + sys1.tick( + 4600.0, + PowerInputs( + bess_scheduled_state="discharging", + bess_requested_w=3000.0, + load_demand_w=3000.0, + ), + ) + + state2 = sys2.tick( + 1000.0, + PowerInputs( + bess_scheduled_state="discharging", + bess_requested_w=3000.0, + load_demand_w=3000.0, + ), + ) + assert state2.soe_kwh == 10.0 + + +class TestEVSE: + def test_evse_is_pure_load(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_online(), + loads=[ + LoadConfig(demand_w=3000.0), + LoadConfig(demand_w=7200.0), + ], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + load_demand_w=10200.0, + grid_connected=True, + ), + ) + assert state.balanced + assert abs(state.grid_power_w - 10200.0) < 0.01 + assert abs(state.load_power_w - 10200.0) < 0.01 + + +class TestNameplateDuration: + def test_larger_nameplate_sustains_longer(self) -> None: + small_config = EnergySystemConfig( + grid=_grid_online(), + bess=_bess(nameplate_kwh=5.0, max_discharge_w=2500.0, initial_soe_kwh=2.5), + loads=[LoadConfig(demand_w=2500.0)], + ) + large_config = EnergySystemConfig( + grid=_grid_online(), + bess=_bess(nameplate_kwh=20.0, max_discharge_w=2500.0, initial_soe_kwh=10.0), + loads=[LoadConfig(demand_w=2500.0)], + ) + small = EnergySystem.from_config(small_config) + large = EnergySystem.from_config(large_config) + + inputs = PowerInputs( + bess_scheduled_state="discharging", + bess_requested_w=2500.0, + load_demand_w=2500.0, + grid_connected=True, + ) + + ts = 0.0 + s_small = None + s_large = None + for _ in range(120): + ts += 60.0 + s_small = small.tick(ts, inputs) + s_large = large.tick(ts, inputs) + + assert s_small is not None + assert s_large is not None + assert s_small.bess_state == "idle" + assert s_large.bess_state == "discharging" From 1813d1b68ba6a226eac49ef24f6f1634bdd71105 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:33:33 -0700 Subject: [PATCH 09/34] Wire EnergySystem construction into engine initialization --- src/span_panel_simulator/bsee.py | 23 +++++++++++- src/span_panel_simulator/engine.py | 57 ++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/span_panel_simulator/bsee.py b/src/span_panel_simulator/bsee.py index d330d11..c7bd80f 100644 --- a/src/span_panel_simulator/bsee.py +++ b/src/span_panel_simulator/bsee.py @@ -71,17 +71,29 @@ def __init__( # Public update — called each snapshot tick # ------------------------------------------------------------------ - def update(self, current_time: float, battery_power_w: float) -> None: + def update( + self, + current_time: float, + battery_power_w: float, + site_power_w: float = 0.0, + ) -> None: """Refresh BSEE state for the current tick. Resolves the scheduled battery state, enforces SOE bounds (the battery transitions to idle when it hits the reserve or full charge), then integrates the effective power over time. + As the Grid Forming Entity (GFE), the BESS only discharges to + meet actual site demand — it never pushes excess power back + through the grid meter. + Args: current_time: Simulation timestamp (seconds since epoch). battery_power_w: Instantaneous battery circuit power (watts). Positive = charging/discharging magnitude from the engine. + site_power_w: Net site demand (consumption - production) in + watts. Used to throttle discharge so the GFE only + sources what loads require. """ self._battery_state = self._resolve_battery_state(current_time) @@ -97,6 +109,15 @@ def update(self, current_time: float, battery_power_w: float) -> None: self._battery_state = "idle" battery_power_w = 0.0 + # GFE throttling: when discharging, only source what the site + # actually demands. If solar already covers all loads + # (site_power_w <= 0) the battery has nothing to offset. + if self._battery_state == "discharging" and site_power_w >= 0: + battery_power_w = min(abs(battery_power_w), site_power_w) + elif self._battery_state == "discharging" and site_power_w < 0: + # Solar exceeds consumption — no discharge needed + battery_power_w = 0.0 + self._battery_power_w = battery_power_w self._integrate_energy(current_time, battery_power_w) self._last_update_time = current_time diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index fc2c5d4..8399a0a 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -27,6 +27,14 @@ from span_panel_simulator.circuit import SimulatedCircuit from span_panel_simulator.clock import SimulationClock from span_panel_simulator.const import DEFAULT_FIRMWARE_VERSION +from span_panel_simulator.energy import ( + BESSConfig, + EnergySystem, + EnergySystemConfig, + GridConfig, + LoadConfig, + PVConfig, +) from span_panel_simulator.exceptions import SimulationConfigurationError if TYPE_CHECKING: @@ -841,6 +849,7 @@ def __init__( self._behavior_engine: RealisticBehaviorEngine | None = None self._circuits: dict[str, SimulatedCircuit] = {} self._bsee: BatteryStorageEquipment | None = None + self._energy_system: EnergySystem | None = None # Dynamic overrides (dispatched to circuits) self._dynamic_overrides: dict[str, dict[str, Any]] = {} @@ -904,6 +913,7 @@ async def initialize_async(self) -> None: self._build_circuits() self._bsee = self._create_bsee() + self._energy_system = self._build_energy_system() self._initialized = True async def _load_config_async(self) -> None: @@ -1283,7 +1293,7 @@ async def get_snapshot(self) -> SpanPanelSnapshot: battery_circuit = self._find_battery_circuit() battery_power_w = battery_circuit.instant_power_w if battery_circuit else 0.0 if self._bsee is not None: - self._bsee.update(current_time, battery_power_w) + self._bsee.update(current_time, battery_power_w, site_power_w=site_power) # Reflect effective power back — BSEE may have zeroed it # (e.g. SOE hit backup reserve or full charge). @@ -1636,7 +1646,7 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: # bounds are reached. signed_battery_after = 0.0 if cloned_bsee is not None: - cloned_bsee.update(ts, raw_batt_a) + cloned_bsee.update(ts, raw_batt_a, site_power_w=site_a) signed_battery_after = -cloned_bsee.battery_power_w grid_before = site_b + signed_battery_before @@ -1835,3 +1845,46 @@ def _create_bsee(self) -> BatteryStorageEquipment | None: panel_timezone=panel_tz, ) return None + + def _build_energy_system(self) -> EnergySystem | None: + """Construct an EnergySystem from current circuit configuration.""" + if not self._config: + return None + + grid_config = GridConfig(connected=not self._forced_grid_offline) + + pv_config: PVConfig | None = None + for circuit in self._circuits.values(): + if circuit.energy_mode == "producer": + nameplate = float(circuit.template["energy_profile"]["typical_power"]) + inverter_type = str(circuit.template.get("inverter_type", "ac_coupled")) + pv_config = PVConfig(nameplate_w=abs(nameplate), inverter_type=inverter_type) + break + + bess_config: BESSConfig | None = None + battery_circuit = self._find_battery_circuit() + if battery_circuit is not None: + battery_cfg = battery_circuit.template.get("battery_behavior", {}) + if isinstance(battery_cfg, dict) and battery_cfg.get("enabled", False): + nameplate = float(battery_cfg.get("nameplate_capacity_kwh", 13.5)) + hybrid = battery_cfg.get("inverter_type") == "hybrid" + bess_config = BESSConfig( + nameplate_kwh=nameplate, + max_charge_w=abs(float(battery_cfg.get("max_charge_power", 3500.0))), + max_discharge_w=abs(float(battery_cfg.get("max_discharge_power", 3500.0))), + charge_efficiency=float(battery_cfg.get("charge_efficiency", 0.95)), + discharge_efficiency=float(battery_cfg.get("discharge_efficiency", 0.95)), + backup_reserve_pct=float(battery_cfg.get("backup_reserve_pct", 20.0)), + hybrid=hybrid, + initial_soe_kwh=self._bsee.soe_kwh if self._bsee is not None else None, + ) + + loads = [LoadConfig() for c in self._circuits.values() if c.energy_mode == "consumer"] + + config = EnergySystemConfig( + grid=grid_config, + pv=pv_config, + bess=bess_config, + loads=loads, + ) + return EnergySystem.from_config(config) From a09cd4cba9fbdb5a32f79b045d1da1691e2840d6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:36:25 -0700 Subject: [PATCH 10/34] Use EnergySystem for power flow resolution in get_snapshot() --- src/span_panel_simulator/engine.py | 107 +++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 27 deletions(-) diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index 8399a0a..f33644a 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -33,7 +33,9 @@ EnergySystemConfig, GridConfig, LoadConfig, + PowerInputs, PVConfig, + SystemState, ) from span_panel_simulator.exceptions import SimulationConfigurationError @@ -1256,7 +1258,7 @@ async def get_snapshot(self) -> SpanPanelSnapshot: # 4. Add unmapped tabs self._add_unmapped_tabs(circuit_snapshots) - # 5. Aggregate totals + # 5. Aggregate totals (energy accumulators + raw power for fallback) total_consumption = 0.0 total_production = 0.0 total_produced_energy = 0.0 @@ -1274,35 +1276,52 @@ async def get_snapshot(self) -> SpanPanelSnapshot: total_produced_energy += circuit.produced_energy_wh total_consumed_energy += circuit.consumed_energy_wh - # Site power = net demand at the panel lugs (loads - solar). - # This is what the SPAN panel's feedthrough CTs measure and is - # independent of any upstream BESS. - # See https://github.com/spanio/SPAN-API-Client-Docs - site_power = total_consumption - total_production - - # Grid power = what the utility meter sees. When a BESS is - # present upstream, battery discharge offsets grid import and - # battery charge increases it. - grid_power = site_power - battery_circuit_power - - # Disconnected from grid → no grid power flow - if self._forced_grid_offline: - grid_power = 0.0 + # 5b. Resolve power flows + battery_circuit = self._find_battery_circuit() + system_state: SystemState | None = None + + if self._energy_system is not None: + # Use EnergySystem for power flow resolution (single source of truth) + inputs = self._collect_power_inputs() + system_state = self._energy_system.tick(current_time, inputs) + site_power = system_state.load_power_w - system_state.pv_power_w + grid_power = system_state.grid_power_w + battery_power_w = system_state.bess_power_w + + # Reflect effective battery power back to circuit + if battery_circuit is not None and self._energy_system.bess is not None: + battery_circuit._instant_power_w = self._energy_system.bess.effective_power_w + else: + # Fallback: old manual calculation (pre-initialization) + site_power = total_consumption - total_production + grid_power = site_power - battery_circuit_power + if self._forced_grid_offline: + grid_power = 0.0 + battery_power_w = battery_circuit.instant_power_w if battery_circuit else 0.0 # 6. Battery / BSEE - battery_circuit = self._find_battery_circuit() - battery_power_w = battery_circuit.instant_power_w if battery_circuit else 0.0 if self._bsee is not None: - self._bsee.update(current_time, battery_power_w, site_power_w=site_power) + # When energy system is active, sync BSEE state for identity properties + if self._energy_system is not None and system_state is not None: + self._bsee._battery_power_w = battery_power_w + if system_state.bess_state == "discharging": + self._bsee._battery_state = "discharging" + elif system_state.bess_state == "charging": + self._bsee._battery_state = "charging" + else: + self._bsee._battery_state = "idle" + self._bsee._soe_kwh = system_state.soe_kwh + else: + self._bsee.update(current_time, battery_power_w, site_power_w=site_power) - # Reflect effective power back — BSEE may have zeroed it - # (e.g. SOE hit backup reserve or full charge). - # Recompute grid_power since the battery contribution changed. - effective_power = self._bsee.battery_power_w - if battery_circuit is not None and effective_power != battery_power_w: - battery_circuit._instant_power_w = effective_power - battery_power_w = effective_power - grid_power = site_power - battery_power_w + # Reflect effective power back — BSEE may have zeroed it + # (e.g. SOE hit backup reserve or full charge). + # Recompute grid_power since the battery contribution changed. + effective_power = self._bsee.battery_power_w + if battery_circuit is not None and effective_power != battery_power_w: + battery_circuit._instant_power_w = effective_power + battery_power_w = effective_power + grid_power = site_power - battery_power_w battery_snapshot = SpanBatterySnapshot( soe_percentage=self._bsee.soe_percentage, @@ -1321,11 +1340,17 @@ async def get_snapshot(self) -> SpanPanelSnapshot: grid_islandable = self._bsee.grid_islandable dsm_state = DSM_OFF_GRID if grid_state == "OFF_GRID" else DSM_ON_GRID current_run_config = PANEL_OFF_GRID if grid_state == "OFF_GRID" else PANEL_ON_GRID + # Battery power flow uses SPAN panel sign convention # (matches real hardware per SpanPanel/span#184): # positive = charging (panel sending power TO battery) # negative = discharging (battery sending power TO panel) - if self._forced_grid_offline: + if self._energy_system is not None and system_state is not None: + if system_state.bess_state == "discharging": + power_flow_battery = -system_state.bess_power_w + else: + power_flow_battery = system_state.bess_power_w + elif self._forced_grid_offline: # Off-grid: battery covers load deficit — always discharging power_flow_battery = -(total_consumption - total_production) elif self._bsee.battery_state == "discharging": @@ -1808,6 +1833,34 @@ def _add_unmapped_tabs(self, circuit_snapshots: dict[str, SpanCircuitSnapshot]) is_never_backup=False, ) + def _collect_power_inputs(self) -> PowerInputs: + """Collect current circuit state into PowerInputs for the energy system.""" + pv_power = 0.0 + load_power = 0.0 + bess_power = 0.0 + bess_state = "idle" + + for circuit in self._circuits.values(): + power = circuit.instant_power_w + if circuit.energy_mode == "producer": + pv_power += power + elif circuit.energy_mode == "bidirectional": + bess_power = power + if self._bsee is not None: + bess_state = self._bsee.battery_state + elif self._behavior_engine is not None: + bess_state = self._behavior_engine.last_battery_direction + else: + load_power += power + + return PowerInputs( + pv_available_w=pv_power, + bess_requested_w=bess_power, + bess_scheduled_state=bess_state, + load_demand_w=load_power, + grid_connected=not self._forced_grid_offline, + ) + def _find_battery_circuit(self) -> SimulatedCircuit | None: """Find the battery circuit instance, if any.""" for circuit in self._circuits.values(): From 0c5bf2896630619dbfe6d79ece6b54fc0ed3bb13 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:39:08 -0700 Subject: [PATCH 11/34] Use EnergySystem in power summary and modeling data --- src/span_panel_simulator/engine.py | 119 +++++++++++++++++++---------- 1 file changed, 77 insertions(+), 42 deletions(-) diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index f33644a..c3c0393 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -852,6 +852,7 @@ def __init__( self._circuits: dict[str, SimulatedCircuit] = {} self._bsee: BatteryStorageEquipment | None = None self._energy_system: EnergySystem | None = None + self._last_system_state: SystemState | None = None # Dynamic overrides (dispatched to circuits) self._dynamic_overrides: dict[str, dict[str, Any]] = {} @@ -1087,39 +1088,49 @@ def get_power_summary(self) -> dict[str, object]: """ sim_time = self.get_current_simulation_time() - grid = 0.0 - pv = 0.0 - battery = 0.0 - total_consumption = 0.0 - total_production = 0.0 - for circuit in self._circuits.values(): - power = circuit.instant_power_w - if circuit.energy_mode == "producer": - pv += power - total_production += power - elif circuit.energy_mode == "bidirectional": - battery = power - else: - total_consumption += power - - # Battery sign: positive = discharging (producer), negative = charging (consumer) - battery_state = self._bsee.battery_state if self._bsee is not None else "idle" - if battery_state == "discharging": - # Battery is a producer — offsets grid - total_production += battery - elif battery_state == "charging": - # Battery is a consumer — adds to grid demand - total_consumption += battery - battery = -battery - - # Grid = net energy demand: all consumers - all producers - # Floors at zero (can't push to grid without net metering) - if self.grid_online: - grid = max(0.0, total_consumption - total_production) + if self._last_system_state is not None: + ss = self._last_system_state + grid = ss.grid_power_w + pv = ss.pv_power_w + battery = ss.bess_power_w + if ss.bess_state == "charging": + battery = -battery # dashboard convention: negative = charging + consumption = ss.load_power_w else: + # Fallback: old inline aggregation (pre-initialization) grid = 0.0 - if self.has_battery: - battery = total_consumption - pv + pv = 0.0 + battery = 0.0 + consumption = 0.0 + total_production = 0.0 + for circuit in self._circuits.values(): + power = circuit.instant_power_w + if circuit.energy_mode == "producer": + pv += power + total_production += power + elif circuit.energy_mode == "bidirectional": + battery = power + else: + consumption += power + + # Battery sign: positive = discharging (producer), negative = charging (consumer) + battery_state = self._bsee.battery_state if self._bsee is not None else "idle" + if battery_state == "discharging": + # Battery is a producer — offsets grid + total_production += battery + elif battery_state == "charging": + # Battery is a consumer — adds to grid demand + consumption += battery + battery = -battery + + # Grid = net energy demand: all consumers - all producers + # Floors at zero (can't push to grid without net metering) + if self.grid_online: + grid = max(0.0, consumption - total_production) + else: + grid = 0.0 + if self.has_battery: + battery = consumption - pv # Shedding info shed_ids: list[str] = [] @@ -1148,7 +1159,7 @@ def get_power_summary(self) -> dict[str, object]: "grid_w": round(grid, 1), "pv_w": round(pv, 1), "battery_w": round(battery, 1), - "consumption_w": round(total_consumption, 1), + "consumption_w": round(consumption, 1), "simulation_time": sim_time, "grid_online": self.grid_online, "has_battery": self.has_battery, @@ -1284,6 +1295,7 @@ async def get_snapshot(self) -> SpanPanelSnapshot: # Use EnergySystem for power flow resolution (single source of truth) inputs = self._collect_power_inputs() system_state = self._energy_system.tick(current_time, inputs) + self._last_system_state = system_state site_power = system_state.load_power_w - system_state.pv_power_w grid_power = system_state.grid_power_w battery_power_w = system_state.bess_power_w @@ -1625,6 +1637,11 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: panel_timezone=(cloned_behavior.panel_timezone if cloned_behavior else None), ) + # Energy system for the After pass + after_energy_system: EnergySystem | None = None + if self._energy_system is not None: + after_energy_system = self._build_energy_system() + if cloned_behavior is None: return {"error": "Simulation not initialised"} @@ -1664,18 +1681,36 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: # match the grid convention (discharge reduces grid import). signed_battery_before = -raw_batt_b - # After: BSEE applies current config (SOE tracking, user edits). - # Same sign convention as Before: negate raw power so that - # discharge (positive raw) reduces grid and charge (negative - # raw) increases grid. BSEE may clamp power to 0 when SOE - # bounds are reached. - signed_battery_after = 0.0 - if cloned_bsee is not None: - cloned_bsee.update(ts, raw_batt_a, site_power_w=site_a) - signed_battery_after = -cloned_bsee.battery_power_w + # After: use energy system if available, else fall back to cloned BSEE + if after_energy_system is not None: + # Determine battery state from schedule + bess_state = "idle" + if cloned_bsee is not None: + bess_state = cloned_bsee._resolve_battery_state(ts) + + inputs_a = PowerInputs( + pv_available_w=prod_a, + bess_requested_w=abs(raw_batt_a), + bess_scheduled_state=bess_state, + load_demand_w=max(0.0, site_a + prod_a), # consumption = site + production + grid_connected=True, + ) + state_a = after_energy_system.tick(ts, inputs_a) + grid_after = state_a.grid_power_w + # Battery sign for modeling: negative = discharge reduces grid + if state_a.bess_state == "discharging": + signed_battery_after = -state_a.bess_power_w + else: + signed_battery_after = state_a.bess_power_w + else: + # Fallback: existing cloned BSEE logic + signed_battery_after = 0.0 + if cloned_bsee is not None: + cloned_bsee.update(ts, raw_batt_a, site_power_w=site_a) + signed_battery_after = -cloned_bsee.battery_power_w + grid_after = site_a + signed_battery_after grid_before = site_b + signed_battery_before - grid_after = site_a + signed_battery_after site_power_arr.append(round(grid_before, 1)) pv_before_arr.append(round(prod_b, 1)) From 181fc60e362f85dace59a46f617603b97db895f7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:44:09 -0700 Subject: [PATCH 12/34] Remove old energy balance fallback paths from engine --- src/span_panel_simulator/engine.py | 196 +++++++---------------------- 1 file changed, 48 insertions(+), 148 deletions(-) diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index c3c0393..e4a3230 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -484,11 +484,6 @@ def _apply_battery_behavior( current_hour = self.local_hour(current_time) - # Grid outage override: battery must discharge to supply loads - if self._grid_offline: - self._last_battery_direction = "discharging" - return self._get_discharge_power(battery_config, current_hour) - # Skip inactive days — return idle power active_days: list[int] = battery_config.get("active_days", []) if active_days and self.local_weekday(current_time) not in active_days: @@ -1097,40 +1092,11 @@ def get_power_summary(self) -> dict[str, object]: battery = -battery # dashboard convention: negative = charging consumption = ss.load_power_w else: - # Fallback: old inline aggregation (pre-initialization) + # Before first snapshot — return zeroed values grid = 0.0 pv = 0.0 battery = 0.0 consumption = 0.0 - total_production = 0.0 - for circuit in self._circuits.values(): - power = circuit.instant_power_w - if circuit.energy_mode == "producer": - pv += power - total_production += power - elif circuit.energy_mode == "bidirectional": - battery = power - else: - consumption += power - - # Battery sign: positive = discharging (producer), negative = charging (consumer) - battery_state = self._bsee.battery_state if self._bsee is not None else "idle" - if battery_state == "discharging": - # Battery is a producer — offsets grid - total_production += battery - elif battery_state == "charging": - # Battery is a consumer — adds to grid demand - consumption += battery - battery = -battery - - # Grid = net energy demand: all consumers - all producers - # Floors at zero (can't push to grid without net metering) - if self.grid_online: - grid = max(0.0, consumption - total_production) - else: - grid = 0.0 - if self.has_battery: - battery = consumption - pv # Shedding info shed_ids: list[str] = [] @@ -1269,71 +1235,41 @@ async def get_snapshot(self) -> SpanPanelSnapshot: # 4. Add unmapped tabs self._add_unmapped_tabs(circuit_snapshots) - # 5. Aggregate totals (energy accumulators + raw power for fallback) - total_consumption = 0.0 - total_production = 0.0 + # 5. Aggregate energy accumulators total_produced_energy = 0.0 total_consumed_energy = 0.0 - battery_circuit_power = 0.0 for circuit in self._circuits.values(): - power = circuit.instant_power_w - if circuit.energy_mode == "producer": - total_production += power - elif circuit.energy_mode == "bidirectional": - battery_circuit_power = power - else: - total_consumption += power total_produced_energy += circuit.produced_energy_wh total_consumed_energy += circuit.consumed_energy_wh - # 5b. Resolve power flows + # 5b. Resolve power flows via EnergySystem (single source of truth) battery_circuit = self._find_battery_circuit() - system_state: SystemState | None = None - - if self._energy_system is not None: - # Use EnergySystem for power flow resolution (single source of truth) - inputs = self._collect_power_inputs() - system_state = self._energy_system.tick(current_time, inputs) - self._last_system_state = system_state - site_power = system_state.load_power_w - system_state.pv_power_w - grid_power = system_state.grid_power_w - battery_power_w = system_state.bess_power_w - - # Reflect effective battery power back to circuit - if battery_circuit is not None and self._energy_system.bess is not None: - battery_circuit._instant_power_w = self._energy_system.bess.effective_power_w - else: - # Fallback: old manual calculation (pre-initialization) - site_power = total_consumption - total_production - grid_power = site_power - battery_circuit_power - if self._forced_grid_offline: - grid_power = 0.0 - battery_power_w = battery_circuit.instant_power_w if battery_circuit else 0.0 + if self._energy_system is None: + raise SimulationConfigurationError("Energy system not initialized") + + inputs = self._collect_power_inputs() + system_state = self._energy_system.tick(current_time, inputs) + self._last_system_state = system_state + site_power = system_state.load_power_w - system_state.pv_power_w + grid_power = system_state.grid_power_w + battery_power_w = system_state.bess_power_w + + # Reflect effective battery power back to circuit + if battery_circuit is not None and self._energy_system.bess is not None: + battery_circuit._instant_power_w = self._energy_system.bess.effective_power_w # 6. Battery / BSEE if self._bsee is not None: - # When energy system is active, sync BSEE state for identity properties - if self._energy_system is not None and system_state is not None: - self._bsee._battery_power_w = battery_power_w - if system_state.bess_state == "discharging": - self._bsee._battery_state = "discharging" - elif system_state.bess_state == "charging": - self._bsee._battery_state = "charging" - else: - self._bsee._battery_state = "idle" - self._bsee._soe_kwh = system_state.soe_kwh + # Sync BSEE state from energy system for identity properties + self._bsee._battery_power_w = battery_power_w + if system_state.bess_state == "discharging": + self._bsee._battery_state = "discharging" + elif system_state.bess_state == "charging": + self._bsee._battery_state = "charging" else: - self._bsee.update(current_time, battery_power_w, site_power_w=site_power) - - # Reflect effective power back — BSEE may have zeroed it - # (e.g. SOE hit backup reserve or full charge). - # Recompute grid_power since the battery contribution changed. - effective_power = self._bsee.battery_power_w - if battery_circuit is not None and effective_power != battery_power_w: - battery_circuit._instant_power_w = effective_power - battery_power_w = effective_power - grid_power = site_power - battery_power_w + self._bsee._battery_state = "idle" + self._bsee._soe_kwh = system_state.soe_kwh battery_snapshot = SpanBatterySnapshot( soe_percentage=self._bsee.soe_percentage, @@ -1357,18 +1293,10 @@ async def get_snapshot(self) -> SpanPanelSnapshot: # (matches real hardware per SpanPanel/span#184): # positive = charging (panel sending power TO battery) # negative = discharging (battery sending power TO panel) - if self._energy_system is not None and system_state is not None: - if system_state.bess_state == "discharging": - power_flow_battery = -system_state.bess_power_w - else: - power_flow_battery = system_state.bess_power_w - elif self._forced_grid_offline: - # Off-grid: battery covers load deficit — always discharging - power_flow_battery = -(total_consumption - total_production) - elif self._bsee.battery_state == "discharging": - power_flow_battery = -battery_power_w + if system_state.bess_state == "discharging": + power_flow_battery = -system_state.bess_power_w else: - power_flow_battery = battery_power_w + power_flow_battery = system_state.bess_power_w # Rebuild battery circuit snapshot — the original was captured # before the BSEE update and off-grid deficit calculation, so it @@ -1619,30 +1547,10 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: ): solar_excess_ids.add(cid) - # BSEE for the After pass (applies current config). - # The Before pass needs no BSEE — the recorder already contains - # the battery's charge/discharge power with correct sign - # (negative = charging, positive = discharging). - cloned_bsee: BatteryStorageEquipment | None = None - battery_circuit = self._find_battery_circuit() - if self._bsee is not None and battery_circuit is not None: - battery_cfg = battery_circuit.template.get("battery_behavior", {}) - if isinstance(battery_cfg, dict): - cloned_bsee = BatteryStorageEquipment( - battery_behavior=dict(battery_cfg), - panel_serial=self._config["panel_config"]["serial_number"], - feed_circuit_id=battery_circuit.circuit_id, - nameplate_capacity_kwh=self._bsee.nameplate_capacity_kwh, - behavior_engine=cloned_behavior, - panel_timezone=(cloned_behavior.panel_timezone if cloned_behavior else None), - ) - # Energy system for the After pass - after_energy_system: EnergySystem | None = None - if self._energy_system is not None: - after_energy_system = self._build_energy_system() + after_energy_system = self._build_energy_system() - if cloned_behavior is None: + if cloned_behavior is None or after_energy_system is None: return {"error": "Simulation not initialised"} # Result arrays @@ -1681,34 +1589,26 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: # match the grid convention (discharge reduces grid import). signed_battery_before = -raw_batt_b - # After: use energy system if available, else fall back to cloned BSEE - if after_energy_system is not None: - # Determine battery state from schedule - bess_state = "idle" - if cloned_bsee is not None: - bess_state = cloned_bsee._resolve_battery_state(ts) - - inputs_a = PowerInputs( - pv_available_w=prod_a, - bess_requested_w=abs(raw_batt_a), - bess_scheduled_state=bess_state, - load_demand_w=max(0.0, site_a + prod_a), # consumption = site + production - grid_connected=True, - ) - state_a = after_energy_system.tick(ts, inputs_a) - grid_after = state_a.grid_power_w - # Battery sign for modeling: negative = discharge reduces grid - if state_a.bess_state == "discharging": - signed_battery_after = -state_a.bess_power_w - else: - signed_battery_after = state_a.bess_power_w + # After: use energy system for power flow resolution + # Determine battery state from schedule + bess_state = "idle" + if self._bsee is not None: + bess_state = self._bsee._resolve_battery_state(ts) + + inputs_a = PowerInputs( + pv_available_w=prod_a, + bess_requested_w=abs(raw_batt_a), + bess_scheduled_state=bess_state, + load_demand_w=max(0.0, site_a + prod_a), # consumption = site + production + grid_connected=True, + ) + state_a = after_energy_system.tick(ts, inputs_a) + grid_after = state_a.grid_power_w + # Battery sign for modeling: negative = discharge reduces grid + if state_a.bess_state == "discharging": + signed_battery_after = -state_a.bess_power_w else: - # Fallback: existing cloned BSEE logic - signed_battery_after = 0.0 - if cloned_bsee is not None: - cloned_bsee.update(ts, raw_batt_a, site_power_w=site_a) - signed_battery_after = -cloned_bsee.battery_power_w - grid_after = site_a + signed_battery_after + signed_battery_after = state_a.bess_power_w grid_before = site_b + signed_battery_before From f0939a55b91e0b8e76369c68c87e6d79acc827f1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:50:01 -0700 Subject: [PATCH 13/34] Remove dead code from energy system migration Remove BSEE update()/integrate_energy() and related dead constants, fields, and imports now that EnergySystem handles all power-flow resolution and SOE bookkeeping. The engine syncs results back to BSEE each tick; BSEE retains only identity and grid-state properties. --- src/span_panel_simulator/bsee.py | 137 +++-------------------------- src/span_panel_simulator/engine.py | 1 - 2 files changed, 11 insertions(+), 127 deletions(-) diff --git a/src/span_panel_simulator/bsee.py b/src/span_panel_simulator/bsee.py index c7bd80f..8c1ea40 100644 --- a/src/span_panel_simulator/bsee.py +++ b/src/span_panel_simulator/bsee.py @@ -1,35 +1,29 @@ -"""Battery Storage Energy Equipment (BSEE) — encapsulates all BESS state. +"""Battery Storage Energy Equipment (BSEE) — identity and grid-state facade. -The BSEE determines the Grid Frequency Entity (GFE) values that drive -the HA integration's grid state display. When the battery is discharging, -the panel reports OFF_GRID / BATTERY; otherwise ON_GRID / GRID. +Holds BESS identity properties (serial, vendor, model) and GFE grid-state +logic. Power-flow resolution and SOE integration are delegated to the +``EnergySystem``; the engine syncs results back each tick. """ from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING, Any +from typing import Any from zoneinfo import ZoneInfo from span_panel_simulator.const import DEFAULT_FIRMWARE_VERSION -if TYPE_CHECKING: - from span_panel_simulator.engine import RealisticBehaviorEngine - - # SOE bounds (percentage of nameplate) -_SOE_HARD_MIN_PCT = 5.0 # Absolute floor — even in grid-disconnect emergencies -_SOE_MAX_PCT = 100.0 # Fully charged ceiling _SOE_INITIAL_PCT = 50.0 # Starting SOE when no prior state -_DEFAULT_BACKUP_RESERVE_PCT = 20.0 # Normal discharge stops here; outages go deeper -_MAX_INTEGRATION_DELTA_S = 300.0 # Cap per-tick delta to 5 minutes of sim time class BatteryStorageEquipment: - """Encapsulates BESS state and controls GFE-driven grid state. + """Encapsulates BESS identity and grid-state properties. - Created once per engine initialisation (when a battery circuit exists) - and updated every snapshot tick. + Created once per engine initialisation (when a battery circuit exists). + Power-flow and SOE bookkeeping are handled by the ``EnergySystem``; + the engine syncs the results back to this object each tick so the + snapshot builder can read identity and grid properties from one place. """ def __init__( @@ -39,89 +33,23 @@ def __init__( feed_circuit_id: str, *, nameplate_capacity_kwh: float = 13.5, - behavior_engine: RealisticBehaviorEngine | None = None, panel_timezone: ZoneInfo | None = None, ) -> None: self._battery_behavior = battery_behavior self._panel_serial = panel_serial self._feed_circuit_id = feed_circuit_id self._nameplate_capacity_kwh = nameplate_capacity_kwh - self._behavior_engine = behavior_engine self._tz: ZoneInfo = panel_timezone or ZoneInfo("America/Los_Angeles") - self._charge_efficiency: float = float(battery_behavior.get("charge_efficiency", 0.95)) - self._discharge_efficiency: float = float( - battery_behavior.get("discharge_efficiency", 0.95) - ) - self._backup_reserve_pct: float = float( - battery_behavior.get("backup_reserve_pct", _DEFAULT_BACKUP_RESERVE_PCT) - ) - - # Mutable state refreshed by update() + # Mutable state refreshed by the energy system each tick self._battery_state: str = "idle" self._soe_kwh: float = nameplate_capacity_kwh * _SOE_INITIAL_PCT / 100.0 self._battery_power_w: float = 0.0 - self._last_update_time: float | None = None # Grid control overrides (set by dashboard) self._forced_offline: bool = False self._islandable: bool = True - # ------------------------------------------------------------------ - # Public update — called each snapshot tick - # ------------------------------------------------------------------ - - def update( - self, - current_time: float, - battery_power_w: float, - site_power_w: float = 0.0, - ) -> None: - """Refresh BSEE state for the current tick. - - Resolves the scheduled battery state, enforces SOE bounds (the - battery transitions to idle when it hits the reserve or full - charge), then integrates the effective power over time. - - As the Grid Forming Entity (GFE), the BESS only discharges to - meet actual site demand — it never pushes excess power back - through the grid meter. - - Args: - current_time: Simulation timestamp (seconds since epoch). - battery_power_w: Instantaneous battery circuit power (watts). - Positive = charging/discharging magnitude from the engine. - site_power_w: Net site demand (consumption - production) in - watts. Used to throttle discharge so the GFE only - sources what loads require. - """ - self._battery_state = self._resolve_battery_state(current_time) - - # Enforce schedule: battery does nothing during idle hours - if self._battery_state == "idle": - battery_power_w = 0.0 - - # Enforce SOE bounds — stop discharge at reserve, stop charge at max - effective_min_pct = _SOE_HARD_MIN_PCT if self._forced_offline else self._backup_reserve_pct - if (self._battery_state == "discharging" and self.soe_percentage <= effective_min_pct) or ( - self._battery_state == "charging" and self.soe_percentage >= _SOE_MAX_PCT - ): - self._battery_state = "idle" - battery_power_w = 0.0 - - # GFE throttling: when discharging, only source what the site - # actually demands. If solar already covers all loads - # (site_power_w <= 0) the battery has nothing to offset. - if self._battery_state == "discharging" and site_power_w >= 0: - battery_power_w = min(abs(battery_power_w), site_power_w) - elif self._battery_state == "discharging" and site_power_w < 0: - # Solar exceeds consumption — no discharge needed - battery_power_w = 0.0 - - self._battery_power_w = battery_power_w - self._integrate_energy(current_time, battery_power_w) - self._last_update_time = current_time - # ------------------------------------------------------------------ # Grid control overrides # ------------------------------------------------------------------ @@ -191,10 +119,6 @@ def connected(self) -> bool: def nameplate_capacity_kwh(self) -> float: return self._nameplate_capacity_kwh - @property - def backup_reserve_pct(self) -> float: - return self._backup_reserve_pct - @property def feed_circuit_id(self) -> str: return self._feed_circuit_id @@ -253,42 +177,3 @@ def _resolve_battery_state(self, current_time: float) -> str: if current_hour in charge_hours: return "charging" return "idle" - - def _integrate_energy(self, current_time: float, power_w: float) -> None: - """Integrate power over elapsed time to update stored energy. - - Applies charge/discharge efficiency and clamps to capacity bounds. - """ - if self._last_update_time is None: - # First tick — no delta to integrate - return - - delta_s = current_time - self._last_update_time - if delta_s <= 0: - return - - # Cap delta to prevent runaway integration on time jumps - delta_s = min(delta_s, _MAX_INTEGRATION_DELTA_S) - delta_hours = delta_s / 3600.0 - - # Use abs(power_w) so integration works regardless of sign - # convention. Recorder data is signed (negative = charging, - # positive = discharging) while synthetic power is always positive. - # The battery_state already tells us the direction; magnitude is - # all that matters for energy bookkeeping. - mag = abs(power_w) - if self._battery_state == "charging" and mag > 0: - energy_kwh = (mag / 1000.0) * delta_hours * self._charge_efficiency - self._soe_kwh += energy_kwh - elif self._battery_state == "discharging" and mag > 0: - # Discharge: power delivered = stored energy * discharge_efficiency - # So stored energy consumed = power / efficiency - energy_kwh = (mag / 1000.0) * delta_hours / self._discharge_efficiency - self._soe_kwh -= energy_kwh - - # Clamp to bounds — use backup reserve for normal discharge, - # hard minimum only during grid-disconnect emergencies - max_kwh = self._nameplate_capacity_kwh * _SOE_MAX_PCT / 100.0 - min_pct = _SOE_HARD_MIN_PCT if self._forced_offline else self._backup_reserve_pct - min_kwh = self._nameplate_capacity_kwh * min_pct / 100.0 - self._soe_kwh = max(min_kwh, min(max_kwh, self._soe_kwh)) diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index e4a3230..4fc8a48 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -1829,7 +1829,6 @@ def _create_bsee(self) -> BatteryStorageEquipment | None: panel_serial=self._config["panel_config"]["serial_number"], feed_circuit_id=circuit_def["id"], nameplate_capacity_kwh=nameplate, - behavior_engine=self._behavior_engine, panel_timezone=panel_tz, ) return None From d71cd1a0b12058f3e3f49b6aecdc9b7a0074c234 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:59:20 -0700 Subject: [PATCH 14/34] =?UTF-8?q?Remove=20BSEE=20class=20=E2=80=94=20ident?= =?UTF-8?q?ity=20and=20grid=20state=20absorbed=20into=20BESSUnit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BatteryStorageEquipment class was a thin facade that held identity properties, schedule resolution, and grid state derived from forced_offline. All of these are now provided directly by BESSUnit (identity + schedule) and EnergySystem (grid_state, dominant_power_source, islandable). The circular state-sync from SystemState back to BSEE each tick is eliminated — the engine reads values straight from the energy system's resolved state. --- src/span_panel_simulator/bsee.py | 179 ------------------ src/span_panel_simulator/energy/components.py | 59 ++++++ src/span_panel_simulator/energy/system.py | 18 ++ src/span_panel_simulator/energy/types.py | 5 + src/span_panel_simulator/engine.py | 129 +++++-------- 5 files changed, 134 insertions(+), 256 deletions(-) delete mode 100644 src/span_panel_simulator/bsee.py diff --git a/src/span_panel_simulator/bsee.py b/src/span_panel_simulator/bsee.py deleted file mode 100644 index 8c1ea40..0000000 --- a/src/span_panel_simulator/bsee.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Battery Storage Energy Equipment (BSEE) — identity and grid-state facade. - -Holds BESS identity properties (serial, vendor, model) and GFE grid-state -logic. Power-flow resolution and SOE integration are delegated to the -``EnergySystem``; the engine syncs results back each tick. -""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any -from zoneinfo import ZoneInfo - -from span_panel_simulator.const import DEFAULT_FIRMWARE_VERSION - -# SOE bounds (percentage of nameplate) -_SOE_INITIAL_PCT = 50.0 # Starting SOE when no prior state - - -class BatteryStorageEquipment: - """Encapsulates BESS identity and grid-state properties. - - Created once per engine initialisation (when a battery circuit exists). - Power-flow and SOE bookkeeping are handled by the ``EnergySystem``; - the engine syncs the results back to this object each tick so the - snapshot builder can read identity and grid properties from one place. - """ - - def __init__( - self, - battery_behavior: dict[str, Any], - panel_serial: str, - feed_circuit_id: str, - *, - nameplate_capacity_kwh: float = 13.5, - panel_timezone: ZoneInfo | None = None, - ) -> None: - self._battery_behavior = battery_behavior - self._panel_serial = panel_serial - self._feed_circuit_id = feed_circuit_id - self._nameplate_capacity_kwh = nameplate_capacity_kwh - self._tz: ZoneInfo = panel_timezone or ZoneInfo("America/Los_Angeles") - - # Mutable state refreshed by the energy system each tick - self._battery_state: str = "idle" - self._soe_kwh: float = nameplate_capacity_kwh * _SOE_INITIAL_PCT / 100.0 - self._battery_power_w: float = 0.0 - - # Grid control overrides (set by dashboard) - self._forced_offline: bool = False - self._islandable: bool = True - - # ------------------------------------------------------------------ - # Grid control overrides - # ------------------------------------------------------------------ - - def set_forced_offline(self, offline: bool) -> None: - """Force the grid offline (or back online).""" - self._forced_offline = offline - - def set_islandable(self, islandable: bool) -> None: - """Set whether PV can operate when grid is disconnected.""" - self._islandable = islandable - - # ------------------------------------------------------------------ - # GFE / grid properties - # ------------------------------------------------------------------ - - @property - def grid_state(self) -> str: - """``ON_GRID`` or ``OFF_GRID`` — published on ``bess-0/grid-state``. - - Reflects the physical grid connection, not the battery schedule. - The panel is only OFF_GRID when the grid is actually disconnected - (forced offline via dashboard or real outage). - """ - return "OFF_GRID" if self._forced_offline else "ON_GRID" - - @property - def dominant_power_source(self) -> str: - """``BATTERY`` or ``GRID`` — published on ``core/dominant-power-source``.""" - if self._forced_offline: - return "BATTERY" - return "GRID" - - @property - def grid_islandable(self) -> bool: - """Whether PV can operate during grid disconnection.""" - return self._islandable - - # ------------------------------------------------------------------ - # Battery state properties - # ------------------------------------------------------------------ - - @property - def battery_state(self) -> str: - """``charging``, ``discharging``, or ``idle``.""" - return self._battery_state - - @property - def soe_percentage(self) -> float: - if self._nameplate_capacity_kwh <= 0: - return 0.0 - return self._soe_kwh / self._nameplate_capacity_kwh * 100.0 - - @property - def soe_kwh(self) -> float: - return self._soe_kwh - - @property - def battery_power_w(self) -> float: - return self._battery_power_w - - @property - def connected(self) -> bool: - return True - - @property - def nameplate_capacity_kwh(self) -> float: - return self._nameplate_capacity_kwh - - @property - def feed_circuit_id(self) -> str: - return self._feed_circuit_id - - # ------------------------------------------------------------------ - # Identity properties - # ------------------------------------------------------------------ - - @property - def serial_number(self) -> str: - return f"SIM-BESS-{self._panel_serial}" - - @property - def vendor_name(self) -> str: - return "Simulated BESS" - - @property - def product_name(self) -> str: - return "Battery Storage" - - @property - def model(self) -> str: - cap = self._nameplate_capacity_kwh - return f"SIM-BESS-{cap:.1f}" - - @property - def software_version(self) -> str: - return DEFAULT_FIRMWARE_VERSION - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _resolve_battery_state(self, current_time: float) -> str: - """Determine battery state from grid status or schedule. - - The schedule (charge/discharge/idle hours) is always authoritative - for state resolution. The charge mode (solar-gen, solar-excess, - custom) affects power *magnitude* via the behavior engine's - ``_apply_battery_behavior``, not the state. This separation - ensures correct behavior in both the live simulation (where the - behavior engine is active) and the modeling pass (where the - battery circuit may be replayed from recorder data, leaving - ``last_battery_direction`` stale). - """ - if self._forced_offline: - return "discharging" - - current_hour = datetime.fromtimestamp(current_time, tz=self._tz).hour - - charge_hours: list[int] = self._battery_behavior.get("charge_hours", []) - discharge_hours: list[int] = self._battery_behavior.get("discharge_hours", []) - - if current_hour in discharge_hours: - return "discharging" - if current_hour in charge_hours: - return "charging" - return "idle" diff --git a/src/span_panel_simulator/energy/components.py b/src/span_panel_simulator/energy/components.py index 3ffb961..49a5df4 100644 --- a/src/span_panel_simulator/energy/components.py +++ b/src/span_panel_simulator/energy/components.py @@ -8,6 +8,10 @@ from __future__ import annotations +from datetime import datetime +from zoneinfo import ZoneInfo + +from span_panel_simulator.const import DEFAULT_FIRMWARE_VERSION from span_panel_simulator.energy.types import ( BusState, ComponentRole, @@ -100,6 +104,11 @@ def __init__( soe_kwh: float, scheduled_state: str = "idle", requested_power_w: float = 0.0, + panel_serial: str = "", + feed_circuit_id: str = "", + charge_hours: tuple[int, ...] = (), + discharge_hours: tuple[int, ...] = (), + panel_timezone: ZoneInfo | None = None, ) -> None: self.nameplate_capacity_kwh = nameplate_capacity_kwh self.max_charge_w = max_charge_w @@ -114,6 +123,13 @@ def __init__( self.scheduled_state = scheduled_state self.requested_power_w = requested_power_w + # Identity / schedule + self.panel_serial = panel_serial + self.feed_circuit_id = feed_circuit_id + self._charge_hours = charge_hours + self._discharge_hours = discharge_hours + self._panel_timezone: ZoneInfo = panel_timezone or ZoneInfo("America/Los_Angeles") + # Output — set by resolve() self.effective_power_w: float = 0.0 self.effective_state: str = "idle" @@ -127,6 +143,49 @@ def soe_percentage(self) -> float: return 0.0 return self.soe_kwh / self.nameplate_capacity_kwh * 100.0 + # ------------------------------------------------------------------ + # Identity properties + # ------------------------------------------------------------------ + + @property + def serial_number(self) -> str: + return f"SIM-BESS-{self.panel_serial}" + + @property + def vendor_name(self) -> str: + return "Simulated BESS" + + @property + def product_name(self) -> str: + return "Battery Storage" + + @property + def model(self) -> str: + return f"SIM-BESS-{self.nameplate_capacity_kwh:.1f}" + + @property + def software_version(self) -> str: + return DEFAULT_FIRMWARE_VERSION + + @property + def connected(self) -> bool: + return True + + # ------------------------------------------------------------------ + # Schedule resolution + # ------------------------------------------------------------------ + + def resolve_scheduled_state(self, ts: float, *, forced_offline: bool = False) -> str: + """Determine battery state from schedule or grid status.""" + if forced_offline: + return "discharging" + current_hour = datetime.fromtimestamp(ts, tz=self._panel_timezone).hour + if self._discharge_hours and current_hour in self._discharge_hours: + return "discharging" + if self._charge_hours and current_hour in self._charge_hours: + return "charging" + return "idle" + def resolve(self, bus_state: BusState) -> PowerContribution: if self.scheduled_state == "idle": self.effective_power_w = 0.0 diff --git a/src/span_panel_simulator/energy/system.py b/src/span_panel_simulator/energy/system.py index a59647b..fc65c65 100644 --- a/src/span_panel_simulator/energy/system.py +++ b/src/span_panel_simulator/energy/system.py @@ -42,6 +42,17 @@ def __init__( self.pv = pv self.bess = bess self.load = load + self.islandable: bool = True + + @property + def grid_state(self) -> str: + """``ON_GRID`` or ``OFF_GRID`` based on grid connection status.""" + return "OFF_GRID" if not self.grid.connected else "ON_GRID" + + @property + def dominant_power_source(self) -> str: + """``BATTERY`` or ``GRID`` based on grid connection status.""" + return "BATTERY" if not self.grid.connected else "GRID" @staticmethod def from_config(config: EnergySystemConfig) -> EnergySystem: @@ -57,6 +68,8 @@ def from_config(config: EnergySystemConfig) -> EnergySystem: initial_soe = bc.initial_soe_kwh if initial_soe is None: initial_soe = bc.nameplate_kwh * 0.5 + from zoneinfo import ZoneInfo + bess = BESSUnit( nameplate_capacity_kwh=bc.nameplate_kwh, max_charge_w=bc.max_charge_w, @@ -68,6 +81,11 @@ def from_config(config: EnergySystemConfig) -> EnergySystem: hybrid=bc.hybrid, pv_source=pv, soe_kwh=initial_soe, + panel_serial=bc.panel_serial, + feed_circuit_id=bc.feed_circuit_id, + charge_hours=bc.charge_hours, + discharge_hours=bc.discharge_hours, + panel_timezone=ZoneInfo(bc.panel_timezone), ) total_demand = sum(lc.demand_w for lc in config.loads) diff --git a/src/span_panel_simulator/energy/types.py b/src/span_panel_simulator/energy/types.py index 0a7e733..0fca614 100644 --- a/src/span_panel_simulator/energy/types.py +++ b/src/span_panel_simulator/energy/types.py @@ -99,6 +99,11 @@ class BESSConfig: hard_min_pct: float = 5.0 hybrid: bool = False initial_soe_kwh: float | None = None + panel_serial: str = "" + feed_circuit_id: str = "" + charge_hours: tuple[int, ...] = () + discharge_hours: tuple[int, ...] = () + panel_timezone: str = "America/Los_Angeles" @dataclass(frozen=True) diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index 4fc8a48..0104672 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -1,7 +1,7 @@ """Simulation engine for the standalone eBus simulator. Orchestrates ``SimulatedCircuit`` instances, a ``SimulationClock``, and -optional ``BatteryStorageEquipment`` (BSEE) to produce +an ``EnergySystem`` (with ``BESSUnit``) to produce ``SpanPanelSnapshot`` objects from YAML configuration. Circuit-level logic lives in ``circuit.py``; time management in @@ -23,7 +23,6 @@ import yaml from span_panel_simulator.behavior_mutable_state import BehaviorEngineMutableState -from span_panel_simulator.bsee import BatteryStorageEquipment from span_panel_simulator.circuit import SimulatedCircuit from span_panel_simulator.clock import SimulationClock from span_panel_simulator.const import DEFAULT_FIRMWARE_VERSION @@ -845,7 +844,6 @@ def __init__( self._clock = SimulationClock() self._behavior_engine: RealisticBehaviorEngine | None = None self._circuits: dict[str, SimulatedCircuit] = {} - self._bsee: BatteryStorageEquipment | None = None self._energy_system: EnergySystem | None = None self._last_system_state: SystemState | None = None @@ -910,7 +908,6 @@ async def initialize_async(self) -> None: self._clock.set_time(anchor.isoformat()) self._build_circuits() - self._bsee = self._create_bsee() self._energy_system = self._build_energy_system() self._initialized = True @@ -987,27 +984,25 @@ def grid_online(self) -> bool: def set_grid_online(self, online: bool) -> None: """Force the grid online or offline.""" self._forced_grid_offline = not online - if self._bsee is not None: - self._bsee.set_forced_offline(not online) if self._behavior_engine is not None: self._behavior_engine.set_grid_offline(not online) @property def is_grid_islandable(self) -> bool: """Whether PV can operate when grid is disconnected.""" - if self._bsee is not None: - return self._bsee.grid_islandable + if self._energy_system is not None: + return self._energy_system.islandable return False def set_grid_islandable(self, islandable: bool) -> None: """Set whether PV can operate when grid is disconnected.""" - if self._bsee is not None: - self._bsee.set_islandable(islandable) + if self._energy_system is not None: + self._energy_system.islandable = islandable @property def has_battery(self) -> bool: """Whether a BESS is configured.""" - return self._bsee is not None + return self._energy_system is not None and self._energy_system.bess is not None # ------------------------------------------------------------------ # Public properties & accessors @@ -1045,8 +1040,8 @@ def panel_timezone(self) -> str: @property def soc_percentage(self) -> float | None: """Battery state-of-charge percentage, or None if no BESS.""" - if self._bsee is not None: - return self._bsee.soe_percentage + if self._energy_system is not None and self._energy_system.bess is not None: + return self._energy_system.bess.soe_percentage return None @property @@ -1192,17 +1187,17 @@ async def get_snapshot(self) -> SpanPanelSnapshot: # 2b. Handle forced grid offline + load shedding shed_ids: set[str] = set() if self._forced_grid_offline: - if self._bsee is None: + if self._energy_system is None or self._energy_system.bess is None: # No battery: panel is dead — zero all circuits for circuit in self._circuits.values(): circuit._instant_power_w = 0.0 else: - soc = self._bsee.soe_percentage + soc = self._energy_system.bess.soe_percentage soc_threshold = self._config["panel_config"].get("soc_shed_threshold", 20.0) for circuit in self._circuits.values(): # PV: shed if not islandable if circuit.energy_mode == "producer": - if not self._bsee.grid_islandable: + if not self._energy_system.islandable: circuit._instant_power_w = 0.0 continue # Battery: never shed @@ -1253,39 +1248,29 @@ async def get_snapshot(self) -> SpanPanelSnapshot: self._last_system_state = system_state site_power = system_state.load_power_w - system_state.pv_power_w grid_power = system_state.grid_power_w - battery_power_w = system_state.bess_power_w # Reflect effective battery power back to circuit if battery_circuit is not None and self._energy_system.bess is not None: battery_circuit._instant_power_w = self._energy_system.bess.effective_power_w - # 6. Battery / BSEE - if self._bsee is not None: - # Sync BSEE state from energy system for identity properties - self._bsee._battery_power_w = battery_power_w - if system_state.bess_state == "discharging": - self._bsee._battery_state = "discharging" - elif system_state.bess_state == "charging": - self._bsee._battery_state = "charging" - else: - self._bsee._battery_state = "idle" - self._bsee._soe_kwh = system_state.soe_kwh - + # 6. Battery snapshot + bess = self._energy_system.bess + if bess is not None: battery_snapshot = SpanBatterySnapshot( - soe_percentage=self._bsee.soe_percentage, - soe_kwh=self._bsee.soe_kwh, - vendor_name=self._bsee.vendor_name, - product_name=self._bsee.product_name, - model=self._bsee.model, - serial_number=self._bsee.serial_number, - software_version=self._bsee.software_version, - nameplate_capacity_kwh=self._bsee.nameplate_capacity_kwh, - connected=self._bsee.connected, - feed_circuit_id=self._bsee.feed_circuit_id, + soe_percentage=system_state.soe_percentage, + soe_kwh=system_state.soe_kwh, + vendor_name=bess.vendor_name, + product_name=bess.product_name, + model=bess.model, + serial_number=bess.serial_number, + software_version=bess.software_version, + nameplate_capacity_kwh=bess.nameplate_capacity_kwh, + connected=bess.connected, + feed_circuit_id=bess.feed_circuit_id, ) - dominant_power_source = self._bsee.dominant_power_source - grid_state = self._bsee.grid_state - grid_islandable = self._bsee.grid_islandable + dominant_power_source = self._energy_system.dominant_power_source + grid_state = self._energy_system.grid_state + grid_islandable = self._energy_system.islandable dsm_state = DSM_OFF_GRID if grid_state == "OFF_GRID" else DSM_ON_GRID current_run_config = PANEL_OFF_GRID if grid_state == "OFF_GRID" else PANEL_ON_GRID @@ -1370,7 +1355,8 @@ async def get_snapshot(self) -> SpanPanelSnapshot: # Main relay is open when grid is disconnected main_relay = "OPEN" if self._forced_grid_offline else MAIN_RELAY_CLOSED # Voltage drops to 0 when offline without battery - line_voltage = 0.0 if (self._forced_grid_offline and self._bsee is None) else 120.0 + has_bess = self._energy_system is not None and self._energy_system.bess is not None + line_voltage = 0.0 if (self._forced_grid_offline and not has_bess) else 120.0 # Panel model derived from tab count panel_model = _PANEL_SIZE_TO_MODEL.get(total_tabs) @@ -1592,8 +1578,10 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: # After: use energy system for power flow resolution # Determine battery state from schedule bess_state = "idle" - if self._bsee is not None: - bess_state = self._bsee._resolve_battery_state(ts) + if self._energy_system is not None and self._energy_system.bess is not None: + bess_state = self._energy_system.bess.resolve_scheduled_state( + ts, forced_offline=self._forced_grid_offline + ) inputs_a = PowerInputs( pv_available_w=prod_a, @@ -1781,8 +1769,8 @@ def _collect_power_inputs(self) -> PowerInputs: pv_power += power elif circuit.energy_mode == "bidirectional": bess_power = power - if self._bsee is not None: - bess_state = self._bsee.battery_state + if self._energy_system is not None and self._energy_system.bess is not None: + bess_state = self._energy_system.bess.effective_state elif self._behavior_engine is not None: bess_state = self._behavior_engine.last_battery_direction else: @@ -1804,35 +1792,6 @@ def _find_battery_circuit(self) -> SimulatedCircuit | None: return circuit return None - def _create_bsee(self) -> BatteryStorageEquipment | None: - """Create a BSEE if the config contains a battery circuit.""" - if not self._config: - return None - for circuit_def in self._config["circuits"]: - template_name = circuit_def.get("template", "") - template: CircuitTemplateExtended | dict[str, Any] = self._config[ - "circuit_templates" - ].get(template_name, {}) - if not isinstance(template, dict): - continue - battery_cfg = template.get("battery_behavior", {}) - if isinstance(battery_cfg, dict) and battery_cfg.get("enabled", False): - battery_dict: dict[str, Any] = dict(battery_cfg) - nameplate: float = float(battery_cfg.get("nameplate_capacity_kwh", 13.5)) - panel_tz = ( - self._behavior_engine.panel_timezone - if self._behavior_engine is not None - else ZoneInfo(RealisticBehaviorEngine._DEFAULT_TZ) - ) - return BatteryStorageEquipment( - battery_behavior=battery_dict, - panel_serial=self._config["panel_config"]["serial_number"], - feed_circuit_id=circuit_def["id"], - nameplate_capacity_kwh=nameplate, - panel_timezone=panel_tz, - ) - return None - def _build_energy_system(self) -> EnergySystem | None: """Construct an EnergySystem from current circuit configuration.""" if not self._config: @@ -1855,6 +1814,13 @@ def _build_energy_system(self) -> EnergySystem | None: if isinstance(battery_cfg, dict) and battery_cfg.get("enabled", False): nameplate = float(battery_cfg.get("nameplate_capacity_kwh", 13.5)) hybrid = battery_cfg.get("inverter_type") == "hybrid" + charge_hours_raw: list[int] = battery_cfg.get("charge_hours", []) + discharge_hours_raw: list[int] = battery_cfg.get("discharge_hours", []) + panel_tz = ( + str(self._behavior_engine.panel_timezone) + if self._behavior_engine is not None + else RealisticBehaviorEngine._DEFAULT_TZ + ) bess_config = BESSConfig( nameplate_kwh=nameplate, max_charge_w=abs(float(battery_cfg.get("max_charge_power", 3500.0))), @@ -1863,7 +1829,16 @@ def _build_energy_system(self) -> EnergySystem | None: discharge_efficiency=float(battery_cfg.get("discharge_efficiency", 0.95)), backup_reserve_pct=float(battery_cfg.get("backup_reserve_pct", 20.0)), hybrid=hybrid, - initial_soe_kwh=self._bsee.soe_kwh if self._bsee is not None else None, + initial_soe_kwh=( + self._energy_system.bess.soe_kwh + if self._energy_system is not None and self._energy_system.bess is not None + else None + ), + panel_serial=self._config["panel_config"]["serial_number"], + feed_circuit_id=battery_circuit.circuit_id, + charge_hours=tuple(charge_hours_raw), + discharge_hours=tuple(discharge_hours_raw), + panel_timezone=panel_tz, ) loads = [LoadConfig() for c in self._circuits.values() if c.energy_mode == "consumer"] From 435494f0baf35a7df7c439f6fa814cc20d3b6ed8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:12:18 -0700 Subject: [PATCH 15/34] Fix modeling baseline: exclude user-added circuits from Before pass Circuits without a recorder_entity are user-added and did not exist in the baseline system. The Before pass now returns 0 power for these circuits instead of letting the behavior engine synthesise values that leak into the Before graph. --- src/span_panel_simulator/engine.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index 0104672..8b40781 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -1438,6 +1438,15 @@ def _aggregate_modeling_at_ts( modeling_recorder_baseline=modeling_recorder_baseline, modeling_deterministic=True, ) + + # Baseline pass: circuits with no recorder data should produce 0 + # — they didn't exist in the baseline system. Without this + # guard the behavior engine synthesises power for user-added + # circuits (battery, PV, loads) that leaks into the "Before" + # graph. + if modeling_recorder_baseline and not circuit.template.get("recorder_entity"): + power = 0.0 + circuit_powers[cid] = power if circuit.energy_mode == "producer": @@ -1459,6 +1468,8 @@ def _aggregate_modeling_at_ts( modeling_recorder_baseline=modeling_recorder_baseline, modeling_deterministic=True, ) + if modeling_recorder_baseline and not circuit.template.get("recorder_entity"): + power = 0.0 circuit_powers[cid] = power raw_battery_power = power From 97d6c370a5adeefaeb0c6851fe2ba41b6ad8677e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:29:50 -0700 Subject: [PATCH 16/34] Refactor modeling pass: separate data collection from energy balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace _aggregate_modeling_at_ts with two focused methods: - _collect_circuit_powers_at_ts: pure per-circuit power collection - _powers_to_energy_inputs: converts circuit powers to PowerInputs Both Before and After passes now follow the same pattern: collect circuit powers, feed into an EnergySystem, read derived values. The Before pass only includes recorder-backed circuits (baseline system). The After pass includes all current circuits. No if-guards needed — circuit participation is determined by set membership. --- src/span_panel_simulator/engine.py | 205 ++++++++++++++++++----------- 1 file changed, 127 insertions(+), 78 deletions(-) diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index 8b40781..3f57fc5 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -1414,67 +1414,88 @@ async def get_snapshot(self) -> SpanPanelSnapshot: # Modeling computation # ------------------------------------------------------------------ - def _aggregate_modeling_at_ts( + def _collect_circuit_powers_at_ts( self, ts: float, behavior: RealisticBehaviorEngine, + circuit_ids: set[str], solar_excess_ids: set[str], *, - modeling_recorder_baseline: bool, - ) -> tuple[dict[str, float], float, float, float]: - """Return circuit powers, site power, sum of producer output, raw battery W.""" - total_consumption = 0.0 - total_production = 0.0 - raw_battery_power = 0.0 + use_recorder_baseline: bool, + ) -> dict[str, float]: + """Collect per-circuit power values at a timestamp. + + Pure data collection — no energy balance math. Only circuits + in *circuit_ids* are evaluated; others are omitted from the + result (they don't exist in this pass's system). + + Solar-excess battery circuits are handled in a second pass + after the excess is known from the first pass. + """ circuit_powers: dict[str, float] = {} + pv_total = 0.0 + load_total = 0.0 - for cid, circuit in self._circuits.items(): + for cid in circuit_ids: if cid in solar_excess_ids: continue + circuit = self._circuits[cid] power = behavior.get_circuit_power( cid, circuit.template, ts, - modeling_recorder_baseline=modeling_recorder_baseline, + modeling_recorder_baseline=use_recorder_baseline, modeling_deterministic=True, ) - - # Baseline pass: circuits with no recorder data should produce 0 - # — they didn't exist in the baseline system. Without this - # guard the behavior engine synthesises power for user-added - # circuits (battery, PV, loads) that leaks into the "Before" - # graph. - if modeling_recorder_baseline and not circuit.template.get("recorder_entity"): - power = 0.0 - circuit_powers[cid] = power - if circuit.energy_mode == "producer": - total_production += power - elif circuit.energy_mode == "bidirectional": - raw_battery_power = power - else: - total_consumption += power - - if solar_excess_ids: - excess = max(0.0, total_production - total_consumption) + pv_total += power + elif circuit.energy_mode != "bidirectional": + load_total += power + + # Solar-excess batteries need the excess computed from other circuits + active_solar_excess = solar_excess_ids & circuit_ids + if active_solar_excess: + excess = max(0.0, pv_total - load_total) behavior.set_solar_excess(excess) - for cid in solar_excess_ids: + for cid in active_solar_excess: circuit = self._circuits[cid] power = behavior.get_circuit_power( cid, circuit.template, ts, - modeling_recorder_baseline=modeling_recorder_baseline, + modeling_recorder_baseline=use_recorder_baseline, modeling_deterministic=True, ) - if modeling_recorder_baseline and not circuit.template.get("recorder_entity"): - power = 0.0 circuit_powers[cid] = power - raw_battery_power = power - site_power = total_consumption - total_production - return circuit_powers, site_power, total_production, raw_battery_power + return circuit_powers + + def _powers_to_energy_inputs( + self, + circuit_powers: dict[str, float], + ) -> PowerInputs: + """Convert per-circuit power dict into PowerInputs for the energy system.""" + pv_power = 0.0 + load_power = 0.0 + bess_power = 0.0 + + for cid, power in circuit_powers.items(): + circuit = self._circuits[cid] + if circuit.energy_mode == "producer": + pv_power += power + elif circuit.energy_mode == "bidirectional": + bess_power = power + else: + load_power += power + + return PowerInputs( + pv_available_w=pv_power, + bess_requested_w=abs(bess_power), + bess_scheduled_state="idle", # caller sets this + load_demand_w=load_power, + grid_connected=True, # caller overrides if needed + ) async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: """Compute Before/After modeling data over recorder history. @@ -1544,7 +1565,14 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: ): solar_excess_ids.add(cid) - # Energy system for the After pass + # Partition circuits: baseline set (recorder-backed) vs full set + baseline_circuit_ids = { + cid for cid, c in self._circuits.items() if c.template.get("recorder_entity") + } + all_circuit_ids = set(self._circuits.keys()) + + # Build energy systems for each pass + before_energy_system = self._build_energy_system(circuit_ids=baseline_circuit_ids) after_energy_system = self._build_energy_system() if cloned_behavior is None or after_energy_system is None: @@ -1561,62 +1589,67 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: circuit_arrays_after: dict[str, list[float]] = {cid: [] for cid in self._circuits} for ts in timestamps: - # Baseline vs after use the same engine; restore mutable state between - # passes so cycling / solar-excess bookkeeping does not cross-contaminate. + # Restore mutable state between passes so cycling / solar-excess + # bookkeeping does not cross-contaminate. modeling_checkpoint = cloned_behavior.capture_mutable_state() - powers_b, site_b, prod_b, raw_batt_b = self._aggregate_modeling_at_ts( + # --- Before pass: only recorder-backed circuits --- + powers_b = self._collect_circuit_powers_at_ts( ts, cloned_behavior, + baseline_circuit_ids, solar_excess_ids, - modeling_recorder_baseline=True, + use_recorder_baseline=True, ) + inputs_b = self._powers_to_energy_inputs(powers_b) + if before_energy_system is not None: + state_b = before_energy_system.tick(ts, inputs_b) + grid_before = state_b.grid_power_w + pv_before = state_b.pv_power_w + batt_before = state_b.bess_power_w + if state_b.bess_state == "discharging": + batt_before = -batt_before + else: + grid_before = inputs_b.load_demand_w - inputs_b.pv_available_w + pv_before = inputs_b.pv_available_w + batt_before = 0.0 cloned_behavior.restore_mutable_state(modeling_checkpoint) - powers_a, site_a, prod_a, raw_batt_a = self._aggregate_modeling_at_ts( + # --- After pass: all current circuits --- + powers_a = self._collect_circuit_powers_at_ts( ts, cloned_behavior, + all_circuit_ids, solar_excess_ids, - modeling_recorder_baseline=False, + use_recorder_baseline=False, ) - - # Before: recorder data already has correct battery sign - # (negative = charging, positive = discharging). Invert to - # match the grid convention (discharge reduces grid import). - signed_battery_before = -raw_batt_b - - # After: use energy system for power flow resolution - # Determine battery state from schedule - bess_state = "idle" - if self._energy_system is not None and self._energy_system.bess is not None: - bess_state = self._energy_system.bess.resolve_scheduled_state( - ts, forced_offline=self._forced_grid_offline + inputs_a = self._powers_to_energy_inputs(powers_a) + + # Set battery schedule state for the after pass + if after_energy_system.bess is not None: + inputs_a = PowerInputs( + pv_available_w=inputs_a.pv_available_w, + bess_requested_w=inputs_a.bess_requested_w, + bess_scheduled_state=after_energy_system.bess.resolve_scheduled_state( + ts, forced_offline=self._forced_grid_offline + ), + load_demand_w=inputs_a.load_demand_w, + grid_connected=inputs_a.grid_connected, ) - inputs_a = PowerInputs( - pv_available_w=prod_a, - bess_requested_w=abs(raw_batt_a), - bess_scheduled_state=bess_state, - load_demand_w=max(0.0, site_a + prod_a), # consumption = site + production - grid_connected=True, - ) state_a = after_energy_system.tick(ts, inputs_a) grid_after = state_a.grid_power_w - # Battery sign for modeling: negative = discharge reduces grid + batt_after = state_a.bess_power_w if state_a.bess_state == "discharging": - signed_battery_after = -state_a.bess_power_w - else: - signed_battery_after = state_a.bess_power_w - - grid_before = site_b + signed_battery_before + batt_after = -batt_after site_power_arr.append(round(grid_before, 1)) - pv_before_arr.append(round(prod_b, 1)) + pv_before_arr.append(round(pv_before, 1)) grid_power_arr.append(round(grid_after, 1)) - pv_after_arr.append(round(prod_a, 1)) - battery_power_arr.append(round(signed_battery_after, 1)) - battery_before_arr.append(round(signed_battery_before, 1)) + pv_after_arr.append(round(state_a.pv_power_w, 1)) + battery_power_arr.append(round(batt_after, 1)) + battery_before_arr.append(round(batt_before, 1)) for cid in self._circuits: circuit_arrays_before[cid].append(round(powers_b.get(cid, 0.0), 1)) @@ -1803,15 +1836,31 @@ def _find_battery_circuit(self) -> SimulatedCircuit | None: return circuit return None - def _build_energy_system(self) -> EnergySystem | None: - """Construct an EnergySystem from current circuit configuration.""" + def _build_energy_system( + self, + *, + circuit_ids: set[str] | None = None, + ) -> EnergySystem | None: + """Construct an EnergySystem from circuit configuration. + + When *circuit_ids* is provided, only those circuits participate + in the energy system. This is used for the modeling baseline + pass where only recorder-backed circuits existed. When ``None`` + (the default), all current circuits are included. + """ if not self._config: return None + included = { + cid: c + for cid, c in self._circuits.items() + if circuit_ids is None or cid in circuit_ids + } + grid_config = GridConfig(connected=not self._forced_grid_offline) pv_config: PVConfig | None = None - for circuit in self._circuits.values(): + for circuit in included.values(): if circuit.energy_mode == "producer": nameplate = float(circuit.template["energy_profile"]["typical_power"]) inverter_type = str(circuit.template.get("inverter_type", "ac_coupled")) @@ -1819,9 +1868,8 @@ def _build_energy_system(self) -> EnergySystem | None: break bess_config: BESSConfig | None = None - battery_circuit = self._find_battery_circuit() - if battery_circuit is not None: - battery_cfg = battery_circuit.template.get("battery_behavior", {}) + for circuit in included.values(): + battery_cfg = circuit.template.get("battery_behavior", {}) if isinstance(battery_cfg, dict) and battery_cfg.get("enabled", False): nameplate = float(battery_cfg.get("nameplate_capacity_kwh", 13.5)) hybrid = battery_cfg.get("inverter_type") == "hybrid" @@ -1846,13 +1894,14 @@ def _build_energy_system(self) -> EnergySystem | None: else None ), panel_serial=self._config["panel_config"]["serial_number"], - feed_circuit_id=battery_circuit.circuit_id, + feed_circuit_id=circuit.circuit_id, charge_hours=tuple(charge_hours_raw), discharge_hours=tuple(discharge_hours_raw), panel_timezone=panel_tz, ) + break - loads = [LoadConfig() for c in self._circuits.values() if c.energy_mode == "consumer"] + loads = [LoadConfig() for c in included.values() if c.energy_mode == "consumer"] config = EnergySystemConfig( grid=grid_config, From 4bf8a7375b148f593afdef7db6a07f8f6d2fdbae Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:44:05 -0700 Subject: [PATCH 17/34] BESS operates at full inverter rate like a real system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove bess_requested_w from PowerInputs — the energy system now sets discharge/charge power to the max inverter rate for the scheduled state. The GFE throttle and SOE bounds naturally limit actual power to what the home needs, matching how a Powerwall or similar residential BESS operates. --- src/span_panel_simulator/energy/system.py | 12 +++++++++++- src/span_panel_simulator/energy/types.py | 9 +++++++-- src/span_panel_simulator/engine.py | 17 +++++++---------- tests/test_energy/test_scenarios.py | 14 -------------- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/src/span_panel_simulator/energy/system.py b/src/span_panel_simulator/energy/system.py index fc65c65..230dbe7 100644 --- a/src/span_panel_simulator/energy/system.py +++ b/src/span_panel_simulator/energy/system.py @@ -115,7 +115,17 @@ def tick(self, ts: float, inputs: PowerInputs) -> SystemState: self.pv.available_power_w = inputs.pv_available_w if self.bess is not None: self.bess.scheduled_state = inputs.bess_scheduled_state - self.bess.requested_power_w = inputs.bess_requested_w + + # Real BESS behaviour: request the full inverter rate for the + # scheduled state. The bus resolution (GFE throttle + SOE + # bounds) limits actual power to what the home needs — exactly + # how a Powerwall or similar system operates. + if self.bess.scheduled_state == "discharging": + self.bess.requested_power_w = self.bess.max_discharge_w + elif self.bess.scheduled_state == "charging": + self.bess.requested_power_w = self.bess.max_charge_w + else: + self.bess.requested_power_w = 0.0 # Non-hybrid islanding override: if grid disconnected and PV # is offline, BESS must discharge regardless of schedule diff --git a/src/span_panel_simulator/energy/types.py b/src/span_panel_simulator/energy/types.py index 0fca614..9802e1e 100644 --- a/src/span_panel_simulator/energy/types.py +++ b/src/span_panel_simulator/energy/types.py @@ -48,10 +48,15 @@ def is_balanced(self) -> bool: @dataclass class PowerInputs: - """External inputs fed into the energy resolution pipeline.""" + """External inputs fed into the energy resolution pipeline. + + The BESS operates like a real system — it delivers whatever power + the home needs (up to its inverter rate), gated only by schedule + and SOE. There is no ``bess_requested_w``; the energy system uses + the max inverter rate from the BESSConfig. + """ pv_available_w: float = 0.0 - bess_requested_w: float = 0.0 bess_scheduled_state: str = "idle" load_demand_w: float = 0.0 grid_connected: bool = True diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index 3f57fc5..91db708 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -1475,23 +1475,24 @@ def _powers_to_energy_inputs( self, circuit_powers: dict[str, float], ) -> PowerInputs: - """Convert per-circuit power dict into PowerInputs for the energy system.""" + """Convert per-circuit power dict into PowerInputs for the energy system. + + Battery circuits are excluded from the power summation — the + energy system determines BESS power from the inverter rate and + bus state, not from the behavior engine's synthetic output. + """ pv_power = 0.0 load_power = 0.0 - bess_power = 0.0 for cid, power in circuit_powers.items(): circuit = self._circuits[cid] if circuit.energy_mode == "producer": pv_power += power - elif circuit.energy_mode == "bidirectional": - bess_power = power - else: + elif circuit.energy_mode != "bidirectional": load_power += power return PowerInputs( pv_available_w=pv_power, - bess_requested_w=abs(bess_power), bess_scheduled_state="idle", # caller sets this load_demand_w=load_power, grid_connected=True, # caller overrides if needed @@ -1630,7 +1631,6 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: if after_energy_system.bess is not None: inputs_a = PowerInputs( pv_available_w=inputs_a.pv_available_w, - bess_requested_w=inputs_a.bess_requested_w, bess_scheduled_state=after_energy_system.bess.resolve_scheduled_state( ts, forced_offline=self._forced_grid_offline ), @@ -1804,7 +1804,6 @@ def _collect_power_inputs(self) -> PowerInputs: """Collect current circuit state into PowerInputs for the energy system.""" pv_power = 0.0 load_power = 0.0 - bess_power = 0.0 bess_state = "idle" for circuit in self._circuits.values(): @@ -1812,7 +1811,6 @@ def _collect_power_inputs(self) -> PowerInputs: if circuit.energy_mode == "producer": pv_power += power elif circuit.energy_mode == "bidirectional": - bess_power = power if self._energy_system is not None and self._energy_system.bess is not None: bess_state = self._energy_system.bess.effective_state elif self._behavior_engine is not None: @@ -1822,7 +1820,6 @@ def _collect_power_inputs(self) -> PowerInputs: return PowerInputs( pv_available_w=pv_power, - bess_requested_w=bess_power, bess_scheduled_state=bess_state, load_demand_w=load_power, grid_connected=not self._forced_grid_offline, diff --git a/tests/test_energy/test_scenarios.py b/tests/test_energy/test_scenarios.py index 48902cd..7474756 100644 --- a/tests/test_energy/test_scenarios.py +++ b/tests/test_energy/test_scenarios.py @@ -59,7 +59,6 @@ def test_grid_never_negative_from_bess_discharge(self) -> None: PowerInputs( pv_available_w=2000.0, bess_scheduled_state="discharging", - bess_requested_w=5000.0, load_demand_w=3000.0, grid_connected=True, ), @@ -80,7 +79,6 @@ def test_bess_covers_exact_deficit(self) -> None: 1000.0, PowerInputs( bess_scheduled_state="discharging", - bess_requested_w=5000.0, load_demand_w=3000.0, grid_connected=True, ), @@ -103,7 +101,6 @@ def test_bess_idle_when_pv_exceeds_load(self) -> None: PowerInputs( pv_available_w=5000.0, bess_scheduled_state="discharging", - bess_requested_w=5000.0, load_demand_w=2000.0, grid_connected=True, ), @@ -128,7 +125,6 @@ def test_non_hybrid_pv_offline_bess_covers_all(self) -> None: PowerInputs( pv_available_w=4000.0, bess_scheduled_state="discharging", - bess_requested_w=5000.0, load_demand_w=3000.0, grid_connected=False, ), @@ -152,7 +148,6 @@ def test_hybrid_pv_online_bess_covers_gap(self) -> None: PowerInputs( pv_available_w=3000.0, bess_scheduled_state="discharging", - bess_requested_w=5000.0, load_demand_w=5000.0, grid_connected=False, ), @@ -176,7 +171,6 @@ def test_hybrid_island_solar_excess_charges_bess(self) -> None: PowerInputs( pv_available_w=5000.0, bess_scheduled_state="charging", - bess_requested_w=3000.0, load_demand_w=2000.0, grid_connected=False, ), @@ -201,7 +195,6 @@ def test_non_hybrid_island_ignores_solar_excess(self) -> None: PowerInputs( pv_available_w=5000.0, bess_scheduled_state="charging", - bess_requested_w=3000.0, load_demand_w=3000.0, grid_connected=False, ), @@ -225,7 +218,6 @@ def test_charging_increases_grid_import(self) -> None: 1000.0, PowerInputs( bess_scheduled_state="charging", - bess_requested_w=3000.0, load_demand_w=2000.0, grid_connected=True, ), @@ -245,7 +237,6 @@ def test_discharging_decreases_grid_import(self) -> None: 1000.0, PowerInputs( bess_scheduled_state="discharging", - bess_requested_w=3000.0, load_demand_w=5000.0, grid_connected=True, ), @@ -272,7 +263,6 @@ def test_add_bess_reduces_grid_in_modeling(self) -> None: PowerInputs( load_demand_w=5000.0, bess_scheduled_state="discharging", - bess_requested_w=3000.0, ), ) assert state_b.balanced and state_a.balanced @@ -294,7 +284,6 @@ def test_two_instances_share_no_state(self) -> None: 1000.0, PowerInputs( bess_scheduled_state="discharging", - bess_requested_w=3000.0, load_demand_w=3000.0, ), ) @@ -302,7 +291,6 @@ def test_two_instances_share_no_state(self) -> None: 4600.0, PowerInputs( bess_scheduled_state="discharging", - bess_requested_w=3000.0, load_demand_w=3000.0, ), ) @@ -311,7 +299,6 @@ def test_two_instances_share_no_state(self) -> None: 1000.0, PowerInputs( bess_scheduled_state="discharging", - bess_requested_w=3000.0, load_demand_w=3000.0, ), ) @@ -358,7 +345,6 @@ def test_larger_nameplate_sustains_longer(self) -> None: inputs = PowerInputs( bess_scheduled_state="discharging", - bess_requested_w=2500.0, load_demand_w=2500.0, grid_connected=True, ) From 45818117cac4ac625a0abd61dd4c19344e3459da Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:01:34 -0700 Subject: [PATCH 18/34] Replace BESS charge modes with real-world operation modes New modes: Self-Consumption (default, discharge to offset grid import, charge from PV excess only), Time-of-Use (user-set hourly schedule), Backup Only (hold at full SOC, discharge during outages). Removed solar-gen and solar-excess modes and all associated two-pass tick machinery. Self-consumption charges only from actual PV excess, never from grid. --- .../behavior_mutable_state.py | 1 - src/span_panel_simulator/config_types.py | 2 +- .../dashboard/config_store.py | 6 +- .../dashboard/defaults.py | 10 +- .../partials/battery_profile_editor.html | 16 +- src/span_panel_simulator/energy/components.py | 3 + src/span_panel_simulator/energy/system.py | 56 ++++-- src/span_panel_simulator/energy/types.py | 1 + src/span_panel_simulator/engine.py | 162 ++---------------- tests/test_energy/test_scenarios.py | 144 ++++++++++++++++ 10 files changed, 225 insertions(+), 176 deletions(-) diff --git a/src/span_panel_simulator/behavior_mutable_state.py b/src/span_panel_simulator/behavior_mutable_state.py index a7266bb..20c0051 100644 --- a/src/span_panel_simulator/behavior_mutable_state.py +++ b/src/span_panel_simulator/behavior_mutable_state.py @@ -16,5 +16,4 @@ class BehaviorEngineMutableState: circuit_cycle_states: dict[str, dict[str, Any]] last_battery_direction: str - solar_excess_w: float grid_offline: bool diff --git a/src/span_panel_simulator/config_types.py b/src/span_panel_simulator/config_types.py index 5e74b29..aa3c65b 100644 --- a/src/span_panel_simulator/config_types.py +++ b/src/span_panel_simulator/config_types.py @@ -100,7 +100,7 @@ class BatteryBehavior(TypedDict, total=False): """Battery behavior configuration.""" enabled: bool - charge_mode: Literal["solar-gen", "solar-excess", "custom"] + charge_mode: Literal["self-consumption", "custom", "backup-only"] charge_power: float discharge_power: float idle_power: float diff --git a/src/span_panel_simulator/dashboard/config_store.py b/src/span_panel_simulator/dashboard/config_store.py index fec0c5b..16ebdcb 100644 --- a/src/span_panel_simulator/dashboard/config_store.py +++ b/src/span_panel_simulator/dashboard/config_store.py @@ -643,14 +643,14 @@ def apply_preset( # -- Battery charge mode -- def get_battery_charge_mode(self, entity_id: str) -> str: - """Return the charge mode for a battery entity (default ``"custom"``).""" + """Return the charge mode for a battery entity (default ``"self-consumption"``).""" entity = self.get_entity(entity_id) bb = entity.battery_behavior or {} - return str(bb.get("charge_mode", "custom")) + return str(bb.get("charge_mode", "self-consumption")) def update_battery_charge_mode(self, entity_id: str, mode: str) -> None: """Set the charge mode on a battery entity's template.""" - valid_modes = ("custom", "solar-gen", "solar-excess") + valid_modes = ("self-consumption", "custom", "backup-only") if mode not in valid_modes: raise ValueError(f"Invalid charge mode: {mode!r}") diff --git a/src/span_panel_simulator/dashboard/defaults.py b/src/span_panel_simulator/dashboard/defaults.py index 1cf56c7..d2885e4 100644 --- a/src/span_panel_simulator/dashboard/defaults.py +++ b/src/span_panel_simulator/dashboard/defaults.py @@ -116,13 +116,13 @@ def _slugify(name: str) -> str: "priority": "NEVER", "battery_behavior": { "enabled": True, - "charge_mode": "solar-excess", + "charge_mode": "self-consumption", "nameplate_capacity_kwh": 13.5, "backup_reserve_pct": 20.0, - "max_charge_power": 3500.0, - "max_discharge_power": 3500.0, - "charge_hours": [8, 9, 10, 11, 12, 13, 14, 15], - "discharge_hours": [16, 17, 18, 19, 20, 21, 22], + "max_charge_power": 5000.0, + "max_discharge_power": 5000.0, + "charge_hours": [], + "discharge_hours": [], }, }, "circuit": {}, diff --git a/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html b/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html index b6d7b1d..e1d31ce 100644 --- a/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html +++ b/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html @@ -6,9 +6,9 @@
- {% if battery_charge_mode == 'solar-gen' %} - Charging tracks the geographic solar curve × weather factor. - {% elif battery_charge_mode == 'solar-excess' %} - Charges from surplus PV after all loads are met. + {% if battery_charge_mode == 'self-consumption' %} + Battery automatically discharges to reduce grid import and charges from surplus solar. No schedule needed. + {% elif battery_charge_mode == 'backup-only' %} + Battery stays fully charged and only discharges during grid outages. {% else %} - Charge hours are set manually in the schedule below. + Set charge and discharge hours in the schedule below. {% endif %}
diff --git a/src/span_panel_simulator/energy/components.py b/src/span_panel_simulator/energy/components.py index 49a5df4..98aae1d 100644 --- a/src/span_panel_simulator/energy/components.py +++ b/src/span_panel_simulator/energy/components.py @@ -109,6 +109,7 @@ def __init__( charge_hours: tuple[int, ...] = (), discharge_hours: tuple[int, ...] = (), panel_timezone: ZoneInfo | None = None, + charge_mode: str = "self-consumption", ) -> None: self.nameplate_capacity_kwh = nameplate_capacity_kwh self.max_charge_w = max_charge_w @@ -123,6 +124,8 @@ def __init__( self.scheduled_state = scheduled_state self.requested_power_w = requested_power_w + self.charge_mode = charge_mode + # Identity / schedule self.panel_serial = panel_serial self.feed_circuit_id = feed_circuit_id diff --git a/src/span_panel_simulator/energy/system.py b/src/span_panel_simulator/energy/system.py index 230dbe7..65a7260 100644 --- a/src/span_panel_simulator/energy/system.py +++ b/src/span_panel_simulator/energy/system.py @@ -86,6 +86,7 @@ def from_config(config: EnergySystemConfig) -> EnergySystem: charge_hours=bc.charge_hours, discharge_hours=bc.discharge_hours, panel_timezone=ZoneInfo(bc.panel_timezone), + charge_mode=bc.charge_mode, ) total_demand = sum(lc.demand_w for lc in config.loads) @@ -114,21 +115,46 @@ def tick(self, ts: float, inputs: PowerInputs) -> SystemState: if self.pv is not None: self.pv.available_power_w = inputs.pv_available_w if self.bess is not None: - self.bess.scheduled_state = inputs.bess_scheduled_state - - # Real BESS behaviour: request the full inverter rate for the - # scheduled state. The bus resolution (GFE throttle + SOE - # bounds) limits actual power to what the home needs — exactly - # how a Powerwall or similar system operates. - if self.bess.scheduled_state == "discharging": - self.bess.requested_power_w = self.bess.max_discharge_w - elif self.bess.scheduled_state == "charging": - self.bess.requested_power_w = self.bess.max_charge_w - else: - self.bess.requested_power_w = 0.0 - - # Non-hybrid islanding override: if grid disconnected and PV - # is offline, BESS must discharge regardless of schedule + if self.bess.charge_mode == "self-consumption": + # Real-time response: determine direction from load vs PV. + # Discharge: covers grid import up to inverter rate (GFE + # throttle limits to actual deficit). + # Charge: absorbs only the PV excess — never pulls from + # grid. Clamped to inverter rate. + preliminary_deficit = inputs.load_demand_w - inputs.pv_available_w + if preliminary_deficit > 0: + self.bess.scheduled_state = "discharging" + self.bess.requested_power_w = self.bess.max_discharge_w + elif preliminary_deficit < 0: + excess = -preliminary_deficit + self.bess.scheduled_state = "charging" + self.bess.requested_power_w = min(excess, self.bess.max_charge_w) + else: + self.bess.scheduled_state = "idle" + self.bess.requested_power_w = 0.0 + + elif self.bess.charge_mode == "backup-only": + if not inputs.grid_connected: + self.bess.scheduled_state = "discharging" + self.bess.requested_power_w = self.bess.max_discharge_w + elif self.bess.soe_percentage < 100.0: + self.bess.scheduled_state = "charging" + self.bess.requested_power_w = self.bess.max_charge_w + else: + self.bess.scheduled_state = "idle" + self.bess.requested_power_w = 0.0 + + else: # custom (TOU): use schedule + self.bess.scheduled_state = inputs.bess_scheduled_state + if self.bess.scheduled_state == "discharging": + self.bess.requested_power_w = self.bess.max_discharge_w + elif self.bess.scheduled_state == "charging": + self.bess.requested_power_w = self.bess.max_charge_w + else: + self.bess.requested_power_w = 0.0 + + # Non-hybrid islanding override (applies to ALL modes): + # if grid disconnected and PV is offline, BESS must discharge if not inputs.grid_connected and not self.bess.hybrid: self.bess.scheduled_state = "discharging" self.bess.requested_power_w = self.bess.max_discharge_w diff --git a/src/span_panel_simulator/energy/types.py b/src/span_panel_simulator/energy/types.py index 9802e1e..5012db6 100644 --- a/src/span_panel_simulator/energy/types.py +++ b/src/span_panel_simulator/energy/types.py @@ -109,6 +109,7 @@ class BESSConfig: charge_hours: tuple[int, ...] = () discharge_hours: tuple[int, ...] = () panel_timezone: str = "America/Los_Angeles" + charge_mode: str = "self-consumption" @dataclass(frozen=True) diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index 91db708..2c5c5e6 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -98,7 +98,6 @@ def __init__( self._recorder = recorder self._circuit_cycle_states: dict[str, dict[str, Any]] = {} self._last_battery_direction: str = "idle" - self._solar_excess_w: float = 0.0 self._grid_offline: bool = False self._tz = self._resolve_timezone(config) @@ -150,10 +149,6 @@ def last_battery_direction(self) -> str: """Most recent battery direction set by charge mode logic.""" return self._last_battery_direction - def set_solar_excess(self, excess_w: float) -> None: - """Set the solar excess watts for solar-excess charge mode.""" - self._solar_excess_w = excess_w - def set_grid_offline(self, offline: bool) -> None: """Propagate grid state so battery behaviour overrides schedules.""" self._grid_offline = offline @@ -163,7 +158,6 @@ def capture_mutable_state(self) -> BehaviorEngineMutableState: return BehaviorEngineMutableState( circuit_cycle_states=copy.deepcopy(self._circuit_cycle_states), last_battery_direction=self._last_battery_direction, - solar_excess_w=self._solar_excess_w, grid_offline=self._grid_offline, ) @@ -171,7 +165,6 @@ def restore_mutable_state(self, state: BehaviorEngineMutableState) -> None: """Restore fields previously captured with :meth:`capture_mutable_state`.""" self._circuit_cycle_states = copy.deepcopy(state.circuit_cycle_states) self._last_battery_direction = state.last_battery_direction - self._solar_excess_w = state.solar_excess_w self._grid_offline = state.grid_offline def copy_mutable_state_from(self, other: RealisticBehaviorEngine) -> None: @@ -501,19 +494,15 @@ def _apply_battery_behavior( self._last_battery_direction = "idle" return self._get_idle_power(battery_config, stochastic_noise=stochastic_noise) - charge_mode: str = battery_config.get("charge_mode", "custom") - - if charge_mode == "solar-gen": - return self._get_solar_gen_charge_power( - battery_config, current_time, stochastic_noise=stochastic_noise - ) + charge_mode: str = battery_config.get("charge_mode", "self-consumption") - if charge_mode == "solar-excess": - return self._get_solar_excess_charge_power( - battery_config, stochastic_noise=stochastic_noise - ) + if charge_mode in ("self-consumption", "backup-only"): + # Energy system drives BESS power for these modes; behavior + # engine returns idle power so circuit-level output is minimal. + self._last_battery_direction = "idle" + return self._get_idle_power(battery_config, stochastic_noise=stochastic_noise) - # "custom" — original schedule-based logic + # "custom" (TOU) — original schedule-based logic custom_charge_hours: list[int] = battery_config.get("charge_hours", []) if current_hour in custom_charge_hours: self._last_battery_direction = "charging" @@ -566,52 +555,6 @@ def _get_demand_factor_from_config(self, hour: int, battery_config: BatteryBehav demand_profile: dict[int, float] = battery_config.get("demand_factor_profile", {}) return demand_profile.get(hour, 0.3) - def _get_solar_gen_charge_power( - self, - battery_config: BatteryBehavior, - current_time: float, - *, - stochastic_noise: bool = True, - ) -> float: - """Charge at max_charge_power * solar_factor * weather_factor.""" - lat = self._config["panel_config"].get("latitude", 37.7) - lon = self._config["panel_config"].get("longitude", -122.4) - factor = solar_production_factor(current_time, lat, lon) - - if factor <= 0.0: - self._last_battery_direction = "idle" - return self._get_idle_power(battery_config, stochastic_noise=stochastic_noise) - - monthly_factors: dict[int, float] | None = None - cached = get_cached_weather(lat, lon) - if cached is not None: - monthly_factors = cached.monthly_factors - - weather = daily_weather_factor( - current_time, - seed=hash(self._config["panel_config"]["serial_number"]), - monthly_factors=monthly_factors, - ) - - max_charge: float = battery_config.get("max_charge_power", 3000.0) - self._last_battery_direction = "charging" - return abs(max_charge) * factor * weather - - def _get_solar_excess_charge_power( - self, - battery_config: BatteryBehavior, - *, - stochastic_noise: bool = True, - ) -> float: - """Charge from surplus solar after loads are met.""" - if self._solar_excess_w <= 0.0: - self._last_battery_direction = "idle" - return self._get_idle_power(battery_config, stochastic_noise=stochastic_noise) - - max_charge: float = battery_config.get("max_charge_power", 3000.0) - self._last_battery_direction = "charging" - return min(self._solar_excess_w, abs(max_charge)) - # ------------------------------------------------------------------ # Annual energy estimation (seeds initial circuit counters) # ------------------------------------------------------------------ @@ -801,14 +744,16 @@ def _estimate_battery_annual_wh( ) / len(charge_hours) consumed_wh = avg_charge * len(charge_hours) * 365 - elif charge_mode == "solar-gen": - solar_factor = self._estimate_solar_annual_factor() - consumed_wh = max_charge * solar_factor * 8760 - - elif charge_mode == "solar-excess": + elif charge_mode == "self-consumption": + # Self-consumption charges from PV excess; estimate ~30% of + # solar capacity goes to battery on average. solar_factor = self._estimate_solar_annual_factor() consumed_wh = 0.3 * max_charge * solar_factor * 8760 + elif charge_mode == "backup-only": + # Backup-only keeps the battery topped up; minimal cycling. + consumed_wh = max_charge * 0.05 * 8760 + return (produced_wh, consumed_wh) @@ -1146,41 +1091,11 @@ async def get_snapshot(self) -> SpanPanelSnapshot: current_time = self._clock.current_time - # 1. Identify solar-excess battery circuits for two-pass tick - solar_excess_ids: set[str] = set() - for cid, circuit in self._circuits.items(): - battery_cfg = circuit.template.get("battery_behavior", {}) - if ( - isinstance(battery_cfg, dict) - and battery_cfg.get("enabled", False) - and battery_cfg.get("charge_mode") == "solar-excess" - ): - solar_excess_ids.add(cid) - - # Pass 1: tick all circuits except solar-excess batteries - for cid, circuit in self._circuits.items(): - if cid in solar_excess_ids: - continue + # 1. Tick all circuits + for _cid, circuit in self._circuits.items(): sync_override = self._get_sync_power_override(circuit) circuit.tick(current_time, power_override=sync_override) - # Pass 2: compute excess and tick solar-excess batteries - if solar_excess_ids and self._behavior_engine is not None: - pv_total = 0.0 - load_total = 0.0 - for circuit in self._circuits.values(): - if circuit.circuit_id in solar_excess_ids: - continue - if circuit.energy_mode == "producer": - pv_total += circuit.instant_power_w - elif circuit.energy_mode != "bidirectional": - load_total += circuit.instant_power_w - self._behavior_engine.set_solar_excess(max(0.0, pv_total - load_total)) - for cid in solar_excess_ids: - circuit = self._circuits[cid] - sync_override = self._get_sync_power_override(circuit) - circuit.tick(current_time, power_override=sync_override) - # 2. Apply global overrides self._apply_global_overrides() @@ -1419,7 +1334,6 @@ def _collect_circuit_powers_at_ts( ts: float, behavior: RealisticBehaviorEngine, circuit_ids: set[str], - solar_excess_ids: set[str], *, use_recorder_baseline: bool, ) -> dict[str, float]: @@ -1428,17 +1342,10 @@ def _collect_circuit_powers_at_ts( Pure data collection — no energy balance math. Only circuits in *circuit_ids* are evaluated; others are omitted from the result (they don't exist in this pass's system). - - Solar-excess battery circuits are handled in a second pass - after the excess is known from the first pass. """ circuit_powers: dict[str, float] = {} - pv_total = 0.0 - load_total = 0.0 for cid in circuit_ids: - if cid in solar_excess_ids: - continue circuit = self._circuits[cid] power = behavior.get_circuit_power( cid, @@ -1448,26 +1355,6 @@ def _collect_circuit_powers_at_ts( modeling_deterministic=True, ) circuit_powers[cid] = power - if circuit.energy_mode == "producer": - pv_total += power - elif circuit.energy_mode != "bidirectional": - load_total += power - - # Solar-excess batteries need the excess computed from other circuits - active_solar_excess = solar_excess_ids & circuit_ids - if active_solar_excess: - excess = max(0.0, pv_total - load_total) - behavior.set_solar_excess(excess) - for cid in active_solar_excess: - circuit = self._circuits[cid] - power = behavior.get_circuit_power( - cid, - circuit.template, - ts, - modeling_recorder_baseline=use_recorder_baseline, - modeling_deterministic=True, - ) - circuit_powers[cid] = power return circuit_powers @@ -1555,17 +1442,6 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: ) cloned_behavior.copy_mutable_state_from(be) - # Identify solar-excess battery circuits - solar_excess_ids: set[str] = set() - for cid, circuit in self._circuits.items(): - battery_cfg = circuit.template.get("battery_behavior", {}) - if ( - isinstance(battery_cfg, dict) - and battery_cfg.get("enabled", False) - and battery_cfg.get("charge_mode") == "solar-excess" - ): - solar_excess_ids.add(cid) - # Partition circuits: baseline set (recorder-backed) vs full set baseline_circuit_ids = { cid for cid, c in self._circuits.items() if c.template.get("recorder_entity") @@ -1590,7 +1466,7 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: circuit_arrays_after: dict[str, list[float]] = {cid: [] for cid in self._circuits} for ts in timestamps: - # Restore mutable state between passes so cycling / solar-excess + # Restore mutable state between passes so cycling # bookkeeping does not cross-contaminate. modeling_checkpoint = cloned_behavior.capture_mutable_state() @@ -1599,7 +1475,6 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: ts, cloned_behavior, baseline_circuit_ids, - solar_excess_ids, use_recorder_baseline=True, ) inputs_b = self._powers_to_energy_inputs(powers_b) @@ -1622,7 +1497,6 @@ async def compute_modeling_data(self, horizon_hours: int) -> dict[str, Any]: ts, cloned_behavior, all_circuit_ids, - solar_excess_ids, use_recorder_baseline=False, ) inputs_a = self._powers_to_energy_inputs(powers_a) @@ -1877,6 +1751,7 @@ def _build_energy_system( if self._behavior_engine is not None else RealisticBehaviorEngine._DEFAULT_TZ ) + charge_mode = str(battery_cfg.get("charge_mode", "self-consumption")) bess_config = BESSConfig( nameplate_kwh=nameplate, max_charge_w=abs(float(battery_cfg.get("max_charge_power", 3500.0))), @@ -1895,6 +1770,7 @@ def _build_energy_system( charge_hours=tuple(charge_hours_raw), discharge_hours=tuple(discharge_hours_raw), panel_timezone=panel_tz, + charge_mode=charge_mode, ) break diff --git a/tests/test_energy/test_scenarios.py b/tests/test_energy/test_scenarios.py index 7474756..a77ba86 100644 --- a/tests/test_energy/test_scenarios.py +++ b/tests/test_energy/test_scenarios.py @@ -33,6 +33,7 @@ def _bess( hybrid: bool = False, backup_reserve_pct: float = 20.0, initial_soe_kwh: float | None = None, + charge_mode: str = "custom", ) -> BESSConfig: return BESSConfig( nameplate_kwh=nameplate_kwh, @@ -41,6 +42,7 @@ def _bess( hybrid=hybrid, backup_reserve_pct=backup_reserve_pct, initial_soe_kwh=initial_soe_kwh, + charge_mode=charge_mode, ) @@ -361,3 +363,145 @@ def test_larger_nameplate_sustains_longer(self) -> None: assert s_large is not None assert s_small.bess_state == "idle" assert s_large.bess_state == "discharging" + + +class TestSelfConsumptionMode: + """Tests for the self-consumption charge mode.""" + + def test_discharges_when_load_exceeds_pv(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_online(), + pv=_pv(), + bess=_bess(charge_mode="self-consumption"), + loads=[LoadConfig(demand_w=4000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + pv_available_w=2000.0, + load_demand_w=4000.0, + grid_connected=True, + ), + ) + assert state.balanced + assert state.bess_state == "discharging" + assert abs(state.bess_power_w - 2000.0) < 0.01 + assert abs(state.grid_power_w) < 0.01 + + def test_charges_when_pv_exceeds_load(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_online(), + pv=_pv(), + bess=_bess( + charge_mode="self-consumption", + max_charge_w=3000.0, + initial_soe_kwh=5.0, + ), + loads=[LoadConfig(demand_w=2000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + pv_available_w=5000.0, + load_demand_w=2000.0, + grid_connected=True, + ), + ) + assert state.balanced + assert state.bess_state == "charging" + assert state.bess_power_w == 3000.0 + + def test_idle_when_pv_equals_load(self) -> None: + """When PV exactly meets load, no deficit or excess: BESS charges at 0.""" + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_online(), + pv=_pv(), + bess=_bess(charge_mode="self-consumption", initial_soe_kwh=5.0), + loads=[LoadConfig(demand_w=3000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + pv_available_w=3000.0, + load_demand_w=3000.0, + grid_connected=True, + ), + ) + assert state.balanced + # No deficit so BESS tries to charge, but no excess either + # The grid absorbs the BESS charging demand + assert state.grid_power_w >= 0.0 + + +class TestBackupOnlyMode: + """Tests for the backup-only charge mode.""" + + def test_charges_to_full_on_grid(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_online(), + bess=_bess( + charge_mode="backup-only", + max_charge_w=3000.0, + initial_soe_kwh=5.0, + ), + loads=[LoadConfig(demand_w=2000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + load_demand_w=2000.0, + grid_connected=True, + ), + ) + assert state.balanced + assert state.bess_state == "charging" + assert state.bess_power_w == 3000.0 + + def test_idle_when_full_on_grid(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_online(), + bess=_bess( + charge_mode="backup-only", + initial_soe_kwh=13.5, + ), + loads=[LoadConfig(demand_w=2000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + load_demand_w=2000.0, + grid_connected=True, + ), + ) + assert state.balanced + assert state.bess_state == "idle" + assert state.bess_power_w == 0.0 + + def test_discharges_during_outage(self) -> None: + system = EnergySystem.from_config( + EnergySystemConfig( + grid=_grid_offline(), + bess=_bess(charge_mode="backup-only", hybrid=False), + loads=[LoadConfig(demand_w=3000.0)], + ) + ) + state = system.tick( + 1000.0, + PowerInputs( + load_demand_w=3000.0, + grid_connected=False, + ), + ) + assert state.balanced + assert state.bess_state == "discharging" + assert abs(state.bess_power_w - 3000.0) < 0.01 From 526251b0f91112a21c848855148ae761409b64dd Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:12:56 -0700 Subject: [PATCH 19/34] Always show imported/exported breakdown in modeling labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The After label was hiding the breakdown when exported < 0.5 kWh, which happens when BESS absorbs all PV excess. Now both Before and After always show the full breakdown for clarity. Also hide discharge presets, active days, and hourly schedule when charge mode is Self-Consumption or Backup Only — those controls only apply to Time-of-Use mode. --- .../templates/partials/battery_profile_editor.html | 4 +++- .../dashboard/templates/partials/modeling_view.html | 10 ++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html b/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html index e1d31ce..5c91d35 100644 --- a/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html +++ b/src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html @@ -33,6 +33,7 @@