loadcast 0.1.1__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.
loadcast/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """loadcast -- synthetic multi-carrier industrial energy demand.
2
+
3
+ Hourly heat, cold and electricity demand for an industrial site, from the
4
+ handful of numbers someone actually knows about a plant: how much energy it uses
5
+ in a year, roughly how much more heat than cold it wants, how much of the
6
+ total is electricity, and whether the process runs continuously, in shifts or in
7
+ batches.
8
+
9
+ import loadcast
10
+
11
+ profiles = loadcast.generate(
12
+ annual_mwh=10_000,
13
+ heat_to_cold=7.4,
14
+ elec_share=0.20,
15
+ process_class="semi_continuous",
16
+ )
17
+
18
+ What makes it different
19
+ -----------------------
20
+ Existing tools either produce deterministic profiles or leave the relationship
21
+ between carriers to fall out of whatever drives them. Both give carriers that
22
+ move in lockstep, which is an artefact of the construction rather than a
23
+ property of industry -- and for anything with storage, *whether the carriers
24
+ peak together* is precisely what decides how much buffering is needed.
25
+
26
+ loadcast makes that relationship an explicit input. `carrier_correlation` sets
27
+ how tightly the carriers move together and `carrier_lag_h` sets how far apart
28
+ they are offset, so a study can sweep coincidence rather than inherit it.
29
+
30
+ What it does not do
31
+ -------------------
32
+ It carries energy by carrier, not temperature: whether a machine can *reach* a
33
+ given process is a separate question, answered from the machine's own limits.
34
+ And the parameters are yours to choose. Defaults are indicative values with a
35
+ stated provenance, not predictions about any real site -- results are meant to
36
+ be reported as they hold across a swept space, not at a single assumed point.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from .classes import (
42
+ ProcessClass,
43
+ available_classes,
44
+ get_class,
45
+ load_classes,
46
+ register_class,
47
+ )
48
+ from .fitting import (
49
+ bias_report,
50
+ class_from_observations,
51
+ correlation_floor,
52
+ correlation_transfer,
53
+ phi_from_lag1,
54
+ sigma_from_cv,
55
+ )
56
+ from . import reference
57
+ from .generator import (
58
+ build_profiles,
59
+ generate,
60
+ rescale_persistence,
61
+ solve_correlation_knob,
62
+ step_hours,
63
+ )
64
+ from .spec import CARRIERS, DemandSpec
65
+ from .stats import (
66
+ cross_correlogram,
67
+ duration_curve,
68
+ integrated_drift,
69
+ profile_statistics,
70
+ summary_table,
71
+ )
72
+
73
+ __version__ = "0.1.1"
74
+
75
+ __all__ = [
76
+ "__version__",
77
+ # what to generate
78
+ "DemandSpec", "CARRIERS",
79
+ # generating
80
+ "generate", "build_profiles", "solve_correlation_knob",
81
+ "step_hours", "rescale_persistence",
82
+ # process classes
83
+ "ProcessClass", "available_classes", "get_class", "load_classes",
84
+ "register_class",
85
+ # fitting to your own measurements
86
+ "class_from_observations", "bias_report",
87
+ "sigma_from_cv", "phi_from_lag1",
88
+ "correlation_floor", "correlation_transfer",
89
+ # diagnostics
90
+ # indicative published values
91
+ "reference",
92
+ "profile_statistics", "summary_table", "duration_curve",
93
+ "cross_correlogram", "integrated_drift",
94
+ ]
loadcast/classes.py ADDED
@@ -0,0 +1,187 @@
1
+ """Process classes: how peaky an industrial process is, and on what rhythm.
2
+
3
+ A process class bundles everything the generator needs that is *not* specific to
4
+ one site: the calendar rhythm (month, day-of-week, hour-of-day factors) and the
5
+ three statistics that describe how the load fluctuates around it (load factor,
6
+ coefficient of variation, lag-1 persistence), plus an on/off description for
7
+ processes that are genuinely intermittent rather than merely variable.
8
+
9
+ Each class is calibrated on one metered industrial site. The sites themselves
10
+ are not distributed -- what ships is the derived statistics and the calendar
11
+ factors, which are group means and carry no identifying information.
12
+
13
+ The four classes span the range the archive covers:
14
+
15
+ continuous runs around the clock, small excursions
16
+ semi_continuous weekday shift pattern with a weekend trough
17
+ campaign long runs separated by genuine shutdowns
18
+ batch strongly bimodal, most hours near zero
19
+
20
+ Choose one from the *process description* of the site you are modelling, not by
21
+ fitting to an answer you already have. If none fits, `loadcast.fitting` builds a
22
+ class from your own observed statistics.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from dataclasses import dataclass
29
+ from functools import lru_cache
30
+ from importlib import resources
31
+
32
+ import numpy as np
33
+
34
+ __all__ = ["ProcessClass", "available_classes", "get_class", "load_classes",
35
+ "register_class"]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ProcessClass:
40
+ """One calibrated process rhythm.
41
+
42
+ Attributes
43
+ ----------
44
+ name
45
+ Identifier used in `DemandSpec.process_class`.
46
+ month, dayofweek, hour
47
+ Calendar factors, each normalised to mean one over a balanced calendar.
48
+ `month` is indexed January..December, `dayofweek` Monday..Sunday, `hour`
49
+ 00..23. They carry *shape only*: the level comes from the spec.
50
+ target_load_factor
51
+ Mean over peak of the finished profile. Imposed exactly by the capacity
52
+ cap, not fitted.
53
+ target_cv
54
+ Coefficient of variation of the finished profile, calendar included.
55
+ target_lag1
56
+ Lag-1 autocorrelation of the finished profile.
57
+ mode
58
+ ``"lognormal"`` for a process that varies continuously, ``"onoff"`` for
59
+ one that genuinely stops. The choice is forced by the data: a lognormal
60
+ with the right spread still puts a batch process's load factor at 0.02
61
+ against 0.22 measured, because a lognormal tail is far longer than an
62
+ on/off process at equal spread.
63
+ off_fraction
64
+ Share of hours in the off state. Zero for ``"lognormal"`` classes.
65
+ off_level
66
+ Load in the off state, as a fraction of the running level. Non-zero
67
+ because real plants idle rather than stop.
68
+ description
69
+ The kind of process this rhythm was measured on.
70
+ anchor_site
71
+ Anonymised label of the metered site it was calibrated against.
72
+ n_hours
73
+ Length of the series it was calibrated on.
74
+ """
75
+
76
+ name: str
77
+ month: tuple[float, ...]
78
+ dayofweek: tuple[float, ...]
79
+ hour: tuple[float, ...]
80
+ target_load_factor: float
81
+ target_cv: float
82
+ target_lag1: float
83
+ mode: str
84
+ off_fraction: float
85
+ off_level: float
86
+ description: str
87
+ anchor_site: str
88
+ n_hours: int
89
+
90
+ def __post_init__(self) -> None:
91
+ if self.mode not in ("lognormal", "onoff"):
92
+ raise ValueError(f"unknown mode {self.mode!r}")
93
+ for field, expected in (("month", 12), ("dayofweek", 7), ("hour", 24)):
94
+ got = len(getattr(self, field))
95
+ if got != expected:
96
+ raise ValueError(
97
+ f"{self.name}: {field} needs {expected} factors, got {got}"
98
+ )
99
+
100
+ def calendar(self, index) -> np.ndarray:
101
+ """Calendar factor `S(t)` for every timestamp in `index`.
102
+
103
+ The three periodic factors multiply. Each has mean one over a balanced
104
+ calendar, so their product carries shape and no level -- which is what
105
+ lets the same rhythm be reused at any plant size.
106
+ """
107
+ month = np.asarray(self.month, dtype=float)
108
+ dayofweek = np.asarray(self.dayofweek, dtype=float)
109
+ hour = np.asarray(self.hour, dtype=float)
110
+ return (month[index.month - 1]
111
+ * dayofweek[index.dayofweek]
112
+ * hour[index.hour])
113
+
114
+
115
+ @lru_cache(maxsize=1)
116
+ def load_classes() -> dict[str, ProcessClass]:
117
+ """Every calibrated process class shipped with the package."""
118
+ source = resources.files("loadcast.data").joinpath("process_classes.json")
119
+ raw = json.loads(source.read_text(encoding="utf-8"))
120
+
121
+ classes: dict[str, ProcessClass] = {}
122
+ for name, entry in raw.items():
123
+ classes[name] = ProcessClass(
124
+ name=name,
125
+ month=tuple(entry["month"]),
126
+ dayofweek=tuple(entry["dayofweek"]),
127
+ hour=tuple(entry["hour"]),
128
+ target_load_factor=float(entry["target_load_factor"]),
129
+ target_cv=float(entry["target_cv"]),
130
+ target_lag1=float(entry["target_lag1"]),
131
+ mode=str(entry["mode"]),
132
+ off_fraction=float(entry["off_fraction"]),
133
+ off_level=float(entry["off_level"]),
134
+ description=str(entry["description"]),
135
+ anchor_site=str(entry["anchor_site"]),
136
+ n_hours=int(entry["n_hours"]),
137
+ )
138
+ return classes
139
+
140
+
141
+ # Classes built at runtime from a user's own measurements. Kept apart from the
142
+ # shipped ones so `available_classes` can say which is which, and so nothing
143
+ # a user registers can silently shadow a calibrated class.
144
+ _REGISTERED: dict[str, ProcessClass] = {}
145
+
146
+
147
+ def register_class(process_class: ProcessClass, overwrite: bool = False) -> None:
148
+ """Make a process class usable by name in a `DemandSpec`.
149
+
150
+ Pair this with `loadcast.fitting.class_from_observations` to generate from
151
+ statistics you measured yourself:
152
+
153
+ own = class_from_observations("my_plant", 0.45, 0.6, 0.9)
154
+ register_class(own)
155
+ generate(process_class="my_plant", annual_mwh=5_000)
156
+ """
157
+ name = process_class.name
158
+ if name in load_classes():
159
+ raise ValueError(
160
+ f"{name!r} is a calibrated class shipped with loadcast; "
161
+ f"choose another name rather than shadowing it"
162
+ )
163
+ if name in _REGISTERED and not overwrite:
164
+ raise ValueError(f"{name!r} is already registered; pass overwrite=True")
165
+ _REGISTERED[name] = process_class
166
+
167
+
168
+ def available_classes(include_registered: bool = True) -> list[str]:
169
+ """Names of the process classes that can be generated from."""
170
+ names = set(load_classes())
171
+ if include_registered:
172
+ names |= set(_REGISTERED)
173
+ return sorted(names)
174
+
175
+
176
+ def get_class(name: str) -> ProcessClass:
177
+ """One process class by name, with a helpful error if it does not exist."""
178
+ classes = load_classes()
179
+ if name in classes:
180
+ return classes[name]
181
+ if name in _REGISTERED:
182
+ return _REGISTERED[name]
183
+ raise KeyError(
184
+ f"unknown process class {name!r}; available: {available_classes()}. "
185
+ f"To build one from your own measurements, see "
186
+ f"loadcast.fitting.class_from_observations and register_class."
187
+ )
@@ -0,0 +1,5 @@
1
+ """Bundled calibration data.
2
+
3
+ A regular package rather than a bare directory: ``importlib.resources.files``
4
+ cannot read from a namespace package on Python 3.9.
5
+ """
@@ -0,0 +1,242 @@
1
+ {
2
+ "batch": {
3
+ "anchor_site": "site_c",
4
+ "dayofweek": [
5
+ 1.55929,
6
+ 1.50644,
7
+ 1.234482,
8
+ 1.053401,
9
+ 1.217668,
10
+ 0.25477,
11
+ 0.169895
12
+ ],
13
+ "description": "long on/off runs; coffee roasting",
14
+ "hour": [
15
+ 0.154848,
16
+ 0.154795,
17
+ 0.158523,
18
+ 0.157162,
19
+ 0.281185,
20
+ 1.229629,
21
+ 1.857477,
22
+ 1.894545,
23
+ 1.853708,
24
+ 1.773206,
25
+ 1.719132,
26
+ 1.632496,
27
+ 1.446623,
28
+ 1.373535,
29
+ 1.369311,
30
+ 1.322143,
31
+ 1.253803,
32
+ 1.170113,
33
+ 1.100534,
34
+ 1.024897,
35
+ 0.553287,
36
+ 0.210613,
37
+ 0.154494,
38
+ 0.15394
39
+ ],
40
+ "mode": "onoff",
41
+ "month": [
42
+ 1.135009,
43
+ 1.011868,
44
+ 0.962326,
45
+ 0.930036,
46
+ 0.965991,
47
+ 1.067641,
48
+ 0.445614,
49
+ 0.913278,
50
+ 0.995257,
51
+ 1.142671,
52
+ 1.422581,
53
+ 1.022282
54
+ ],
55
+ "n_hours": 8760,
56
+ "off_fraction": 0.4,
57
+ "off_level": 0.02,
58
+ "target_cv": 1.177,
59
+ "target_lag1": 0.848,
60
+ "target_load_factor": 0.221
61
+ },
62
+ "campaign": {
63
+ "anchor_site": "site_d",
64
+ "dayofweek": [
65
+ 0.954028,
66
+ 0.960068,
67
+ 0.975323,
68
+ 1.033885,
69
+ 1.014871,
70
+ 1.045056,
71
+ 1.017728
72
+ ],
73
+ "description": "long campaigns on and off; phosphate burner",
74
+ "hour": [
75
+ 1.032559,
76
+ 1.035685,
77
+ 1.036415,
78
+ 1.037355,
79
+ 1.039047,
80
+ 1.029501,
81
+ 1.026594,
82
+ 1.001638,
83
+ 0.886791,
84
+ 0.898781,
85
+ 0.966302,
86
+ 0.990871,
87
+ 0.988767,
88
+ 0.98998,
89
+ 0.992691,
90
+ 0.998371,
91
+ 0.996536,
92
+ 0.997509,
93
+ 0.99553,
94
+ 0.998669,
95
+ 1.004324,
96
+ 1.011664,
97
+ 1.016607,
98
+ 1.027876
99
+ ],
100
+ "mode": "onoff",
101
+ "month": [
102
+ 1.101382,
103
+ 1.153565,
104
+ 1.260548,
105
+ 1.025009,
106
+ 1.080069,
107
+ 0.909929,
108
+ 0.907351,
109
+ 0.907123,
110
+ 1.051813,
111
+ 0.838124,
112
+ 0.765126,
113
+ 0.928618
114
+ ],
115
+ "n_hours": 16535,
116
+ "off_fraction": 0.196,
117
+ "off_level": 0.02,
118
+ "target_cv": 0.527,
119
+ "target_lag1": 0.957,
120
+ "target_load_factor": 0.503
121
+ },
122
+ "continuous": {
123
+ "anchor_site": "site_a",
124
+ "dayofweek": [
125
+ 0.883038,
126
+ 1.201896,
127
+ 1.195369,
128
+ 1.191803,
129
+ 1.105632,
130
+ 0.722996,
131
+ 0.697166
132
+ ],
133
+ "description": "runs around the clock; pasta drying, mineral drying",
134
+ "hour": [
135
+ 0.990317,
136
+ 0.993397,
137
+ 0.993791,
138
+ 0.962328,
139
+ 0.95992,
140
+ 0.962603,
141
+ 0.969056,
142
+ 0.956698,
143
+ 0.932786,
144
+ 0.949027,
145
+ 0.992933,
146
+ 1.005735,
147
+ 1.008749,
148
+ 1.024708,
149
+ 1.023066,
150
+ 1.016832,
151
+ 0.992313,
152
+ 1.014779,
153
+ 1.047381,
154
+ 1.04819,
155
+ 1.037668,
156
+ 1.052113,
157
+ 1.046296,
158
+ 1.019314
159
+ ],
160
+ "mode": "lognormal",
161
+ "month": [
162
+ 1.165743,
163
+ 1.129872,
164
+ 1.088858,
165
+ 1.09592,
166
+ 0.950285,
167
+ 1.109809,
168
+ 0.733752,
169
+ 0.930212,
170
+ 0.989356,
171
+ 1.045419,
172
+ 0.880077,
173
+ 0.895691
174
+ ],
175
+ "n_hours": 8760,
176
+ "off_fraction": 0.0,
177
+ "off_level": 0.0,
178
+ "target_cv": 0.409,
179
+ "target_lag1": 0.973,
180
+ "target_load_factor": 0.576
181
+ },
182
+ "semi_continuous": {
183
+ "anchor_site": "site_b",
184
+ "dayofweek": [
185
+ 1.236234,
186
+ 1.256554,
187
+ 1.231145,
188
+ 1.190143,
189
+ 0.962295,
190
+ 0.442463,
191
+ 0.681708
192
+ ],
193
+ "description": "shifts with weekend shutdown; brewing, phosphate burners",
194
+ "hour": [
195
+ 1.004733,
196
+ 0.954934,
197
+ 0.943026,
198
+ 0.953191,
199
+ 1.027009,
200
+ 1.047198,
201
+ 1.039582,
202
+ 1.032001,
203
+ 1.000225,
204
+ 1.042484,
205
+ 1.040865,
206
+ 1.050062,
207
+ 1.035649,
208
+ 1.015254,
209
+ 1.021087,
210
+ 1.038695,
211
+ 1.022148,
212
+ 0.977781,
213
+ 0.973244,
214
+ 0.96839,
215
+ 0.93851,
216
+ 0.917547,
217
+ 0.950893,
218
+ 1.005491
219
+ ],
220
+ "mode": "lognormal",
221
+ "month": [
222
+ 0.983435,
223
+ 1.034075,
224
+ 1.068202,
225
+ 1.160898,
226
+ 0.98848,
227
+ 0.973074,
228
+ 0.929518,
229
+ 1.093648,
230
+ 0.843058,
231
+ 0.864036,
232
+ 0.983501,
233
+ 1.080097
234
+ ],
235
+ "n_hours": 8760,
236
+ "off_fraction": 0.0,
237
+ "off_level": 0.0,
238
+ "target_cv": 0.478,
239
+ "target_lag1": 0.793,
240
+ "target_load_factor": 0.401
241
+ }
242
+ }
@@ -0,0 +1,158 @@
1
+ {
2
+ "compositions": {
3
+ "_source": "jericho",
4
+ "industry_aggregate": {
5
+ "cold": 0.049,
6
+ "elec": 0.213,
7
+ "heat": 0.738,
8
+ "heat_to_cold": 15.0
9
+ }
10
+ },
11
+ "sector_temperatures": {
12
+ "Chemical": {
13
+ "process_median_c": 109.6,
14
+ "process_p25_c": 71.0,
15
+ "process_p75_c": 129.0,
16
+ "process_pj_15_200": 354.6,
17
+ "waste_median_c": 52.1,
18
+ "waste_pj_15_200": 337.1,
19
+ "waste_to_process": 0.95
20
+ },
21
+ "Food and Beverage": {
22
+ "process_median_c": 120.9,
23
+ "process_p25_c": 105.5,
24
+ "process_p75_c": 180.0,
25
+ "process_pj_15_200": 193.1,
26
+ "waste_median_c": 57.0,
27
+ "waste_pj_15_200": 97.2,
28
+ "waste_to_process": 0.5
29
+ },
30
+ "Paper": {
31
+ "process_median_c": 140.0,
32
+ "process_p25_c": 125.0,
33
+ "process_p75_c": 157.2,
34
+ "process_pj_15_200": 356.5,
35
+ "waste_median_c": 45.0,
36
+ "waste_pj_15_200": 230.9,
37
+ "waste_to_process": 0.65
38
+ },
39
+ "Refinery": {
40
+ "process_median_c": 275.3,
41
+ "process_p25_c": 173.4,
42
+ "process_p75_c": 309.0,
43
+ "process_pj_15_200": 219.1,
44
+ "waste_median_c": 89.5,
45
+ "waste_pj_15_200": 464.9,
46
+ "waste_to_process": 2.12
47
+ }
48
+ },
49
+ "sources": {
50
+ "jericho": {
51
+ "caveat": "Annual shares only. Its hourly series cannot be used: carriers are a fixed scalar times one shared profile, so their correlation is 1.000000 and the composition is identical across all 38 regions to 4e-8 while regional scale varies 17.9x.",
52
+ "citation": "Priesmann J, Nolting L, Kockel C, Praktiknjo A. Time series of useful energy consumption patterns for energy system modeling (JERICHO-E-usage). Scientific Data 8:148, 2021. doi:10.1038/s41597-021-00907-w",
53
+ "kind": "useful energy by carrier, annual totals",
54
+ "peer_reviewed": true,
55
+ "scope": "German industry, 2019, 38 NUTS2 regions"
56
+ },
57
+ "rehfeldt": {
58
+ "caveat": "Digitised from Fig. 16 to about +-5 percentage points. Its own two datasets disagree by more than the subsectors differ from one another, so this is reported as a range and never as a point.",
59
+ "citation": "Rehfeldt M, Fleiter T, Toro F. A bottom-up estimation of the heating and cooling demand in European industry. Energy Efficiency 11:1057-1082, 2018. doi:10.1007/s12053-017-9571-y",
60
+ "kind": "heat by temperature band and cooling, shares of subsector demand",
61
+ "peer_reviewed": true,
62
+ "scope": "UK industry, 7 subsectors, two datasets (ECUK and FORECAST)"
63
+ },
64
+ "tno": {
65
+ "caveat": "No electricity and essentially no refrigeration. Its waste heat is rejected above ambient and is a heat pump source, not a cooling demand.",
66
+ "citation": "Marina A, Spoelstra S, Zondag HA, Wemmers AK. An estimation of the European industrial heat pump market potential. Renewable and Sustainable Energy Reviews 139:110545, 2021. doi:10.1016/j.rser.2020.110545",
67
+ "kind": "process and waste heat quantities with stream temperatures",
68
+ "peer_reviewed": true,
69
+ "scope": "EU-28, 2016 production, 4 sectors, ~66 products"
70
+ }
71
+ },
72
+ "subsector_heat_to_cold": {
73
+ "Chemicals": {
74
+ "ECUK": {
75
+ "cold_pct": 13.0,
76
+ "heat_pct": 87.0,
77
+ "heat_to_cold": 6.7
78
+ },
79
+ "FORECAST": {
80
+ "cold_pct": 2.0,
81
+ "heat_pct": 98.0,
82
+ "heat_to_cold": 49.0
83
+ }
84
+ },
85
+ "Food, beverages and tobacco": {
86
+ "ECUK": {
87
+ "cold_pct": 8.0,
88
+ "heat_pct": 92.0,
89
+ "heat_to_cold": 11.5
90
+ },
91
+ "FORECAST": {
92
+ "cold_pct": 3.0,
93
+ "heat_pct": 97.0,
94
+ "heat_to_cold": 32.3
95
+ }
96
+ },
97
+ "Iron and steel, non-ferrous metals": {
98
+ "ECUK": {
99
+ "cold_pct": 0.0,
100
+ "heat_pct": 100.0,
101
+ "heat_to_cold": null
102
+ },
103
+ "FORECAST": {
104
+ "cold_pct": 0.0,
105
+ "heat_pct": 100.0,
106
+ "heat_to_cold": null
107
+ }
108
+ },
109
+ "Machinery and Transport": {
110
+ "ECUK": {
111
+ "cold_pct": 2.0,
112
+ "heat_pct": 98.0,
113
+ "heat_to_cold": 49.0
114
+ },
115
+ "FORECAST": {
116
+ "cold_pct": 2.0,
117
+ "heat_pct": 98.0,
118
+ "heat_to_cold": 49.0
119
+ }
120
+ },
121
+ "Non-metallic minerals": {
122
+ "ECUK": {
123
+ "cold_pct": 0.0,
124
+ "heat_pct": 100.0,
125
+ "heat_to_cold": null
126
+ },
127
+ "FORECAST": {
128
+ "cold_pct": 0.0,
129
+ "heat_pct": 100.0,
130
+ "heat_to_cold": null
131
+ }
132
+ },
133
+ "Other industry": {
134
+ "ECUK": {
135
+ "cold_pct": 0.0,
136
+ "heat_pct": 100.0,
137
+ "heat_to_cold": null
138
+ },
139
+ "FORECAST": {
140
+ "cold_pct": 0.0,
141
+ "heat_pct": 100.0,
142
+ "heat_to_cold": null
143
+ }
144
+ },
145
+ "Paper": {
146
+ "ECUK": {
147
+ "cold_pct": 0.0,
148
+ "heat_pct": 100.0,
149
+ "heat_to_cold": null
150
+ },
151
+ "FORECAST": {
152
+ "cold_pct": 0.0,
153
+ "heat_pct": 100.0,
154
+ "heat_to_cold": null
155
+ }
156
+ }
157
+ }
158
+ }