Skip to content

Commit d18231c

Browse files
authored
Merge pull request #233 from traktore-org/develop
Release v1.5.8: Calculation audit fixes and quality assurance
2 parents 5293f06 + 63037da commit d18231c

14 files changed

Lines changed: 3418 additions & 25 deletions

.gitignore

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,21 @@ Thumbs.db
7878
.claude/
7979
CLAUDE.md
8080

81+
# Ruflo / Agent databases (local only, not for distribution)
82+
ruvector.db
83+
agentdb.rvf
84+
agentdb.rvf.lock
85+
.claude-flow/
86+
dashboard/card/ruvector.db
87+
dashboard/card/.claude-flow/
88+
89+
# GStack browser data
90+
.gstack/
91+
92+
# Temporary image assets (not for distribution)
93+
dashboard/card/sem-solar-diagram-bg*.png
94+
dashboard/card/sem-system-diagram-v2-card.js
95+
8196
# Logo source assets (large files, only sized outputs in root)
8297
logo/
8398

coordinator/coordinator.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
HeatPumpSensorData, PVAnalyticsData, EnergyAssistantSensorData,
4545
UtilitySignalSensorData, SessionData, BatterySessionData,
4646
)
47+
from .health_check import HealthCheck
4748
from .sensor_reader import SensorReader
4849
from .energy_calculator import EnergyCalculator
4950
from .flow_calculator import FlowCalculator
@@ -138,6 +139,7 @@ def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
138139
custom_entities=None, # Was config.get("forecast_entities") — never set via UI
139140
)
140141
self._forecast_tracker = ForecastTracker()
142+
self._forecast_tracker.set_hass(hass)
141143

142144
# Phase 1: Tariff provider
143145
tariff_mode = config.get("tariff_mode", "static")
@@ -210,6 +212,9 @@ def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
210212
self._ev_taper_detector = EVTaperDetector(config) # Primary charger
211213
self._ev_taper_detectors: Dict[str, EVTaperDetector] = {} # Per-charger (#112)
212214

215+
# Calculation integrity checker (runs every cycle)
216+
self._health_check = HealthCheck()
217+
213218
# Hourly activity tracker for schedule card (#63)
214219
self._today_surplus_hours: list = [False] * 24
215220
self._today_ev_hours: list = [False] * 24
@@ -898,6 +903,15 @@ async def _async_update_data(self) -> Dict[str, Any]:
898903
last_update=dt_util.now(),
899904
)
900905

906+
# Step 11.5: Energy balance / calculation health check
907+
self._health_check.run_all_checks(
908+
power,
909+
flows=power_flows,
910+
autarky=performance.autarky_rate,
911+
self_consumption=performance.self_consumption_rate,
912+
costs=costs,
913+
)
914+
901915
# Step 12: Notifications (extracted for readability, #29)
902916
await self._send_notifications(
903917
charging_state, power, energy, costs, performance,
@@ -1024,6 +1038,7 @@ async def _async_update_data(self) -> Dict[str, Any]:
10241038
result["diag_observer_mode"] = self._observer_mode
10251039
unavail_count = sum(1 for eid in self._sensor_reader._sensor_unavailable)
10261040
result["diag_sensors_unavailable"] = unavail_count
1041+
result["diag_health_violations"] = self._health_check.total_violations
10271042

10281043
# Tariff schedule for dashboard card (#25)
10291044
if hasattr(self._tariff_provider, 'get_schedule_for_day'):
@@ -2030,12 +2045,14 @@ def _update_battery_session_tracking(
20302045
session.grid_energy_kwh += grid_increment
20312046
if session.energy_kwh > 0:
20322047
session.solar_share_pct = (session.solar_energy_kwh / session.energy_kwh) * 100
2033-
import_rate = self.config.get("electricity_import_rate", 0.30)
2048+
# Use live dynamic tariff rate instead of static config value (#223)
2049+
import_rate = self._energy_calculator._import_rate
20342050
session.cost += grid_increment * import_rate
20352051
else: # discharge
20362052
discharge_increment = (power.battery_discharge_power * hours) / 1000.0
20372053
session.energy_kwh += discharge_increment
2038-
import_rate = self.config.get("electricity_import_rate", 0.30)
2054+
# Use live dynamic tariff rate instead of static config value (#223)
2055+
import_rate = self._energy_calculator._import_rate
20392056
session.savings += discharge_increment * import_rate
20402057

20412058
# Update duration and average power

coordinator/energy_calculator.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,17 @@ def calculate_energy(self, power: PowerReadings) -> EnergyTotals:
171171
energy.monthly_battery_discharge = self._get_monthly("battery_discharge", month_key)
172172
energy.yearly_battery_discharge = self._get_yearly("battery_discharge", year_key)
173173

174-
# Self-consumption savings (incremental, rate-weighted for dynamic tariff accuracy)
174+
# Solar self-consumption savings (incremental, rate-weighted for dynamic tariff accuracy)
175+
# Only tracks savings from solar — battery discharge savings are in cost_batt_savings.
176+
# Subtracting discharge_incr prevents double-counting: battery discharge is not solar.
175177
home_incr = (power.home_consumption_power * interval_hours) / 1000 if power.home_consumption_power >= MIN_POWER_THRESHOLD else 0.0
176178
ev_incr = (power.ev_power * interval_hours) / 1000 if power.ev_power >= MIN_POWER_THRESHOLD else 0.0
177179
import_incr = (power.grid_import_power * interval_hours) / 1000 if power.grid_import_power >= MIN_POWER_THRESHOLD else 0.0
178-
savings_incr = max(0.0, (home_incr + ev_incr) - import_incr)
179-
if savings_incr > 0.0:
180-
self._accumulate_cost("cost_savings", today, month_key, year_key, savings_incr * self._import_rate)
180+
discharge_incr = (power.battery_discharge_power * interval_hours) / 1000 if power.battery_discharge_power >= MIN_POWER_THRESHOLD else 0.0
181+
# Solar self-consumed = total consumption minus what came from grid or battery discharge
182+
solar_self_consumed = max(0.0, (home_incr + ev_incr) - import_incr - discharge_incr)
183+
if solar_self_consumed > 0.0:
184+
self._accumulate_cost("cost_savings", today, month_key, year_key, solar_self_consumed * self._import_rate)
181185

182186
# Sanity checks — warn and cap if values exceed physical limits
183187
battery_capacity = self.config.get("battery_capacity_kwh", 15)
@@ -603,6 +607,7 @@ def calculate_costs(self, energy: EnergyTotals) -> CostData:
603607
costs.monthly_export_revenue = self._get_monthly_cost("cost_export", month_key)
604608
costs.monthly_net_cost = round(costs.monthly_costs - costs.monthly_export_revenue, 2)
605609
costs.monthly_savings = max(0, self._get_monthly_cost("cost_savings", month_key))
610+
costs.monthly_battery_savings = self._get_monthly_cost("cost_batt_savings", month_key)
606611

607612
# Yearly calculations
608613
costs.yearly_costs = self._get_yearly_cost("cost_import", year_key)
@@ -817,11 +822,13 @@ def _snapshot_daily_costs(self, today: date) -> None:
817822
self._accumulated_self_consumed_kwh += daily_self_consumed
818823
self._accumulated_export_kwh += daily_grid_export
819824

820-
# Store today's rate for averaging
825+
# Store today's rate for averaging; also record whether snapshot had meaningful data
826+
# (used by _check_rollover to detect trivial snapshots that should be retried)
821827
self._rate_history.append({
822828
"date": today_str,
823829
"import_rate": self._import_rate,
824830
"export_rate": self._export_rate,
831+
"energy_kwh": round(daily_solar + daily_grid_import, 4),
825832
})
826833

827834
def _check_rollover(self, today: date, month_key: str, year_key: str = None) -> None:
@@ -838,9 +845,13 @@ def _check_rollover(self, today: date, month_key: str, year_key: str = None) ->
838845
k.endswith(yesterday_str) and not k.startswith("ev_daily_sun")
839846
for k in self._daily_accumulators
840847
)
841-
already_snapshotted = any(
842-
r.get("date") == yesterday_str for r in self._rate_history
848+
# A prior snapshot is only considered valid if it captured non-zero energy.
849+
# If the system restarted around midnight and snapshotted before real data
850+
# accumulated, allow a re-snapshot when meaningful data is now present. (#225)
851+
prior_snapshot = next(
852+
(r for r in self._rate_history if r.get("date") == yesterday_str), None
843853
)
854+
already_snapshotted = prior_snapshot is not None and prior_snapshot.get("energy_kwh", 1.0) > 0
844855
if has_yesterday_data and not already_snapshotted:
845856
self._snapshot_daily_costs(yesterday)
846857

coordinator/ev_control.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,11 @@ async def _execute_ev_control(
152152
watts_per_amp = power.ev_power / max(1, ev._current_setpoint)
153153

154154
peak_limit_w = self._get_peak_limit_w()
155-
headroom_w = peak_limit_w - power.grid_import_power
156-
target = headroom_w / max(1, watts_per_amp)
155+
# Subtract EV's own draw: grid_import includes EV, so
156+
# non_ev_grid = home + other loads (what the grid would
157+
# import if the EV were off)
158+
non_ev_grid = power.grid_import_power - power.ev_power
159+
target = (peak_limit_w - non_ev_grid) / max(1, watts_per_amp)
157160

158161
# Clamp: configurable min current, max from charger config
159162
target = min(ev.max_current, max(min_amps, round(target)))

coordinator/ev_taper_detector.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,11 +567,21 @@ async def async_seed_from_history(
567567
_LOGGER.debug("Could not read EV history from recorder: %s", e)
568568
return None
569569

570+
# Detect sensor unit to apply correct scale factor.
571+
# Some EV power sensors report in W (e.g. KEBA actual_power),
572+
# others report in kW. Thresholds below assume kW, so W values need /1000.
573+
entity = hass.states.get(ev_power_entity)
574+
is_watts = (
575+
entity is not None
576+
and entity.attributes.get("unit_of_measurement", "").strip().lower() == "w"
577+
)
578+
scale = 0.001 if is_watts else 1.0 # convert W → kW if needed
579+
570580
# Parse into (timestamp, power_kw) pairs
571581
readings = []
572582
for state in states:
573583
try:
574-
val = float(state.state)
584+
val = float(state.state) * scale
575585
readings.append((state.last_changed, val))
576586
except (ValueError, TypeError):
577587
continue

coordinator/flow_calculator.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,18 @@ def calculate_ev_budget(self, power: PowerReadings,
172172
1. Grid export (power going unused to grid)
173173
2. Redirectable battery charge (slow battery charging to free power for EV)
174174
3. Active battery discharge (handled separately in coordinator for battery-assist mode)
175+
176+
Semantics: the returned value is the SETPOINT sent to the charger (total watts
177+
the charger is allowed to draw), NOT an increment on top of current consumption.
178+
When the EV is already charging at ev_power W and grid_export_power W is going to
179+
the grid unused, the new setpoint is ev_power + grid_export_power — this tells the
180+
charger to absorb all the surplus. The charger adjusts its draw from ev_power to
181+
the new setpoint, naturally consuming the exported surplus without importing. (#229)
175182
"""
176183
# Source 1: Grid export — always redirectable
177184
if power.ev_power > 0:
185+
# EV already charging: new setpoint = current draw + unused grid export
186+
# This is a SETPOINT (target total watts), not a delta to add to current draw.
178187
base = power.ev_power + power.grid_export_power
179188
else:
180189
base = power.grid_export_power
@@ -203,9 +212,11 @@ def _calculate_battery_redirect(self, battery_charge_w: float,
203212
if forecast_remaining_kwh > 0:
204213
# Forecast available: redirect proportional to excess forecast
205214
if forecast_remaining_kwh >= battery_need_kwh and battery_need_kwh > 0:
206-
# Forecast covers battery — redirect proportionally
215+
# Forecast covers battery — redirect proportionally.
216+
# Use max(0.05, ratio) to always redirect at least 5% at the
217+
# exact boundary (forecast == battery_need gives ratio=0 without this).
207218
ratio = min(1.0, 1.0 - battery_need_kwh / forecast_remaining_kwh)
208-
return battery_charge_w * ratio
219+
return battery_charge_w * max(0.05, ratio)
209220
elif battery_need_kwh <= 0.5:
210221
# Battery nearly full — redirect all
211222
return battery_charge_w
@@ -221,19 +232,24 @@ def _calculate_battery_redirect(self, battery_charge_w: float,
221232
def calculate_available_power(self, power: PowerReadings) -> float:
222233
"""Calculate power available for EV charging.
223234
224-
Available = Solar - Home - Battery Charge
225-
Grid export is already a consequence of this surplus, not additive.
235+
Available = Solar - Home - Battery Charge + Battery Discharge (when assisting)
236+
Grid export is already a consequence of surplus, not additive.
237+
Battery discharge is included when the battery is actively discharging
238+
to assist loads — that power is available for the EV as well.
226239
"""
227240
excess = (
228241
power.solar_power
229242
- power.home_consumption_power
230243
- power.battery_charge_power
231244
)
245+
available = max(0, excess)
232246

233-
# Don't report more than solar production
234-
available = min(power.solar_power, max(0, excess))
247+
# Include battery discharge as available when battery is actively discharging
248+
if power.battery_discharge_power > 0:
249+
available += power.battery_discharge_power
235250

236-
return round(available, 0)
251+
# Cap at solar + battery discharge (can't report more than combined sources)
252+
return round(min(available, power.solar_power + power.battery_discharge_power), 0)
237253

238254
def calculate_charging_current(
239255
self, available_power: float, voltage: float = 230, phases: int = 3

coordinator/forecast_tracker.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,38 @@ def __init__(self):
6262
self._today_date: Optional[str] = None
6363
self._correction_factor: float = 1.0
6464
self._weather_today: str = "unknown"
65+
self._hass: Any = None
66+
67+
def set_hass(self, hass: Any) -> None:
68+
"""Set Home Assistant instance for reading sun.sun entity."""
69+
self._hass = hass
70+
71+
def _get_sun_hours(self) -> tuple[float, float]:
72+
"""Get sunrise/sunset hours from sun.sun entity, fallback to defaults.
73+
74+
Returns (sunrise_hour, sunset_hour) as fractional hours (e.g. 6.5 = 6:30).
75+
Falls back to module-level constants when sun.sun is unavailable (polar regions
76+
or HA startup). This prevents hardcoded 6/20 from being wrong in polar areas.
77+
"""
78+
try:
79+
if self._hass is not None:
80+
sun = self._hass.states.get("sun.sun")
81+
if sun and sun.attributes:
82+
rise_str = sun.attributes.get("next_rising", "")
83+
sett_str = sun.attributes.get("next_setting", "")
84+
if rise_str and sett_str:
85+
rise = datetime.fromisoformat(rise_str.replace("Z", "+00:00"))
86+
sett = datetime.fromisoformat(sett_str.replace("Z", "+00:00"))
87+
# Convert to local time hours
88+
local_rise = rise.astimezone(dt_util.DEFAULT_TIME_ZONE)
89+
local_sett = sett.astimezone(dt_util.DEFAULT_TIME_ZONE)
90+
return (
91+
local_rise.hour + local_rise.minute / 60.0,
92+
local_sett.hour + local_sett.minute / 60.0,
93+
)
94+
except Exception:
95+
pass
96+
return float(SUNRISE_HOUR), float(SUNSET_HOUR)
6597

6698
def update(
6799
self,
@@ -288,23 +320,46 @@ def _solar_curve_fraction(solar_hours: float) -> float:
288320
# = (1 - cos(π·t/T)) / 2
289321
return (1 - math.cos(math.pi * solar_hours / DAYLIGHT_HOURS)) / 2
290322

323+
@staticmethod
324+
def _solar_curve_fraction_dynamic(solar_hours: float, daylight_hours: float) -> float:
325+
"""Expected fraction of daily solar using the actual day length.
326+
327+
Same sine-curve model as _solar_curve_fraction but uses the real
328+
daylight_hours derived from sun.sun, so the curve is correct in
329+
polar regions where day length differs significantly from 14h.
330+
"""
331+
import math
332+
if solar_hours <= 0:
333+
return 0.0
334+
if solar_hours >= daylight_hours:
335+
return 1.0
336+
return (1 - math.cos(math.pi * solar_hours / daylight_hours)) / 2
337+
291338
def _calculate_dampening_factor(self) -> float:
292-
"""Calculate the dampening factor from live data + history."""
339+
"""Calculate the dampening factor from live data + history.
340+
341+
Uses actual sunrise/sunset from sun.sun entity so the daylight window
342+
is correct in polar regions. Falls back to hardcoded defaults when
343+
sun.sun is unavailable (HA startup, polar night).
344+
"""
293345
now = dt_util.now()
294346
hour = now.hour + now.minute / 60.0
295347

348+
sunrise_hour, sunset_hour = self._get_sun_hours()
349+
daylight_hours = max(1.0, sunset_hour - sunrise_hour)
350+
296351
# Outside daylight hours: use historical correction only
297-
if hour < SUNRISE_HOUR or hour > SUNSET_HOUR + 1:
352+
if hour < sunrise_hour or hour > sunset_hour + 1:
298353
return self._correction_factor
299354

300355
if self._today_forecast < MIN_FORECAST_KWH:
301356
return self._correction_factor
302357

303358
# How many solar hours have elapsed
304-
solar_hours = max(0, hour - SUNRISE_HOUR)
359+
solar_hours = max(0, hour - sunrise_hour)
305360

306361
# Expected fraction using sine-curve model (not linear)
307-
expected_fraction = self._solar_curve_fraction(solar_hours)
362+
expected_fraction = self._solar_curve_fraction_dynamic(solar_hours, daylight_hours)
308363
expected_so_far = self._today_forecast * expected_fraction
309364

310365
# Too early to judge — not enough expected production yet

0 commit comments

Comments
 (0)