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.
- warmcast/__init__.py +0 -0
- warmcast/core/__init__.py +156 -0
- warmcast/core/backtest.py +112 -0
- warmcast/core/engine.py +102 -0
- warmcast/core/house.py +348 -0
- warmcast/core/learning.py +473 -0
- warmcast/core/model.py +578 -0
- warmcast/core/ports.py +26 -0
- warmcast/core/scoring.py +158 -0
- warmcast/core/settings.py +42 -0
- warmcast/core/signals.py +607 -0
- warmcast/core/sun.py +53 -0
- warmcast/ha_connector/__init__.py +2 -0
- warmcast/ha_connector/app.py +160 -0
- warmcast/ha_connector/binding.py +133 -0
- warmcast/ha_connector/client.py +113 -0
- warmcast/ha_connector/nightly.py +73 -0
- warmcast/ha_connector/reading_log.py +73 -0
- warmcast/ha_connector/sampler.py +49 -0
- warmcast/offline/__init__.py +0 -0
- warmcast/offline/cli.py +433 -0
- warmcast/offline/csv_sources.py +103 -0
- warmcast/offline/signal_map.py +108 -0
- warmcast/storage/__init__.py +6 -0
- warmcast/storage/json_store.py +101 -0
- warmcast/storage/refit_record.py +47 -0
- warmcast/web/__init__.py +9 -0
- warmcast/web/dashboard.py +186 -0
- warmcast/web/log_view.py +89 -0
- warmcast/web/server.py +84 -0
- warmcast/web/static/app.css +245 -0
- warmcast/web/static/app.js +320 -0
- warmcast/web/static/chart.umd.min.js +1 -0
- warmcast/web/static/charts.html +57 -0
- warmcast/web/static/guide.html +92 -0
- warmcast/web/static/index.html +33 -0
- warmcast/web/static/plan.html +41 -0
- warmcast/web/static/settings.html +28 -0
- warmcast-0.1.0.dist-info/METADATA +11 -0
- warmcast-0.1.0.dist-info/RECORD +42 -0
- warmcast-0.1.0.dist-info/WHEEL +4 -0
- 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)
|
warmcast/core/engine.py
ADDED
|
@@ -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
|
+
)
|