warmcast 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. warmcast/__init__.py +0 -0
  2. warmcast/core/__init__.py +156 -0
  3. warmcast/core/backtest.py +112 -0
  4. warmcast/core/engine.py +102 -0
  5. warmcast/core/house.py +348 -0
  6. warmcast/core/learning.py +473 -0
  7. warmcast/core/model.py +578 -0
  8. warmcast/core/ports.py +26 -0
  9. warmcast/core/scoring.py +158 -0
  10. warmcast/core/settings.py +42 -0
  11. warmcast/core/signals.py +607 -0
  12. warmcast/core/sun.py +53 -0
  13. warmcast/ha_connector/__init__.py +2 -0
  14. warmcast/ha_connector/app.py +160 -0
  15. warmcast/ha_connector/binding.py +133 -0
  16. warmcast/ha_connector/client.py +113 -0
  17. warmcast/ha_connector/nightly.py +73 -0
  18. warmcast/ha_connector/reading_log.py +73 -0
  19. warmcast/ha_connector/sampler.py +49 -0
  20. warmcast/offline/__init__.py +0 -0
  21. warmcast/offline/cli.py +433 -0
  22. warmcast/offline/csv_sources.py +103 -0
  23. warmcast/offline/signal_map.py +108 -0
  24. warmcast/storage/__init__.py +6 -0
  25. warmcast/storage/json_store.py +101 -0
  26. warmcast/storage/refit_record.py +47 -0
  27. warmcast/web/__init__.py +9 -0
  28. warmcast/web/dashboard.py +186 -0
  29. warmcast/web/log_view.py +89 -0
  30. warmcast/web/server.py +84 -0
  31. warmcast/web/static/app.css +245 -0
  32. warmcast/web/static/app.js +320 -0
  33. warmcast/web/static/chart.umd.min.js +1 -0
  34. warmcast/web/static/charts.html +57 -0
  35. warmcast/web/static/guide.html +92 -0
  36. warmcast/web/static/index.html +33 -0
  37. warmcast/web/static/plan.html +41 -0
  38. warmcast/web/static/settings.html +28 -0
  39. warmcast-0.1.0.dist-info/METADATA +11 -0
  40. warmcast-0.1.0.dist-info/RECORD +42 -0
  41. warmcast-0.1.0.dist-info/WHEEL +4 -0
  42. warmcast-0.1.0.dist-info/entry_points.txt +3 -0
warmcast/__init__.py ADDED
File without changes
@@ -0,0 +1,156 @@
1
+ """Warmcast core engine. Callers import from here, never from the submodules."""
2
+
3
+ from .backtest import GATE2_DAYS, GATE2_MAE_C, NightlyFit, Scorecard, ZoneSummary, walk_forward
4
+ from .engine import Engine
5
+ from .house import (
6
+ EMITTER_PRIORS,
7
+ INTERNAL_GAIN_KW,
8
+ THERMOSTAT_SUN_C_PER_KW_PV,
9
+ ComfortBand,
10
+ Emitter,
11
+ EmitterPriors,
12
+ House,
13
+ HouseConfigError,
14
+ OffPeriod,
15
+ Prior,
16
+ SolarGain,
17
+ Zone,
18
+ ZoneId,
19
+ ZoneSensor,
20
+ parse_house,
21
+ )
22
+ from .learning import (
23
+ Accepted,
24
+ FitOutcome,
25
+ KeptPrevious,
26
+ NotEnoughData,
27
+ Scores,
28
+ ShapeChoice,
29
+ estimate_dhw_baseline,
30
+ learn,
31
+ )
32
+ from .model import (
33
+ CODE_VERSION,
34
+ AirAndFabric,
35
+ AirOnly,
36
+ DiscreteZone,
37
+ Drivers,
38
+ Estimate,
39
+ FitWindow,
40
+ HeatShare,
41
+ ThermalModel,
42
+ Trajectory,
43
+ ZoneShape,
44
+ ZoneState,
45
+ describe_thermostats,
46
+ discretise,
47
+ drivers_from_segment,
48
+ estimate_state,
49
+ prior_model,
50
+ prior_shape,
51
+ share_heat,
52
+ simulate,
53
+ solar_gain_kw,
54
+ thermostat_offset_c,
55
+ )
56
+ from .ports import HistorySource, ModelStore
57
+ from .scoring import PvForecast, ZoneDayScore, nightly_cutoff, scorable_days, score_day
58
+ from .settings import LearnSettings
59
+ from .signals import (
60
+ Aggregation,
61
+ DataReport,
62
+ Feed,
63
+ FeedCoverage,
64
+ HeatSource,
65
+ History,
66
+ HourlyStats,
67
+ RawSeries,
68
+ RawValue,
69
+ Samples,
70
+ Segment,
71
+ Signal,
72
+ ZoneSeries,
73
+ build_history,
74
+ house_feeds,
75
+ )
76
+ from .sun import morning_weight, sun_position
77
+
78
+ __all__ = [
79
+ "CODE_VERSION",
80
+ "EMITTER_PRIORS",
81
+ "GATE2_DAYS",
82
+ "GATE2_MAE_C",
83
+ "INTERNAL_GAIN_KW",
84
+ "THERMOSTAT_SUN_C_PER_KW_PV",
85
+ "Accepted",
86
+ "Aggregation",
87
+ "AirAndFabric",
88
+ "AirOnly",
89
+ "ComfortBand",
90
+ "DataReport",
91
+ "DiscreteZone",
92
+ "Drivers",
93
+ "Emitter",
94
+ "EmitterPriors",
95
+ "Engine",
96
+ "Estimate",
97
+ "Feed",
98
+ "FeedCoverage",
99
+ "FitOutcome",
100
+ "FitWindow",
101
+ "HeatShare",
102
+ "HeatSource",
103
+ "History",
104
+ "HistorySource",
105
+ "HourlyStats",
106
+ "House",
107
+ "HouseConfigError",
108
+ "KeptPrevious",
109
+ "LearnSettings",
110
+ "ModelStore",
111
+ "NightlyFit",
112
+ "NotEnoughData",
113
+ "OffPeriod",
114
+ "Prior",
115
+ "PvForecast",
116
+ "RawSeries",
117
+ "RawValue",
118
+ "Samples",
119
+ "Scorecard",
120
+ "Scores",
121
+ "Segment",
122
+ "ShapeChoice",
123
+ "Signal",
124
+ "SolarGain",
125
+ "ThermalModel",
126
+ "Trajectory",
127
+ "Zone",
128
+ "ZoneDayScore",
129
+ "ZoneId",
130
+ "ZoneSensor",
131
+ "ZoneSeries",
132
+ "ZoneShape",
133
+ "ZoneState",
134
+ "ZoneSummary",
135
+ "build_history",
136
+ "describe_thermostats",
137
+ "discretise",
138
+ "drivers_from_segment",
139
+ "estimate_dhw_baseline",
140
+ "estimate_state",
141
+ "house_feeds",
142
+ "learn",
143
+ "morning_weight",
144
+ "nightly_cutoff",
145
+ "parse_house",
146
+ "prior_model",
147
+ "prior_shape",
148
+ "scorable_days",
149
+ "score_day",
150
+ "share_heat",
151
+ "simulate",
152
+ "solar_gain_kw",
153
+ "sun_position",
154
+ "thermostat_offset_c",
155
+ "walk_forward",
156
+ ]
@@ -0,0 +1,112 @@
1
+ """Replaying the nightly loop over past days, to see how well it would have predicted them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from datetime import date, timedelta
9
+
10
+ import numpy as np
11
+
12
+ from .house import House, ZoneId
13
+ from .learning import Accepted, FitOutcome, learn
14
+ from .model import ThermalModel, prior_model
15
+ from .scoring import ZoneDayScore, nightly_cutoff, scorable_days, score_day
16
+ from .settings import LearnSettings
17
+ from .signals import History
18
+
19
+ GATE2_DAYS = 7
20
+ GATE2_MAE_C = 0.5
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class NightlyFit:
25
+ """The refit that ran at 22:00 on the evening before `day`."""
26
+
27
+ day: date
28
+ outcome: FitOutcome
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ZoneSummary:
33
+ zone: ZoneId
34
+ days: tuple[date, ...]
35
+ mae_c: float
36
+ max_error_c: float
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Scorecard:
41
+ """Per zone and day, what the nightly loop predicted and what happened.
42
+
43
+ `unscored` lists the days whose readings did not cover the replay.
44
+ """
45
+
46
+ rows: tuple[ZoneDayScore, ...]
47
+ fits: tuple[NightlyFit, ...]
48
+ unscored: tuple[date, ...]
49
+ run_seconds: float
50
+
51
+ def summary(self, last_days: int = GATE2_DAYS) -> Mapping[ZoneId, ZoneSummary]:
52
+ """Each zone's mean and largest absolute peak error over its last scored days."""
53
+ by_zone: dict[ZoneId, list[ZoneDayScore]] = {}
54
+ for row in self.rows:
55
+ by_zone.setdefault(row.zone, []).append(row)
56
+ out = {}
57
+ for zone, rows in by_zone.items():
58
+ recent = sorted(rows, key=lambda r: r.day)[-last_days:]
59
+ errors = np.abs([r.error_c for r in recent])
60
+ out[zone] = ZoneSummary(
61
+ zone, tuple(r.day for r in recent), float(np.mean(errors)), float(np.max(errors))
62
+ )
63
+ return out
64
+
65
+ def gate2_met(self) -> bool:
66
+ """Every zone has seven scored days and a mean peak error within 0.5 °C over them."""
67
+ summaries = self.summary(GATE2_DAYS).values()
68
+ return bool(summaries) and all(
69
+ len(s.days) == GATE2_DAYS and s.mae_c <= GATE2_MAE_C for s in summaries
70
+ )
71
+
72
+
73
+ def walk_forward(
74
+ history: History,
75
+ house: House,
76
+ first_day: date,
77
+ last_day: date,
78
+ settings: LearnSettings = LearnSettings(), # noqa: B008
79
+ refit_every_days: int = 1,
80
+ ) -> Scorecard:
81
+ """Replay the nightly loop from `first_day` to `last_day`, both included.
82
+
83
+ Before each day, `learn` runs on the readings up to 22:00 the evening before, with the
84
+ model the loop would have carried from earlier nights (none at the start), and its
85
+ accept rule decides which model predicts the day. With `refit_every_days` above 1 the
86
+ model is refitted only on every so many days, to save time. Nothing is saved.
87
+ """
88
+ if last_day < first_day:
89
+ raise ValueError(f"The last day {last_day} is before the first day {first_day}.")
90
+ if refit_every_days < 1:
91
+ raise ValueError("refit_every_days must be at least 1.")
92
+ started = time.perf_counter()
93
+ scorable = set(scorable_days(history, settings))
94
+ current: ThermalModel | None = None
95
+ in_use = prior_model(house)
96
+ rows: list[ZoneDayScore] = []
97
+ fits: list[NightlyFit] = []
98
+ unscored: list[date] = []
99
+ for i in range((last_day - first_day).days + 1):
100
+ day = first_day + timedelta(days=i)
101
+ if i % refit_every_days == 0:
102
+ cutoff = nightly_cutoff(day, settings)
103
+ outcome = learn(history.before(cutoff), house, current, cutoff, settings)
104
+ fits.append(NightlyFit(day, outcome))
105
+ if isinstance(outcome, Accepted):
106
+ current = outcome.model
107
+ in_use = outcome.model
108
+ if day in scorable:
109
+ rows.extend(score_day(in_use, history, house, day, settings).values())
110
+ else:
111
+ unscored.append(day)
112
+ return Scorecard(tuple(rows), tuple(fits), tuple(unscored), time.perf_counter() - started)
@@ -0,0 +1,102 @@
1
+ """The engine the app runs: a house bound to its history source and its model store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from datetime import UTC, date, datetime, time, timedelta
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from .backtest import Scorecard, walk_forward
10
+ from .house import House, ZoneId
11
+ from .learning import Accepted, FitOutcome, KeptPrevious, estimate_dhw_baseline, learn
12
+ from .model import Drivers, ThermalModel, Trajectory, ZoneState, estimate_state, prior_model
13
+ from .model import simulate as simulate_model
14
+ from .ports import HistorySource, ModelStore
15
+ from .scoring import nightly_cutoff
16
+ from .settings import LearnSettings
17
+ from .signals import History, build_history, house_feeds
18
+
19
+
20
+ class Engine:
21
+ """Learns, saves and runs one house's thermal model.
22
+
23
+ `dhw_baseline_kwh_per_day` is the hot water heat per day used to estimate space heat
24
+ without valve data; left out, it is worked out from the days with the heating off.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ house: House,
30
+ history_source: HistorySource,
31
+ store: ModelStore[ThermalModel],
32
+ *,
33
+ step: timedelta = timedelta(minutes=30),
34
+ settings: LearnSettings = LearnSettings(), # noqa: B008
35
+ lookback: timedelta = timedelta(days=365),
36
+ dhw_baseline_kwh_per_day: float | None = None,
37
+ ) -> None:
38
+ self._house = house
39
+ self._source = history_source
40
+ self._store = store
41
+ self._step = step
42
+ self._settings = settings
43
+ self._lookback = lookback
44
+ self._dhw_baseline = dhw_baseline_kwh_per_day
45
+
46
+ def history(self, start: datetime, end: datetime) -> History:
47
+ raw = self._source.read(frozenset(house_feeds(self._house)), start, end)
48
+ baseline = self._dhw_baseline
49
+ if baseline is None:
50
+ baseline = estimate_dhw_baseline(build_history(raw, self._house, self._step, None)[0])
51
+ return build_history(raw, self._house, self._step, baseline)[0]
52
+
53
+ def model(self) -> ThermalModel:
54
+ """The current model, or the starting guesses before the first accepted fit."""
55
+ return self._store.current() or prior_model(self._house)
56
+
57
+ def refit(self, now: datetime) -> FitOutcome:
58
+ """Learn from the readings up to `now`.
59
+
60
+ An accepted model is saved and made current. A rejected candidate is saved but the
61
+ current model stays. Not enough data saves nothing.
62
+ """
63
+ history = self.history(now - self._lookback, now)
64
+ outcome = learn(history, self._house, self._store.current(), now, self._settings)
65
+ if isinstance(outcome, Accepted):
66
+ self._store.save(outcome.model, make_current=True)
67
+ elif isinstance(outcome, KeptPrevious):
68
+ self._store.save(outcome.candidate, make_current=False)
69
+ return outcome
70
+
71
+ def state(self, at: datetime) -> Mapping[ZoneId, ZoneState]:
72
+ """Each zone's state at `at`, from the readings in the slots just before it."""
73
+ window = timedelta(hours=self._settings.window_hours)
74
+ history = self.history(at - window, at)
75
+ if not history.segments or history.segments[-1].end < at - self._step:
76
+ raise ValueError(f"There are no usable readings in the slot before {at}.")
77
+ return estimate_state(self.model(), self._house, history.segments[-1])
78
+
79
+ def simulate(
80
+ self, drivers: Drivers, state: Mapping[ZoneId, ZoneState] | None = None
81
+ ) -> Trajectory:
82
+ """Run the current model forward over `drivers`.
83
+
84
+ Without a `state`, it starts from the readings just before `drivers.start`. A caller
85
+ that simulates many plans from the same moment can pass `state(...)` once instead.
86
+ """
87
+ start = self.state(drivers.start) if state is None else state
88
+ return simulate_model(self.model(), start, drivers)
89
+
90
+ def backtest(self, first_day: date, last_day: date, refit_every_days: int = 1) -> Scorecard:
91
+ """Replay the nightly loop over past days. Nothing is saved to the store."""
92
+ tz = ZoneInfo(self._settings.timezone)
93
+ start = nightly_cutoff(first_day, self._settings) - self._lookback
94
+ end = datetime.combine(last_day + timedelta(days=1), time(), tzinfo=tz).astimezone(UTC)
95
+ return walk_forward(
96
+ self.history(start, end),
97
+ self._house,
98
+ first_day,
99
+ last_day,
100
+ self._settings,
101
+ refit_every_days,
102
+ )