+{% endif %}
+```
+
+- [ ] **Step 2: Include BESS card in dashboard layout**
+
+In `src/span_panel_simulator/dashboard/templates/dashboard.html`, add after the sim-config section (line 34) and before the entity list (line 36):
+
+```html
+
+ {% include "partials/bess_card.html" %}
+
+```
+
+- [ ] **Step 3: Remove battery fieldset from entity_edit.html**
+
+In `src/span_panel_simulator/dashboard/templates/partials/entity_edit.html`, remove lines 135-162 (the `{% if e.battery_behavior %}` block).
+
+- [ ] **Step 4: Update battery_profile_editor.html for panel-level routes**
+
+In `src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html`, replace all entity-based route references:
+
+- `hx-put="entities/{{ entity.id }}/battery-charge-mode"` → `hx-put="bess/charge-mode"`
+- `hx-target="#battery-profile-{{ entity.id }}"` → `hx-target="#bess-card-section"`
+- `hx-post="entities/{{ entity.id }}/battery-profile/preset"` → `hx-post="bess/schedule/preset"`
+- `hx-put="entities/{{ entity.id }}/active-days"` → `hx-put="bess/active-days"`
+- `hx-put="entities/{{ entity.id }}/battery-profile"` → `hx-put="bess/schedule"`
+- `id="charge-mode-{{ entity.id }}"` → `id="bess-charge-mode"`
+- `id="days-{{ entity.id }}"` → `id="bess-days"`
+
+Remove all `{{ entity.id }}` references — the template no longer needs an entity context. Keep `battery_profile`, `battery_charge_mode`, `battery_preset_labels`, `battery_active_preset`, and `active_days` context variables.
+
+- [ ] **Step 5: Add BESS context to `_dashboard_context`**
+
+In `routes.py`, in `_dashboard_context` (line 165), add BESS config to the context:
+
+```python
+ "bess_config": store.get_bess_config(),
+```
+
+- [ ] **Step 6: Add new BESS route handlers and register routes**
+
+Replace the old entity-based battery routes with panel-level ones. In `routes.py`:
+
+```python
+def _bess_card_context(request: web.Request, editing: bool = False, schedule: bool = False) -> dict[str, Any]:
+ """Build the BESS card template context."""
+ store = _store(request)
+ ctx: dict[str, Any] = {
+ "bess_config": store.get_bess_config(),
+ "bess_editing": editing,
+ "readonly": _is_readonly(_ctx(request)),
+ }
+ if schedule:
+ battery_profile = store.get_battery_profile()
+ ctx["bess_schedule"] = True
+ ctx["battery_profile"] = battery_profile
+ ctx["battery_preset_labels"] = _presets(request).battery_labels
+ ctx["battery_charge_mode"] = store.get_battery_charge_mode()
+ ctx["battery_active_preset"] = match_battery_preset(battery_profile)
+ ctx["active_days"] = store.get_bess_active_days()
+ return ctx
+
+
+async def handle_get_bess(request: web.Request) -> web.Response:
+ """GET /bess — return BESS card in display mode."""
+ return _render("partials/bess_card.html", request, _bess_card_context(request))
+
+
+async def handle_get_bess_edit(request: web.Request) -> web.Response:
+ """GET /bess/edit — return BESS card in edit mode."""
+ return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True))
+
+
+async def handle_put_bess(request: web.Request) -> web.Response:
+ """PUT /bess — save BESS settings."""
+ data = await request.post()
+ _store(request).update_bess_config(dict(data))
+ _persist_config(request)
+ return _render("partials/bess_card.html", request, _bess_card_context(request))
+
+
+async def handle_get_bess_schedule(request: web.Request) -> web.Response:
+ """GET /bess/schedule — return BESS card with schedule editor."""
+ return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True))
+
+
+async def handle_put_bess_schedule(request: web.Request) -> web.Response:
+ """PUT /bess/schedule — save BESS charge/discharge schedule."""
+ data = await request.post()
+ hour_modes: dict[int, str] = {}
+ for h in range(24):
+ key = f"hour_{h}"
+ mode = str(data.get(key, "idle"))
+ hour_modes[h] = mode if mode in ("charge", "discharge", "idle") else "idle"
+ store = _store(request)
+ store.update_battery_profile(hour_modes)
+ active = _parse_active_days(data)
+ if active is not None:
+ store.update_bess_active_days(active)
+ _persist_config(request)
+ return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True))
+
+
+async def handle_post_bess_schedule_preset(request: web.Request) -> web.Response:
+ """POST /bess/schedule/preset — apply a schedule preset."""
+ data = await request.post()
+ preset_name = str(data.get("preset", "custom"))
+ _store(request).apply_battery_preset(preset_name)
+ _persist_config(request)
+ return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True))
+
+
+async def handle_put_bess_charge_mode(request: web.Request) -> web.Response:
+ """PUT /bess/charge-mode — change BESS charge mode."""
+ data = await request.post()
+ mode = str(data.get("charge_mode", "custom"))
+ _store(request).update_battery_charge_mode(mode)
+ _persist_config(request)
+ return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True))
+
+
+async def handle_put_bess_active_days(request: web.Request) -> web.Response:
+ """PUT /bess/active-days — update BESS active days."""
+ data = await request.post()
+ active = _parse_active_days(data)
+ if active is not None:
+ _store(request).update_bess_active_days(active)
+ _persist_config(request)
+ return _render("partials/bess_card.html", request, _bess_card_context(request, schedule=True))
+```
+
+- [ ] **Step 7: Register new routes and remove old ones**
+
+In the route registration function (around line 506-510), replace:
+
+```python
+ # Battery profile
+ app.router.add_get("/entities/{id}/battery-profile", handle_get_battery_profile)
+ app.router.add_put("/entities/{id}/battery-profile", handle_put_battery_profile)
+ app.router.add_post("/entities/{id}/battery-profile/preset", handle_apply_battery_preset)
+ app.router.add_put("/entities/{id}/battery-charge-mode", handle_put_battery_charge_mode)
+```
+
+With:
+
+```python
+ # BESS (panel-level)
+ app.router.add_get("/bess", handle_get_bess)
+ app.router.add_get("/bess/edit", handle_get_bess_edit)
+ app.router.add_put("/bess", handle_put_bess)
+ app.router.add_get("/bess/schedule", handle_get_bess_schedule)
+ app.router.add_put("/bess/schedule", handle_put_bess_schedule)
+ app.router.add_post("/bess/schedule/preset", handle_post_bess_schedule_preset)
+ app.router.add_put("/bess/charge-mode", handle_put_bess_charge_mode)
+ app.router.add_put("/bess/active-days", handle_put_bess_active_days)
+```
+
+Remove the old handler functions: `handle_get_battery_profile`, `handle_put_battery_profile`, `handle_apply_battery_preset`, `handle_put_battery_charge_mode`.
+
+Also remove `_battery_profile_context` (lines 257-269) and the battery-specific section from `_entity_list_context` (lines 218-223).
+
+- [ ] **Step 8: Run type checker and tests**
+
+Run: `mypy src/span_panel_simulator/dashboard/ && pytest tests/ -q`
+Expected: PASS
+
+- [ ] **Step 9: Commit**
+
+```
+git add src/span_panel_simulator/dashboard/
+git commit -m "Add BESS card, panel-level routes, remove entity-based battery handling"
+```
+
+---
+
+### Task 4: Update bess_card.html to support schedule view
+
+**Files:**
+- Modify: `src/span_panel_simulator/dashboard/templates/partials/bess_card.html`
+
+- [ ] **Step 1: Add schedule view to BESS card**
+
+The BESS card has three states: display, edit (settings), and schedule. Add the schedule state after the edit block and before the display block in `bess_card.html`:
+
+```html
+ {% elif bess_schedule is defined and bess_schedule %}
+
+ {% include "partials/battery_profile_editor.html" %}
+
+
+
+
+```
+
+Insert this between `{% if bess_editing %}...{% else %}` — making it `{% elif bess_schedule %}`.
+
+- [ ] **Step 2: Run manually to verify**
+
+Start the simulator and verify:
+- BESS card appears between sim config and entity list
+- "Edit Settings" opens the settings form
+- "Schedule" opens the schedule editor
+- Save persists to YAML
+- Charge mode radio buttons work
+- Schedule grid works
+
+- [ ] **Step 3: Commit**
+
+```
+git add src/span_panel_simulator/dashboard/templates/partials/bess_card.html
+git commit -m "Add schedule view to BESS card"
+```
+
+---
+
+### Task 5: Final verification and cleanup
+
+- [ ] **Step 1: Run full type check**
+
+Run: `mypy src/`
+Expected: PASS
+
+- [ ] **Step 2: Run full test suite**
+
+Run: `pytest tests/ -v`
+Expected: All tests pass
+
+- [ ] **Step 3: Search for orphaned battery_behavior references**
+
+Run: `grep -rn "battery_behavior" src/ tests/ configs/`
+
+Expected: No matches in non-dashboard code. Dashboard should have zero remaining references.
+
+- [ ] **Step 4: Verify entity list no longer shows battery**
+
+Start the simulator and confirm:
+- Entity list count reflects only circuits + PV + EVSE
+- "Add Entity" dropdown does not include "Battery"
+- BESS card is the only place to manage battery settings
+
+- [ ] **Step 5: Commit any cleanup**
+
+```
+git add -A
+git commit -m "Final cleanup: remove orphaned dashboard battery_behavior references"
+```
diff --git a/docs/superpowers/specs/2026-03-30-dashboard-i18n-design.md b/docs/superpowers/specs/2026-03-30-dashboard-i18n-design.md
new file mode 100644
index 0000000..90dae76
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-30-dashboard-i18n-design.md
@@ -0,0 +1,177 @@
+# Dashboard Internationalization (i18n) Design
+
+## Overview
+
+Add internationalization support to the simulator dashboard so all
+user-visible strings (labels, buttons, tabs, tutorial text) render in the
+language appropriate to the deployment context. Standalone installations
+use the host system locale; Home Assistant add-on installations use HA's
+configured language.
+
+Supported languages match the existing translation files: en, nl, de, fr,
+es, pt-BR.
+
+## Locale Resolution
+
+A single locale is determined once at dashboard startup and held for the
+lifetime of the process.
+
+**Resolution order:**
+
+1. **HA add-on mode** (`SUPERVISOR_TOKEN` present): GET
+ `http://supervisor/core/api/config` with the supervisor token, read
+ the `language` field (e.g. `"nl"`).
+2. **Standalone mode**: `locale.getlocale()` -> parse the language code
+ (e.g. `en_US.UTF-8` -> `"en"`).
+3. **Fallback**: `"en"`.
+
+The resolved locale is validated against available translation files. If
+the locale has no matching YAML file, fall back to `"en"`.
+
+The locale string is stored on `DashboardContext`, which already flows
+into every route handler.
+
+## Translator Class
+
+A `Translator` class loads all YAML files from
+`span_panel_simulator/translations/` at startup. Each file's `dashboard:`
+section is flattened into dot-notation keys:
+
+```yaml
+dashboard:
+ controls:
+ grid_online: Grid Online
+```
+
+Becomes: `{"controls.grid_online": "Grid Online"}`
+
+### Interface
+
+- `t(key: str) -> str` -- look up key in active locale, fall back to
+ `en` if missing, return the raw key string as last resort.
+- `to_json() -> str` -- serialize the active locale's dashboard
+ dictionary as JSON for the JavaScript bridge.
+
+The translator is created once during `create_dashboard_app()` and
+registered as a Jinja2 global.
+
+## Translation File Structure
+
+The existing `span_panel_simulator/translations/*.yaml` files are
+extended with a `dashboard:` section alongside the existing
+`configuration:` section:
+
+```yaml
+configuration:
+ # ... existing HA add-on config strings unchanged ...
+
+dashboard:
+ title: SPAN Panel Simulator Dashboard
+ theme:
+ label: Theme
+ system: System
+ light: Light
+ dark: Dark
+ tabs:
+ getting_started: Getting started
+ clone: Clone
+ model: Model
+ purge: Purge
+ export: Export
+ getting_started:
+ title: Getting started
+ step_1: "Click a simulator configuration..."
+ # ... full tutorial text
+ controls:
+ grid_online: Grid Online
+ grid_offline: Grid Offline
+ islandable: Islandable
+ not_islandable: Not Islandable
+ runtime: Runtime
+ modeling: Modeling
+ date: Date
+ time_of_day: Time of Day
+ speed: Speed
+ chart:
+ live_power_flows: Live Power Flows
+ grid: Grid
+ solar: Solar
+ battery: Battery
+ panel_config:
+ serial: "Serial:"
+ tabs: "Tabs:"
+ main_breaker: "Main Breaker (A):"
+ # ... remaining config labels
+ sim_config:
+ interval: "Interval (s)"
+ noise: Noise
+ save_reload: Save & Reload
+ update: Update
+ panels:
+ title: Panels
+ import: Import
+ overwrite: Overwrite
+ cancel: Cancel
+```
+
+All 6 language files get the same `dashboard:` key structure. English is
+the source of truth; other languages are translated to match.
+
+## Template Integration
+
+### Server-rendered HTML (Jinja2)
+
+Every hardcoded English string is replaced with a `{{ t('key') }}` call:
+
+```html
+
+
+
+
+
+```
+
+### Inline JavaScript bridge
+
+In `base.html`, the locale and full dictionary are injected once:
+
+```html
+
+```
+
+JS code references strings via `window.i18n['controls.grid_online']`.
+
+### Date and number formatting
+
+Hardcoded month arrays and manual number formatting are replaced with
+`Intl` APIs using the locale:
+
+```js
+new Intl.DateTimeFormat(window.i18nLocale, { month: 'short' }).format(date)
+```
+
+## Error Handling
+
+- `t(key)` never throws. Fallback chain: active locale -> `en` -> raw
+ key string.
+- Raw keys appearing in the UI make missing translations obvious during
+ development without breaking rendering.
+
+## Testing
+
+- **Translator unit tests**: loading, key lookup, fallback chain,
+ `to_json()` output.
+- **Locale resolution unit tests**: mock `SUPERVISOR_TOKEN` for HA mode,
+ mock `locale.getlocale()` for standalone, verify fallback to `"en"` for
+ unsupported locales.
+- **Translation key parity test**: load all YAML files and assert every
+ non-English file has the same set of `dashboard:` keys as `en.yaml`.
+ Catches missing translations at CI time.
+
+## Dependencies
+
+No new dependencies. PyYAML is already in the project; `json` and
+`locale` are stdlib.
diff --git a/docs/superpowers/specs/2026-04-02-bess-circuit-removal-design.md b/docs/superpowers/specs/2026-04-02-bess-circuit-removal-design.md
new file mode 100644
index 0000000..98bf4fe
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-02-bess-circuit-removal-design.md
@@ -0,0 +1,122 @@
+# BESS Circuit Removal — Design Spec
+
+**Date:** 2026-04-02
+**Status:** Draft
+**Scope:** Remove phantom battery circuit; BESS exists only as GFE on upstream lugs
+
+## Problem
+
+The simulator creates a `battery_storage` circuit for BESS, but real SPAN panels
+with BESS have the battery on the upstream lugs acting as Grid Forming Equipment
+(GFE). There is no breaker, no relay, and no circuit for the battery. The current
+design forces a fake circuit into existence and uses it as a data proxy between
+the energy system and the API layer, requiring workarounds like excluding the
+battery circuit from load summation and writing resolved power back onto the
+circuit each tick.
+
+## Physical Topology
+
+```
+Utility <--[grid sensor]--> BESS <--> Panel (loads + PV)
+```
+
+- BESS sits between the grid sensor and the panel on the upstream lugs.
+- The grid sensor measures net power at the utility meter point.
+- `grid_sensor = load - pv - bess_discharge + bess_charge` (all positive magnitudes).
+- In self-consumption mode, BESS charges only from solar excess. In TOU/custom mode, BESS may charge from the grid during off-peak periods.
+- Grid sensor: positive = importing, negative = exporting.
+
+## Approach
+
+Remove the battery circuit entirely (Approach B — full energy system decoupling).
+BESS state exists only in `EnergySystem` / `BESSUnit` and `SpanBatterySnapshot`.
+No circuit proxy, no writeback, no exclusion workarounds.
+
+## Changes
+
+### 1. Configuration Schema
+
+**Remove from each config YAML that defines them:**
+- `battery_storage` circuit entry from the `circuits` list
+- `battery` template from `circuit_templates` (only consumer was the circuit entry above)
+- `BatteryBehavior` TypedDict from `config_types.py`
+
+**Add:**
+- Top-level `bess` key in YAML config (peer to `panel_config`):
+
+```yaml
+bess:
+ enabled: true
+ nameplate_capacity_kwh: 13.5
+ max_charge_w: 3500.0
+ max_discharge_w: 3500.0
+ charge_efficiency: 0.95
+ discharge_efficiency: 0.95
+ backup_reserve_pct: 20.0
+ charge_mode: solar-gen
+ charge_hours: [8, 9, 10, 11, 12, 13, 14, 15]
+ discharge_hours: [16, 17, 18, 19, 20, 21, 22]
+```
+
+- `BESSConfigYAML` TypedDict in `config_types.py` for the new top-level section.
+
+### 2. Engine Refactor
+
+**Remove from `engine.py`:**
+- `_find_battery_circuit()` — no battery circuit exists
+- `_is_battery_circuit()` — no battery circuit to detect
+- `_apply_battery_behavior()` — battery power resolved by EnergySystem, not circuit behavior
+- Circuit-writeback logic in `get_snapshot()` — BESS power no longer proxied through a circuit
+- Battery circuit exclusion in `_collect_power_inputs()` and `_powers_to_energy_inputs()`
+
+**Modify in `engine.py`:**
+- `_build_energy_system()` — read BESS config from `self._config["bess"]` instead of
+ scanning circuits for `battery_behavior`. Direct dict-to-`BESSConfig` mapping.
+- `get_snapshot()` — build `SpanBatterySnapshot` purely from `EnergySystem.bess` state.
+ No circuit snapshot rebuild step.
+- `compute_modeling_data()` — battery power from `SystemState` only, no circuit intermediate.
+
+Grid sensor calculation stays in `EnergySystem.tick()` where it already lives. The bus
+resolves load, PV, and BESS, then grid power is the remainder.
+
+### 3. Model & Publisher Cleanup
+
+**`energy/types.py` — `BESSConfig`:**
+- Remove `feed_circuit_id` field.
+
+**`energy/components.py` — `BESSUnit`:**
+- Remove `feed_circuit_id` parameter and property.
+- GFE constraint, SOE integration, hybrid PV control unchanged.
+
+**`models.py` — `SpanBatterySnapshot`:**
+- Remove `feed_circuit_id` field.
+
+**`publisher.py`:**
+- Remove `feed` property publishing from `_map_bess()`.
+- `bess-0` MQTT node still publishes SOE, capacity, grid-state.
+
+**`clone.py` — `_enrich_bess_template()`:**
+- Refactor to write to top-level `bess` key in cloned config instead of enriching a
+ circuit template. The `feed` property from scraped panel data is ignored.
+
+### 4. Test Impact
+
+**`tests/test_clone.py` — `test_bess_mode()`:**
+- Rewrite to assert cloned config has top-level `bess` section with expected fields.
+ The former battery circuit should not exist in cloned output.
+
+**`tests/test_modeling.py`:**
+- Move `battery_behavior` from circuit template fixtures to top-level `bess` key.
+
+**`tests/test_energy/test_scenarios.py`:**
+- Remove `feed_circuit_id` from explicit `BESSConfig` constructor calls.
+ Energy layer behavior unchanged.
+
+No new tests needed — structural refactor, not behavioral change.
+
+## Out of Scope
+
+- Enforcing "never charge from grid" constraint at the `BESSUnit._resolve_charge()` level
+ (currently enforced by the scheduler in `EnergySystem.tick()`; works correctly today).
+- AC-coupled BESS behind a breaker (different product configuration, design when needed).
+- EVSE two-tab allocation (each EVSE needs two tabs; follow-on task).
diff --git a/docs/superpowers/specs/2026-04-02-dashboard-bess-refactor-design.md b/docs/superpowers/specs/2026-04-02-dashboard-bess-refactor-design.md
new file mode 100644
index 0000000..73db4f0
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-02-dashboard-bess-refactor-design.md
@@ -0,0 +1,94 @@
+# Dashboard BESS Refactor — Design Spec
+
+**Date:** 2026-04-02
+**Status:** Draft
+**Scope:** Migrate dashboard battery management from circuit-template-based to panel-level BESS config
+**Depends on:** BESS circuit removal refactor (complete)
+
+## Problem
+
+The engine now reads BESS config from a top-level `bess` YAML section, but the
+dashboard still reads/writes `battery_behavior` on circuit templates. This is a
+complete data flow mismatch — dashboard edits have no effect on the running
+simulation.
+
+## Approach
+
+Present BESS as a dedicated panel-level card in the dashboard (Option A from
+mockup review). Battery is not an entity — it's a system-level feature of the
+panel sitting on the upstream lugs as GFE. The dashboard shows a "Battery (GFE)"
+card between Panel Config and the Entity list, with inline stats and edit/schedule
+controls.
+
+## Changes
+
+### 1. ConfigStore — BESS as Panel-Level Config
+
+**Remove:**
+- `EntityView.battery_behavior` field
+- `_detect_entity_type()` check for `battery_behavior.enabled`
+- `"battery"` from entity type defaults in `defaults.py`
+- Battery entity from `get_entities()` output
+
+**Add:**
+- `get_bess_config() -> dict` — returns `self._state.get("bess", {})`
+- `update_bess_config(data: dict)` — writes to `self._state["bess"]` with field
+ name mapping (`max_charge_power` from form → `max_charge_w` in config)
+
+**Rewrite to use `self._state["bess"]` instead of circuit template navigation:**
+- `get_battery_charge_mode()` — no entity_id param, reads `self._state["bess"]`
+- `update_battery_charge_mode(mode)` — no entity_id param, writes `self._state["bess"]`
+- `get_battery_profile()` — no entity_id param
+- `update_battery_profile(hour_modes)` — no entity_id param
+- `get_active_days()` — battery branch reads `self._state["bess"]`
+- `update_active_days(days)` — battery branch writes `self._state["bess"]`
+- `apply_battery_preset(preset_name)` — no entity_id param
+
+### 2. Routes and Templates
+
+**New partial:** `partials/bess_card.html` — dedicated battery card between panel
+config and entity list. Shows nameplate, reserve, charge/discharge power, charge
+mode, SOC from engine state. "Edit Settings" and "Schedule" buttons.
+
+**New routes (panel-level, no entity ID):**
+- `GET /bess/edit` — returns BESS settings edit form
+- `PUT /bess` — saves BESS settings (nameplate, reserve, power limits, charge mode)
+- `PUT /bess/schedule` — saves the 24-hour charge/discharge schedule
+- `POST /bess/schedule/preset` — applies a schedule preset
+- `PUT /bess/active-days` — saves active days
+
+All new routes call `_persist_config()` after writing (fixing existing bug where
+battery profile updates did not persist to YAML).
+
+**Remove old entity-based battery routes:**
+- `PUT /entities/{id}/battery-charge-mode`
+- `PUT /entities/{id}/battery-profile`
+- `POST /entities/{id}/battery-profile/preset`
+
+**Form field mapping:** HTML forms use user-facing names (`max_charge_power`,
+`max_discharge_power`). Route handlers translate to engine field names
+(`max_charge_w`, `max_discharge_w`) when writing to config.
+
+**Entity list cleanup:**
+- Remove `"battery"` from addable entity types dropdown
+- Remove battery row handling from entity list template
+- Entity count reflects only circuits + PV + EVSE
+
+**Battery profile editor:** Existing `battery_profile_editor.html` adapted to
+work without entity ID. Schedule grid, charge mode radio buttons, and active days
+checkboxes remain functionally identical.
+
+### 3. Energy Projection and Cleanup
+
+**Energy projection** in `config_store.py`: Read battery specs from
+`self._state["bess"]` directly using new field names (`max_charge_w`,
+`max_discharge_w`). Battery is not iterated as an entity.
+
+**Remove from templates:**
+- `entity_edit.html`: Remove the `{% if e.battery_behavior %}` fieldset block
+
+## Out of Scope
+
+- EVSE two-tab allocation (separate follow-on)
+- Charge mode enum changes (dashboard continues to offer `self-consumption`,
+ `custom`, `backup-only` — engine already accepts these)
diff --git a/pyproject.toml b/pyproject.toml
index ba5ca03..e6c20d6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,9 +4,9 @@ build-backend = "hatchling.build"
[project]
name = "span-panel-simulator"
-version = "1.0.9"
+version = "1.0.10"
description = "Standalone eBus simulator for SPAN panels"
-requires-python = ">=3.12"
+requires-python = ">=3.14"
dependencies = [
"aiomqtt>=2.0.0",
"aiohttp>=3.9.0",
diff --git a/span_panel_simulator/CHANGELOG.md b/span_panel_simulator/CHANGELOG.md
index 27ba130..f719018 100644
--- a/span_panel_simulator/CHANGELOG.md
+++ b/span_panel_simulator/CHANGELOG.md
@@ -1,5 +1,51 @@
# Changelog
+## 1.0.10 — 2026-04-02
+
+### Features
+
+- Dedicated BESS card in dashboard replaces entity-based battery editing — add, configure, and remove battery storage from a single panel-level view
+- Consolidated BESS settings and schedule into a single edit view with immediate schedule display on add
+- Rate-aware TOU dispatch: BESS charge/discharge schedule derived automatically from the active URDB electricity rate plan
+- Default post-solar discharge schedule applied when switching to TOU mode with an empty schedule
+- Modeling Before pass now built from original clone-time config snapshot for accurate baseline comparison
+- 32-tab EV charger template renamed to SPAN Drive for consistency with 40-tab panel naming
+
+### Fixes
+
+- Savings display shows absolute value without sign prefix; negative savings labeled "more expensive"
+- Savings sign convention corrected in modeling cost comparison
+- Empty BESS slots skipped during clone instead of creating phantom battery entries
+- Modeling chart refreshes automatically when BESS card settings change
+- Duplicate HTML IDs eliminated across dashboard views
+
+## 1.0.9 — 2026-03-30
+
+### Features
+
+- Rate plan selection UI in modeling view with URDB utility and plan discovery
+- Cost comparison columns in modeling summary cards (Before vs After estimated cost)
+- Opower billing integration: actual billed cost from utility account used for Before baseline when available
+- Opower account discovery and selection with utility-filtered URDB rate list
+- URDB rate plans filtered to latest version per plan name
+
+### Fixes
+
+- Cost engine units corrected (power arrays are Watts, not kW)
+
+## 1.0.8 — 2026-03-30
+
+### Features
+
+- Time-of-Use rate engine: hourly power-to-cost calculation using URDB schedule matrix lookup
+- Per-chart energy summary tables in modeling view
+
+### Fixes
+
+- Modeling energy difference uses imported energy instead of net
+- Only BESS circuit excluded from load power calculation (was incorrectly excluding all bidirectional circuits)
+- Sanitize address input in TLS cert generation to prevent syntax errors
+
## 1.0.7 — 2026-03-28
### Features
diff --git a/span_panel_simulator/Dockerfile b/span_panel_simulator/Dockerfile
index b1279b4..dffad18 100644
--- a/span_panel_simulator/Dockerfile
+++ b/span_panel_simulator/Dockerfile
@@ -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.9" \
+ io.hass.version="1.0.10" \
io.hass.arch="aarch64|amd64"
CMD ["/run.sh"]
diff --git a/span_panel_simulator/config.yaml b/span_panel_simulator/config.yaml
index 1456a4f..72ef6ee 100644
--- a/span_panel_simulator/config.yaml
+++ b/span_panel_simulator/config.yaml
@@ -1,6 +1,6 @@
name: "SPAN Panel Simulator"
description: "Simulates a SPAN electrical panel for testing and upgrade modeling"
-version: "1.0.9"
+version: "1.0.10"
slug: "span_panel_simulator"
url: "https://github.com/SpanPanel/simulator"
image: "ghcr.io/spanpanel/simulator/{arch}"
diff --git a/src/span_panel_simulator/__init__.py b/src/span_panel_simulator/__init__.py
index fdf3591..90764fd 100644
--- a/src/span_panel_simulator/__init__.py
+++ b/src/span_panel_simulator/__init__.py
@@ -1,3 +1,3 @@
"""Standalone eBus simulator for SPAN panels."""
-__version__ = "1.0.9"
+__version__ = "1.0.10"
diff --git a/src/span_panel_simulator/behavior_mutable_state.py b/src/span_panel_simulator/behavior_mutable_state.py
index 20c0051..9ce2eb9 100644
--- a/src/span_panel_simulator/behavior_mutable_state.py
+++ b/src/span_panel_simulator/behavior_mutable_state.py
@@ -15,5 +15,4 @@ class BehaviorEngineMutableState:
"""Immutable snapshot of fields that change during simulation ticks."""
circuit_cycle_states: dict[str, dict[str, Any]]
- last_battery_direction: str
grid_offline: bool
diff --git a/src/span_panel_simulator/circuit.py b/src/span_panel_simulator/circuit.py
index 1c69dbf..c7ae3f0 100644
--- a/src/span_panel_simulator/circuit.py
+++ b/src/span_panel_simulator/circuit.py
@@ -231,8 +231,7 @@ def _derive_device_type(self) -> str:
"""Derive device_type from the template.
Checks for an explicit ``device_type`` field first, then falls back
- to mode-based detection. Bidirectional circuits with
- ``battery_behavior.enabled`` are batteries, not EVSE.
+ to mode-based detection.
"""
explicit = self._template.get("device_type")
if explicit:
@@ -241,9 +240,6 @@ def _derive_device_type(self) -> str:
if mode == "producer":
return "pv"
if mode == "bidirectional":
- battery = self._template.get("battery_behavior", {})
- if isinstance(battery, dict) and battery.get("enabled", False):
- return "circuit"
return "evse"
return "circuit"
@@ -294,27 +290,11 @@ def _accumulate_energy(self, current_time: float) -> None:
# consumer
self._consumed_energy_wh += energy_increment
- def _resolve_battery_direction(self, current_time: float) -> str:
- """Determine battery direction from template config or engine state."""
- battery_config = self._template.get("battery_behavior", {})
- if not isinstance(battery_config, dict):
- return "unknown"
- if not battery_config.get("enabled", True):
- return "unknown"
-
- charge_mode: str = battery_config.get("charge_mode", "custom")
- if charge_mode != "custom":
- return self._behavior_engine.last_battery_direction
-
- current_hour = self._behavior_engine.local_hour(current_time)
- charge_hours: list[int] = battery_config.get("charge_hours", [])
- discharge_hours: list[int] = battery_config.get("discharge_hours", [])
- idle_hours: list[int] = battery_config.get("idle_hours", [])
-
- if current_hour in charge_hours:
- return "charging"
- if current_hour in discharge_hours:
- return "discharging"
- if current_hour in idle_hours:
- return "idle"
+ def _resolve_battery_direction(self, _current_time: float) -> str:
+ """Determine bidirectional circuit energy direction.
+
+ With the battery circuit removed (BESS is GFE on upstream lugs),
+ bidirectional circuits are EVSE/V2G — direction is unknown at the
+ circuit level so energy is conservatively counted as consumption.
+ """
return "unknown"
diff --git a/src/span_panel_simulator/clone.py b/src/span_panel_simulator/clone.py
index 1dfd988..eebdd77 100644
--- a/src/span_panel_simulator/clone.py
+++ b/src/span_panel_simulator/clone.py
@@ -101,7 +101,7 @@ def translate_scraped_panel(
evse_nodes = _nodes_of_type(nodes, TYPE_EVSE)
# Build feed cross-reference: circuit_uuid → device_type
- feed_map = _build_feed_map(scraped.properties, prefix, bess_nodes, pv_nodes, evse_nodes)
+ feed_map = _build_feed_map(scraped.properties, prefix, pv_nodes, evse_nodes)
# Extract panel-level values
main_breaker = _int_prop(scraped.properties, prefix, "core", "breaker-rating") or 200
@@ -139,10 +139,6 @@ def translate_scraped_panel(
circuits.append(circuit_def)
used_tabs.update(tabs)
- # Enrich BESS circuit template
- for bess_id in bess_nodes:
- _enrich_bess_template(scraped.properties, prefix, bess_id, feed_map, templates)
-
# Enrich PV circuit template
for pv_id in pv_nodes:
_enrich_pv_template(scraped.properties, prefix, pv_id, feed_map, templates)
@@ -151,16 +147,6 @@ def translate_scraped_panel(
for evse_id in evse_nodes:
_enrich_evse_template(scraped.properties, prefix, evse_id, feed_map, templates)
- # Battery entities sit between panel lugs and grid — strip their tabs.
- for circ in circuits:
- tpl_name = circ.get("template")
- tpl = templates.get(str(tpl_name), {}) if tpl_name else {}
- bb = tpl.get("battery_behavior")
- if isinstance(bb, dict) and bb.get("enabled"):
- freed = circ.pop("tabs", [])
- if isinstance(freed, list):
- used_tabs -= set(freed)
-
# Unmapped tabs
all_tabs = set(range(1, total_tabs + 1))
unmapped = sorted(all_tabs - used_tabs)
@@ -178,13 +164,26 @@ def translate_scraped_panel(
},
}
+ # Build top-level BESS config (only when a battery is actually connected)
+ for bess_id in bess_nodes:
+ bess_cfg = _build_bess_config(scraped.properties, prefix, bess_id)
+ if bess_cfg is not None:
+ config["bess"] = bess_cfg
+
if host is not None:
- config["panel_source"] = {
+ panel_source: dict[str, object] = {
"origin_serial": scraped.serial_number,
"host": host,
"passphrase": passphrase,
"last_synced": datetime.now(UTC).isoformat(),
}
+ # Snapshot the original BESS config so the modeling Before pass
+ # can reconstruct the clone-time energy system accurately.
+ if "bess" in config:
+ import copy
+
+ panel_source["original_bess"] = copy.deepcopy(config["bess"])
+ config["panel_source"] = panel_source
_LOGGER.info(
"Translated panel %s: %d circuits, %d templates, bess=%s, pv=%s, evse=%s",
@@ -432,22 +431,17 @@ def _nodes_of_type(
def _build_feed_map(
properties: dict[str, str],
prefix: str,
- bess_nodes: list[str],
pv_nodes: list[str],
evse_nodes: list[str],
) -> dict[str, str]:
"""Build a mapping from circuit UUID to device type based on feed properties.
- Device nodes (BESS, PV, EVSE) have a ``feed`` property whose value is the
- UUID of the circuit they're associated with.
+ PV and EVSE nodes have a ``feed`` property whose value is the UUID of the
+ circuit they're associated with. BESS nodes no longer use a feed circuit —
+ their config goes to the top-level ``bess`` section.
"""
feed_map: dict[str, str] = {}
- for node_id in bess_nodes:
- circuit_uuid = _get_prop(properties, prefix, node_id, "feed")
- if circuit_uuid:
- feed_map[circuit_uuid] = "bess"
-
for node_id in pv_nodes:
circuit_uuid = _get_prop(properties, prefix, node_id, "feed")
if circuit_uuid:
@@ -585,51 +579,36 @@ def _device_role_to_mode(device_role: str | None) -> str:
"""Map a device role from the feed map to an energy profile mode."""
if device_role == "pv":
return "producer"
- if device_role in ("bess", "evse"):
+ if device_role == "evse":
return "bidirectional"
return "consumer"
-def _enrich_bess_template(
+def _build_bess_config(
properties: dict[str, str],
prefix: str,
bess_node_id: str,
- feed_map: dict[str, str],
- templates: dict[str, dict[str, object]],
-) -> None:
- """Add battery_behavior to the circuit template fed by this BESS node."""
- circuit_uuid = _get_prop(properties, prefix, bess_node_id, "feed")
- template = _find_template_for_feed(circuit_uuid, feed_map, templates, properties, prefix)
- if template is None:
- return
+) -> dict[str, object] | None:
+ """Build top-level bess config from scraped BESS node properties.
+ Returns ``None`` when the BESS node is an empty slot (no battery
+ connected) — indicated by a missing or zero nameplate capacity.
+ """
nameplate = _float_prop(properties, prefix, bess_node_id, "nameplate-capacity")
- nameplate_kwh = nameplate if nameplate is not None else 13.5
-
- # Derive max charge/discharge from breaker rating
- breaker = template.get("breaker_rating", 40)
- breaker_val = float(breaker) if isinstance(breaker, int | float) else 40.0
- ep = template.get("energy_profile")
- is_240v = False
- if isinstance(ep, dict):
- pr = ep.get("power_range")
- if isinstance(pr, list) and len(pr) == 2:
- is_240v = abs(pr[0]) > 120 * breaker_val
- voltage = 240.0 if is_240v else 120.0
- max_power = breaker_val * voltage * 0.8
-
- template["battery_behavior"] = {
+ if not nameplate:
+ return None
+
+ return {
"enabled": True,
"charge_mode": "custom",
- "nameplate_capacity_kwh": nameplate_kwh,
+ "nameplate_capacity_kwh": nameplate,
"backup_reserve_pct": 20.0,
"charge_efficiency": 0.95,
"discharge_efficiency": 0.95,
- "max_charge_power": max_power,
- "max_discharge_power": max_power,
+ "max_charge_w": 3500.0,
+ "max_discharge_w": 3500.0,
"charge_hours": [0, 1, 2, 3, 4, 5],
"discharge_hours": [16, 17, 18, 19, 20, 21],
- "idle_hours": [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 22, 23],
}
diff --git a/src/span_panel_simulator/config_types.py b/src/span_panel_simulator/config_types.py
index aa3c65b..ebbcc3d 100644
--- a/src/span_panel_simulator/config_types.py
+++ b/src/span_panel_simulator/config_types.py
@@ -96,27 +96,19 @@ class CircuitTemplate(TypedDict):
priority: str # "MUST_HAVE", "NON_ESSENTIAL"
-class BatteryBehavior(TypedDict, total=False):
- """Battery behavior configuration."""
+class BESSConfigYAML(TypedDict, total=False):
+ """Top-level BESS configuration in the simulator YAML."""
enabled: bool
- charge_mode: Literal["self-consumption", "custom", "backup-only"]
- charge_power: float
- discharge_power: float
- idle_power: float
+ nameplate_capacity_kwh: float
+ max_charge_w: float
+ max_discharge_w: float
charge_efficiency: float
discharge_efficiency: float
- nameplate_capacity_kwh: float # Total battery capacity in kWh
- backup_reserve_pct: float # SOE % reserved for outages (default 20)
+ backup_reserve_pct: float
+ charge_mode: Literal["self-consumption", "custom", "backup-only"]
charge_hours: list[int]
discharge_hours: list[int]
- max_charge_power: float
- max_discharge_power: float
- idle_hours: list[int]
- idle_power_range: list[float]
- solar_intensity_profile: dict[int, float]
- demand_factor_profile: dict[int, float]
- active_days: list[int] # Days of week active (0=Mon..6=Sun); empty = all
class CircuitTemplateExtended(CircuitTemplate, total=False):
@@ -125,7 +117,6 @@ class CircuitTemplateExtended(CircuitTemplate, total=False):
cycling_pattern: CyclingPattern
time_of_day_profile: TimeOfDayProfile
smart_behavior: SmartBehavior
- battery_behavior: BatteryBehavior
device_type: str # Explicit override: "circuit", "evse", "pv"
hvac_type: str # "central_ac", "heat_pump", "heat_pump_aux"
monthly_factors: dict[int, float] # month (1-12) -> multiplier (1.0 = peak month)
diff --git a/src/span_panel_simulator/dashboard/config_store.py b/src/span_panel_simulator/dashboard/config_store.py
index 16ebdcb..3269c1d 100644
--- a/src/span_panel_simulator/dashboard/config_store.py
+++ b/src/span_panel_simulator/dashboard/config_store.py
@@ -34,7 +34,7 @@ class EntityView:
id: str
name: str
- entity_type: str # "circuit" | "pv" | "evse" | "battery"
+ entity_type: str # "circuit" | "pv" | "evse"
template_name: str
tabs: list[int]
energy_profile: dict[str, Any]
@@ -43,7 +43,6 @@ class EntityView:
cycling_pattern: dict[str, Any] | None = None
time_of_day_profile: dict[str, Any] | None = None
smart_behavior: dict[str, Any] | None = None
- battery_behavior: dict[str, Any] | None = None
hvac_type: str | None = None
breaker_rating: int | None = None
overrides: dict[str, Any] = field(default_factory=dict)
@@ -58,8 +57,6 @@ def _detect_entity_type(template: dict[str, Any]) -> str:
return "pv"
if device_type == "evse":
return "evse"
- if template.get("battery_behavior", {}).get("enabled"):
- return "battery"
return "circuit"
@@ -151,6 +148,58 @@ def get_origin_serial(self) -> str | None:
return str(origin) if isinstance(origin, str) else None
return None
+ # -- BESS config --
+
+ def get_bess_config(self) -> dict[str, Any]:
+ """Return the top-level BESS configuration, or empty dict if absent."""
+ bess = self._state.get("bess")
+ return dict(bess) if isinstance(bess, dict) else {}
+
+ def has_bess(self) -> bool:
+ """Whether a BESS is configured and enabled."""
+ bess = self._state.get("bess")
+ return isinstance(bess, dict) and bool(bess.get("enabled"))
+
+ def update_bess_config(self, data: dict[str, Any]) -> None:
+ """Update top-level BESS settings from form data.
+
+ Translates form field names to YAML field names:
+ ``max_charge_power`` -> ``max_charge_w``,
+ ``max_discharge_power`` -> ``max_discharge_w``.
+ """
+ bess = self._state.setdefault("bess", {"enabled": True})
+ field_map = {
+ "nameplate_capacity_kwh": "nameplate_capacity_kwh",
+ "backup_reserve_pct": "backup_reserve_pct",
+ "max_charge_power": "max_charge_w",
+ "max_discharge_power": "max_discharge_w",
+ }
+ for form_key, yaml_key in field_map.items():
+ if form_key in data:
+ bess[yaml_key] = float(data[form_key])
+ self._dirty = True
+
+ def add_bess(self) -> None:
+ """Add a default BESS configuration."""
+ self._state["bess"] = {
+ "enabled": True,
+ "nameplate_capacity_kwh": 13.5,
+ "max_charge_w": 3500.0,
+ "max_discharge_w": 3500.0,
+ "charge_efficiency": 0.95,
+ "discharge_efficiency": 0.95,
+ "backup_reserve_pct": 20.0,
+ "charge_mode": "self-consumption",
+ "charge_hours": [],
+ "discharge_hours": [],
+ }
+ self._dirty = True
+
+ def remove_bess(self) -> None:
+ """Remove the BESS configuration."""
+ self._state.pop("bess", None)
+ self._dirty = True
+
# -- Simulation params --
def get_simulation_params(self) -> dict[str, Any]:
@@ -284,7 +333,6 @@ def _merge_entity(self, circuit: dict[str, Any]) -> EntityView:
cycling_pattern=template.get("cycling_pattern"),
time_of_day_profile=template.get("time_of_day_profile"),
smart_behavior=template.get("smart_behavior"),
- battery_behavior=template.get("battery_behavior"),
hvac_type=template.get("hvac_type"),
breaker_rating=circuit.get("breaker_rating") or template.get("breaker_rating"),
overrides=dict(overrides),
@@ -293,8 +341,8 @@ def _merge_entity(self, circuit: dict[str, Any]) -> EntityView:
)
def list_entities(self) -> list[EntityView]:
- """Return entities with infrastructure (pv, battery, evse) first, then circuits."""
- _type_order = {"pv": 0, "battery": 1, "evse": 2, "circuit": 3}
+ """Return entities with infrastructure (pv, evse) first, then circuits."""
+ _type_order = {"pv": 0, "evse": 1, "circuit": 2}
entities = [self._merge_entity(c) for c in self._circuits()]
entities.sort(key=lambda e: (_type_order.get(e.entity_type, 9), e.name.lower()))
return entities
@@ -324,7 +372,7 @@ def update_entity(self, entity_id: str, data: dict[str, Any]) -> None:
if "name" in data:
circuit["name"] = data["name"]
- if "tabs" in data and _detect_entity_type(template) != "battery":
+ if "tabs" in data:
tabs_raw = data["tabs"]
if isinstance(tabs_raw, str):
tabs_raw = [int(t.strip()) for t in tabs_raw.split(",") if t.strip()]
@@ -369,18 +417,6 @@ def update_entity(self, entity_id: str, data: dict[str, Any]) -> None:
else:
overrides.pop("power_range", None)
- battery_keys = (
- "nameplate_capacity_kwh",
- "backup_reserve_pct",
- "max_charge_power",
- "max_discharge_power",
- )
- if any(k in data for k in battery_keys):
- bb: dict[str, Any] = template.setdefault("battery_behavior", {})
- for k in battery_keys:
- if k in data:
- bb[k] = float(data[k])
-
if "breaker_rating" in data:
br_val = str(data["breaker_rating"]).strip()
if br_val:
@@ -422,17 +458,11 @@ def add_entity(self, entity_type: str) -> EntityView:
return self._merge_entity(circuit_dict)
def get_unmapped_tabs(self) -> list[int]:
- """Return tab numbers not assigned to any circuit, sorted ascending.
-
- Battery entities are excluded — they sit between the panel lugs
- and the grid, not on breaker tabs.
- """
+ """Return tab numbers not assigned to any circuit, sorted ascending."""
total_tabs = self._state.get("panel_config", {}).get("total_tabs", 32)
used: set[int] = set()
for circ in self._circuits():
- tpl = self._templates().get(circ.get("template", ""), {})
- if _detect_entity_type(tpl) != "battery":
- used.update(circ.get("tabs", []))
+ used.update(circ.get("tabs", []))
return sorted(t for t in range(1, total_tabs + 1) if t not in used)
def add_entity_from_tabs(self, tabs: list[int]) -> EntityView:
@@ -498,50 +528,50 @@ def delete_entity(self, entity_id: str) -> None:
def get_active_days(self, entity_id: str) -> list[int]:
"""Return active weekdays (0=Mon..6=Sun) for an entity.
- Reads from ``time_of_day_profile`` for circuits/EVSE or
- ``battery_behavior`` for battery entities. Empty list = all days.
+ Reads from ``time_of_day_profile``. Empty list = all days.
"""
entity = self.get_entity(entity_id)
- if entity.entity_type == "battery":
- bb = entity.battery_behavior or {}
- days: list[int] = bb.get("active_days", [])
- else:
- tod = entity.time_of_day_profile or {}
- days = tod.get("active_days", [])
+ tod = entity.time_of_day_profile or {}
+ days: list[int] = tod.get("active_days", [])
return [d for d in days if isinstance(d, int) and 0 <= d <= 6]
def update_active_days(self, entity_id: str, days: list[int]) -> None:
- """Write active weekdays into the entity's template.
-
- Omits the key entirely when all 7 days are selected (backward compat).
- """
+ """Write active weekdays into the entity's template."""
circuit = self._find_circuit(entity_id)
if circuit is None:
raise KeyError(f"Entity not found: {entity_id}")
template_name = circuit["template"]
template = self._templates().get(template_name, {})
- entity = self._merge_entity(circuit)
clean = sorted(set(d for d in days if 0 <= d <= 6))
store_value = clean if len(clean) < 7 else []
- if entity.entity_type == "battery":
- bb: dict[str, Any] = template.setdefault("battery_behavior", {"enabled": True})
- if store_value:
- bb["active_days"] = store_value
- else:
- bb.pop("active_days", None)
+ tod: dict[str, Any] = template.setdefault("time_of_day_profile", {"enabled": True})
+ if store_value:
+ tod["active_days"] = store_value
else:
- tod: dict[str, Any] = template.setdefault("time_of_day_profile", {"enabled": True})
- if store_value:
- tod["active_days"] = store_value
- else:
- tod.pop("active_days", None)
+ tod.pop("active_days", None)
self._mark_user_modified(template_name)
self._dirty = True
+ def get_bess_active_days(self) -> list[int]:
+ """Return active weekdays for BESS (empty = all days)."""
+ bess = self.get_bess_config()
+ days: list[int] = bess.get("active_days", [])
+ return [d for d in days if isinstance(d, int) and 0 <= d <= 6]
+
+ def update_bess_active_days(self, days: list[int]) -> None:
+ """Write active weekdays into BESS config."""
+ bess = self._state.setdefault("bess", {"enabled": True})
+ clean = sorted(set(d for d in days if 0 <= d <= 6))
+ if clean and len(clean) < 7:
+ bess["active_days"] = clean
+ else:
+ bess.pop("active_days", None)
+ self._dirty = True
+
# -- Profile --
def get_entity_profile(self, entity_id: str) -> dict[int, float]:
@@ -642,41 +672,43 @@ 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 ``"self-consumption"``)."""
- entity = self.get_entity(entity_id)
- bb = entity.battery_behavior or {}
- return str(bb.get("charge_mode", "self-consumption"))
+ def get_battery_charge_mode(self) -> str:
+ """Return the BESS charge mode (default ``"self-consumption"``)."""
+ bess = self.get_bess_config()
+ return str(bess.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."""
+ def update_battery_charge_mode(
+ self,
+ mode: str,
+ rate_label: str | None = None,
+ ) -> None:
+ """Set the BESS charge mode.
+
+ When *rate_label* is provided and mode is ``custom``, the label
+ is stored so the energy system resolves the full URDB record for
+ rate-aware dispatch. Static charge/discharge hour lists are
+ cleared since the rate record supersedes them.
+ """
valid_modes = ("self-consumption", "custom", "backup-only")
if mode not in valid_modes:
raise ValueError(f"Invalid charge mode: {mode!r}")
-
- circuit = self._find_circuit(entity_id)
- if circuit is None:
- raise KeyError(f"Entity not found: {entity_id}")
-
- template_name = circuit["template"]
- template = self._templates().get(template_name, {})
- bb: dict[str, Any] = template.setdefault("battery_behavior", {"enabled": True})
- bb["charge_mode"] = mode
-
- self._mark_user_modified(template_name)
+ bess = self._state.setdefault("bess", {"enabled": True})
+ bess["charge_mode"] = mode
+ if mode == "custom" and rate_label:
+ bess["rate_label"] = rate_label
+ bess.pop("charge_hours", None)
+ bess.pop("discharge_hours", None)
+ elif mode != "custom":
+ bess.pop("rate_label", None)
self._dirty = True
# -- Battery profile --
- def get_battery_profile(self, entity_id: str) -> dict[int, str]:
- """Return the 24-hour battery schedule as hour → mode mapping.
-
- Mode is one of ``"charge"``, ``"discharge"``, or ``"idle"``.
- """
- entity = self.get_entity(entity_id)
- bb = entity.battery_behavior or {}
- charge_hours = set(bb.get("charge_hours", []))
- discharge_hours = set(bb.get("discharge_hours", []))
+ def get_battery_profile(self) -> dict[int, str]:
+ """Return the 24-hour BESS schedule as hour -> mode mapping."""
+ bess = self.get_bess_config()
+ charge_hours = set(bess.get("charge_hours", []))
+ discharge_hours = set(bess.get("discharge_hours", []))
profile: dict[int, str] = {}
for h in range(24):
@@ -688,27 +720,17 @@ def get_battery_profile(self, entity_id: str) -> dict[int, str]:
profile[h] = "idle"
return profile
- def update_battery_profile(self, entity_id: str, hour_modes: dict[int, str]) -> None:
- """Write per-hour charge/discharge/idle schedule into battery_behavior."""
- circuit = self._find_circuit(entity_id)
- if circuit is None:
- raise KeyError(f"Entity not found: {entity_id}")
-
- template_name = circuit["template"]
- template = self._templates().get(template_name, {})
- bb = template.setdefault("battery_behavior", {"enabled": True})
-
- bb["charge_hours"] = sorted(h for h, m in hour_modes.items() if m == "charge")
- bb["discharge_hours"] = sorted(h for h, m in hour_modes.items() if m == "discharge")
- bb["idle_hours"] = sorted(h for h, m in hour_modes.items() if m == "idle")
-
- self._mark_user_modified(template_name)
+ def update_battery_profile(self, hour_modes: dict[int, str]) -> None:
+ """Write per-hour charge/discharge/idle schedule into BESS config."""
+ bess = self._state.setdefault("bess", {"enabled": True})
+ bess["charge_hours"] = sorted(h for h, m in hour_modes.items() if m == "charge")
+ bess["discharge_hours"] = sorted(h for h, m in hour_modes.items() if m == "discharge")
self._dirty = True
- def apply_battery_preset(self, entity_id: str, preset_name: str) -> dict[int, str]:
+ def apply_battery_preset(self, preset_name: str) -> dict[int, str]:
"""Apply a named battery preset and return the schedule."""
hour_modes = get_battery_preset(preset_name)
- self.update_battery_profile(entity_id, hour_modes)
+ self.update_battery_profile(hour_modes)
self._dirty = True
return hour_modes
@@ -831,6 +853,15 @@ def compute_energy_projection(self, period: str = "year") -> list[dict[str, floa
pv_specs: list[tuple[float, float]] = [] # (nameplate, efficiency)
battery_specs: list[tuple[float, float, list[int], list[int]]] = []
+ # Battery from top-level bess config (not an entity)
+ bess = self.get_bess_config()
+ if bess.get("enabled"):
+ charge_p = abs(float(bess.get("max_charge_w") or 3500))
+ discharge_p = abs(float(bess.get("max_discharge_w") or 3500))
+ charge_hrs: list[int] = bess.get("charge_hours") or []
+ discharge_hrs: list[int] = bess.get("discharge_hours") or []
+ battery_specs.append((charge_p, discharge_p, charge_hrs, discharge_hrs))
+
for entity in entities:
ep = entity.energy_profile
if entity.entity_type == "pv":
@@ -841,13 +872,6 @@ def compute_energy_projection(self, period: str = "year") -> list[dict[str, floa
raw_eff = ep.get("efficiency")
efficiency = float(raw_eff) if raw_eff is not None else 0.85
pv_specs.append((nameplate, efficiency))
- elif entity.entity_type == "battery":
- bb: dict[str, Any] = entity.battery_behavior or {}
- charge_p = abs(float(bb.get("max_charge_power") or 3500))
- discharge_p = abs(float(bb.get("max_discharge_power") or 3500))
- charge_hrs: list[int] = bb.get("charge_hours") or []
- discharge_hrs: list[int] = bb.get("discharge_hours") or []
- battery_specs.append((charge_p, discharge_p, charge_hrs, discharge_hrs))
else:
profile = self.get_entity_profile(entity.id)
typical = float(ep["typical_power"])
diff --git a/src/span_panel_simulator/dashboard/defaults.py b/src/span_panel_simulator/dashboard/defaults.py
index d2885e4..1db9a8c 100644
--- a/src/span_panel_simulator/dashboard/defaults.py
+++ b/src/span_panel_simulator/dashboard/defaults.py
@@ -103,30 +103,6 @@ def _slugify(name: str) -> str:
"tabs": [],
},
},
- "battery": {
- "template": {
- "energy_profile": {
- "mode": "bidirectional",
- "power_range": [-5000.0, 5000.0],
- "typical_power": 0.0,
- "power_variation": 0.02,
- "efficiency": 0.95,
- },
- "relay_behavior": "controllable",
- "priority": "NEVER",
- "battery_behavior": {
- "enabled": True,
- "charge_mode": "self-consumption",
- "nameplate_capacity_kwh": 13.5,
- "backup_reserve_pct": 20.0,
- "max_charge_power": 5000.0,
- "max_discharge_power": 5000.0,
- "charge_hours": [],
- "discharge_hours": [],
- },
- },
- "circuit": {},
- },
}
@@ -136,7 +112,6 @@ def default_name_for_type(entity_type: str) -> str:
"circuit": "New Circuit",
"pv": "Solar Inverter",
"evse": "SPAN Drive",
- "battery": "Battery Storage",
}.get(entity_type, "New Entity")
diff --git a/src/span_panel_simulator/dashboard/routes.py b/src/span_panel_simulator/dashboard/routes.py
index a990301..043607d 100644
--- a/src/span_panel_simulator/dashboard/routes.py
+++ b/src/span_panel_simulator/dashboard/routes.py
@@ -21,7 +21,8 @@
from span_panel_simulator.dashboard.context import DashboardContext
-from datetime import UTC
+from datetime import UTC, datetime
+from zoneinfo import ZoneInfo
from span_panel_simulator.dashboard.keys import (
APP_KEY_DASHBOARD_CONTEXT,
@@ -74,15 +75,15 @@ class RecorderPurgeResult:
"OFF_GRID",
]
RELAY_BEHAVIORS = ["controllable", "non_controllable"]
-ENTITY_TYPES = ["circuit", "pv", "evse", "battery"]
+ENTITY_TYPES = ["circuit", "pv", "evse"]
# Infrastructure types that should only appear once in a panel config.
-_SINGLETON_TYPES = {"pv", "battery"}
+_SINGLETON_TYPES = {"pv"}
def _available_entity_types(store: ConfigStore) -> list[str]:
"""Return entity types available for adding.
- Singleton types (pv, battery) are excluded when one already exists.
+ Singleton types (pv) are excluded when one already exists.
"""
existing = {e.entity_type for e in store.list_entities()}
return [t for t in ENTITY_TYPES if t not in _SINGLETON_TYPES or t not in existing]
@@ -182,6 +183,7 @@ def _dashboard_context(request: web.Request) -> dict[str, Any]:
"clone_host": panel_source.get("host", "") if panel_source else "",
"panels": _all_panels(request),
"readonly": _is_readonly(ctx),
+ "bess_config": store.get_bess_config(),
}
@@ -215,12 +217,6 @@ def _entity_list_context(request: web.Request, editing_id: str | None = None) ->
ctx["relay_behaviors"] = RELAY_BEHAVIORS
ctx["preset_labels"] = _presets_for_type(request, entity.entity_type)
ctx["active_days"] = store.get_active_days(editing_id)
- if entity.entity_type == "battery":
- ctx["battery_preset_labels"] = _presets(request).battery_labels
- battery_profile = store.get_battery_profile(editing_id)
- ctx["battery_profile"] = battery_profile
- ctx["battery_charge_mode"] = store.get_battery_charge_mode(editing_id)
- ctx["battery_active_preset"] = match_battery_preset(battery_profile)
if entity.entity_type == "pv":
panel = store.get_panel_config()
lat = panel.get("latitude", 37.7)
@@ -254,19 +250,94 @@ def _profile_context(request: web.Request, entity_id: str) -> dict[str, Any]:
}
-def _battery_profile_context(request: web.Request, entity_id: str) -> dict[str, Any]:
- """Build the battery profile editor template context."""
+def _bess_card_context(
+ request: web.Request,
+ editing: bool = False,
+) -> dict[str, Any]:
+ """Build the BESS card template context.
+
+ The edit view includes both settings and schedule, so schedule
+ context is always included when editing.
+ """
store = _store(request)
- entity = store.get_entity(entity_id)
- battery_profile = store.get_battery_profile(entity_id)
- return {
- "entity": entity,
- "battery_profile": battery_profile,
- "battery_preset_labels": _presets(request).battery_labels,
- "battery_charge_mode": store.get_battery_charge_mode(entity_id),
- "battery_active_preset": match_battery_preset(battery_profile),
- "active_days": store.get_active_days(entity_id),
+ ctx: dict[str, Any] = {
+ "bess_config": store.get_bess_config(),
+ "bess_editing": editing,
+ "readonly": _is_readonly(_ctx(request)),
}
+ if editing:
+ charge_mode = store.get_battery_charge_mode()
+ ctx["battery_charge_mode"] = charge_mode
+
+ # When TOU mode has a rate record, derive the display schedule
+ # from the URDB record for the current month instead of the
+ # static charge_hours/discharge_hours lists.
+ rate_profile = _rate_derived_profile(request, store) if charge_mode == "custom" else None
+ if rate_profile is not None:
+ battery_profile, month_label = rate_profile
+ ctx["battery_profile"] = battery_profile
+ ctx["tou_rate_month"] = month_label
+ ctx["tou_rate_driven"] = True
+ else:
+ battery_profile = store.get_battery_profile()
+ ctx["battery_profile"] = battery_profile
+ ctx["tou_rate_driven"] = False
+
+ ctx["battery_preset_labels"] = _presets(request).battery_labels
+ ctx["battery_active_preset"] = match_battery_preset(battery_profile)
+ ctx["active_days"] = store.get_bess_active_days()
+ return ctx
+
+
+def _rate_derived_profile(
+ request: web.Request,
+ store: ConfigStore,
+) -> tuple[dict[int, str], str] | None:
+ """Derive a display schedule from the URDB rate record for today.
+
+ Returns ``(profile, month_label)`` or ``None`` when no rate record
+ is available.
+ """
+ bess = store.get_bess_config()
+ rate_label = bess.get("rate_label")
+ if not rate_label:
+ return None
+
+ cache = _rate_cache(request)
+ entry = cache.get_cached_rate(rate_label)
+ if entry is None:
+ return None
+
+ record = entry.record
+ panel_cfg = store.get_panel_config()
+ tz_name = panel_cfg.get("time_zone", "America/Los_Angeles")
+ tz = ZoneInfo(tz_name)
+ now = datetime.now(tz)
+
+ from span_panel_simulator.energy.tou import all_rates_for_day
+
+ day_rates = all_rates_for_day(now, record)
+ if not day_rates:
+ return None
+
+ min_rate = min(day_rates.values())
+ max_rate = max(day_rates.values())
+
+ profile: dict[int, str] = {}
+ for h in range(24):
+ rate = day_rates.get(h, 0.0)
+ if min_rate == max_rate:
+ profile[h] = "idle"
+ elif rate <= min_rate:
+ profile[h] = "charge"
+ elif rate >= max_rate:
+ profile[h] = "discharge"
+ else:
+ profile[h] = "idle"
+ month_label = now.strftime("%B") + (
+ " — Summer rates" if now.month in (6, 7, 8, 9) else " — Winter rates"
+ )
+ return profile, month_label
async def handle_get_openei_config(request: web.Request) -> web.Response:
@@ -397,12 +468,23 @@ async def handle_get_current_rate(request: web.Request) -> web.Response:
async def handle_put_current_rate(request: web.Request) -> web.Response:
- """PUT /rates/current {label}"""
+ """PUT /rates/current {label}
+
+ When the BESS is already in TOU mode, propagates the new rate label
+ into the BESS config and reloads the engine so dispatch and the
+ modeling projection reflect the newly selected rate immediately.
+ """
body = await request.json()
label = body.get("label", "").strip()
if not label:
return web.json_response({"error": "label is required"}, status=400)
_rate_cache(request).set_current_rate_label(label)
+
+ store = _store(request)
+ if store.get_battery_charge_mode() == "custom":
+ store.update_battery_charge_mode("custom", rate_label=label)
+ _persist_config(request)
+
return web.json_response({"ok": True})
@@ -503,11 +585,17 @@ def setup_routes(app: web.Application) -> None:
# Active days (auto-save on toggle)
app.router.add_put("/entities/{id}/active-days", handle_put_active_days)
- # Battery profile
- app.router.add_get("/entities/{id}/battery-profile", handle_get_battery_profile)
- app.router.add_put("/entities/{id}/battery-profile", handle_put_battery_profile)
- app.router.add_post("/entities/{id}/battery-profile/preset", handle_apply_battery_preset)
- app.router.add_put("/entities/{id}/battery-charge-mode", handle_put_battery_charge_mode)
+ # BESS (panel-level)
+ app.router.add_get("/bess", handle_get_bess)
+ app.router.add_post("/bess", handle_post_bess)
+ app.router.add_delete("/bess", handle_delete_bess)
+ app.router.add_get("/bess/edit", handle_get_bess_edit)
+ app.router.add_put("/bess", handle_put_bess)
+ app.router.add_get("/bess/schedule", handle_get_bess_schedule)
+ app.router.add_put("/bess/schedule", handle_put_bess_schedule)
+ app.router.add_post("/bess/schedule/preset", handle_post_bess_schedule_preset)
+ app.router.add_put("/bess/charge-mode", handle_put_bess_charge_mode)
+ app.router.add_put("/bess/active-days", handle_put_bess_active_days)
# EVSE schedule
app.router.add_get("/entities/{id}/evse-schedule", handle_get_evse_schedule)
@@ -782,66 +870,98 @@ async def handle_apply_preset(request: web.Request) -> web.Response:
return _render("partials/profile_editor.html", request, _profile_context(request, entity_id))
-# -- Battery profile --
+# -- BESS (panel-level) --
-async def handle_get_battery_profile(request: web.Request) -> web.Response:
- entity_id = request.match_info["id"]
- return _render(
- "partials/battery_profile_editor.html",
- request,
- _battery_profile_context(request, entity_id),
- )
+async def handle_get_bess(request: web.Request) -> web.Response:
+ """GET /bess — return BESS card in display mode."""
+ return _render("partials/bess_card.html", request, _bess_card_context(request))
-async def handle_put_battery_profile(request: web.Request) -> web.Response:
- entity_id = request.match_info["id"]
+async def handle_post_bess(request: web.Request) -> web.Response:
+ """POST /bess — add a default BESS configuration and show edit view."""
+ _store(request).add_bess()
+ _persist_config(request)
+ return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True))
+
+
+async def handle_delete_bess(request: web.Request) -> web.Response:
+ """DELETE /bess — remove BESS configuration."""
+ _store(request).remove_bess()
+ _persist_config(request)
+ return _render("partials/bess_card.html", request, _bess_card_context(request))
+
+
+async def handle_get_bess_edit(request: web.Request) -> web.Response:
+ """GET /bess/edit — return BESS card in edit mode."""
+ return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True))
+
+
+async def handle_put_bess(request: web.Request) -> web.Response:
+ """PUT /bess — save BESS settings."""
+ data = await request.post()
+ _store(request).update_bess_config(dict(data))
+ _persist_config(request)
+ return _render("partials/bess_card.html", request, _bess_card_context(request))
+
+
+async def handle_get_bess_schedule(request: web.Request) -> web.Response:
+ """GET /bess/schedule — return BESS card with schedule editor."""
+ return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True))
+
+
+async def handle_put_bess_schedule(request: web.Request) -> web.Response:
+ """PUT /bess/schedule — save BESS charge/discharge schedule."""
data = await request.post()
hour_modes: dict[int, str] = {}
for h in range(24):
key = f"hour_{h}"
- if key in data:
- mode = str(data[key])
- if mode in ("charge", "discharge", "idle"):
- hour_modes[h] = mode
- else:
- hour_modes[h] = "idle"
- else:
- hour_modes[h] = "idle"
+ mode = str(data.get(key, "idle"))
+ hour_modes[h] = mode if mode in ("charge", "discharge", "idle") else "idle"
store = _store(request)
- store.update_battery_profile(entity_id, hour_modes)
+ store.update_battery_profile(hour_modes)
active = _parse_active_days(data)
if active is not None:
- store.update_active_days(entity_id, active)
- return _render(
- "partials/battery_profile_editor.html",
- request,
- _battery_profile_context(request, entity_id),
- )
+ store.update_bess_active_days(active)
+ _persist_config(request)
+ return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True))
-async def handle_apply_battery_preset(request: web.Request) -> web.Response:
- entity_id = request.match_info["id"]
+async def handle_post_bess_schedule_preset(request: web.Request) -> web.Response:
+ """POST /bess/schedule/preset — apply a schedule preset."""
data = await request.post()
preset_name = str(data.get("preset", "custom"))
- _store(request).apply_battery_preset(entity_id, preset_name)
- return _render(
- "partials/battery_profile_editor.html",
- request,
- _battery_profile_context(request, entity_id),
- )
+ _store(request).apply_battery_preset(preset_name)
+ _persist_config(request)
+ return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True))
-async def handle_put_battery_charge_mode(request: web.Request) -> web.Response:
- entity_id = request.match_info["id"]
+async def handle_put_bess_charge_mode(request: web.Request) -> web.Response:
+ """PUT /bess/charge-mode — change BESS charge mode.
+
+ When switching to TOU with an active rate, stores the rate label so
+ the energy system resolves dispatch from the URDB record at each tick.
+ """
data = await request.post()
mode = str(data.get("charge_mode", "custom"))
- _store(request).update_battery_charge_mode(entity_id, mode)
- return _render(
- "partials/battery_profile_editor.html",
- request,
- _battery_profile_context(request, entity_id),
- )
+
+ rate_label: str | None = None
+ if mode == "custom":
+ rate_label = _rate_cache(request).get_current_rate_label()
+
+ _store(request).update_battery_charge_mode(mode, rate_label=rate_label)
+ _persist_config(request)
+ return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True))
+
+
+async def handle_put_bess_active_days(request: web.Request) -> web.Response:
+ """PUT /bess/active-days — update BESS active days."""
+ data = await request.post()
+ active = _parse_active_days(data)
+ if active is not None:
+ _store(request).update_bess_active_days(active)
+ _persist_config(request)
+ return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True))
# -- EVSE schedule --
diff --git a/src/span_panel_simulator/dashboard/templates/dashboard.html b/src/span_panel_simulator/dashboard/templates/dashboard.html
index 0db1896..20acf9c 100644
--- a/src/span_panel_simulator/dashboard/templates/dashboard.html
+++ b/src/span_panel_simulator/dashboard/templates/dashboard.html
@@ -33,6 +33,10 @@
Getting started
{% include "partials/sim_config.html" %}
+
+ {% include "partials/bess_card.html" %}
+
+
{% include "partials/entity_list.html" %}
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 5c91d35..0e3aa78 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
@@ -4,15 +4,15 @@
Charge Mode
+ id="bess-charge-mode">
{% for mode, label, hint in [
("self-consumption", "Self-Consumption", "Discharge to offset grid import, charge from solar excess — always active"),
("custom", "Time-of-Use", "Charge and discharge on a manual hourly schedule"),
("backup-only", "Backup Only", "Holds battery at full charge, discharges only during grid outages"),
] %}
-
+
Active Days
{% for d, label in [(0,'Mo'),(1,'Tu'),(2,'We'),(3,'Th'),(4,'Fr'),(5,'Sa'),(6,'Su')] %}
{{ label }}
{% endfor %}
-
+ {% if not tou_rate_driven %}
+ {% endif %}
{% endif %}
diff --git a/src/span_panel_simulator/dashboard/templates/partials/bess_card.html b/src/span_panel_simulator/dashboard/templates/partials/bess_card.html
new file mode 100644
index 0000000..37ed887
--- /dev/null
+++ b/src/span_panel_simulator/dashboard/templates/partials/bess_card.html
@@ -0,0 +1,80 @@
+{% if bess_config and bess_config.enabled is defined and bess_config.enabled %}
+
+
+
Battery (GFE) UPSTREAM LUGS
+ {% if not readonly %}
+
+ {% if not bess_editing %}
+
+ {% endif %}
+
+
+ {% endif %}
+
+
+ {% if bess_editing is defined and bess_editing %}
+
+
+
+
+
+ {% include "partials/battery_profile_editor.html" %}
+