Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 10 additions & 19 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,18 @@ Rules in this file apply to all AI coding agents working in this repository.

## Energy System Encapsulation

The `span_panel_simulator.energy` package is the **sole authority** for all
energy and power-flow calculations. This boundary was deliberately established
to replace scattered inline logic and must not be eroded.
The `span_panel_simulator.energy` package is the **sole authority** for all energy and power-flow calculations. This boundary was deliberately established to
replace scattered inline logic and must not be eroded.

**Rules:**

- The engine (`engine.py`) provides **raw measurements** to the energy module
(PV power, load power, grid status). It must never pre-compute, resolve, or
- The engine (`engine.py`) provides **raw measurements** to the energy module (PV power, load power, grid status). It must never pre-compute, resolve, or
override energy scheduling, dispatch, or balance decisions.
- `PowerInputs` carries only observable state — never derived energy decisions
like BESS scheduled state.
- All BESS scheduling (charge mode logic, TOU hour resolution, islanding
overrides, forced-offline behavior) lives inside `EnergySystem.tick()` and
`BESSUnit`. The engine must not call `resolve_scheduled_state()` or read
`effective_state` to feed back into inputs.
- PV curtailment, GFE throttling, SOE enforcement, and bus balancing are
energy-module concerns. The engine consumes `SystemState` results — it does
not participate in producing them.
- New energy behaviors (e.g. demand response, rate optimization) must be added
inside the energy package, not grafted onto the engine.
- `PowerInputs` carries only observable state — never derived energy decisions like BESS scheduled state.
- All BESS scheduling (charge mode logic, TOU hour resolution, islanding overrides, forced-offline behavior) lives inside `EnergySystem.tick()` and `BESSUnit`.
The engine must not call `resolve_scheduled_state()` or read `effective_state` to feed back into inputs.
- PV curtailment, GFE throttling, SOE enforcement, and bus balancing are energy-module concerns. The engine consumes `SystemState` results — it does not
participate in producing them.
- New energy behaviors (e.g. demand response, rate optimization) must be added inside the energy package, not grafted onto the engine.

**Test discipline:** Tests drive BESS behavior through `BESSConfig`
(charge_mode, charge_hours, discharge_hours), not by injecting state into
`PowerInputs`.
**Test discipline:** Tests drive BESS behavior through `BESSConfig` (charge_mode, charge_hours, discharge_hours), not by injecting state into `PowerInputs`.
3 changes: 1 addition & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# Simulator — Claude Rules

All agent-wide rules are in [AGENTS.md](AGENTS.md). This file is for
Claude-specific configuration only.
All agent-wide rules are in [AGENTS.md](AGENTS.md). This file is for Claude-specific configuration only.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "span-panel-simulator"
version = "1.0.10"
version = "1.0.11"
description = "Standalone eBus simulator for SPAN panels"
requires-python = ">=3.12"
dependencies = [
Expand Down
20 changes: 19 additions & 1 deletion span_panel_simulator/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## 1.0.11 — 2026-04-19

### Fixes

- Homie schema served over `GET /api/v2/homie/schema` now declares the correct panel size per panel. Previously the `space` property's `format` field was
hardcoded to `"1:32:1"` regardless of the panel's configured `total_tabs`, causing span-panel-api (and downstream span-card) to render 40-tab panels as 32-tab
- `typesSchemaHash` is now content-derived (same algorithm span-panel-api uses) and changes with panel size, replacing a hardcoded value that did not reflect
schema contents
- `DynamicSimulationEngine.total_tabs` raises instead of silently returning 32 when accessed before `initialize_async()`
- Panels configured with `total_tabs` outside the supported model set (16, 24, 32, 40, 48) now fail loudly at panel-add time instead of being silently labeled
`MAIN_32`; validation runs before panel registration so unsupported configs no longer leave an orphan tick task
- `PanelInstance.total_tabs` property added, mirroring the existing `serial_number` lifecycle guard
- Config-directory reload no longer aborts when a single panel fails to start — each config is processed independently, errors are recorded per filename, and
successful panels continue to start, stop, or reload as expected
- Failed configs' file hashes are withheld so the next reload automatically retries after the user fixes the config
- Dashboard panel list surfaces per-panel start errors as a badge next to the failing filename, with the full error message available on hover

## 1.0.10 — 2026-04-02

### Features
Expand Down Expand Up @@ -55,7 +72,8 @@
- Backup Only mode: battery holds at full SOC and only discharges during grid outages
- BESS operates at full inverter rate like a real system — GFE throttle limits discharge to actual load deficit
- Hybrid inverter support: PV stays online during islanding when co-located with BESS
- PV curtailment during islanding: hybrid inverter reduces output to match load + BESS charge capacity when grid is disconnected, matching real MPPT setpoint behavior
- PV curtailment during islanding: hybrid inverter reduces output to match load + BESS charge capacity when grid is disconnected, matching real MPPT setpoint
behavior

### Improvements

Expand Down
2 changes: 1 addition & 1 deletion span_panel_simulator/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ EXPOSE 18883 8081 18080
LABEL io.hass.name="SPAN Panel Simulator" \
io.hass.description="Simulates a SPAN electrical panel for testing and upgrade modeling" \
io.hass.type="addon" \
io.hass.version="1.0.10" \
io.hass.version="1.0.11" \
io.hass.arch="aarch64|amd64"

CMD ["/run.sh"]
2 changes: 1 addition & 1 deletion span_panel_simulator/config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: "SPAN Panel Simulator"
description: "Simulates a SPAN electrical panel for testing and upgrade modeling"
version: "1.0.10"
version: "1.0.11"
slug: "span_panel_simulator"
url: "https://github.com/SpanPanel/simulator"
image: "ghcr.io/spanpanel/simulator/{arch}"
Expand Down
2 changes: 1 addition & 1 deletion src/span_panel_simulator/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Standalone eBus simulator for SPAN panels."""

__version__ = "1.0.10"
__version__ = "1.0.11"
106 changes: 82 additions & 24 deletions src/span_panel_simulator/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from span_panel_simulator.engine import _PANEL_SIZE_TO_MODEL
from span_panel_simulator.panel import PanelInstance
from span_panel_simulator.recorder import RecorderDataSource
from span_panel_simulator.schema import HomieSchemaRegistry, load_schema
from span_panel_simulator.schema import HomieSchemaRegistry, load_schema, render_for_panel

if TYPE_CHECKING:
from span_panel_simulator.certs import CertificateBundle
Expand Down Expand Up @@ -136,6 +136,7 @@ def __init__(
self._config_hashes: dict[Path, str] = {}
self._serial_to_panel: dict[str, PanelInstance] = {}
self._stopped_configs: set[str] = set()
self._panel_start_errors: dict[str, str] = {} # filename -> last error message
self._panel_servers: dict[str, BootstrapHttpServer] = {}
self._panel_ports: dict[str, int] = {}
self._used_ports: set[int] = set()
Expand Down Expand Up @@ -179,6 +180,10 @@ def _get_panel_ports(self) -> dict[str, int]:
"""Return a mapping of serial number to HTTP port for running panels."""
return dict(self._panel_ports)

def _get_panel_start_errors(self) -> dict[str, str]:
"""Return the most recent per-filename start/reload errors."""
return dict(self._panel_start_errors)

def _get_first_engine(self) -> DynamicSimulationEngine | None:
"""Return the engine of the first running panel, if any."""
for panel in self._panels.values():
Expand Down Expand Up @@ -282,16 +287,29 @@ async def _start_panel(self, config_path: Path) -> PanelInstance:
)
serial = await panel.start()

# Validate panel size and render schema before registering. If the
# panel's total_tabs is not a SPAN model size, stop the started panel
# cleanly so we don't leave a zombie tick task and MQTT publisher
# without a bootstrap HTTP server to match.
try:
total_tabs = panel.total_tabs
panel_model = _PANEL_SIZE_TO_MODEL[total_tabs]
panel_schema = render_for_panel(self._schema, total_tabs)
except Exception:
await panel.stop()
raise

# Ensure unique serial — cloned configs may share the same serial.
# Append a suffix to avoid MQTT topic and mDNS name collisions.
# Moved below validation — no point renaming a panel we are about to discard.
if serial in self._serial_to_panel:
base_serial = serial
suffix = 2
while f"{base_serial}-{suffix}" in self._serial_to_panel:
suffix += 1
serial = f"{base_serial}-{suffix}"
if panel.engine is not None:
panel.engine.override_serial_number(serial)
assert panel.engine is not None # narrowed by panel.total_tabs above
panel.engine.override_serial_number(serial)
if panel.publisher is not None:
panel.publisher.override_serial(serial)
_LOGGER.warning(
Expand All @@ -303,18 +321,13 @@ async def _start_panel(self, config_path: Path) -> PanelInstance:
self._panels[config_path] = panel
self._serial_to_panel[serial] = panel

# Derive model from panel tab count
panel_model = "MAIN_32"
if panel.engine is not None:
panel_model = _PANEL_SIZE_TO_MODEL.get(panel.engine.total_tabs, "MAIN_32")

# Create per-panel bootstrap HTTP server with port allocation
port = self._allocate_port()
server = BootstrapHttpServer(
serial,
self._firmware,
self._certs,
self._schema,
panel_schema,
broker_username=self._broker_username,
broker_password=self._broker_password,
broker_host=self._broker_host,
Expand All @@ -335,7 +348,7 @@ async def _start_panel(self, config_path: Path) -> PanelInstance:
serial,
self._firmware,
self._certs,
self._schema,
panel_schema,
broker_username=self._broker_username,
broker_password=self._broker_password,
broker_host=self._broker_host,
Expand Down Expand Up @@ -528,7 +541,12 @@ async def reload(self) -> dict[str, list[str]]:

Returns a summary of what changed::

{"started": [...], "stopped": [...], "reloaded": [...]}
{"started": [...], "stopped": [...], "reloaded": [...], "errors": [...]}

Per-path failures are captured rather than aborting the reload — a
broken config cannot block other panels from being started,
stopped, or reloaded. Errors for affected filenames are stored
on ``self._panel_start_errors`` so the dashboard can surface them.
"""
current = _discover_configs(self._config_dir, self._config_filter)

Expand All @@ -544,38 +562,77 @@ async def reload(self) -> dict[str, list[str]]:
to_check = set(current) & set(prev)
to_reload = {p for p in to_check if current[p] != prev[p]}

result: dict[str, list[str]] = {"started": [], "stopped": [], "reloaded": []}
result: dict[str, list[str]] = {
"started": [],
"stopped": [],
"reloaded": [],
"errors": [],
}

def _record_error(path: Path, exc: BaseException) -> None:
msg = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
self._panel_start_errors[path.name] = msg
result["errors"].append(f"{path.name}: {msg}")
_LOGGER.exception("Panel operation failed for %s", path.name)

# Stop removed panels
for path in to_stop:
panel = self._panels.get(path)
serial = panel.serial_number if panel and panel.is_running else path.stem
await self._stop_panel(path)
try:
await self._stop_panel(path)
except Exception as exc:
_record_error(path, exc)
continue
result["stopped"].append(serial)
self._panel_start_errors.pop(path.name, None)

# Reload changed panels
for path in to_reload:
panel = self._panels.get(path)
if panel is not None:
await self._stop_panel(path)
new_panel = await self._start_panel(path)
result["reloaded"].append(new_panel.serial_number)
else:
new_panel = await self._start_panel(path)
result["started"].append(new_panel.serial_number)
try:
if panel is not None:
await self._stop_panel(path)
new_panel = await self._start_panel(path)
result["reloaded"].append(new_panel.serial_number)
else:
new_panel = await self._start_panel(path)
result["started"].append(new_panel.serial_number)
except Exception as exc:
_record_error(path, exc)
# Do not record the new hash — next reload retries after fix.
continue
self._panel_start_errors.pop(path.name, None)

# Start new panels
for path in to_start:
panel = await self._start_panel(path)
try:
panel = await self._start_panel(path)
except Exception as exc:
_record_error(path, exc)
continue
result["started"].append(panel.serial_number)

self._config_hashes = current
self._panel_start_errors.pop(path.name, None)

# Record hashes only for paths we successfully processed. Failed
# paths retain their previous hash (or absence) so reload retries
# on the next tick after the user fixes the config.
new_hashes = dict(self._config_hashes)
successful = set(current) - {
p for p in (to_start | to_reload) if p.name in self._panel_start_errors
}
for path in successful:
new_hashes[path] = current[path]
for path in to_stop:
new_hashes.pop(path, None)
self._config_hashes = new_hashes

_LOGGER.info(
"Reload complete: started=%d, stopped=%d, reloaded=%d",
"Reload complete: started=%d, stopped=%d, reloaded=%d, errors=%d",
len(result["started"]),
len(result["stopped"]),
len(result["reloaded"]),
len(result["errors"]),
)
return result

Expand Down Expand Up @@ -784,6 +841,7 @@ async def run(self) -> None:
config_filter=self._config_filter,
get_panel_configs=self._get_panel_configs,
get_panel_ports=self._get_panel_ports,
get_panel_start_errors=self._get_panel_start_errors,
request_reload=self.request_reload,
set_config_filter=self.set_config_filter,
start_panel=self.request_start_panel,
Expand Down
1 change: 1 addition & 0 deletions src/span_panel_simulator/dashboard/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class DashboardContext:
get_panel_configs: Callable[[], dict[Path, str]] # path -> serial
get_panel_ports: Callable[[], dict[str, int]] # serial -> port
request_reload: Callable[[], None]
get_panel_start_errors: Callable[[], dict[str, str]] = lambda: {} # filename -> error message
set_config_filter: Callable[[str | None], None] = lambda _: None
start_panel: Callable[[str], None] = lambda _: None
stop_panel: Callable[[str], None] = lambda _: None
Expand Down
2 changes: 2 additions & 0 deletions src/span_panel_simulator/dashboard/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ def _all_panels(request: web.Request) -> list[dict[str, object]]:

# Port lookup: serial -> port
port_map = ctx.get_panel_ports()
errors = ctx.get_panel_start_errors()

return [
{
Expand All @@ -152,6 +153,7 @@ def _all_panels(request: web.Request) -> list[dict[str, object]]:
"active": fname == active_file,
"is_default": fname.startswith("default_"),
"port": port_map.get(running_map.get(fname, ""), 0),
"error": errors.get(fname, ""),
}
for fname in configs
]
Expand Down
6 changes: 6 additions & 0 deletions src/span_panel_simulator/dashboard/static/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -1276,6 +1276,12 @@ legend {
background: #6c757d;
font-size: 0.65rem;
}
.badge-error {
background: #fee;
color: #a00;
border: 1px solid #faa;
font-size: 0.65rem;
}

.active-panel {
border-left: 3px solid var(--primary);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
<span class="panel-serial">{{ p.serial }}</span>
{% if p.port %}<span class="panel-port" title="Bootstrap HTTP port">:{{ p.port }}</span>{% endif %}
<span class="panel-filename">{{ p.filename }}</span>
{% if p.error %}
<span class="badge badge-error" title="{{ p.error }}">error</span>
{% endif %}
{% if p.is_default %}
<span class="badge badge-default" title="Clone this template to create an editable config">template</span>
{% endif %}
Expand Down
11 changes: 6 additions & 5 deletions src/span_panel_simulator/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -809,9 +809,10 @@ def override_serial_number(self, serial: str) -> None:
@property
def total_tabs(self) -> int:
"""Total panel tab count from configuration."""
if self._config:
return int(self._config["panel_config"].get("total_tabs", 32))
return 32
if self._config is None:
msg = "Engine not initialised — call initialize_async() first"
raise RuntimeError(msg)
return int(self._config["panel_config"]["total_tabs"])

@property
def panel_timezone(self) -> str:
Expand Down Expand Up @@ -1109,7 +1110,7 @@ async def get_snapshot(self) -> SpanPanelSnapshot:
)

# 9. Build panel snapshot
total_tabs = self._config["panel_config"].get("total_tabs", 32)
total_tabs = self._config["panel_config"]["total_tabs"]
main_size = self._config["panel_config"].get("main_size", 200)
feedthrough_power = 0.0

Expand Down Expand Up @@ -1495,7 +1496,7 @@ def _add_unmapped_tabs(self, circuit_snapshots: dict[str, SpanCircuitSnapshot])
if not occupied_tabs:
return

total_tabs = self._config["panel_config"].get("total_tabs", 32)
total_tabs = self._config["panel_config"]["total_tabs"]
panel_size = max(*occupied_tabs, total_tabs)
for tab in range(1, panel_size + 1):
if tab not in occupied_tabs:
Expand Down
Loading
Loading