diff --git a/configs/MAIN_40.yaml b/configs/MAIN_40.yaml new file mode 100644 index 0000000..d501a8e --- /dev/null +++ b/configs/MAIN_40.yaml @@ -0,0 +1,801 @@ +panel_config: + serial_number: sim-40t-001 + total_tabs: 40 + main_size: 200 + latitude: 37.7 + longitude: -122.4 +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] +circuit_templates: + lighting: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 500.0 + typical_power: 80.0 + power_variation: 0.1 + relay_behavior: controllable + priority: NEVER + breaker_rating: 15 + time_of_day_profile: + enabled: true + hourly_multipliers: + 0: 0.1 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.1 + 7: 0.3 + 8: 0.2 + 9: 0.1 + 10: 0.1 + 11: 0.1 + 12: 0.1 + 13: 0.1 + 14: 0.1 + 15: 0.1 + 16: 0.2 + 17: 0.4 + 18: 0.8 + 19: 1.0 + 20: 1.0 + 21: 1.0 + 22: 0.7 + 23: 0.3 + recorder_entity: sensor.sim_40t_001_master_bedroom_lights_power + exterior_lighting: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 300.0 + typical_power: 60.0 + power_variation: 0.1 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 15 + time_of_day_profile: + enabled: true + hourly_multipliers: + 0: 0.3 + 1: 0.3 + 2: 0.3 + 3: 0.3 + 4: 0.3 + 5: 0.3 + 6: 0.1 + 7: 0.0 + 8: 0.0 + 9: 0.0 + 10: 0.0 + 11: 0.0 + 12: 0.0 + 13: 0.0 + 14: 0.0 + 15: 0.0 + 16: 0.0 + 17: 0.0 + 18: 0.3 + 19: 0.7 + 20: 1.0 + 21: 1.0 + 22: 1.0 + 23: 0.5 + recorder_entity: sensor.sim_40t_001_exterior_lights_power + outlets: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 1800.0 + typical_power: 150.0 + power_variation: 0.4 + relay_behavior: controllable + priority: NEVER + breaker_rating: 15 + recorder_entity: sensor.sim_40t_001_master_bedroom_outlets_power + kitchen_outlets: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 2400.0 + typical_power: 300.0 + power_variation: 0.5 + relay_behavior: controllable + priority: NEVER + breaker_rating: 20 + recorder_entity: sensor.sim_40t_001_kitchen_outlets_1_power + refrigerator: + energy_profile: + mode: consumer + power_range: + - 50.0 + - 200.0 + typical_power: 120.0 + power_variation: 0.2 + relay_behavior: non_controllable + priority: NEVER + breaker_rating: 20 + cycling_pattern: + on_duration: 600 + off_duration: 1800 + recorder_entity: sensor.sim_40t_001_refrigerator_power + large_appliance: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 2500.0 + typical_power: 1200.0 + power_variation: 0.2 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 20 + hvac: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 3500.0 + typical_power: 2800.0 + power_variation: 0.1 + relay_behavior: controllable + priority: NEVER + breaker_rating: 30 + cycling_pattern: + on_duration: 1200 + off_duration: 2400 + hvac_type: central_ac + time_of_day_profile: + enabled: true + hourly_multipliers: + 0: 0.3 + 1: 0.3 + 2: 0.3 + 3: 0.3 + 4: 0.3 + 5: 0.3 + 6: 0.4 + 7: 0.5 + 8: 0.5 + 9: 0.5 + 10: 0.6 + 11: 0.6 + 12: 0.7 + 13: 0.7 + 14: 0.8 + 15: 0.9 + 16: 1.0 + 17: 1.0 + 18: 1.0 + 19: 1.0 + 20: 1.0 + 21: 1.0 + 22: 0.7 + 23: 0.4 + peak_hours: + - 14 + - 15 + - 16 + - 17 + - 18 + - 19 + - 20 + - 21 + recorder_entity: sensor.sim_40t_001_main_hvac_power + heat_pump: + energy_profile: + mode: consumer + power_range: + - 500.0 + - 4000.0 + typical_power: 2800.0 + power_variation: 0.25 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 30 + cycling_pattern: + on_duration: 900 + off_duration: 1800 + hvac_type: heat_pump + recorder_entity: sensor.sim_40t_001_heat_pump_power + dishwasher: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 1800.0 + typical_power: 1200.0 + power_variation: 0.15 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 20 + cycling_pattern: + on_duration: 1200 + off_duration: 600 + time_of_day_profile: + enabled: true + hour_factors: + 0: 0.0 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.0 + 7: 0.0 + 8: 0.0 + 9: 0.0 + 10: 0.0 + 11: 0.0 + 12: 0.0 + 13: 0.0 + 14: 0.0 + 15: 0.0 + 16: 0.0 + 17: 0.0 + 18: 0.0 + 19: 0.8 + 20: 1.0 + 21: 0.5 + 22: 0.0 + 23: 0.0 + recorder_entity: sensor.sim_40t_001_dishwasher_power + washing_machine: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 1500.0 + typical_power: 500.0 + power_variation: 0.3 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 20 + cycling_pattern: + on_duration: 600 + off_duration: 300 + time_of_day_profile: + enabled: true + hour_factors: + 0: 0.0 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.0 + 7: 0.0 + 8: 0.3 + 9: 0.7 + 10: 1.0 + 11: 0.8 + 12: 0.0 + 13: 0.0 + 14: 0.0 + 15: 0.5 + 16: 0.7 + 17: 0.3 + 18: 0.0 + 19: 0.0 + 20: 0.0 + 21: 0.0 + 22: 0.0 + 23: 0.0 + recorder_entity: sensor.sim_40t_001_washing_machine_power + microwave: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 1500.0 + typical_power: 1000.0 + power_variation: 0.1 + relay_behavior: controllable + priority: NEVER + breaker_rating: 20 + cycling_pattern: + on_duration: 180 + off_duration: 3420 + time_of_day_profile: + enabled: true + hour_factors: + 0: 0.0 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.0 + 7: 0.4 + 8: 0.3 + 9: 0.0 + 10: 0.0 + 11: 0.0 + 12: 0.6 + 13: 0.4 + 14: 0.0 + 15: 0.0 + 16: 0.0 + 17: 0.3 + 18: 0.5 + 19: 0.3 + 20: 0.0 + 21: 0.0 + 22: 0.0 + 23: 0.0 + recorder_entity: sensor.sim_40t_001_microwave_power + dryer: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 5000.0 + typical_power: 3000.0 + power_variation: 0.1 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 30 + cycling_pattern: + on_duration: 2400 + off_duration: 300 + time_of_day_profile: + enabled: true + hour_factors: + 0: 0.0 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.0 + 7: 0.0 + 8: 0.0 + 9: 0.0 + 10: 0.0 + 11: 0.0 + 12: 0.0 + 13: 0.0 + 14: 0.5 + 15: 1.0 + 16: 1.0 + 17: 0.5 + 18: 0.0 + 19: 0.0 + 20: 0.0 + 21: 0.0 + 22: 0.0 + 23: 0.0 + recorder_entity: sensor.sim_40t_001_dryer_power + oven_range: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 5000.0 + typical_power: 2000.0 + power_variation: 0.2 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 40 + cycling_pattern: + on_duration: 600 + off_duration: 600 + time_of_day_profile: + enabled: true + hour_factors: + 0: 0.0 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.0 + 7: 0.2 + 8: 0.2 + 9: 0.0 + 10: 0.0 + 11: 0.0 + 12: 0.3 + 13: 0.0 + 14: 0.0 + 15: 0.0 + 16: 0.0 + 17: 0.5 + 18: 1.0 + 19: 0.8 + 20: 0.3 + 21: 0.0 + 22: 0.0 + 23: 0.0 + recorder_entity: sensor.sim_40t_001_oven_power + water_heater: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 4500.0 + typical_power: 4500.0 + power_variation: 0.05 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 30 + cycling_pattern: + on_duration: 600 + off_duration: 2400 + time_of_day_profile: + enabled: true + hour_factors: + 0: 0.1 + 1: 0.1 + 2: 0.1 + 3: 0.1 + 4: 0.1 + 5: 0.2 + 6: 0.6 + 7: 1.0 + 8: 0.8 + 9: 0.4 + 10: 0.2 + 11: 0.2 + 12: 0.2 + 13: 0.2 + 14: 0.2 + 15: 0.2 + 16: 0.3 + 17: 0.5 + 18: 0.8 + 19: 1.0 + 20: 0.7 + 21: 0.4 + 22: 0.2 + 23: 0.1 + recorder_entity: sensor.sim_40t_001_water_heater_power + span_drive: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 11500.0 + typical_power: 7200.0 + power_variation: 0.05 + relay_behavior: controllable + priority: OFF_GRID + device_type: evse + breaker_rating: 50 + smart_behavior: + responds_to_grid: true + max_power_reduction: 0.6 + time_of_day_profile: + enabled: true + hour_factors: + 0: 1.0 + 1: 1.0 + 2: 1.0 + 3: 1.0 + 4: 1.0 + 5: 1.0 + 6: 0.0 + 7: 0.0 + 8: 0.0 + 9: 0.0 + 10: 0.0 + 11: 0.0 + 12: 0.0 + 13: 0.0 + 14: 0.0 + 15: 0.0 + 16: 0.0 + 17: 0.0 + 18: 0.0 + 19: 0.0 + 20: 0.0 + 21: 0.0 + 22: 0.0 + 23: 0.0 + active_days: + - 0 + - 2 + - 4 + - 6 + recorder_entity: sensor.sim_40t_001_span_drive_garage_power + solar: + energy_profile: + mode: producer + power_range: + - -10000.0 + - 0.0 + typical_power: -6000.0 + power_variation: 0.25 + efficiency: 0.85 + nameplate_capacity_w: 10000.0 + relay_behavior: non_controllable + priority: NEVER + device_type: pv + breaker_rating: 30 + recorder_entity: sensor.sim_40t_001_solar_inverter_power + pool: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 1200.0 + typical_power: 800.0 + power_variation: 0.1 + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 20 + cycling_pattern: + on_duration: 7200 + off_duration: 14400 + time_of_day_profile: + enabled: true + hourly_multipliers: + 0: 0.0 + 1: 0.0 + 2: 0.0 + 3: 0.0 + 4: 0.0 + 5: 0.0 + 6: 0.0 + 7: 0.1 + 8: 0.3 + 9: 0.7 + 10: 1.0 + 11: 1.0 + 12: 1.0 + 13: 1.0 + 14: 1.0 + 15: 0.8 + 16: 0.5 + 17: 0.3 + 18: 0.1 + 19: 0.0 + 20: 0.0 + 21: 0.0 + 22: 0.0 + 23: 0.0 + peak_hours: + - 10 + - 11 + - 12 + - 13 + - 14 + - 15 + recorder_entity: sensor.sim_40t_001_pool_pump_power + always_on: + energy_profile: + mode: consumer + power_range: + - 40.0 + - 100.0 + typical_power: 60.0 + power_variation: 0.1 + relay_behavior: controllable + priority: NEVER + breaker_rating: 15 + recorder_entity: sensor.sim_40t_001_garbage_disposal_power + new_circuit_tpl: + energy_profile: + mode: consumer + power_range: + - 0.0 + - 1800.0 + typical_power: 150.0 + power_variation: 0.3 + relay_behavior: controllable + priority: NEVER + breaker_rating: 15 + recorder_entity: sensor.sim_40t_001_new_circuit_power +circuits: +- id: master_bedroom_lights + name: Master Bedroom Lights + template: lighting + tabs: + - 1 + overrides: + typical_power: 40.0 +- id: living_room_lights + name: Living Room Lights + template: lighting + tabs: + - 2 + overrides: + typical_power: 50.0 +- id: bedroom_lights + name: Bedroom Lights + template: lighting + tabs: + - 4 +- id: bathroom_lights + name: Bathroom Lights + template: lighting + tabs: + - 5 + overrides: + typical_power: 30.0 +- id: exterior_lights + name: Exterior Lights + template: exterior_lighting + tabs: + - 6 +- id: master_bedroom_outlets + name: Master Bedroom Outlets + template: outlets + tabs: + - 7 +- id: living_room_outlets + name: Living Room Outlets + template: outlets + tabs: + - 8 + overrides: + typical_power: 250.0 +- id: kitchen_outlets_1 + name: Kitchen Outlets (Counter) + template: kitchen_outlets + tabs: + - 9 +- id: kitchen_outlets_2 + name: Kitchen Outlets (Island) + template: kitchen_outlets + tabs: + - 10 +- id: office_outlets + name: Office Outlets + template: outlets + tabs: + - 11 + overrides: + typical_power: 300.0 +- id: garage_outlets + name: Garage Outlets + template: outlets + tabs: + - 12 +- id: laundry_outlets + name: Laundry Room Outlets + template: outlets + tabs: + - 13 +- id: guest_room_outlets + name: Guest Room Outlets + template: outlets + tabs: + - 14 +- id: refrigerator + name: Refrigerator + template: refrigerator + tabs: + - 15 +- id: dishwasher + name: Dishwasher + template: dishwasher + tabs: + - 16 +- id: washing_machine + name: Washing Machine + template: washing_machine + tabs: + - 17 +- id: microwave + name: Microwave + template: microwave + tabs: + - 18 +- id: freezer + name: Chest Freezer + template: refrigerator + tabs: + - 19 + overrides: + typical_power: 80.0 +- id: garbage_disposal + name: Garbage Disposal + template: always_on + tabs: + - 21 + overrides: + typical_power: 0.0 + power_range: + - 0.0 + - 500.0 +- id: pool_pump + name: Pool Pump + template: pool + tabs: + - 39 + breaker_rating: 20 +- id: smoke_detectors + name: Smoke Detectors + template: always_on + tabs: + - 40 + overrides: + typical_power: 5.0 + power_range: + - 3.0 + - 10.0 +- id: dryer + name: Electric Dryer + template: dryer + tabs: + - 20 + - 22 +- id: main_hvac + name: Main HVAC + template: hvac + tabs: + - 23 + - 25 + breaker_rating: 30 + overrides: + typical_power: 1400.0 +- id: heat_pump + name: Heat Pump + template: heat_pump + tabs: + - 27 + - 29 +- id: oven + name: Electric Oven/Range + template: oven_range + tabs: + - 28 + - 30 +- id: water_heater + name: Water Heater + template: water_heater + tabs: + - 31 + - 33 +- id: span_drive_garage + name: SPAN Drive - Garage + template: span_drive + tabs: + - 32 + - 34 + breaker_rating: 50 +- id: span_drive_driveway + name: SPAN Drive - Driveway + template: span_drive + tabs: + - 35 + - 37 +- id: solar_inverter + name: Solar Inverter + template: solar + tabs: + - 36 + - 38 + breaker_rating: 30 +- id: new_circuit + name: kitchen Lights + template: new_circuit_tpl + tabs: + - 3 + overrides: + power_range: + - 0.0 + - 250.0 +unmapped_tabs: +- 24 +- 26 +simulation_params: + update_interval: 5.0 + time_acceleration: 1.0 + noise_factor: 0.01 + enable_realistic_behaviors: true diff --git a/configs/default_MAIN_16.yaml b/configs/default_MAIN_16.yaml index e29de9e..43a2d08 100644 --- a/configs/default_MAIN_16.yaml +++ b/configs/default_MAIN_16.yaml @@ -8,6 +8,18 @@ panel_config: latitude: 37.7 longitude: -122.4 +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] + circuit_templates: lighting: energy_profile: @@ -52,27 +64,6 @@ circuit_templates: device_type: "pv" breaker_rating: 30 - battery: - 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" - breaker_rating: 40 - battery_behavior: - enabled: true - nameplate_capacity_kwh: 13.5 - backup_reserve_pct: 20.0 - charge_mode: "solar-gen" - 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] - idle_hours: [0, 1, 2, 3, 4, 5, 6, 7, 23] - circuits: - id: "living_room_lights" name: "Living Room Lights" @@ -104,10 +95,6 @@ circuits: template: "solar" tabs: [6, 8] - - id: "battery_storage" - name: "Battery Storage" - template: "battery" - unmapped_tabs: [9, 10, 11, 12, 13, 14, 15, 16] simulation_params: diff --git a/configs/default_MAIN_32.yaml b/configs/default_MAIN_32.yaml index c4b2c1c..8e04e7c 100644 --- a/configs/default_MAIN_32.yaml +++ b/configs/default_MAIN_32.yaml @@ -8,6 +8,18 @@ panel_config: latitude: 37.7 longitude: -122.4 +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] + # Circuit templates define reusable behavior patterns circuit_templates: # Always-on base load @@ -357,8 +369,8 @@ circuit_templates: on_duration: 600 # 10 minutes off_duration: 1800 # 30 minutes - # EV charger (SPAN Drive) with smart grid response - ev_charger: + # SPAN Drive EV charger with smart grid response + span_drive: energy_profile: mode: "consumer" power_range: [0.0, 11500.0] @@ -367,7 +379,7 @@ circuit_templates: relay_behavior: "controllable" priority: "OFF_GRID" breaker_rating: 50 - device_type: "evse" # Generates EVSE (SPAN Drive) snapshot + device_type: "evse" smart_behavior: responds_to_grid: true max_power_reduction: 0.6 # Can reduce to 40% during grid stress @@ -439,28 +451,6 @@ circuit_templates: priority: "NEVER" breaker_rating: 40 - # Battery storage - bidirectional - battery_storage: - energy_profile: - mode: "bidirectional" # Can charge or discharge - power_range: [-5000.0, 5000.0] # ±5kW battery - typical_power: 0.0 # Neutral when idle - power_variation: 0.02 # Very stable - efficiency: 0.95 # 95% round-trip efficiency - relay_behavior: "controllable" - priority: "NEVER" - breaker_rating: 40 - battery_behavior: - enabled: true - nameplate_capacity_kwh: 13.5 - backup_reserve_pct: 20.0 - charge_mode: "solar-gen" - 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] - idle_hours: [0, 1, 2, 3, 4, 5, 6, 7, 23] - # Wind turbine - variable producer wind_production: energy_profile: @@ -651,9 +641,9 @@ circuits: tabs: [24, 26] # 240V system # EV charging (tabs 27-29) - - id: "ev_charger_garage" - name: "Garage EV Charger" - template: "ev_charger" + - id: "span_drive_garage" + name: "SPAN Drive - Garage" + template: "span_drive" tabs: [27, 29] # 240V Level 2 charger # Solar (tabs 28-30) @@ -665,10 +655,6 @@ circuits: overrides: nameplate_capacity_w: 8000.0 - - id: "battery_storage_1" - name: "Battery Storage" - template: "battery_storage" - unmapped_tabs: [31, 32] # Global simulation parameters diff --git a/configs/default_MAIN_40.yaml b/configs/default_MAIN_40.yaml index b0c9803..8741ccb 100644 --- a/configs/default_MAIN_40.yaml +++ b/configs/default_MAIN_40.yaml @@ -4,6 +4,17 @@ panel_config: main_size: 200 latitude: 37.7 longitude: -122.4 +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] circuit_templates: lighting: energy_profile: @@ -501,52 +512,6 @@ circuit_templates: priority: NEVER device_type: pv breaker_rating: 30 - battery: - 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 - breaker_rating: 40 - battery_behavior: - enabled: true - nameplate_capacity_kwh: 13.5 - backup_reserve_pct: 20.0 - charge_mode: solar-gen - 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 - idle_hours: - - 0 - - 1 - - 2 - - 3 - - 4 - - 5 - - 6 - - 7 - - 23 pool: energy_profile: mode: consumer @@ -799,9 +764,6 @@ circuits: - 36 - 38 breaker_rating: 30 -- id: battery_storage - name: Battery Storage - template: battery - id: new_circuit name: kitchen Lights template: new_circuit_tpl diff --git a/docs/images/dashboard_battery.png b/docs/images/dashboard_battery.png index dd386ba..6d0e02a 100644 Binary files a/docs/images/dashboard_battery.png and b/docs/images/dashboard_battery.png differ diff --git a/docs/images/modeling.png b/docs/images/modeling.png index 60e8971..4d1cc66 100644 Binary files a/docs/images/modeling.png and b/docs/images/modeling.png differ diff --git a/docs/superpowers/plans/2026-03-31-dashboard-i18n.md b/docs/superpowers/plans/2026-03-31-dashboard-i18n.md new file mode 100644 index 0000000..4af27ca --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-dashboard-i18n.md @@ -0,0 +1,1444 @@ +# Dashboard Internationalization 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:** Internationalize the simulator dashboard so all user-visible strings render in the host system's language (standalone) or HA's configured language (add-on mode). + +**Architecture:** A `Translator` class loads YAML translation files at startup and exposes a `t(key)` function registered as a Jinja2 global. Templates call `{{ t('key') }}` for server-rendered strings. Inline JS receives the full dictionary as `window.i18n` and uses `Intl` APIs for date/number formatting. Locale is resolved once at startup from the HA supervisor API or host `locale.getlocale()`. + +**Tech Stack:** Python stdlib (`locale`, `json`), PyYAML (existing dep), aiohttp/Jinja2 (existing), JS `Intl` APIs. + +**Spec:** `docs/superpowers/specs/2026-03-30-dashboard-i18n-design.md` + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `src/span_panel_simulator/dashboard/translator.py` | Translator class: load YAMLs, flatten keys, `t()`, `to_json()`, locale resolution | +| Create | `tests/test_translator.py` | Unit tests for Translator, locale resolution, fallback, key parity | +| Modify | `src/span_panel_simulator/dashboard/context.py` | Add `locale: str` field to `DashboardContext` | +| Modify | `src/span_panel_simulator/dashboard/__init__.py` | Instantiate Translator, register Jinja2 globals | +| Modify | `src/span_panel_simulator/dashboard/keys.py` | Add `APP_KEY_TRANSLATOR` app key | +| Modify | `src/span_panel_simulator/app.py` | Resolve locale and pass to DashboardContext | +| Modify | `span_panel_simulator/translations/en.yaml` | Add `dashboard:` section with all UI strings | +| Modify | `span_panel_simulator/translations/nl.yaml` | Add `dashboard:` section (Dutch) | +| Modify | `span_panel_simulator/translations/de.yaml` | Add `dashboard:` section (German) | +| Modify | `span_panel_simulator/translations/fr.yaml` | Add `dashboard:` section (French) | +| Modify | `span_panel_simulator/translations/es.yaml` | Add `dashboard:` section (Spanish) | +| Modify | `span_panel_simulator/translations/pt-BR.yaml` | Add `dashboard:` section (Portuguese) | +| Modify | `src/span_panel_simulator/dashboard/templates/base.html` | Inject i18n JS bridge, translate theme strings | +| Modify | `src/span_panel_simulator/dashboard/templates/dashboard.html` | Translate getting-started text | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/runtime_controls.html` | Translate labels, buttons, chart legends; replace month arrays with Intl | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/panel_config.html` | Translate form labels and JS messages | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/sim_config.html` | Translate form labels and buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/entity_list.html` | Translate headings, buttons, hints | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/entity_row.html` | Translate badges, tooltips, buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/entity_edit.html` | Translate all form labels | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/clone_panel.html` | Translate labels, hints, buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/clone_confirm.html` | Translate dialog text | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/running_panels.html` | Translate headings, buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/panels_list_rows.html` | Translate badges, buttons, tooltips, JS messages | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/profile_editor.html` | Translate labels, buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/pv_profile.html` | Translate labels, replace month arrays with Intl | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html` | Translate mode labels, hints, buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/evse_schedule.html` | Translate labels, buttons | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/panel_source.html` | Translate headings, status text | +| Modify | `src/span_panel_simulator/dashboard/templates/partials/modeling_view.html` | Translate all labels, dialogs, chart text, JS messages | + +--- + +## Task 1: Translator Class — Core + +**Files:** +- Create: `src/span_panel_simulator/dashboard/translator.py` +- Create: `tests/test_translator.py` + +- [ ] **Step 1: Write test for YAML loading and key flattening** + +```python +# tests/test_translator.py +"""Tests for the dashboard Translator.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from span_panel_simulator.dashboard.translator import Translator + + +@pytest.fixture() +def translations_dir(tmp_path: Path) -> Path: + """Create a temporary translations directory with test YAML files.""" + en = { + "configuration": {"tick_interval": {"name": "Tick interval"}}, + "dashboard": { + "title": "Dashboard Title", + "controls": {"grid_online": "Grid Online", "speed": "Speed"}, + }, + } + nl = { + "configuration": {"tick_interval": {"name": "Tick-interval"}}, + "dashboard": { + "title": "Dashboard Titel", + "controls": {"grid_online": "Grid Aan", "speed": "Snelheid"}, + }, + } + (tmp_path / "en.yaml").write_text(yaml.dump(en, allow_unicode=True)) + (tmp_path / "nl.yaml").write_text(yaml.dump(nl, allow_unicode=True)) + return tmp_path + + +class TestTranslatorLoading: + def test_loads_english(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "en") + assert t("title") == "Dashboard Title" + + def test_loads_nested_key(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "en") + assert t("controls.grid_online") == "Grid Online" + + def test_loads_requested_locale(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "nl") + assert t("title") == "Dashboard Titel" + assert t("controls.grid_online") == "Grid Aan" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_translator.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'span_panel_simulator.dashboard.translator'` + +- [ ] **Step 3: Implement Translator class** + +```python +# src/span_panel_simulator/dashboard/translator.py +"""Internationalization support for the dashboard. + +Loads YAML translation files and provides a ``t(key)`` function for +looking up translated strings by dot-notation key. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import yaml + + +def _flatten(data: dict[str, Any], prefix: str = "") -> dict[str, str]: + """Flatten a nested dictionary into dot-notation keys.""" + result: dict[str, str] = {} + for key, value in data.items(): + full_key = f"{prefix}{key}" if prefix else key + if isinstance(value, dict): + result.update(_flatten(value, f"{full_key}.")) + else: + result[full_key] = str(value) + return result + + +class Translator: + """Provides translated strings for the dashboard UI. + + Loads all ``*.yaml`` files from the translations directory at init. + Each file's ``dashboard:`` section is flattened into dot-notation keys. + """ + + def __init__(self, translations_dir: Path, locale: str) -> None: + self._locale = locale + self._strings: dict[str, dict[str, str]] = {} # locale -> flat dict + + for path in translations_dir.glob("*.yaml"): + lang = path.stem # e.g. "en", "nl", "pt-BR" + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + dashboard = raw.get("dashboard", {}) + if dashboard: + self._strings[lang] = _flatten(dashboard) + + @property + def locale(self) -> str: + """The active locale code.""" + return self._locale + + def __call__(self, key: str) -> str: + """Look up a translated string. + + Fallback chain: active locale -> ``en`` -> raw key. + """ + active = self._strings.get(self._locale, {}) + value = active.get(key) + if value is not None: + return value + # Fall back to English. + en = self._strings.get("en", {}) + value = en.get(key) + if value is not None: + return value + # Last resort: return the key itself. + return key + + def to_json(self) -> str: + """Serialize the active locale's dashboard strings as JSON. + + Falls back to English for any keys missing in the active locale. + """ + en = self._strings.get("en", {}) + active = self._strings.get(self._locale, {}) + merged = {**en, **active} + return json.dumps(merged, ensure_ascii=False) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_translator.py::TestTranslatorLoading -v` +Expected: 3 PASSED + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/dashboard/translator.py tests/test_translator.py +git commit -m "Add Translator class with YAML loading and dot-key lookup" +``` + +--- + +## Task 2: Translator — Fallback and JSON Bridge + +**Files:** +- Modify: `tests/test_translator.py` +- (No new source changes — testing existing behavior) + +- [ ] **Step 1: Write tests for fallback chain and to_json** + +Append to `tests/test_translator.py`: + +```python +class TestTranslatorFallback: + def test_falls_back_to_english_for_missing_key(self, translations_dir: Path) -> None: + # Add a partial locale missing some keys + partial = { + "dashboard": {"title": "Titulo"}, + } + (translations_dir / "es.yaml").write_text(yaml.dump(partial, allow_unicode=True)) + t = Translator(translations_dir, "es") + assert t("title") == "Titulo" + assert t("controls.grid_online") == "Grid Online" # falls back to en + + def test_returns_raw_key_when_missing_everywhere(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "en") + assert t("nonexistent.key") == "nonexistent.key" + + def test_unsupported_locale_falls_back_to_english(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "ja") + assert t("title") == "Dashboard Title" + + def test_empty_translations_dir(self, tmp_path: Path) -> None: + t = Translator(tmp_path, "en") + assert t("anything") == "anything" + + +class TestTranslatorJson: + def test_to_json_contains_all_keys(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "en") + data = json.loads(t.to_json()) + assert data["title"] == "Dashboard Title" + assert data["controls.grid_online"] == "Grid Online" + assert data["controls.speed"] == "Speed" + + def test_to_json_merges_active_over_english(self, translations_dir: Path) -> None: + t = Translator(translations_dir, "nl") + data = json.loads(t.to_json()) + assert data["title"] == "Dashboard Titel" + assert data["controls.grid_online"] == "Grid Aan" + + def test_to_json_includes_english_fallbacks(self, translations_dir: Path) -> None: + partial = {"dashboard": {"title": "Titre"}} + (translations_dir / "fr.yaml").write_text(yaml.dump(partial, allow_unicode=True)) + t = Translator(translations_dir, "fr") + data = json.loads(t.to_json()) + assert data["title"] == "Titre" + assert data["controls.grid_online"] == "Grid Online" # en fallback +``` + +Add `import json` to the top of the test file. + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `python -m pytest tests/test_translator.py -v` +Expected: All 10 tests PASS (implementation already handles these cases) + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_translator.py +git commit -m "Add fallback and JSON bridge tests for Translator" +``` + +--- + +## Task 3: Locale Resolution + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/translator.py` +- Modify: `tests/test_translator.py` + +- [ ] **Step 1: Write tests for locale resolution** + +Append to `tests/test_translator.py`: + +```python +from unittest.mock import AsyncMock, patch + +from span_panel_simulator.dashboard.translator import resolve_locale + + +class TestResolveLocale: + async def test_supervisor_mode_fetches_language(self) -> None: + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"language": "nl"}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = AsyncMock(return_value=mock_resp) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + available = {"en", "nl", "de"} + with patch.dict("os.environ", {"SUPERVISOR_TOKEN": "test-token"}): + with patch("aiohttp.ClientSession", return_value=mock_session): + result = await resolve_locale(available) + assert result == "nl" + + async def test_supervisor_unsupported_language_falls_back(self) -> None: + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"language": "ja"}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = AsyncMock(return_value=mock_resp) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + available = {"en", "nl"} + with patch.dict("os.environ", {"SUPERVISOR_TOKEN": "test-token"}): + with patch("aiohttp.ClientSession", return_value=mock_session): + result = await resolve_locale(available) + assert result == "en" + + async def test_standalone_uses_system_locale(self) -> None: + available = {"en", "de", "fr"} + with patch.dict("os.environ", {}, clear=True): + with patch("locale.getlocale", return_value=("de_DE", "UTF-8")): + result = await resolve_locale(available) + assert result == "de" + + async def test_standalone_none_locale_falls_back(self) -> None: + available = {"en", "nl"} + with patch.dict("os.environ", {}, clear=True): + with patch("locale.getlocale", return_value=(None, None)): + result = await resolve_locale(available) + assert result == "en" + + async def test_standalone_unsupported_locale_falls_back(self) -> None: + available = {"en", "nl"} + with patch.dict("os.environ", {}, clear=True): + with patch("locale.getlocale", return_value=("ja_JP", "UTF-8")): + result = await resolve_locale(available) + assert result == "en" + + async def test_supervisor_api_error_falls_back_to_system(self) -> None: + mock_resp = AsyncMock() + mock_resp.status = 500 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = AsyncMock(return_value=mock_resp) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + available = {"en", "fr"} + with patch.dict("os.environ", {"SUPERVISOR_TOKEN": "test-token"}): + with patch("aiohttp.ClientSession", return_value=mock_session): + with patch("locale.getlocale", return_value=("fr_FR", "UTF-8")): + result = await resolve_locale(available) + assert result == "fr" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_translator.py::TestResolveLocale -v` +Expected: FAIL — `ImportError: cannot import name 'resolve_locale'` + +- [ ] **Step 3: Implement resolve_locale** + +Add to `src/span_panel_simulator/dashboard/translator.py` (at the top, add imports; at the bottom, add the function): + +```python +import locale as locale_mod +import logging +import os + +import aiohttp + +_LOGGER = logging.getLogger(__name__) + +_SUPERVISOR_CONFIG_URL = "http://supervisor/core/api/config" + + +async def resolve_locale(available_locales: set[str]) -> str: + """Determine the dashboard locale. + + Resolution order: + 1. HA Supervisor API language (add-on mode) + 2. Host system locale (standalone mode) + 3. Fallback to ``"en"`` + """ + lang = await _locale_from_supervisor() + if lang and lang in available_locales: + _LOGGER.info("Locale from HA Supervisor: %s", lang) + return lang + + lang = _locale_from_system() + if lang and lang in available_locales: + _LOGGER.info("Locale from system: %s", lang) + return lang + + _LOGGER.info("Locale fallback: en") + return "en" + + +async def _locale_from_supervisor() -> str | None: + """Fetch language from the HA Supervisor config API.""" + token = os.environ.get("SUPERVISOR_TOKEN") + if not token: + return None + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + _SUPERVISOR_CONFIG_URL, + headers={"Authorization": f"Bearer {token}"}, + ) as resp: + if resp.status != 200: + _LOGGER.warning("Supervisor config API returned %s", resp.status) + return None + data = await resp.json() + return data.get("language") + except Exception: + _LOGGER.warning("Failed to fetch locale from Supervisor", exc_info=True) + return None + + +def _locale_from_system() -> str | None: + """Extract language code from the host system locale.""" + raw, _ = locale_mod.getlocale() + if not raw: + return None + # "en_US" -> "en", "pt_BR" -> "pt-BR" + parts = raw.split("_") + if len(parts) >= 2: + region = parts[1].split(".")[0] # strip encoding like ".UTF-8" + # Check for regional variants first (e.g. pt-BR) + regional = f"{parts[0]}-{region}" + return regional if regional != f"{parts[0]}-{parts[0].upper()}" else parts[0] + return parts[0] +``` + +Note on `_locale_from_system`: for most locales like `en_US`, `de_DE`, `fr_FR`, the region is just the uppercased language — we return just the language code (`en`, `de`, `fr`). For `pt_BR`, the language and region differ, so we return `pt-BR` to match the translation filename. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_translator.py::TestResolveLocale -v` +Expected: 6 PASSED + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/dashboard/translator.py tests/test_translator.py +git commit -m "Add locale resolution from HA supervisor and system locale" +``` + +--- + +## Task 4: Translation Key Parity Test + +**Files:** +- Modify: `tests/test_translator.py` + +- [ ] **Step 1: Write key parity test against real translation files** + +Append to `tests/test_translator.py`: + +```python +class TestTranslationKeyParity: + """Validate that all non-English YAML files have the same dashboard keys as en.yaml.""" + + @staticmethod + def _real_translations_dir() -> Path: + """Path to the actual translations directory.""" + return Path(__file__).resolve().parent.parent / "span_panel_simulator" / "translations" + + def test_all_languages_have_same_dashboard_keys(self) -> None: + translations_dir = self._real_translations_dir() + en_path = translations_dir / "en.yaml" + if not en_path.exists(): + pytest.skip("translations/en.yaml not found") + + en_raw = yaml.safe_load(en_path.read_text(encoding="utf-8")) or {} + en_dashboard = en_raw.get("dashboard") + if not en_dashboard: + pytest.skip("No dashboard section in en.yaml yet") + + en_keys = set(_flatten(en_dashboard).keys()) + + for path in sorted(translations_dir.glob("*.yaml")): + if path.stem == "en": + continue + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + dashboard = raw.get("dashboard", {}) + lang_keys = set(_flatten(dashboard).keys()) + missing = en_keys - lang_keys + extra = lang_keys - en_keys + assert not missing, f"{path.name} missing keys: {missing}" + assert not extra, f"{path.name} has extra keys: {extra}" +``` + +Add import at top of test file: + +```python +from span_panel_simulator.dashboard.translator import _flatten +``` + +- [ ] **Step 2: Run test — it should skip for now (no dashboard section yet)** + +Run: `python -m pytest tests/test_translator.py::TestTranslationKeyParity -v` +Expected: SKIPPED — "No dashboard section in en.yaml yet" + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_translator.py +git commit -m "Add translation key parity test for CI validation" +``` + +--- + +## Task 5: Wire Translator into Dashboard App + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/keys.py` +- Modify: `src/span_panel_simulator/dashboard/context.py` +- Modify: `src/span_panel_simulator/dashboard/__init__.py` + +- [ ] **Step 1: Add APP_KEY_TRANSLATOR to keys.py** + +Add to `src/span_panel_simulator/dashboard/keys.py`: + +```python +from span_panel_simulator.dashboard.translator import Translator + +APP_KEY_TRANSLATOR = web.AppKey("translator", Translator) +``` + +- [ ] **Step 2: Add locale field to DashboardContext** + +In `src/span_panel_simulator/dashboard/context.py`, add `locale` as the last field: + +```python + panel_browser: Any = None # PanelBrowser | None — mDNS discovery for standalone mode + locale: str = "en" +``` + +- [ ] **Step 3: Wire Translator into create_dashboard_app** + +In `src/span_panel_simulator/dashboard/__init__.py`, add imports: + +```python +from span_panel_simulator.dashboard.keys import ( + APP_KEY_DASHBOARD_CONTEXT, + APP_KEY_PENDING_CLONES, + APP_KEY_PRESET_REGISTRY, + APP_KEY_RATE_CACHE, + APP_KEY_STORE, + APP_KEY_TRANSLATOR, +) +from span_panel_simulator.dashboard.translator import Translator +``` + +After line 54 (`APP_KEY_RATE_CACHE` assignment), add: + +```python + translations_dir = Path(__file__).resolve().parent.parent.parent.parent / ( + "span_panel_simulator" / "translations" + ) + translator = Translator(translations_dir, context.locale) + app[APP_KEY_TRANSLATOR] = translator +``` + +After line 61 (`env.globals["static_url"] = "static"`), add: + +```python + env.globals["t"] = translator + env.globals["locale"] = translator.locale + env.globals["t_json"] = translator.to_json() +``` + +- [ ] **Step 4: Verify the app still starts** + +Run: `python -m pytest tests/ -x -q --timeout=30` +Expected: All existing tests pass (locale defaults to "en", translator loads but templates don't use it yet) + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/dashboard/keys.py \ + src/span_panel_simulator/dashboard/context.py \ + src/span_panel_simulator/dashboard/__init__.py +git commit -m "Wire Translator into dashboard app with Jinja2 globals" +``` + +--- + +## Task 6: Resolve Locale at App Startup + +**Files:** +- Modify: `src/span_panel_simulator/app.py` (the section that builds `DashboardContext`) + +- [ ] **Step 1: Find where DashboardContext is constructed in app.py** + +Search `app.py` for `DashboardContext(` — this is where locale resolution will be called. + +- [ ] **Step 2: Add locale resolution call** + +Add import at top of `app.py`: + +```python +from span_panel_simulator.dashboard.translator import resolve_locale +``` + +Before the `DashboardContext(...)` construction, resolve the locale: + +```python + translations_dir = ( + Path(__file__).resolve().parent.parent / "span_panel_simulator" / "translations" + ) + available_locales = { + p.stem for p in translations_dir.glob("*.yaml") + } + locale = await resolve_locale(available_locales) +``` + +Then pass `locale=locale` to the `DashboardContext(...)` constructor. + +- [ ] **Step 3: Fix translations_dir in __init__.py to use a consistent path** + +The translations directory path needs to resolve correctly in both installed (wheel) and development modes. Instead of hard-coding the path in both `app.py` and `__init__.py`, have the `Translator` find its own translations directory. + +Update `src/span_panel_simulator/dashboard/translator.py` — add a module-level constant: + +```python +TRANSLATIONS_DIR = Path(__file__).resolve().parent.parent.parent.parent / ( + "span_panel_simulator" / "translations" +) +``` + +Then use `TRANSLATIONS_DIR` in both `app.py` (for `resolve_locale`) and `__init__.py` (for `Translator()`). Alternatively, pass the dir into both from `app.py` via `DashboardContext`. + +The cleanest approach: add `translations_dir: Path` as a field on `DashboardContext` alongside `locale`, and use it in `__init__.py` when constructing the Translator. The `app.py` already knows the right path. + +- [ ] **Step 4: Verify the app still starts with locale resolution** + +Run: `python -m pytest tests/ -x -q --timeout=30` +Expected: All tests pass. In standalone mode without `SUPERVISOR_TOKEN`, locale resolves from system or falls back to "en". + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/app.py \ + src/span_panel_simulator/dashboard/context.py \ + src/span_panel_simulator/dashboard/__init__.py \ + src/span_panel_simulator/dashboard/translator.py +git commit -m "Resolve locale at startup from HA supervisor or system locale" +``` + +--- + +## Task 7: English Translation File — Dashboard Strings + +**Files:** +- Modify: `span_panel_simulator/translations/en.yaml` + +- [ ] **Step 1: Add the complete dashboard section to en.yaml** + +This is the source of truth. Every user-visible string from every template gets a key here. Append to `span_panel_simulator/translations/en.yaml` after the existing `configuration:` section: + +```yaml +dashboard: + title: SPAN Panel Simulator Dashboard + + theme: + label: Theme + system: System + light: Light + dark: Dark + + getting_started: + title: Getting started + step_click: >- + Click a simulator configuration to view it. Templates are read-only. + A running simulator appears as a discovered panel in the SpanPanel + integration (default configs excluded). + step_clone: >- + Clone creates an editable copy from a template or from your real + panel — cloning your panel preserves recorder history per circuit. + step_model: >- + Model opens the what-if view; add battery, PV, or circuits and + compare before/after. Edits mark equipment as SYN; click the badge + to revert to REC. + step_purge: >- + Purge removes recorder history written by the simulated panel's + sensors if you added the simulated panel to Home Assistant's + integration. + + tabs: + getting_started: Getting started + clone: Clone + model: Model + purge: Purge + export: Export + + controls: + title: Runtime Controls + date: Date + time_of_day: Time of Day + speed: Speed + grid_online: Grid Online + grid_offline: Grid Offline + islandable: Islandable + not_islandable: Not Islandable + runtime: Runtime + modeling: Modeling + soc: "SOC " + circuits_shed: "{count} circuit(s) shed" + + chart: + live_power_flows: Live Power Flows + grid: Grid + solar: Solar + battery: Battery + watts: Watts + watts_suffix: " W" + + panel_config: + title: Panel Config + serial: "Serial:" + tabs: "Tabs:" + main_breaker: "Main Breaker (A):" + soc_shed: "SOC Shed (%):" + soc_shed_hint: Battery SOC below which SOC_THRESHOLD circuits are shed + location: "Location:" + search_placeholder: Search city or address... + lat: "Lat:" + lon: "Lon:" + update: Update + no_results: No results + fetching_weather: Fetching historical weather data... + deterministic_weather: Using deterministic weather model + + sim_config: + title: Simulation Config + export: Export + save_reload: Save & Reload + interval: "Interval (s):" + noise: "Noise:" + update: Update + + panels: + title: Panels + config: config + configs: configs + import_btn: Import + overwrite: Overwrite + cancel: Cancel + already_exists: already exists. + no_configs: No config files found. Import or clone a panel to get started. + clone_hint: Clone a template above to create your own editable configuration. + clone_as: "Clone as:" + clone_failed: "Clone failed: " + unsaved_warning: You have unsaved changes that will be lost. Switch anyway? + switch_failed: "Failed to switch panel: " + + panel_row: + bootstrap_port: Bootstrap HTTP port + template: template + template_hint: Clone this template to create an editable config + viewing: viewing + editing: editing + clone: Clone + clone_hint: Clone this config + model: Model + model_hint: Open modeling view + restart: Restart + restart_hint: Restart engine + stop: Stop + stop_hint: Stop engine + start: Start + start_hint: Start engine + delete: Del + delete_hint: Delete config file + purge: Purge + purge_hint: Remove HA recorder history for this profile + + clone_panel: + title: Clone from Panel + hint_ha: >- + Clones circuit configuration from a SPAN panel registered with this + Home Assistant instance, including usage profiles from the recorder. + hint_standalone: Scrapes circuit configuration from a real SPAN panel via its eBus. + panel_label: Panel + scanning: "Scanning\u2026" + select_panel: "\u2014 select a panel \u2014" + no_panels: No panels found + discovery_unavailable: Discovery unavailable + panel_ip: Panel IP / hostname + ip_placeholder: 192.168.1.100 + passphrase: Passphrase + required: required + clone: Clone + + clone_confirm: + title: Clone from Panel + exists_prefix: "Config file " + exists_suffix: " already exists. Choose how to proceed:" + overwrite: "Overwrite " + save_as_new: "Save as new name:" + continue_btn: Continue + cancel: Cancel + + entities: + title: "Entities ({count})" + add_entity: "+ Add Entity" + clone_hint: Clone a template to create an editable configuration. + unmapped_tabs: "Unmapped Tabs ({count})" + add_from_tabs: Add Circuit from Selected Tabs + nothing_selected: "Nothing selected \u2014 all enabled" + select_tabs: Select 1 or 2 tabs + single_tab_hint: Add as 120V single-pole, or select a second tab for 240V + valid_pair: Valid 240V double-pole pair + invalid_pair: "Invalid pair: must be same parity, exactly 2 apart" + + entity_row: + overlay_hint: Overlay on modeling charts + toggle_relay: Toggle relay + override_hint: "Overridden \u2014 click to resume replay" + replay_hint: "Replaying recorded data \u2014 click for synthetic" + syn: SYN + rec: REC + rec_lost_hint: "Recorder link lost \u2014 click to restore" + rec_lost: "REC?" + tabs_prefix: "tabs: " + watts_suffix: W + edit: Edit + delete_confirm: "Delete " + delete: Del + + entity_edit: + editing: "Editing: " + name: "Name:" + tabs: "Tabs (comma-separated):" + priority: "Priority:" + relay_behavior: "Relay Behavior:" + breaker: "Breaker (A):" + breaker_placeholder: auto + pv_section: PV System + pv_nameplate: "Nameplate Rating (W):" + pv_efficiency: "Efficiency:" + pv_inverter_type: "Inverter Type:" + pv_grid_tied: Grid-Tied + pv_hybrid: Hybrid + evse_section: EVSE Charger + evse_charge_power: "Charge Power (W):" + evse_max_power: "Max Power (W):" + profile_section: Energy Profile + typical_power: "Typical Power (W):" + min_power: "Min Power (W):" + max_power: "Max Power (W):" + hvac_type: "HVAC Type:" + hvac_none: None + hvac_central: Central AC / Gas Furnace + hvac_heat_pump: Heat Pump + hvac_heat_pump_aux: Heat Pump + Aux Strips + cycling_section: Cycling Pattern + on_duration: "On Duration (s): " + off_duration: "Off Duration (s): " + smart_section: Smart Behavior + responds_to_grid: "Responds to Grid: " + max_power_reduction: "Max Power Reduction: " + battery_section: Battery Behavior + battery_nameplate: "Nameplate Capacity (kWh):" + battery_reserve: "Backup Reserve (%):" + battery_reserve_hint: "Normal discharge stops here; grid outages can draw deeper" + battery_charge_power: "Charge Power (W):" + battery_discharge_power: "Discharge Power (W):" + save: Save + cancel: Cancel + + profile_editor: + title: 24-Hour Profile + select_preset: "-- Select Preset --" + from: "from " + to: "to " + apply: Apply + active_days: Active Days + save_profile: Save Profile + + pv_profile: + title: Solar Production Profile + weather_degradation: Monthly Weather Degradation + no_weather: >- + No historical weather data available. Set a location and weather + data will be fetched automatically. + peak: "Peak: " + weather_label: " W | Weather: " + lat_label: "% | Lat: " + lon_label: ", Lon: " + error_loading: Error loading curve data + production_label: "Production (W)" + + battery_schedule: + title: Battery Schedule + charge_mode: Charge Mode + self_consumption: Self-Consumption + self_consumption_hint: >- + Discharge to offset grid import, charge from solar excess — always active + time_of_use: Time-of-Use + time_of_use_hint: Charge and discharge on a manual hourly schedule + backup_only: Backup Only + backup_only_hint: Holds battery at full charge, discharges only during grid outages + self_consumption_detail: >- + Battery automatically discharges to reduce grid import and charges + from surplus solar. No schedule needed. + backup_only_detail: Battery stays fully charged and only discharges during grid outages. + time_of_use_detail: Set charge and discharge hours in the schedule below. + discharge_preset: Discharge Preset + active_days: Active Days + idle: Idle + charge: Chg + discharge: Dis + save_schedule: Save Schedule + + evse_schedule: + title: Charging Schedule + select_preset: "-- Select Preset --" + apply: Apply + active_days: Active Days + start: "Start:" + duration: "Duration:" + duration_unit: h + apply_schedule: Apply + + panel_source: + title: Source Panel + cloned_from: "Cloned from " + last_synced: "\u2014 last synced " + utc: " UTC" + update_ebus: Update eBus Energy + + modeling: + title: "Modeling \u2014 " + back_to_runtime: Back to Runtime + horizon: Horizon + last_month: Last Month + last_3_months: Last 3 Months + last_6_months: Last 6 Months + last_year: Last Year + visible_range: Visible Range + loading: Loading modeling data... + billing_data: Billing Data (Opower) + change: Change + select_account: Select Electric Account + current_rate: Current Rate + data_source_hint: Data source attribution + no_rate: No rate plan selected + configure: Configure + refresh: Refresh + proposed_rate: Proposed Rate + using_current: Using current rate for comparison + set_proposed: Set Proposed Rate + clear: Clear + openei_title: OpenEI Rate Plan + api_settings: API Settings + api_url: API URL + api_url_placeholder: "https://api.openei.org/utility_rates" + api_key: API Key + api_key_placeholder: Enter your OpenEI API key + save: Save + get_api_key: Get a free API key + select_rate: Select Rate Plan + utility: Utility + loading_utilities: Loading utilities... + rate_plan: Rate Plan + select_utility_first: Select a utility first + use_this_rate: Use This Rate + rate_source_title: Rate Data Source + close: Close + before: Before + before_subtitle: "(Grid Power \u2014 recorder baseline)" + after: After + after_subtitle: "(Grid Power \u2014 current config)" + energy_kwh: Energy (kWh) + cost: Cost + difference: Difference + savings: Savings + full_horizon: Full Horizon + error_loading: "Error loading data: " + bought_suffix: " (bought), " + exported_suffix: " exp)" + cost_billed: " (billed)" + months_prefix: " of " + months_suffix: " months)" + import_suffix: " imp, " + export_net: " exp \u2014 Net: " + elec_label: " \u2014 ELEC " + select_utility: Select a utility... + loading_plans: Loading plans... + select_rate_plan: Select a rate plan... + error_loading_utilities: Error loading utilities + error_loading_plans: Error loading plans + network_error: "Network error: " + provider: "Provider:" + license: "License:" + urdb_label: "URDB Label:" + retrieved: "Retrieved:" + view_on_openei: View on OpenEI + engine_reload_timeout: Engine reload timed out + no_running_sim: No running simulation + cancel: Cancel +``` + +- [ ] **Step 2: Verify YAML is valid** + +Run: `python -c "import yaml; yaml.safe_load(open('span_panel_simulator/translations/en.yaml'))" && echo "valid"` +Expected: `valid` + +- [ ] **Step 3: Run key parity test — it should now run but skip non-English (they have no dashboard section yet)** + +Run: `python -m pytest tests/test_translator.py::TestTranslationKeyParity -v` +Expected: Either PASS (non-English files have no dashboard section so their key set is empty, assertion fails) or indicates what needs to happen next. + +- [ ] **Step 4: Commit** + +```bash +git add span_panel_simulator/translations/en.yaml +git commit -m "Add complete English dashboard translation strings" +``` + +--- + +## Task 8: Non-English Translation Files + +**Files:** +- Modify: `span_panel_simulator/translations/nl.yaml` +- Modify: `span_panel_simulator/translations/de.yaml` +- Modify: `span_panel_simulator/translations/fr.yaml` +- Modify: `span_panel_simulator/translations/es.yaml` +- Modify: `span_panel_simulator/translations/pt-BR.yaml` + +- [ ] **Step 1: Add dashboard sections to all 5 non-English files** + +Each file gets a `dashboard:` section with the exact same key structure as `en.yaml`, with values translated into the target language. The full translations for each language should be appended after the existing `configuration:` section. + +Translate all keys accurately for each language. Use professional-grade translations — these are UI strings, not marketing copy, so prefer clear and concise phrasing. + +- [ ] **Step 2: Validate all YAML files** + +Run: `for f in span_panel_simulator/translations/*.yaml; do python -c "import yaml; yaml.safe_load(open('$f'))" && echo "$f: valid"; done` +Expected: All 6 files report valid. + +- [ ] **Step 3: Run key parity test** + +Run: `python -m pytest tests/test_translator.py::TestTranslationKeyParity -v` +Expected: PASS — all languages have the same dashboard keys as English. + +- [ ] **Step 4: Commit** + +```bash +git add span_panel_simulator/translations/ +git commit -m "Add dashboard translations for nl, de, fr, es, pt-BR" +``` + +--- + +## Task 9: Template — base.html and dashboard.html + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/base.html` +- Modify: `src/span_panel_simulator/dashboard/templates/dashboard.html` + +- [ ] **Step 1: Update base.html** + +Replace hardcoded strings with `{{ t('key') }}` calls. Add the i18n JS bridge in a ` +``` + +- [ ] **Step 2: Update dashboard.html** + +Replace getting-started strings: +- "Getting started" heading → `{{ t('getting_started.title') }}` +- Each instruction paragraph → `{{ t('getting_started.step_click') }}`, `{{ t('getting_started.step_clone') }}`, etc. + +- [ ] **Step 3: Verify the dashboard still renders** + +Run: `python -m pytest tests/ -x -q --timeout=30` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/span_panel_simulator/dashboard/templates/base.html \ + src/span_panel_simulator/dashboard/templates/dashboard.html +git commit -m "Translate base.html and dashboard.html to use i18n" +``` + +--- + +## Task 10: Template — runtime_controls.html + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/runtime_controls.html` + +- [ ] **Step 1: Replace HTML strings** + +- "Runtime Controls" → `{{ t('controls.title') }}` +- "Date" → `{{ t('controls.date') }}` +- "Time of Day" → `{{ t('controls.time_of_day') }}` +- "Speed" → `{{ t('controls.speed') }}` +- "Grid Online" / "Grid Offline" buttons → `{{ t('controls.grid_online') }}` / `{{ t('controls.grid_offline') }}` +- "Islandable" / "Not Islandable" → use `t()` calls +- "Runtime" / "Modeling" → use `t()` calls +- "Live Power Flows" → `{{ t('chart.live_power_flows') }}` +- Legend items "Grid", "Solar", "Battery" → `{{ t('chart.grid') }}`, etc. + +- [ ] **Step 2: Replace JavaScript strings** + +- Replace `MONTH_NAMES` array with `Intl.DateTimeFormat`: + +```javascript +function monthShort(monthIndex) { + return new Intl.DateTimeFormat(window.i18nLocale, { month: 'short' }) + .format(new Date(2024, monthIndex)); +} +``` + +- Replace hardcoded button text toggles in JS (e.g., `btn.textContent = 'Grid Offline'`) with `window.i18n['controls.grid_offline']` +- Replace tooltip `" W"` suffix with `window.i18n['chart.watts_suffix']` +- Replace SOC status text construction with i18n lookups + +- [ ] **Step 3: Verify rendering** + +Run: `python -m pytest tests/ -x -q --timeout=30` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/runtime_controls.html +git commit -m "Translate runtime_controls.html to use i18n" +``` + +--- + +## Task 11: Template — panel_config.html and sim_config.html + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/panel_config.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/sim_config.html` + +- [ ] **Step 1: Replace all hardcoded strings in panel_config.html** + +- "Panel Config" → `{{ t('panel_config.title') }}` +- All form labels (Serial, Tabs, Main Breaker, SOC Shed, Location, Lat, Lon) → `t()` calls +- Placeholder text → `t()` calls +- "Update" button → `{{ t('panel_config.update') }}` +- JS messages ("No results", "Fetching historical weather data...") → `window.i18n[...]` + +- [ ] **Step 2: Replace all hardcoded strings in sim_config.html** + +- "Simulation Config" → `{{ t('sim_config.title') }}` +- "Export", "Save & Reload" → `t()` calls +- "Interval (s):", "Noise:" → `t()` calls +- "Update" → `{{ t('sim_config.update') }}` + +- [ ] **Step 3: Verify and commit** + +Run: `python -m pytest tests/ -x -q --timeout=30` + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/panel_config.html \ + src/span_panel_simulator/dashboard/templates/partials/sim_config.html +git commit -m "Translate panel_config and sim_config templates to use i18n" +``` + +--- + +## Task 12: Template — Entity Templates + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/entity_list.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/entity_row.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/entity_edit.html` + +- [ ] **Step 1: Replace strings in entity_list.html** + +- "Entities (" heading → use `{{ t('entities.title').format(count=entities|length) }}` or split into prefix/suffix +- "+ Add Entity" → `{{ t('entities.add_entity') }}` +- Hint text → `{{ t('entities.clone_hint') }}` +- "Unmapped Tabs" → similar pattern +- JS hint strings → `window.i18n[...]` + +- [ ] **Step 2: Replace strings in entity_row.html** + +- Title attributes → `{{ t('entity_row.overlay_hint') }}`, etc. +- Badge text (SYN/REC) → `{{ t('entity_row.syn') }}`, etc. +- "Edit", "Del" buttons → `t()` calls + +- [ ] **Step 3: Replace strings in entity_edit.html** + +- All form labels (Name, Tabs, Priority, Relay Behavior, Breaker, etc.) → `t()` calls +- Fieldset legends (PV System, EVSE Charger, Energy Profile, etc.) → `t()` calls +- Select options (Grid-Tied, Hybrid, HVAC types) → `t()` calls +- Save/Cancel buttons → `t()` calls + +- [ ] **Step 4: Verify and commit** + +Run: `python -m pytest tests/ -x -q --timeout=30` + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/entity_list.html \ + src/span_panel_simulator/dashboard/templates/partials/entity_row.html \ + src/span_panel_simulator/dashboard/templates/partials/entity_edit.html +git commit -m "Translate entity templates to use i18n" +``` + +--- + +## Task 13: Template — Clone and Panel Templates + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/clone_panel.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/clone_confirm.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/running_panels.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/panels_list_rows.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/panel_source.html` + +- [ ] **Step 1: Replace strings in clone_panel.html** + +- "Clone from Panel" → `{{ t('clone_panel.title') }}` +- Hint text (HA / standalone) → `t()` calls +- Form labels, placeholders, button text → `t()` calls +- JS option text → `window.i18n[...]` + +- [ ] **Step 2: Replace strings in clone_confirm.html** + +- Dialog text and button labels → `t()` calls + +- [ ] **Step 3: Replace strings in running_panels.html** + +- "Panels" heading → `{{ t('panels.title') }}` +- "Import", "Overwrite", "Cancel" → `t()` calls +- "already exists." → `{{ t('panels.already_exists') }}` + +- [ ] **Step 4: Replace strings in panels_list_rows.html** + +- Badge text ("template", "viewing", "editing") → `t()` calls +- Button text and title attributes → `t()` calls +- JS messages (clone prompt, error messages, unsaved warning) → `window.i18n[...]` + +- [ ] **Step 5: Replace strings in panel_source.html** + +- "Source Panel" → `{{ t('panel_source.title') }}` +- "Cloned from", "last synced", "UTC" → `t()` calls +- "Update eBus Energy" → `{{ t('panel_source.update_ebus') }}` + +- [ ] **Step 6: Verify and commit** + +Run: `python -m pytest tests/ -x -q --timeout=30` + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/clone_panel.html \ + src/span_panel_simulator/dashboard/templates/partials/clone_confirm.html \ + src/span_panel_simulator/dashboard/templates/partials/running_panels.html \ + src/span_panel_simulator/dashboard/templates/partials/panels_list_rows.html \ + src/span_panel_simulator/dashboard/templates/partials/panel_source.html +git commit -m "Translate clone and panel management templates to use i18n" +``` + +--- + +## Task 14: Template — Profile and Schedule Templates + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/profile_editor.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/pv_profile.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/evse_schedule.html` + +- [ ] **Step 1: Replace strings in profile_editor.html** + +- "24-Hour Profile" → `{{ t('profile_editor.title') }}` +- "-- Select Preset --", "from", "to", "Apply" → `t()` calls +- "Active Days", "Save Profile" → `t()` calls + +- [ ] **Step 2: Replace strings in pv_profile.html** + +- "Solar Production Profile" → `{{ t('pv_profile.title') }}` +- "Monthly Weather Degradation" → `{{ t('pv_profile.weather_degradation') }}` +- No-weather hint text → `{{ t('pv_profile.no_weather') }}` +- Replace `MONTH_LABELS` and `MONTH_NAMES` arrays with `Intl.DateTimeFormat`: + +```javascript +const MONTH_LABELS = Array.from({length: 12}, (_, i) => + new Intl.DateTimeFormat(window.i18nLocale, { month: 'short' }).format(new Date(2024, i)) +); +const MONTH_NAMES = Array.from({length: 12}, (_, i) => + new Intl.DateTimeFormat(window.i18nLocale, { month: 'long' }).format(new Date(2024, i)) +); +``` + +- Chart labels and info text → `window.i18n[...]` + +- [ ] **Step 3: Replace strings in battery_profile_editor.html** + +- "Battery Schedule" → `{{ t('battery_schedule.title') }}` +- "Charge Mode" → `{{ t('battery_schedule.charge_mode') }}` +- Mode labels and hints → `t()` calls +- "Discharge Preset", "Active Days" → `t()` calls +- Hour labels (Idle, Chg, Dis) → `t()` calls +- "Save Schedule" → `{{ t('battery_schedule.save_schedule') }}` + +- [ ] **Step 4: Replace strings in evse_schedule.html** + +- "Charging Schedule" → `{{ t('evse_schedule.title') }}` +- All labels and buttons → `t()` calls + +- [ ] **Step 5: Verify and commit** + +Run: `python -m pytest tests/ -x -q --timeout=30` + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/profile_editor.html \ + src/span_panel_simulator/dashboard/templates/partials/pv_profile.html \ + src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html \ + src/span_panel_simulator/dashboard/templates/partials/evse_schedule.html +git commit -m "Translate profile and schedule templates to use i18n" +``` + +--- + +## Task 15: Template — modeling_view.html + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/modeling_view.html` + +- [ ] **Step 1: Replace HTML strings** + +This is the largest partial. Replace all hardcoded strings: +- "Modeling —" heading → `{{ t('modeling.title') }}` +- "Back to Runtime" → `{{ t('modeling.back_to_runtime') }}` +- "Horizon", select options (Last Month, etc.) → `t()` calls +- "Visible Range", "Loading modeling data..." → `t()` calls +- "Billing Data (Opower)", "Change" → `t()` calls +- "Select Electric Account" dialog → `t()` calls +- "Current Rate", "Proposed Rate" sections → `t()` calls +- "OpenEI Rate Plan" dialog → `t()` calls +- "Before" / "After" chart sections → `t()` calls +- Table headers (Energy, Cost, Difference, Savings) → `t()` calls +- Legend items (Grid, Solar, Battery) → `t()` calls + +- [ ] **Step 2: Replace JavaScript strings** + +- Error messages → `window.i18n['modeling.error_loading']` +- Tooltip suffixes → `window.i18n[...]` +- Y-axis title "Watts" → `window.i18n['chart.watts']` +- Cost/energy display text construction → `window.i18n[...]` +- Opower account display → `window.i18n[...]` +- Utility/rate plan select options → `window.i18n[...]` +- Attribution popup labels → `window.i18n[...]` +- Engine reload/error messages → `window.i18n[...]` + +- [ ] **Step 3: Verify and commit** + +Run: `python -m pytest tests/ -x -q --timeout=30` + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/modeling_view.html +git commit -m "Translate modeling_view.html to use i18n" +``` + +--- + +## Task 16: Full Integration Test + +**Files:** +- All previously modified files + +- [ ] **Step 1: Run full test suite** + +Run: `python -m pytest tests/ -v --timeout=60` +Expected: All tests pass. + +- [ ] **Step 2: Run type checker** + +Run: `mypy src/span_panel_simulator/dashboard/translator.py` +Expected: No errors. + +- [ ] **Step 3: Run linter** + +Run: `ruff check src/span_panel_simulator/dashboard/translator.py tests/test_translator.py` +Expected: No errors. + +- [ ] **Step 4: Run key parity test to validate all translations** + +Run: `python -m pytest tests/test_translator.py::TestTranslationKeyParity -v` +Expected: PASS — all 6 language files have identical dashboard key sets. + +- [ ] **Step 5: Manual smoke test** + +Start the simulator and verify the dashboard loads correctly in a browser. Check that: +- All strings render (no raw keys visible) +- Theme selector works +- Charts display with correct labels +- All buttons and form labels are translated + +- [ ] **Step 6: Commit any fixes** + +```bash +git add -u +git commit -m "Fix any issues found during integration testing" +``` diff --git a/docs/superpowers/plans/2026-04-02-bess-circuit-removal.md b/docs/superpowers/plans/2026-04-02-bess-circuit-removal.md new file mode 100644 index 0000000..648e434 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-bess-circuit-removal.md @@ -0,0 +1,597 @@ +# BESS Circuit Removal 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:** Remove the phantom battery circuit so BESS exists only as GFE on the upstream lugs, with state living exclusively in the energy system layer. + +**Architecture:** BESS configuration moves from a circuit template's `battery_behavior` dict to a top-level `bess` YAML section. The engine reads BESS config directly from there instead of scanning circuits. All circuit-proxy writeback logic, battery circuit detection helpers, and `feed_circuit_id` references are removed. The energy system's power resolution is unchanged. + +**Tech Stack:** Python 3.14, YAML configs, pytest + +--- + +### Task 1: Add `BESSConfigYAML` TypedDict and remove `BatteryBehavior` + +**Files:** +- Modify: `src/span_panel_simulator/config_types.py:99-120` + +- [ ] **Step 1: Replace `BatteryBehavior` with `BESSConfigYAML`** + +Replace the `BatteryBehavior` TypedDict (lines 99-120) with a new `BESSConfigYAML` TypedDict for the top-level YAML section: + +```python +class BESSConfigYAML(TypedDict, total=False): + """Top-level BESS configuration in the simulator YAML.""" + + enabled: bool + nameplate_capacity_kwh: float + max_charge_w: float + max_discharge_w: float + charge_efficiency: float + discharge_efficiency: float + backup_reserve_pct: float + charge_mode: Literal["self-consumption", "custom", "backup-only"] + charge_hours: list[int] + discharge_hours: list[int] +``` + +- [ ] **Step 2: Update any imports of `BatteryBehavior`** + +Search for imports of `BatteryBehavior` across the codebase and replace with `BESSConfigYAML` where needed, or remove the import if the consuming code is also being deleted (e.g., `_apply_battery_behavior` helpers). + +Run: `grep -rn "BatteryBehavior" src/` + +Remove or replace each occurrence. The helpers `_get_charge_power`, `_get_discharge_power`, `_get_idle_power`, `_get_solar_intensity_from_config` in engine.py use `BatteryBehavior` as a type hint — these methods are deleted in Task 4, so no replacement needed. + +- [ ] **Step 3: Run type checker** + +Run: `mypy src/span_panel_simulator/config_types.py` +Expected: PASS + +- [ ] **Step 4: Commit** + +``` +git add src/span_panel_simulator/config_types.py +git commit -m "Replace BatteryBehavior TypedDict with BESSConfigYAML" +``` + +--- + +### Task 2: Remove `feed_circuit_id` from energy layer + +**Files:** +- Modify: `src/span_panel_simulator/energy/types.py:94-112` +- Modify: `src/span_panel_simulator/energy/components.py:93-142` +- Modify: `src/span_panel_simulator/energy/system.py:73-90` +- Modify: `src/span_panel_simulator/models.py:71-85` + +- [ ] **Step 1: Remove `feed_circuit_id` from `BESSConfig`** + +In `src/span_panel_simulator/energy/types.py`, remove line 108: + +```python + feed_circuit_id: str = "" +``` + +- [ ] **Step 2: Remove `feed_circuit_id` from `BESSUnit.__init__`** + +In `src/span_panel_simulator/energy/components.py`, remove the `feed_circuit_id` parameter (line 109) and the assignment `self.feed_circuit_id = feed_circuit_id` (line 132). + +- [ ] **Step 3: Remove `feed_circuit_id` from `EnergySystem.from_config`** + +In `src/span_panel_simulator/energy/system.py`, remove line 85: + +```python + feed_circuit_id=bc.feed_circuit_id, +``` + +- [ ] **Step 4: Remove `feed_circuit_id` from `SpanBatterySnapshot`** + +In `src/span_panel_simulator/models.py`, remove line 85: + +```python + feed_circuit_id: str | None = None +``` + +- [ ] **Step 5: Run type checker** + +Run: `mypy src/span_panel_simulator/energy/ src/span_panel_simulator/models.py` +Expected: May show errors in engine.py and publisher.py (fixed in later tasks). Energy layer itself should be clean. + +- [ ] **Step 6: Commit** + +``` +git add src/span_panel_simulator/energy/types.py src/span_panel_simulator/energy/components.py src/span_panel_simulator/energy/system.py src/span_panel_simulator/models.py +git commit -m "Remove feed_circuit_id from energy layer and models" +``` + +--- + +### Task 3: Remove `feed` publishing from publisher + +**Files:** +- Modify: `src/span_panel_simulator/publisher.py:417-426` + +- [ ] **Step 1: Remove `feed_circuit_id` publishing from `_map_bess`** + +In `src/span_panel_simulator/publisher.py`, remove lines 423-424: + +```python + if bat.feed_circuit_id: + p[self._prop_topic(n, "feed")] = self._ensure_circuit_uuid(bat.feed_circuit_id) +``` + +- [ ] **Step 2: Run type checker** + +Run: `mypy src/span_panel_simulator/publisher.py` +Expected: PASS + +- [ ] **Step 3: Commit** + +``` +git add src/span_panel_simulator/publisher.py +git commit -m "Remove feed property publishing from BESS MQTT node" +``` + +--- + +### Task 4: Remove battery circuit logic from behavior engine + +**Files:** +- Modify: `src/span_panel_simulator/engine.py:100,147-150,156-168,271-279,461-512,514-544,546-560` +- Modify: `src/span_panel_simulator/behavior_mutable_state.py` + +This task removes all battery-circuit-specific logic from `RealisticBehaviorEngine`. The energy system (not the behavior engine) drives BESS power. + +- [ ] **Step 1: Remove `_last_battery_direction` from `__init__`** + +In `engine.py` line 100, remove: + +```python + self._last_battery_direction: str = "idle" +``` + +- [ ] **Step 2: Remove `last_battery_direction` property** + +Remove lines 147-150: + +```python + @property + def last_battery_direction(self) -> str: + """Most recent battery direction set by charge mode logic.""" + return self._last_battery_direction +``` + +- [ ] **Step 3: Remove `_last_battery_direction` from `capture_mutable_state` and `restore_mutable_state`** + +In `capture_mutable_state` (line 158), remove the `last_battery_direction` kwarg. +In `restore_mutable_state` (line 167), remove the `self._last_battery_direction = ...` line. + +Updated `capture_mutable_state`: + +```python + def capture_mutable_state(self) -> BehaviorEngineMutableState: + """Return a deep snapshot of tick-local mutable fields.""" + return BehaviorEngineMutableState( + circuit_cycle_states=copy.deepcopy(self._circuit_cycle_states), + grid_offline=self._grid_offline, + ) +``` + +Updated `restore_mutable_state`: + +```python + 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._grid_offline = state.grid_offline +``` + +- [ ] **Step 4: Remove `last_battery_direction` from `BehaviorEngineMutableState`** + +In `src/span_panel_simulator/behavior_mutable_state.py`, remove line 18: + +```python + last_battery_direction: str +``` + +- [ ] **Step 5: Remove `_apply_battery_behavior` call site** + +In `engine.py` lines 271-279, remove the battery behavior block: + +```python + # Apply battery behavior + battery_behavior = template.get("battery_behavior", {}) + if isinstance(battery_behavior, dict) and battery_behavior.get("enabled", False): + base_power = self._apply_battery_behavior( + base_power, + template, + current_time, + stochastic_noise=stochastic_noise, + ) +``` + +- [ ] **Step 6: Remove `_apply_battery_behavior` method and its helpers** + +Remove these methods entirely from `engine.py`: +- `_apply_battery_behavior` (lines 461-512) +- `_get_charge_power` (lines 514-518) +- `_get_discharge_power` (lines 520-524) +- `_get_idle_power` (lines 526-544) +- `_get_solar_intensity_from_config` (lines 546-560) + +Also remove the `_get_demand_factor_from_config` method if it exists (check the lines following `_get_solar_intensity_from_config`). + +- [ ] **Step 7: Run type checker** + +Run: `mypy src/span_panel_simulator/engine.py src/span_panel_simulator/behavior_mutable_state.py` +Expected: May show errors from later-task removals. Battery behavior methods should be cleanly gone. + +- [ ] **Step 8: Commit** + +``` +git add src/span_panel_simulator/engine.py src/span_panel_simulator/behavior_mutable_state.py +git commit -m "Remove battery behavior logic from behavior engine" +``` + +--- + +### Task 5: Refactor engine to read BESS config from top-level YAML + +**Files:** +- Modify: `src/span_panel_simulator/engine.py` — `_build_energy_system()` (lines 1741-1822) + +- [ ] **Step 1: Replace circuit-scanning BESS config with top-level config read** + +Replace the BESS config block in `_build_energy_system()` (lines 1776-1812) with a direct read from `self._config.get("bess", {})`: + +```python + bess_config: BESSConfig | None = None + bess_yaml = self._config.get("bess", {}) + if isinstance(bess_yaml, dict) and bess_yaml.get("enabled", False): + nameplate = float(bess_yaml.get("nameplate_capacity_kwh", 13.5)) + hybrid = pv_config is not None and pv_config.inverter_type == "hybrid" + charge_hours_raw: list[int] = bess_yaml.get("charge_hours", []) + discharge_hours_raw: list[int] = bess_yaml.get("discharge_hours", []) + panel_tz = ( + str(self._behavior_engine.panel_timezone) + if self._behavior_engine is not None + else RealisticBehaviorEngine._DEFAULT_TZ + ) + charge_mode = str(bess_yaml.get("charge_mode", "self-consumption")) + bess_config = BESSConfig( + nameplate_kwh=nameplate, + max_charge_w=abs(float(bess_yaml.get("max_charge_w", 3500.0))), + max_discharge_w=abs(float(bess_yaml.get("max_discharge_w", 3500.0))), + charge_efficiency=float(bess_yaml.get("charge_efficiency", 0.95)), + discharge_efficiency=float(bess_yaml.get("discharge_efficiency", 0.95)), + backup_reserve_pct=float(bess_yaml.get("backup_reserve_pct", 20.0)), + hybrid=hybrid, + 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"], + charge_hours=tuple(charge_hours_raw), + discharge_hours=tuple(discharge_hours_raw), + panel_timezone=panel_tz, + charge_mode=charge_mode, + ) +``` + +Note: field names in the YAML now match `BESSConfig` directly (`max_charge_w` not `max_charge_power`). + +- [ ] **Step 2: Run type checker** + +Run: `mypy src/span_panel_simulator/engine.py` +Expected: May still show errors from battery circuit references not yet removed (Task 6). + +- [ ] **Step 3: Commit** + +``` +git add src/span_panel_simulator/engine.py +git commit -m "Read BESS config from top-level YAML instead of circuit templates" +``` + +--- + +### Task 6: Remove battery circuit detection and writeback from engine + +**Files:** +- Modify: `src/span_panel_simulator/engine.py` + +- [ ] **Step 1: Remove `_find_battery_circuit` method** + +Delete lines 1733-1739 (the method and its docstring). After Task 5's rewrite of `_build_energy_system`, this is the earlier line-number block — verify exact location. + +- [ ] **Step 2: Remove `_is_battery_circuit` static method** + +Delete lines 1391-1395. + +- [ ] **Step 3: Remove battery circuit exclusion from `_collect_power_inputs`** + +In `_collect_power_inputs` (lines 1705-1731), remove the `_is_battery_circuit` branch. The loop becomes: + +```python + for circuit in self._circuits.values(): + power = circuit.instant_power_w + if circuit.energy_mode == "producer": + pv_power += power + else: + load_power += power +``` + +Update the docstring to remove the BESS exclusion note. + +- [ ] **Step 4: Remove battery circuit exclusion from `_powers_to_energy_inputs`** + +In `_powers_to_energy_inputs` (lines 1397-1424), remove the `_is_battery_circuit` branch. Same simplification: + +```python + for cid, power in circuit_powers.items(): + circuit = self._circuits[cid] + if circuit.energy_mode == "producer": + pv_power += power + else: + load_power += power +``` + +Update the docstring to remove the BESS exclusion note. + +- [ ] **Step 5: Remove battery circuit references from `get_snapshot`** + +In `get_snapshot()`: + +1. Remove line 1157: `battery_circuit = self._find_battery_circuit()` +2. Remove lines 1167-1169 (reflect battery power back to circuit): + ```python + 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 + ``` +3. Remove `feed_circuit_id` from the `SpanBatterySnapshot` constructor (line 1195): + ```python + feed_circuit_id=bess.feed_circuit_id, + ``` +4. Remove lines 1212-1226 (rebuild battery circuit snapshot block): + ```python + # Rebuild battery circuit snapshot — the original was captured + # before the BSEE update and off-grid deficit calculation, so it + # has stale power. Sync the circuit object then re-snapshot. + if battery_circuit is not None: + battery_circuit._instant_power_w = abs(power_flow_battery) + cid = battery_circuit.circuit_id + snap = battery_circuit.to_snapshot() + if cid in shed_ids: + snap = replace( + snap, + relay_state="OPEN", + relay_requester="BACKUP", + instant_power_w=0.0, + ) + circuit_snapshots[cid] = snap + ``` + +- [ ] **Step 6: Run type checker** + +Run: `mypy src/span_panel_simulator/engine.py` +Expected: PASS (all battery circuit references removed) + +- [ ] **Step 7: Commit** + +``` +git add src/span_panel_simulator/engine.py +git commit -m "Remove battery circuit detection and writeback from engine" +``` + +--- + +### Task 7: Migrate YAML configs to top-level `bess` section + +**Files:** +- Modify: `configs/MAIN_40.yaml` +- Modify: `configs/default_MAIN_40.yaml` +- Modify: `configs/default_MAIN_32.yaml` +- Modify: `configs/default_MAIN_16.yaml` + +For each config file, three changes: (a) add top-level `bess` section after `panel_config`, (b) remove the `battery`/`battery_storage` template from `circuit_templates`, (c) remove the `battery_storage` circuit entry from `circuits`. + +- [ ] **Step 1: Migrate `configs/MAIN_40.yaml`** + +Add after `panel_config` section (after line 6, before `circuit_templates`): + +```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] +``` + +Remove the `battery` template block (lines 519-565). + +Remove the `battery_storage` circuit entry (lines 821-823): +```yaml +- id: battery_storage + name: Battery Storage + template: battery +``` + +- [ ] **Step 2: Migrate `configs/default_MAIN_40.yaml`** + +Same pattern. Add `bess` section after `panel_config`. Remove `battery` template (lines 504-549). Remove `battery_storage` circuit (lines 802-804). + +- [ ] **Step 3: Migrate `configs/default_MAIN_32.yaml`** + +Same pattern. Template is named `battery_storage` here (lines 443-462). Circuit entry is `battery_storage_1` (lines 668-670). Remove both, add top-level `bess`. + +- [ ] **Step 4: Migrate `configs/default_MAIN_16.yaml`** + +Same pattern. Remove `battery` template (lines 55-74). Remove `battery_storage` circuit (lines 107-109). Add top-level `bess`. + +- [ ] **Step 5: Validate YAML** + +Run: `python -c "import yaml; [yaml.safe_load(open(f)) for f in ['configs/MAIN_40.yaml', 'configs/default_MAIN_40.yaml', 'configs/default_MAIN_32.yaml', 'configs/default_MAIN_16.yaml']]"` +Expected: No errors + +- [ ] **Step 6: Commit** + +``` +git add configs/ +git commit -m "Migrate BESS config from circuit templates to top-level bess section" +``` + +--- + +### Task 8: Refactor clone pipeline for top-level BESS config + +**Files:** +- Modify: `src/span_panel_simulator/clone.py:593-632` + +- [ ] **Step 1: Refactor `_enrich_bess_template` to write top-level `bess` section** + +Rename to `_build_bess_config` and change it to return a dict instead of mutating a template. It no longer needs `feed_map` or `templates` parameters since it's not enriching a circuit template. + +```python +def _build_bess_config( + properties: dict[str, str], + prefix: str, + bess_node_id: str, +) -> dict[str, object]: + """Build top-level bess config from scraped BESS node properties.""" + nameplate = _float_prop(properties, prefix, bess_node_id, "nameplate-capacity") + nameplate_kwh = nameplate if nameplate is not None else 13.5 + + return { + "enabled": True, + "charge_mode": "custom", + "nameplate_capacity_kwh": nameplate_kwh, + "backup_reserve_pct": 20.0, + "charge_efficiency": 0.95, + "discharge_efficiency": 0.95, + "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], + } +``` + +- [ ] **Step 2: Update the caller of `_enrich_bess_template`** + +Find where `_enrich_bess_template` is called in `clone.py` and update it to: +1. Call the renamed `_build_bess_config` function +2. Assign the returned dict to `config["bess"]` instead of mutating a template +3. Do NOT create a battery circuit entry in the circuits list + +- [ ] **Step 3: Verify that the cloned config no longer creates a battery circuit** + +The circuit that was formerly the battery circuit's feed target should remain as a normal circuit if it has other uses, or be removed if it only existed for battery purposes. In practice, the clone pipeline scrapes real circuits — the battery circuit was synthetic. The `feed` cross-reference is simply not used. + +- [ ] **Step 4: Run type checker** + +Run: `mypy src/span_panel_simulator/clone.py` +Expected: PASS + +- [ ] **Step 5: Commit** + +``` +git add src/span_panel_simulator/clone.py +git commit -m "Refactor clone pipeline to write top-level bess config" +``` + +--- + +### Task 9: Update tests + +**Files:** +- Modify: `tests/test_clone.py:200-214` +- Modify: `tests/test_modeling.py:140-170` +- Modify: `tests/test_energy/test_scenarios.py` (if `feed_circuit_id` is explicitly passed) + +- [ ] **Step 1: Update `test_bess_mode` in `test_clone.py`** + +The test currently asserts that a circuit template gets `battery_behavior`. Rewrite to assert the cloned config has a top-level `bess` section: + +```python + def test_bess_mode(self) -> None: + """Cloned panel with BESS node gets top-level bess config.""" + config = translate_scraped_panel(_make_scraped()) + bess = config.get("bess") + assert isinstance(bess, dict) + assert bess["enabled"] is True + assert bess["nameplate_capacity_kwh"] == 13.5 +``` + +- [ ] **Step 2: Update `test_modeling.py` fixture** + +Move the battery config from the circuit template to top-level. Replace lines 140-154 (the `battery` template and its `battery_behavior`) with a simple removal of the battery template and circuit. Add a top-level `bess` section to the YAML fixture: + +```yaml +bess: + enabled: true + charge_mode: "custom" + charge_hours: [10, 11, 12, 13, 14] + discharge_hours: [17, 18, 19, 20, 21] + nameplate_capacity_kwh: 13.5 + backup_reserve_pct: 20 + max_charge_w: 3500.0 + max_discharge_w: 3500.0 +``` + +Remove the `battery` template block and the `batt` circuit entry from the fixture's `circuits` list. + +- [ ] **Step 3: Check `test_scenarios.py` for `feed_circuit_id`** + +The `_bess()` helper in `tests/test_energy/test_scenarios.py` does NOT pass `feed_circuit_id` (it uses the default). No change needed — but verify after Task 2's removal that the `BESSConfig` constructor call still works without the field. + +- [ ] **Step 4: Run full test suite** + +Run: `pytest tests/ -v` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +``` +git add tests/ +git commit -m "Update tests for top-level BESS config" +``` + +--- + +### Task 10: Final verification and cleanup + +- [ ] **Step 1: Run full type check** + +Run: `mypy src/` +Expected: PASS with no battery-related errors + +- [ ] **Step 2: Run full test suite** + +Run: `pytest tests/ -v` +Expected: All tests pass + +- [ ] **Step 3: Search for orphaned references** + +Run: `grep -rn "battery_behavior\|feed_circuit_id\|_find_battery_circuit\|_is_battery_circuit\|_apply_battery_behavior\|BatteryBehavior" src/ tests/ configs/` + +Expected: No matches (or only in documentation/comments that should be cleaned up). + +- [ ] **Step 4: Verify no battery circuit in running simulation** + +Start the simulator with MAIN_40.yaml and confirm: +- No `battery_storage` circuit appears in the circuits list +- BESS snapshot still shows SOE, nameplate, vendor info +- Grid sensor reflects correct power flows + +- [ ] **Step 5: Commit any cleanup** + +``` +git add -A +git commit -m "Final cleanup: remove orphaned battery circuit references" +``` diff --git a/docs/superpowers/plans/2026-04-02-dashboard-bess-refactor.md b/docs/superpowers/plans/2026-04-02-dashboard-bess-refactor.md new file mode 100644 index 0000000..dfc06f0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-dashboard-bess-refactor.md @@ -0,0 +1,611 @@ +# Dashboard BESS Refactor 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:** Migrate the dashboard from managing BESS as a circuit entity to a dedicated panel-level card backed by the top-level `bess` YAML section. + +**Architecture:** BESS becomes a dedicated card between panel config and the entity list. ConfigStore reads/writes `self._state["bess"]` directly. All entity-based battery methods lose their `entity_id` parameter. Battery is no longer an entity type — it cannot be added, deleted, or listed alongside circuits. + +**Tech Stack:** Python 3.14, aiohttp, Jinja2, HTMX, pytest + +--- + +### Task 1: Remove battery from entity types and defaults + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/defaults.py:106-129` +- Modify: `src/span_panel_simulator/dashboard/routes.py:77-79` +- Modify: `src/span_panel_simulator/dashboard/config_store.py:54-63,296-299` + +- [ ] **Step 1: Remove `"battery"` from `ENTITY_TYPES` and `_SINGLETON_TYPES`** + +In `src/span_panel_simulator/dashboard/routes.py`, change line 77: + +```python +ENTITY_TYPES = ["circuit", "pv", "evse"] +``` + +And line 79: + +```python +_SINGLETON_TYPES = {"pv"} +``` + +- [ ] **Step 2: Remove battery defaults from `defaults.py`** + +In `src/span_panel_simulator/dashboard/defaults.py`, remove the entire `"battery"` block (lines 106-129): + +```python + "battery": { + "template": { + ... + }, + "circuit": {}, + }, +``` + +Also remove `"battery"` from `default_name_for_type` (line 139). + +- [ ] **Step 3: Remove `battery_behavior` from `_detect_entity_type`** + +In `src/span_panel_simulator/dashboard/config_store.py`, simplify `_detect_entity_type` (lines 54-63): + +```python +def _detect_entity_type(template: dict[str, Any]) -> str: + """Infer entity type from template fields.""" + device_type = template.get("device_type", "") + if device_type == "pv": + return "pv" + if device_type == "evse": + return "evse" + return "circuit" +``` + +- [ ] **Step 4: Remove battery from `list_entities` sort order** + +In `src/span_panel_simulator/dashboard/config_store.py` line 297, update the sort order: + +```python + _type_order = {"pv": 0, "evse": 1, "circuit": 2} +``` + +- [ ] **Step 5: Remove battery exclusion from `get_unmapped_tabs`** + +In `config_store.py` around line 434, the method excludes battery entities from tab counting. Since battery is no longer an entity type, simplify: + +```python + def get_unmapped_tabs(self) -> list[int]: + """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(): + used.update(circ.get("tabs", [])) + return sorted(t for t in range(1, total_tabs + 1) if t not in used) +``` + +- [ ] **Step 6: Run type checker and tests** + +Run: `mypy src/span_panel_simulator/dashboard/ && pytest tests/ -q` +Expected: May have errors from route handlers still referencing battery entity methods — that's OK, fixed in later tasks. + +- [ ] **Step 7: Commit** + +``` +git add src/span_panel_simulator/dashboard/defaults.py src/span_panel_simulator/dashboard/routes.py src/span_panel_simulator/dashboard/config_store.py +git commit -m "Remove battery from entity types, defaults, and detection" +``` + +--- + +### Task 2: Rewrite ConfigStore battery methods to use top-level `bess` + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/config_store.py` + +- [ ] **Step 1: Remove `battery_behavior` from `EntityView` and `_merge_entity`** + +In `EntityView` dataclass (line 46), remove: +```python + battery_behavior: dict[str, Any] | None = None +``` + +In `_merge_entity` (line 287), remove: +```python + battery_behavior=template.get("battery_behavior"), +``` + +- [ ] **Step 2: Remove battery keys handling from `update_entity`** + +In `update_entity` (lines 372-382), remove the `battery_keys` block: +```python + 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]) +``` + +Also remove the battery check from tabs handling (line 327): +```python + if "tabs" in data and _detect_entity_type(template) != "battery": +``` +Changes to: +```python + if "tabs" in data: +``` + +- [ ] **Step 3: Add `get_bess_config` and `update_bess_config` methods** + +Add after the panel config section (around line 140): + +```python + # -- 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 +``` + +- [ ] **Step 4: Rewrite battery charge mode methods** + +Replace existing methods (lines 645-667): + +```python + # -- Battery charge mode -- + + 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, mode: str) -> None: + """Set the BESS charge mode.""" + valid_modes = ("self-consumption", "custom", "backup-only") + if mode not in valid_modes: + raise ValueError(f"Invalid charge mode: {mode!r}") + bess = self._state.setdefault("bess", {"enabled": True}) + bess["charge_mode"] = mode + self._dirty = True +``` + +- [ ] **Step 5: Rewrite battery profile methods** + +Replace existing methods (lines 671-713): + +```python + # -- Battery profile -- + + 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): + if h in charge_hours: + profile[h] = "charge" + elif h in discharge_hours: + profile[h] = "discharge" + else: + profile[h] = "idle" + return profile + + 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, 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(hour_modes) + self._dirty = True + return hour_modes +``` + +- [ ] **Step 6: Rewrite battery branch in `get_active_days` / `update_active_days`** + +In `get_active_days` (lines 498-511), replace the battery branch: + +```python + 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 +``` + +The existing `get_active_days(entity_id)` and `update_active_days(entity_id, days)` methods keep their entity-based signatures but remove the battery branch — they now only handle circuits/EVSE via `time_of_day_profile`. + +- [ ] **Step 7: Update energy projection to read from `bess`** + +In the energy projection method (around line 844), replace the `entity.entity_type == "battery"` branch: + +```python + # 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)) +``` + +Move this outside the entity loop (before or after) since it reads from panel config, not entities. + +- [ ] **Step 8: Run type checker** + +Run: `mypy src/span_panel_simulator/dashboard/config_store.py` +Expected: PASS (routes may still fail — fixed in Task 3) + +- [ ] **Step 9: Commit** + +``` +git add src/span_panel_simulator/dashboard/config_store.py +git commit -m "Rewrite ConfigStore battery methods to use top-level bess config" +``` + +--- + +### Task 3: Create BESS card template and update routes + +**Files:** +- Create: `src/span_panel_simulator/dashboard/templates/partials/bess_card.html` +- Modify: `src/span_panel_simulator/dashboard/templates/dashboard.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/entity_edit.html` +- Modify: `src/span_panel_simulator/dashboard/templates/partials/battery_profile_editor.html` +- Modify: `src/span_panel_simulator/dashboard/routes.py` + +- [ ] **Step 1: Create `bess_card.html` partial** + +Create `src/span_panel_simulator/dashboard/templates/partials/bess_card.html`: + +```html +{% if bess_config.enabled is defined and bess_config.enabled %} +
+
+

Battery (GFE) UPSTREAM LUGS

+
+ + {% if bess_editing %} +
+
+ + + + +
+
+ + +
+
+ {% else %} +
+
+ {{ bess_config.nameplate_capacity_kwh | default(13.5) }} kWh + Reserve: {{ bess_config.backup_reserve_pct | default(20) }}% + Charge: {{ bess_config.max_charge_w | default(3500) }}W + Discharge: {{ bess_config.max_discharge_w | default(3500) }}W + Mode: {{ bess_config.charge_mode | default('self-consumption') }} +
+ {% if not readonly %} +
+ + +
+ {% endif %} +
+ {% endif %} +
+{% 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')] %} {% endfor %}
-
+ Schedule derived from rate plan — {{ tou_rate_month }}. + Dispatch adapts automatically each month. +

+ {% endif %} + +
{% for h in range(24) %} @@ -75,7 +82,8 @@

Discharge Preset

+ + + + +
+
+ + +
+
+ +
+ + {% include "partials/battery_profile_editor.html" %} +
+ + {% else %} +
+
+ {{ bess_config.nameplate_capacity_kwh | default(13.5) }} kWh + Reserve: {{ bess_config.backup_reserve_pct | default(20) }}% + Charge: {{ bess_config.max_charge_w | default(3500) }}W + Discharge: {{ bess_config.max_discharge_w | default(3500) }}W + Mode: {{ bess_config.charge_mode | default('self-consumption') }} +
+
+ {% endif %} +
+ +{% else %} +{# No BESS configured — show add button when editable #} +{% if not readonly %} +
+
+

Battery (GFE)

+ +
+

No battery configured. Add one to model BESS on upstream lugs.

+
+{% endif %} +{% endif %} diff --git a/src/span_panel_simulator/dashboard/templates/partials/entity_edit.html b/src/span_panel_simulator/dashboard/templates/partials/entity_edit.html index 1bbf607..2888672 100644 --- a/src/span_panel_simulator/dashboard/templates/partials/entity_edit.html +++ b/src/span_panel_simulator/dashboard/templates/partials/entity_edit.html @@ -132,35 +132,6 @@

Editing: {{ e.name }}

{% endif %} - {% if e.battery_behavior %} -
- Battery Behavior -
- - - - -
-
- {% endif %} -