|
18 | 18 | """ |
19 | 19 |
|
20 | 20 | from datetime import timedelta |
21 | | -from const import PREDICT_STEP, PV_SCENARIO_PV10, PV_SCENARIO_PV90, RUN_EVERY, TIME_FORMAT, EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE, EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE |
| 21 | +from const import PREDICT_STEP, PV_SCENARIO_PV10, PV_SCENARIO_PV90, RUN_EVERY, TIME_FORMAT, EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE, EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, MINUTE_WATT |
22 | 22 |
|
23 | 23 | from utils import ( |
24 | 24 | remove_intersecting_windows, |
@@ -172,6 +172,8 @@ def __init__( |
172 | 172 | self.inverter_hybrid = base.inverter_hybrid |
173 | 173 | self.inverter_limit = base.inverter_limit |
174 | 174 | self.export_limit = base.export_limit |
| 175 | + # Diagnostic only, in W. It does not modify dispatch, costs or candidate selection. |
| 176 | + self.import_limit = getattr(base, "import_limit", 0.0) |
175 | 177 | self.pv_ac_limit = base.pv_ac_limit |
176 | 178 | self.battery_rate_min = base.battery_rate_min |
177 | 179 | self.battery_rate_max_charge = base.battery_rate_max_charge |
@@ -520,6 +522,10 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi |
520 | 522 | if save and self.pending_batch: |
521 | 523 | self.flush_batch() |
522 | 524 |
|
| 525 | + # Only saved Python runs report this diagnostic. Clear it even on a cache/kernel return |
| 526 | + # so a later trial cannot appear to have inherited a successful check of a different plan. |
| 527 | + self.import_limit_diagnostics = None |
| 528 | + |
523 | 529 | # The cache key is only wanted when the cache is actually in play - a saving run always |
524 | 530 | # simulates - so it is not computed otherwise. Building it as one tuple hash keeps the |
525 | 531 | # per-window hashing in C rather than looping in Python, which matters because this runs on |
@@ -578,6 +584,19 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi |
578 | 584 | predict_pv_power = {} |
579 | 585 | predict_state = {} |
580 | 586 | predict_grid_power = {} |
| 587 | + import_diagnostics = None |
| 588 | + if save and self.import_limit > 0: |
| 589 | + import_diagnostics = { |
| 590 | + "limit_w": self.import_limit, |
| 591 | + "peak_w": 0.0, |
| 592 | + "excess_kwh": 0.0, |
| 593 | + "minutes_over": 0, |
| 594 | + "first_exceeded": None, |
| 595 | + "step_minutes": step, |
| 596 | + "checked_minutes": 0, |
| 597 | + "start": (self.midnight_utc + timedelta(minutes=self.minutes_now)).strftime(TIME_FORMAT), |
| 598 | + "end": (self.midnight_utc + timedelta(minutes=self.minutes_now + self.forecast_minutes)).strftime(TIME_FORMAT), |
| 599 | + } |
581 | 600 | predict_load_power = {} |
582 | 601 | predict_iboost = {} |
583 | 602 | predict_carbon_g = {} |
@@ -1361,6 +1380,21 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi |
1361 | 1380 | # The car in a dispatch that has gone away is met by the grid (or PV surplus), never the battery |
1362 | 1381 | diff += car_gone_kwh |
1363 | 1382 |
|
| 1383 | + # Observe the raw whole-site AC balance, before presentation rounding. Do not clamp |
| 1384 | + # grid demand, invent battery discharge, or change a candidate's cost. This covers the |
| 1385 | + # full displayed forecast, including steps after the optimiser's end_record boundary. |
| 1386 | + if import_diagnostics is not None: |
| 1387 | + # A modelled car outside the house CT is absent from diff; it still shares the |
| 1388 | + # grid connection. Net it against any surplus, without counting metered cars twice. |
| 1389 | + import_w = max(diff + car_load_energy_bypass, 0.0) * MINUTE_WATT / step |
| 1390 | + import_diagnostics["peak_w"] = max(import_diagnostics["peak_w"], import_w) |
| 1391 | + import_diagnostics["checked_minutes"] += step |
| 1392 | + if import_w - self.import_limit > 1e-6: |
| 1393 | + import_diagnostics["excess_kwh"] += (import_w - self.import_limit) * step / MINUTE_WATT |
| 1394 | + import_diagnostics["minutes_over"] += step |
| 1395 | + if import_diagnostics["first_exceeded"] is None: |
| 1396 | + import_diagnostics["first_exceeded"] = stamp |
| 1397 | + |
1364 | 1398 | # Metric keep - pretend the battery is empty and you have to import instead of using the battery |
1365 | 1399 | if best_soc_keep > 0 and soc <= best_soc_keep: |
1366 | 1400 | metric_keep += (best_soc_keep - soc) * import_rate * keep_minute_scaling * step / 60.0 |
@@ -1474,6 +1508,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi |
1474 | 1508 | minute += step |
1475 | 1509 |
|
1476 | 1510 | hours_left = minute_left / 60.0 |
| 1511 | + self.import_limit_diagnostics = import_diagnostics |
1477 | 1512 | if self.debug_enable or save: |
1478 | 1513 | self.hours_left = hours_left |
1479 | 1514 | self.final_car_soc = final_car_soc |
|
0 commit comments