Gen-HVAC commited on
Commit
ba7b0bc
·
verified ·
1 Parent(s): 73a48f9

Upload 6 files

Browse files
utilities/comfort.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import numpy as np
3
+ import pandas as pd
4
+ from typing import Optional
5
+
6
+
7
+ def _sat_vapor_pressure_kpa(t_c: float) -> float:
8
+ return 0.61078 * math.exp((17.2694 * t_c) / (t_c + 237.29))
9
+
10
+
11
+ def pmv_ppd_fanger(
12
+ ta_c: float,
13
+ tr_c: Optional[float] = None,
14
+ rh: float = 50.0,
15
+ vel: float = 0.1,
16
+ met: float = 1.2,
17
+ clo: float = 0.7,
18
+ wme: float = 0.0,
19
+ ):
20
+
21
+ if tr_c is None:
22
+ tr_c = ta_c
23
+
24
+ ta = ta_c
25
+ tr = tr_c
26
+ pa_kpa = rh / 100.0 * _sat_vapor_pressure_kpa(ta)
27
+ pa = pa_kpa * 1000.0
28
+
29
+ m = met * 58.15
30
+ w = wme * 58.15
31
+ mw = m - w
32
+
33
+ icl = 0.155 * clo
34
+ if icl <= 1e-9:
35
+ icl = 1e-9
36
+
37
+ if icl <= 0.078:
38
+ fcl = 1.0 + 1.29 * icl
39
+ else:
40
+ fcl = 1.05 + 0.645 * icl
41
+
42
+ hcf = 12.1 * math.sqrt(max(vel, 1e-9))
43
+
44
+ taa = ta + 273.0
45
+ tra = tr + 273.0
46
+ tcla = taa + (35.5 - ta) / (3.5 * icl + 0.1)
47
+
48
+ p1 = icl * fcl
49
+ p2 = p1 * 3.96
50
+ p3 = p1 * 100.0
51
+ p4 = p1 * taa
52
+ p5 = 308.7 - 0.028 * mw + p2 * ((tra / 100.0) ** 4)
53
+
54
+ xn = tcla / 100.0
55
+ xf = xn
56
+ eps = 0.00015
57
+ n = 0
58
+
59
+ while True:
60
+ xf = (xf + xn) / 2.0
61
+ tcl = 100.0 * xf - 273.0
62
+
63
+ hcn = 2.38 * (abs(100.0 * xf - taa) ** 0.25)
64
+ hc = max(hcf, hcn)
65
+
66
+ xn = (p5 + p4 * hc - p2 * (xf**4)) / (100.0 + p3 * hc)
67
+
68
+ n += 1
69
+ if n > 150 or abs(xn - xf) <= eps:
70
+ break
71
+
72
+ tcl = 100.0 * xn - 273.0
73
+
74
+ hl1 = 3.05 * 0.001 * (5733.0 - 6.99 * mw - pa)
75
+ hl2 = 0.42 * (mw - 58.15) if mw > 58.15 else 0.0
76
+ hl3 = 1.7 * 0.00001 * m * (5867.0 - pa)
77
+ hl4 = 0.0014 * m * (34.0 - ta)
78
+ hl5 = 3.96 * fcl * ((xn**4) - ((tra / 100.0) ** 4))
79
+ hl6 = fcl * hc * (tcl - ta)
80
+
81
+ ts = 0.303 * math.exp(-0.036 * m) + 0.028
82
+ pmv = ts * (mw - hl1 - hl2 - hl3 - hl4 - hl5 - hl6)
83
+ ppd = 100.0 - 95.0 * math.exp(-0.03353 * (pmv**4) - 0.2179 * (pmv**2))
84
+ return pmv, ppd
85
+
86
+
87
+ # ==========================================
88
+ def ashrae_any(df: pd.DataFrame) -> None:
89
+ if {"core_ash55_notcomfortable_summer", "core_ash55_notcomfortable_winter"}.issubset(df.columns):
90
+ # 1. Calculate raw combination
91
+ raw_val = np.maximum(
92
+ df["core_ash55_notcomfortable_summer"].astype(float),
93
+ df["core_ash55_notcomfortable_winter"].astype(float),
94
+ )
95
+ if "core_occ_count" in df.columns:
96
+ is_occupied = (df["core_occ_count"] > 1e-6).astype(float)
97
+ df["core_ash55_any_fixed"] = raw_val * is_occupied
98
+ else:
99
+ df["core_ash55_any_fixed"] = raw_val
100
+ else:
101
+ df["core_ash55_any_fixed"] = np.nan
102
+
103
+ def add_feature_availability_and_registry(
104
+ df: pd.DataFrame,
105
+ base_feature_cols,
106
+ new_feature_cols,
107
+ ) -> None:
108
+ for c in base_feature_cols + new_feature_cols:
109
+ df[f"has_{c}"] = c in df.columns
110
+ present = [c for c in base_feature_cols + new_feature_cols if c in df.columns]
111
+ df["feature_registry"] = ";".join(present)
112
+
113
+ def compute_comfort_metrics_inplace(
114
+ df: pd.DataFrame,
115
+ location: str,
116
+ time_step_hours: float,
117
+ heating_sp: float,
118
+ cooling_sp: float,
119
+ zone_temp_keys,
120
+ zone_occ_keys,
121
+ rh_keys,
122
+ ) -> None:
123
+
124
+
125
+ missing_t = [k for k in zone_temp_keys if k not in df.columns]
126
+ missing_o = [k for k in zone_occ_keys if k not in df.columns]
127
+
128
+ if missing_t or missing_o:
129
+ print(f"[{location}] WARNING: missing temp cols: {missing_t}, occ cols: {missing_o}")
130
+ df["comfort_violation_degCh"] = 0.0
131
+ df["comfort_violation_fixed_degCh"] = 0.0
132
+ df["pmv_weighted"] = np.nan
133
+ df["ppd_weighted"] = np.nan
134
+ df["rh_weighted"] = np.nan
135
+ return
136
+
137
+ temps = df[zone_temp_keys].to_numpy(dtype=np.float64)
138
+ occs = df[zone_occ_keys].to_numpy(dtype=np.float64)
139
+
140
+ total_occ = occs.sum(axis=1)
141
+ mean_temps = temps.mean(axis=1)
142
+
143
+ comfort_temp = np.where(
144
+ total_occ > 1e-6,
145
+ (temps * occs).sum(axis=1) / np.maximum(total_occ, 1e-6),
146
+ mean_temps,
147
+ )
148
+
149
+
150
+ if all(k in df.columns for k in rh_keys):
151
+ rhs = df[rh_keys].to_numpy(dtype=np.float64)
152
+ rh_weighted = np.where(
153
+ total_occ > 1e-6,
154
+ (rhs * occs).sum(axis=1) / np.maximum(total_occ, 1e-6),
155
+ rhs.mean(axis=1),
156
+ )
157
+ df["rh_weighted"] = rh_weighted
158
+ else:
159
+ df["rh_weighted"] = np.nan
160
+
161
+ RH_series = df["rh_weighted"].to_numpy(dtype=np.float64) if "rh_weighted" in df.columns else None
162
+
163
+ VEL = 0.1
164
+ MET = 1.2
165
+ CLO = 0.7
166
+ WME = 0.0
167
+
168
+ pmv_list = []
169
+ ppd_list = []
170
+
171
+ for i, t in enumerate(comfort_temp):
172
+
173
+ if total_occ[i] <= 1e-6:
174
+ pmv_list.append(0.0)
175
+ ppd_list.append(0.0)
176
+ continue
177
+
178
+ rh_i = float(RH_series[i]) if RH_series is not None and np.isfinite(RH_series[i]) else 50.0
179
+ rh_i = float(np.clip(rh_i, 0.0, 100.0))
180
+
181
+ pmv, ppd = pmv_ppd_fanger(
182
+ ta_c=float(t),
183
+ tr_c=float(t),
184
+ rh=rh_i,
185
+ vel=VEL,
186
+ met=MET,
187
+ clo=CLO,
188
+ wme=WME,
189
+ )
190
+ pmv_list.append(pmv)
191
+ ppd_list.append(ppd)
192
+
193
+ df["pmv_weighted"] = np.array(pmv_list, dtype=np.float64)
194
+ df["ppd_weighted"] = np.array(ppd_list, dtype=np.float64)
195
+
196
+
197
+ FIXED_HEAT = 21.0
198
+ FIXED_COOL = 24.0
199
+ fixed_lower = FIXED_HEAT - 0.5
200
+ fixed_upper = FIXED_COOL + 0.5
201
+
202
+ fixed_dev = np.clip(fixed_lower - comfort_temp, 0.0, None) + np.clip(comfort_temp - fixed_upper, 0.0, None)
203
+ is_occupied = (total_occ > 1e-6).astype(np.float64)
204
+ fixed_violation = fixed_dev * time_step_hours * is_occupied
205
+
206
+ df["comfort_violation_degCh"] = fixed_violation
207
+ df["comfort_violation_fixed_degCh"] = fixed_violation
utilities/data_generator.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import numpy as np
3
+ import pandas as pd
4
+
5
+ # ==========================================
6
+ # 1.(Internal Structure)
7
+ # ==========================================
8
+ TIME_COLS = ["step", "month", "day", "hour", "minute"]
9
+ ENV_COLS = ["out_temp", "out_rh"]
10
+ GLOBAL_REWARD_COLS = ["power_kw"]
11
+ GLOBAL_COLS = TIME_COLS + ENV_COLS + GLOBAL_REWARD_COLS
12
+
13
+ ZONE_LIST = ["core", "p1", "p2", "p3", "p4"]
14
+ ZONE_STATE_TEMPLATE = ["temp", "occ", "rh"]
15
+ ZONE_ACTION_TEMPLATE = ["htg", "clg"]
16
+
17
+ def get_full_schema():
18
+ states = []
19
+ actions = []
20
+ for name in ZONE_LIST:
21
+ states += [f"{prefix}_{name}" for prefix in ZONE_STATE_TEMPLATE]
22
+ actions += [f"{prefix}_{name}" for prefix in ZONE_ACTION_TEMPLATE]
23
+ return GLOBAL_COLS + states + actions
24
+
25
+ # ==========================================
26
+ # 2. PHYSICS UTILITIES
27
+ # ==========================================
28
+ def batch_calculate_rh(temp_array: np.ndarray, dewpoint_array: np.ndarray) -> np.ndarray:
29
+ """August-Roche-Magnus approximation for Relative Humidity."""
30
+ A, B = 17.625, 243.04
31
+ rh = 100 * np.exp((A * dewpoint_array / (B + dewpoint_array)) -
32
+ (A * temp_array / (B + temp_array)))
33
+ return np.clip(rh, 0.0, 100.0)
34
+
35
+ # ==========================================
36
+ # 3. MAIN GENERATOR FUNCTION
37
+ # ==========================================
38
+ def save_dt_training_data(df_raw: pd.DataFrame, out_dir: str, location: str):
39
+ dt_df = pd.DataFrame()
40
+
41
+ dt_df['step'] = df_raw.get('step', range(len(df_raw)))
42
+ dt_df['month'] = df_raw.get('month', 1)
43
+ dt_df['day'] = df_raw.get('day_of_month', 1)
44
+ dt_df['hour'] = df_raw.get('hour', 0)
45
+ dt_df['minute'] = (dt_df['step'] % 4) * 15
46
+ dt_df['out_temp'] = df_raw['outdoor_temp']
47
+ dt_df['out_rh'] = batch_calculate_rh(
48
+ df_raw['outdoor_temp'].values,
49
+ df_raw['outdoor_dewpoint'].values
50
+ )
51
+
52
+ dt_df['power_kw'] = df_raw['elec_power'] / 1000.0
53
+
54
+
55
+ for zone in ZONE_LIST:
56
+ s_name = "core" if zone == "core" else f"perim{zone[-1]}"
57
+
58
+ # States
59
+ dt_df[f"temp_{zone}"] = df_raw[f"{s_name}_temp"]
60
+ dt_df[f"occ_{zone}"] = df_raw[f"{s_name}_occ_count"]
61
+ dt_df[f"rh_{zone}"] = df_raw[f"{s_name}_rh"]
62
+ dt_df[f"htg_{zone}"] = df_raw.get("setpoint_htg", 21.0)
63
+ dt_df[f"clg_{zone}"] = df_raw.get("setpoint_clg", 24.0)
64
+
65
+
66
+ ALL_COLUMNS = get_full_schema()
67
+ dt_df = dt_df[ALL_COLUMNS]
68
+
69
+
70
+ os.makedirs(out_dir, exist_ok=True)
71
+ filename = f"{location}_ComfortDT_Training.csv"
72
+ save_path = os.path.join(out_dir, filename)
73
+ dt_df.to_csv(save_path, index=False)
74
+
75
+ print(f" DT Data Saved: {filename} | Shape: {dt_df.shape}")
76
+ return save_path
utilities/policy.py ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # unihvac/policy.py
2
+ from __future__ import annotations
3
+ import os
4
+ import json
5
+ from typing import Any, Dict, Tuple
6
+ import numpy as np
7
+ import torch
8
+ import torch.nn.functional as F
9
+ import requests
10
+ import numpy as np
11
+ import json
12
+
13
+ import requests
14
+ import numpy as np
15
+
16
+ class RemoteHTTPPolicy:
17
+ def __init__(self, server_url: str = "http://host.docker.internal:8000"):
18
+ self.server_url = server_url
19
+ self.predict_endpoint = f"{server_url}/predict"
20
+ self.reset_endpoint = f"{server_url}/reset"
21
+ print(f"[RemotePolicy] Connecting to {self.server_url}...")
22
+
23
+ def reset(self):
24
+ try:
25
+ requests.post(self.reset_endpoint, json={"message": "reset"})
26
+ print("[RemotePolicy] Remote buffer reset.")
27
+ except Exception as e:
28
+ print(f"[RemotePolicy] Reset failed: {e}")
29
+
30
+ def act(self, obs, info, step):
31
+ obs_list = np.array(obs, dtype=np.float32).tolist()
32
+ payload = {"step": int(step), "obs": obs_list, "info": {}}
33
+ try:
34
+ resp = requests.post(self.predict_endpoint, json=payload)
35
+ resp.raise_for_status()
36
+ action = np.array(resp.json()["action"], dtype=np.float32)
37
+ return action, {}, {}
38
+ except Exception as e:
39
+ print(f"[RemotePolicy] Error: {e}")
40
+ return np.array([21.0, 24.0] * 5, dtype=np.float32), {}, {}
41
+
42
+
43
+ def _get_int_env(name: str, default: int) -> int:
44
+ try:
45
+ v = int(os.environ.get(name, str(default)))
46
+ return v
47
+ except Exception:
48
+ return default
49
+
50
+
51
+ def _get_bool_env(name: str, default: bool) -> bool:
52
+ v = os.environ.get(name, None)
53
+ if v is None:
54
+ return default
55
+ return v.strip().lower() in ("1", "true", "yes", "y", "on")
56
+
57
+
58
+ # --------------------------------------------------------------------------------------
59
+ # Policies
60
+ # --------------------------------------------------------------------------------------
61
+ class ConstantSetpointPolicy5Zone:
62
+ """
63
+ Constant rule-based controller: 5 zones × (htg, clg) each.
64
+ Returns action = [htg, clg] * 5.
65
+ """
66
+ def __init__(self, heating_sp: float = 21.0, cooling_sp: float = 24.0):
67
+ self.heating_sp = float(heating_sp)
68
+ self.cooling_sp = float(cooling_sp)
69
+ self.action = np.array([self.heating_sp, self.cooling_sp] * 5, dtype=np.float32)
70
+
71
+ def reset(self):
72
+ return
73
+
74
+ def act(self, obs, info, step):
75
+ return self.action.copy(), {}, {}
76
+
77
+
78
+ class DecisionTransformerPolicy5Zone:
79
+ """
80
+ CPU-safe DT policy with robust observation mapping and deadband protection.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ ckpt_path: str,
86
+ model_config_path: str,
87
+ norm_stats_path: str,
88
+ context_len: int,
89
+ max_tokens_per_step: int,
90
+ device: str = "cpu",
91
+ temperature: float = 0.5,
92
+ ):
93
+ import dataloader as dl
94
+ from embeddings import GeneralistComfortDT
95
+
96
+ # --- 1. CPU Settings ---
97
+ torch.set_grad_enabled(False)
98
+ torch.backends.mha.set_fastpath_enabled(True)
99
+ torch.backends.mkldnn.enabled = _get_bool_env("DT_MKLDNN", True)
100
+ import multiprocessing
101
+ avail = multiprocessing.cpu_count()
102
+ dt_threads = _get_int_env("DT_NUM_THREADS", min(18, avail))
103
+ torch.set_num_threads(dt_threads)
104
+ torch.set_num_interop_threads(1)
105
+
106
+ self.dl = dl
107
+ self.device = torch.device("cpu")
108
+ self.temperature = float(temperature)
109
+ # --- 2. Load Model ---
110
+ with open(model_config_path, "r") as f:
111
+ cfg = json.load(f)
112
+
113
+ cfg["CONTEXT_LEN"] = int(context_len)
114
+ self.L = int(context_len)
115
+ self.K = int(max_tokens_per_step)
116
+
117
+ self.model = GeneralistComfortDT(cfg).to(self.device)
118
+ ckpt = torch.load(ckpt_path, map_location="cpu")
119
+ self.model.load_state_dict(ckpt["model"], strict=True)
120
+ self.model.eval()
121
+
122
+ # --- 3. Load Stats ---
123
+ z = np.load(norm_stats_path)
124
+ self.obs_mean = z["obs_mean"].astype(np.float32)
125
+ self.obs_std = z["obs_std"].astype(np.float32)
126
+ self.act_mean = z["act_mean"].astype(np.float32)
127
+ self.act_std = z["act_std"].astype(np.float32)
128
+ self.max_return = float(z["max_return"][0]) if "max_return" in z else 1.0
129
+
130
+
131
+ self.rtg_scale_mode = "max_return"
132
+ self.rtg_constant_div = 1.0
133
+ self.desired_rtg_raw = -0.5
134
+
135
+ self.prev_action = np.array([21.0, 24.0] * 5, dtype=np.float32)
136
+
137
+ # --- 4. Define Keys (The Fix) ---
138
+
139
+ self.env_keys_order = [
140
+ 'month', 'day_of_month', 'hour',
141
+ 'outdoor_temp', 'core_temp', 'perim1_temp', 'perim2_temp', 'perim3_temp', 'perim4_temp',
142
+ 'elec_power',
143
+ 'core_occ_count', 'perim1_occ_count', 'perim2_occ_count', 'perim3_occ_count', 'perim4_occ_count',
144
+ 'outdoor_dewpoint', 'outdoor_wetbulb',
145
+ 'core_rh', 'perim1_rh', 'perim2_rh', 'perim3_rh', 'perim4_rh',
146
+ 'core_ash55_notcomfortable_summer', 'core_ash55_notcomfortable_winter', 'core_ash55_notcomfortable_any',
147
+ 'p1_ash55_notcomfortable_any', 'p2_ash55_notcomfortable_any', 'p3_ash55_notcomfortable_any', 'p4_ash55_notcomfortable_any',
148
+ 'total_electricity_HVAC'
149
+ ]
150
+
151
+
152
+ self.model_state_keys = [
153
+ 'outdoor_temp', 'core_temp', 'perim1_temp', 'perim2_temp', 'perim3_temp', 'perim4_temp',
154
+ 'elec_power',
155
+ 'core_occ_count', 'perim1_occ_count', 'perim2_occ_count', 'perim3_occ_count', 'perim4_occ_count',
156
+ 'outdoor_dewpoint', 'outdoor_wetbulb',
157
+ 'core_rh', 'perim1_rh', 'perim2_rh', 'perim3_rh', 'perim4_rh',
158
+ 'core_ash55_notcomfortable_summer', 'core_ash55_notcomfortable_winter', 'core_ash55_notcomfortable_any',
159
+ 'p1_ash55_notcomfortable_any', 'p2_ash55_notcomfortable_any', 'p3_ash55_notcomfortable_any', 'p4_ash55_notcomfortable_any',
160
+ 'month', 'hour'
161
+ ]
162
+
163
+ self.obs_indices = []
164
+ for k in self.model_state_keys:
165
+ try:
166
+ self.obs_indices.append(self.env_keys_order.index(k))
167
+ except ValueError:
168
+ print(f"Key {k} missing")
169
+ self.obs_indices.append(0) # Fallback
170
+ self.obs_indices = np.array(self.obs_indices, dtype=np.int64)
171
+
172
+ self.action_keys = [
173
+ "htg_core", "clg_core", "htg_p1", "clg_p1", "htg_p2", "clg_p2",
174
+ "htg_p3", "clg_p3", "htg_p4", "clg_p4",
175
+ ]
176
+
177
+ # Meta info
178
+ self.s_meta = [self.dl.parse_feature_identity(k, is_action=False) for k in self.model_state_keys]
179
+ self.a_meta = [self.dl.parse_feature_identity(k, is_action=True) for k in self.action_keys]
180
+
181
+ self.num_act = min(len(self.a_meta), self.K)
182
+ self.num_state = min(len(self.s_meta), self.K - self.num_act)
183
+
184
+ # --- 5. Precompute Token Layouts ---
185
+ self.row_feat_ids = np.zeros((self.K,), dtype=np.int64)
186
+ self.row_zone_ids = np.zeros((self.K,), dtype=np.int64)
187
+ self.row_attn = np.zeros((self.K,), dtype=np.int64)
188
+ self.row_feat_vals = np.zeros((self.K,), dtype=np.float32)
189
+
190
+ if self.num_state > 0:
191
+ s_meta = self.s_meta[:self.num_state]
192
+ self.row_feat_ids[:self.num_state] = np.array([m[0] for m in s_meta], dtype=np.int64)
193
+ self.row_zone_ids[:self.num_state] = np.array([m[1] for m in s_meta], dtype=np.int64)
194
+ self.row_attn[:self.num_state] = 1
195
+
196
+ if self.num_act > 0:
197
+ start = self.num_state
198
+ end = start + self.num_act
199
+ a_meta = self.a_meta[:self.num_act]
200
+ self.row_feat_ids[start:end] = np.array([m[0] for m in a_meta], dtype=np.int64)
201
+ self.row_zone_ids[start:end] = np.array([m[1] for m in a_meta], dtype=np.int64)
202
+ self.row_attn[start:end] = 1
203
+
204
+ # Context Dimension from Config
205
+ self.context_dim = cfg.get("CONTEXT_DIM", 10)
206
+
207
+
208
+ # Buffers
209
+ self.buf_feature_ids = torch.zeros((self.L, self.K), dtype=torch.long, device=self.device)
210
+ self.buf_feature_vals = torch.zeros((self.L, self.K), dtype=torch.float32, device=self.device)
211
+ self.buf_zone_ids = torch.zeros((self.L, self.K), dtype=torch.long, device=self.device)
212
+ self.buf_attn = torch.zeros((self.L, self.K), dtype=torch.long, device=self.device)
213
+ self.buf_rtg = torch.zeros((self.L,), dtype=torch.float32, device=self.device)
214
+
215
+ # Inputs
216
+ self.t_feature_ids = torch.zeros((1, self.L, self.K), dtype=torch.long, device=self.device)
217
+ self.t_feature_vals = torch.zeros((1, self.L, self.K), dtype=torch.float32, device=self.device)
218
+ self.t_zone_ids = torch.zeros((1, self.L, self.K), dtype=torch.long, device=self.device)
219
+ self.t_attn = torch.zeros((1, self.L, self.K), dtype=torch.long, device=self.device)
220
+ self.t_rtg = torch.zeros((1, self.L), dtype=torch.float32, device=self.device)
221
+
222
+ self.ptr = 0
223
+ self.filled = 0
224
+
225
+ #Context Buffer
226
+ self.t_context = torch.zeros((1, self.context_dim), dtype=torch.float32, device=self.device)
227
+
228
+ def reset(self):
229
+ self.buf_feature_ids.zero_()
230
+ self.buf_feature_vals.zero_()
231
+ self.buf_zone_ids.zero_()
232
+ self.buf_attn.zero_()
233
+ self.buf_rtg.zero_()
234
+ self.t_feature_ids.zero_()
235
+ self.t_feature_vals.zero_()
236
+ self.t_zone_ids.zero_()
237
+ self.t_attn.zero_()
238
+ self.t_rtg.zero_()
239
+ self.prev_action = np.array([21.0, 24.0] * 5, dtype=np.float32)
240
+ self.ptr = 0
241
+ self.filled = 0
242
+
243
+ def _decode_bin_to_setpoint(self, bin_id: int, key: str) -> float:
244
+ if "clg" in key.lower() or "cool" in key.lower():
245
+ lo, hi = self.dl.CLG_LOW, self.dl.CLG_HIGH
246
+ else:
247
+ lo, hi = self.dl.HTG_LOW, self.dl.HTG_HIGH
248
+ x = float(bin_id) / float(self.dl.NUM_ACTION_BINS - 1)
249
+ return lo + x * (hi - lo)
250
+
251
+ def _scale_rtg(self, rtg_raw: float) -> float:
252
+ if self.rtg_scale_mode == "max_return":
253
+ scale = max(self.max_return, 1e-6)
254
+ return float(rtg_raw) / scale
255
+ return float(rtg_raw) / float(self.rtg_constant_div)
256
+
257
+ def _write_model_inputs_from_ring(self):
258
+ if self.filled < self.L:
259
+ start = self.L - self.filled
260
+ self.t_feature_ids.zero_(); self.t_feature_vals.zero_()
261
+ self.t_zone_ids.zero_(); self.t_attn.zero_(); self.t_rtg.zero_()
262
+ self.t_feature_ids[0, start:].copy_(self.buf_feature_ids[: self.filled])
263
+ self.t_feature_vals[0, start:].copy_(self.buf_feature_vals[: self.filled])
264
+ self.t_zone_ids[0, start:].copy_(self.buf_zone_ids[: self.filled])
265
+ self.t_attn[0, start:].copy_(self.buf_attn[: self.filled])
266
+ self.t_rtg[0, start:].copy_(self.buf_rtg[: self.filled])
267
+ return
268
+
269
+ p = self.ptr
270
+ n1 = self.L - p
271
+ self.t_feature_ids[0, :n1].copy_(self.buf_feature_ids[p:])
272
+ self.t_feature_vals[0, :n1].copy_(self.buf_feature_vals[p:])
273
+ self.t_zone_ids[0, :n1].copy_(self.buf_zone_ids[p:])
274
+ self.t_attn[0, :n1].copy_(self.buf_attn[p:])
275
+ self.t_rtg[0, :n1].copy_(self.buf_rtg[p:])
276
+
277
+ self.t_feature_ids[0, n1:].copy_(self.buf_feature_ids[:p])
278
+ self.t_feature_vals[0, n1:].copy_(self.buf_feature_vals[:p])
279
+ self.t_zone_ids[0, n1:].copy_(self.buf_zone_ids[:p])
280
+ self.t_attn[0, n1:].copy_(self.buf_attn[:p])
281
+ self.t_rtg[0, n1:].copy_(self.buf_rtg[:p])
282
+
283
+ def act(self, obs: Any, info: Dict[str, Any], step: int) -> Tuple[np.ndarray, Dict, Dict]:
284
+
285
+ # Map raw obs (30 items) model obs (28 items)
286
+ obs_raw = np.asarray(obs, dtype=np.float32)
287
+ env_map = dict(zip(self.env_keys_order, obs_raw))
288
+ obs_ordered = np.array([env_map.get(k, 0.0) for k in self.model_state_keys], dtype=np.float32)
289
+
290
+ # --- 2. Normalization ---
291
+ obs_norm = obs_ordered.copy()
292
+ D = min(len(self.obs_mean), obs_norm.shape[0])
293
+ eps = 1e-6
294
+ obs_norm[:D] = (obs_norm[:D] - self.obs_mean[:D]) / (self.obs_std[:D] + eps)
295
+
296
+
297
+
298
+ # =========================================================================
299
+ # 3. CALCULATE CONTEXT VECTOR (Dynamic)
300
+ # =========================================================================
301
+
302
+ out_temp = env_map.get('outdoor_temp', 0.0)
303
+ out_dew = env_map.get('outdoor_dewpoint', 0.0)
304
+ hour = env_map.get('hour', 0.0)
305
+ month = env_map.get('month', 1.0)
306
+
307
+ occ_total = 0.0
308
+ occ_keys = ['core_occ_count', 'perim1_occ_count', 'perim2_occ_count', 'perim3_occ_count', 'perim4_occ_count']
309
+ for k in occ_keys:
310
+ if env_map.get(k, 0.0) > 0.5: # Binary occupancy check
311
+ occ_total += 1.0
312
+ occ_frac = occ_total / 5.0
313
+
314
+ hr_sin = np.sin(2 * np.pi * hour / 24.0)
315
+ hr_cos = np.cos(2 * np.pi * hour / 24.0)
316
+ mth_norm = month - 1.0
317
+ mth_sin = np.sin(2 * np.pi * mth_norm / 12.0)
318
+ mth_cos = np.cos(2 * np.pi * mth_norm / 12.0)
319
+
320
+ ctx_vec = np.array([
321
+ out_temp, 0.0, # Temp Mean, Temp Std
322
+ out_dew, # Dewpoint
323
+ occ_frac, # Occ Fraction
324
+ hr_sin, hr_cos, # Hour
325
+ mth_sin, mth_cos, # Month
326
+ 0.0, 0.0 # Spares
327
+ ], dtype=np.float32)
328
+
329
+ self.t_context[0].copy_(torch.from_numpy(ctx_vec))
330
+ act_norm = self.prev_action.copy()
331
+ A = min(len(self.act_mean), act_norm.shape[0])
332
+ act_norm[:A] = (act_norm[:A] - self.act_mean[:A]) / self.act_std[:A]
333
+
334
+
335
+
336
+
337
+ self.row_feat_vals.fill(0.0)
338
+ if self.num_state > 0:
339
+ self.row_feat_vals[: self.num_state] = obs_norm[: self.num_state]
340
+ if self.num_act > 0:
341
+ s, e = self.num_state, self.num_state + self.num_act
342
+ if step < 5:
343
+ good_action = np.array([22.0, 25.0] * 5, dtype=np.float32)
344
+ good_norm = good_action.copy()
345
+ A_len = min(len(self.act_mean), good_norm.shape[0])
346
+ good_norm[:A_len] = (good_norm[:A_len] - self.act_mean[:A_len]) / self.act_std[:A_len]
347
+ self.row_feat_vals[s:e] = good_norm[: self.num_act]
348
+ else:
349
+ self.row_feat_vals[s:e] = act_norm[: self.num_act]
350
+
351
+ i = self.ptr
352
+ self.buf_feature_ids[i].copy_(torch.as_tensor(self.row_feat_ids, dtype=torch.long))
353
+ self.buf_zone_ids[i].copy_(torch.as_tensor(self.row_zone_ids, dtype=torch.long))
354
+ self.buf_attn[i].copy_(torch.as_tensor(self.row_attn, dtype=torch.long))
355
+ self.buf_feature_vals[i].copy_(torch.as_tensor(self.row_feat_vals, dtype=torch.float32))
356
+ self.buf_rtg[i] = float(self._scale_rtg(self.desired_rtg_raw))
357
+
358
+ self.ptr = (self.ptr + 1) % self.L
359
+ self.filled = min(self.filled + 1, self.L)
360
+
361
+ self._write_model_inputs_from_ring()
362
+ with torch.inference_mode():
363
+ with torch.amp.autocast(device_type="cpu", dtype=torch.bfloat16):
364
+ out = self.model(self.t_feature_ids, self.t_feature_vals, self.t_zone_ids, self.t_attn, rtg=self.t_rtg, context=self.t_context)
365
+
366
+
367
+
368
+ logits = out["action_logits"]
369
+ last = logits[0, -1] # [K, n_bins]
370
+ s, e = self.num_state, self.num_state + self.num_act
371
+ temp = max(self.temperature, 1e-4)
372
+ raw_logits = last[s:e]
373
+ if torch.isnan(raw_logits).any() or torch.isinf(raw_logits).any():
374
+ raw_logits = torch.nan_to_num(raw_logits, nan=0.0, posinf=10.0, neginf=-10.0)
375
+
376
+ # 1. Apply Temperature
377
+ action_logits = raw_logits / temp
378
+
379
+ # 2. Convert to Probabilities
380
+ action_probs = F.softmax(action_logits, dim=-1) # [Num_Actions, n_bins]
381
+ if torch.isnan(action_probs).any() or (action_probs < 0).any():
382
+ action_probs = torch.ones_like(action_probs) / action_probs.size(-1)
383
+
384
+ # 3. Sample from distribution
385
+ try:
386
+ pred_bins = torch.multinomial(action_probs, num_samples=1).flatten().cpu().numpy().astype(np.int64)
387
+ except RuntimeError as err:
388
+ pred_bins = torch.argmax(action_probs, dim=-1).cpu().numpy().astype(np.int64)
389
+
390
+ action = self.prev_action.copy()
391
+ for j in range(self.num_act):
392
+ action[j] = self._decode_bin_to_setpoint(int(pred_bins[j]), self.action_keys[j])
393
+
394
+ for j, k in enumerate(self.action_keys):
395
+ if "clg" in k.lower():
396
+ action[j] = float(np.clip(action[j], self.dl.CLG_LOW, self.dl.CLG_HIGH))
397
+ else:
398
+ action[j] = float(np.clip(action[j], self.dl.HTG_LOW, self.dl.HTG_HIGH))
399
+ DEADBAND_GAP = 3.0
400
+
401
+ for z in range(5):
402
+ h_idx = 2 * z
403
+ c_idx = 2 * z + 1
404
+ if action[c_idx] < action[h_idx] + DEADBAND_GAP:
405
+ action[c_idx] = min(self.dl.CLG_HIGH, action[h_idx] + DEADBAND_GAP)
406
+ if action[c_idx] < action[h_idx] + DEADBAND_GAP:
407
+ action[h_idx] = max(self.dl.HTG_LOW, action[c_idx] - DEADBAND_GAP)
408
+
409
+
410
+
411
+ if step < 5 or step % 1000 == 0:
412
+ print(f"[DT] Step {step} Raw Bins: {pred_bins}")
413
+ h_val = self._decode_bin_to_setpoint(int(pred_bins[0]), "htg_core")
414
+ c_val = self._decode_bin_to_setpoint(int(pred_bins[1]), "clg_core")
415
+ print(f"[DT] Step {step} Decoded Core: Heat {h_val:.2f} | Cool {c_val:.2f}")
416
+
417
+
418
+ self.prev_action = action
419
+ return action, {}, {}
420
+
421
+ def make_policy(policy_type: str, **kwargs):
422
+ policy_type = (policy_type or "").lower().strip()
423
+ if policy_type == "dt":
424
+ return DecisionTransformerPolicy5Zone(
425
+ ckpt_path=kwargs["ckpt_path"],
426
+ model_config_path=kwargs["model_config_path"],
427
+ norm_stats_path=kwargs["norm_stats_path"],
428
+ context_len=kwargs["context_len"],
429
+ max_tokens_per_step=kwargs["max_tokens_per_step"],
430
+ device=kwargs.get("device", "cpu"),
431
+ temperature=kwargs.get("temperature", 0.8),
432
+ )
433
+ raise ValueError(f"Unknown policy_type={policy_type}.")
434
+
435
+
436
+
437
+
438
+
439
+
440
+
utilities/rewards.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # unihvac/rewards.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, asdict
5
+ from typing import Dict, Any, Tuple, Optional
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class RewardConfig:
12
+ version: str = "v_ashrae"
13
+ prefer_step_kwh_cols: Tuple[str, ...] = (
14
+ "HVAC_elec_kWh_step",
15
+ "hvac_kWh_step",
16
+ "elec_kWh_step",
17
+ )
18
+
19
+ elec_power_col: str = "elec_power"
20
+
21
+ comfort_col: str = "ppd_weighted"
22
+ w_energy: float = 1.0
23
+ w_comfort: float = 0.1
24
+
25
+
26
+ def config_to_meta(cfg: RewardConfig) -> Dict[str, Any]:
27
+ return asdict(cfg)
28
+
29
+ def compute_reward_components(df: pd.DataFrame, timestep_hours: float, cfg: RewardConfig) -> Tuple[np.ndarray, np.ndarray]:
30
+ if df is None or len(df) == 0:
31
+ return np.zeros((0,), dtype=np.float32), np.zeros((0,), dtype=np.float32)
32
+ energy_kwh = np.zeros(len(df), dtype=np.float32)
33
+ found_energy = False
34
+
35
+ for col in cfg.prefer_step_kwh_cols:
36
+ if col in df.columns:
37
+ energy_kwh = df[col].fillna(0.0).astype(np.float32).values
38
+ found_energy = True
39
+ break
40
+
41
+ if not found_energy and cfg.elec_power_col in df.columns:
42
+ power_w = df[cfg.elec_power_col].fillna(0.0).astype(np.float32).values
43
+ energy_kwh = (power_w / 1000.0) * timestep_hours
44
+ comfort_val = np.zeros(len(df), dtype=np.float32)
45
+ if cfg.comfort_col in df.columns:
46
+ comfort_val = df[cfg.comfort_col].fillna(0.0).astype(np.float32).values
47
+ r_energy = -1.0 * energy_kwh
48
+ r_comfort = -1.0 * comfort_val
49
+
50
+ return r_energy.astype(np.float32), r_comfort.astype(np.float32)
51
+
52
+ def compute_terminals(df: pd.DataFrame) -> np.ndarray:
53
+ T = 0 if df is None else len(df)
54
+ terminals = np.zeros((T,), dtype=np.int8)
55
+ if T > 0:
56
+ terminals[-1] = 1
57
+ return terminals
utilities/rollout.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # unihvac/rollout.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Callable, Dict, Any, Optional, List, Tuple
5
+
6
+ import gymnasium as gym
7
+ import numpy as np
8
+ import pandas as pd
9
+ import sinergym
10
+
11
+ from unihvac.comfort import (
12
+ fix_ashrae_any_fixed,
13
+ quick_stats,
14
+ add_feature_availability_and_registry,
15
+ print_feature_availability,
16
+ compute_comfort_metrics_inplace,
17
+ )
18
+
19
+
20
+ ZONE_TEMP_KEYS = ["core_temp", "perim1_temp", "perim2_temp", "perim3_temp", "perim4_temp"]
21
+ ZONE_OCC_KEYS = ["core_occ_count","perim1_occ_count","perim2_occ_count","perim3_occ_count","perim4_occ_count"]
22
+ RH_KEYS = ["core_rh","perim1_rh","perim2_rh","perim3_rh","perim4_rh"]
23
+
24
+ BASE_FEATURE_COLS = [
25
+ "outdoor_temp","core_temp","perim1_temp","perim2_temp","perim3_temp","perim4_temp",
26
+ "elec_power",
27
+ "core_occ_count","perim1_occ_count","perim2_occ_count","perim3_occ_count","perim4_occ_count",
28
+ ]
29
+ NEW_FEATURE_COLS = [
30
+ "outdoor_dewpoint","outdoor_wetbulb",
31
+ "core_rh","perim1_rh","perim2_rh","perim3_rh","perim4_rh",
32
+ "core_ash55_notcomfortable_summer","core_ash55_notcomfortable_winter","core_ash55_notcomfortable_any",
33
+ "p1_ash55_notcomfortable_any","p2_ash55_notcomfortable_any","p3_ash55_notcomfortable_any","p4_ash55_notcomfortable_any",
34
+ ]
35
+
36
+ ASH_COLS = [
37
+ "core_ash55_notcomfortable_summer",
38
+ "core_ash55_notcomfortable_winter",
39
+ "core_ash55_any_fixed",
40
+ "p1_ash55_notcomfortable_any",
41
+ "p2_ash55_notcomfortable_any",
42
+ "p3_ash55_notcomfortable_any",
43
+ "p4_ash55_notcomfortable_any",
44
+ ]
45
+
46
+ PolicyFn = Callable[[np.ndarray, Dict[str, Any], int], np.ndarray]
47
+
48
+
49
+ class DummyReward:
50
+ def __init__(self, *args, **kwargs):
51
+ pass
52
+
53
+ def __call__(self, obs_dict):
54
+ return 0.0, {}
55
+
56
+
57
+ def make_env_officesmall_5zone(
58
+ building_path: str,
59
+ weather_path: str,
60
+ variables: Dict[str, tuple],
61
+ actuators: Dict[str, tuple],
62
+ action_low: float = 12.0,
63
+ action_high: float = 30.0,
64
+ action_dim: int = 10,
65
+ reward=None,
66
+ ):
67
+
68
+ new_action_space = gym.spaces.Box(
69
+ low=action_low, high=action_high, shape=(action_dim,), dtype=np.float32
70
+ )
71
+
72
+ if reward is None:
73
+ reward = DummyReward
74
+ env = gym.make(
75
+ "Eplus-5zone-mixed-continuous-stochastic-v1",
76
+ building_file=building_path,
77
+ weather_files=[weather_path],
78
+ variables=variables,
79
+ actuators=actuators,
80
+ action_space=new_action_space,
81
+ reward=reward,
82
+ )
83
+ obs_keys = env.unwrapped.observation_variables
84
+ print("ENVIRONMENT VARIABLES:", obs_keys)
85
+
86
+ obs_keys = env.unwrapped.observation_variables
87
+ month_idx = obs_keys.index("month") if "month" in obs_keys else None
88
+ return env, obs_keys, month_idx
89
+
90
+
91
+ def rollout_episode(
92
+ env,
93
+ policy_fn: PolicyFn,
94
+ obs_keys: List[str],
95
+ month_idx: Optional[int],
96
+ max_steps: Optional[int] = None,
97
+ ) -> pd.DataFrame:
98
+
99
+ obs, info = env.reset()
100
+ data_log = []
101
+
102
+ terminated = False
103
+ truncated = False
104
+ step = 0
105
+
106
+ while not (terminated or truncated):
107
+ if max_steps is not None and step >= max_steps:
108
+ break
109
+
110
+ action = policy_fn(obs, info, step)
111
+ htg_sp = float(action[0])
112
+ clg_sp = float(action[1])
113
+ next_obs, _, terminated, truncated, info = env.step(action)
114
+
115
+ month_val = next_obs[month_idx] if month_idx is not None else info.get("month", np.nan)
116
+
117
+ row = {"step": step, "month": month_val}
118
+ row["setpoint_htg"] = htg_sp
119
+ row["setpoint_clg"] = clg_sp
120
+
121
+ row.update(dict(zip(obs_keys, next_obs)))
122
+ data_log.append(row)
123
+
124
+
125
+ obs = next_obs
126
+ step += 1
127
+
128
+ df = pd.DataFrame(data_log)
129
+ if "month" in df.columns:
130
+ df["month"] = df["month"].round().astype(int)
131
+ return df
132
+
133
+
134
+ def add_energy_columns_inplace(
135
+ df: pd.DataFrame,
136
+ timestep_hours: float,
137
+ elec_col: str = "elec_power",
138
+ ) -> None:
139
+ if elec_col in df.columns:
140
+ df["elec_power_kw"] = df[elec_col] / 1000.0
141
+ df["elec_energy_kwh"] = df["elec_power_kw"] * timestep_hours
142
+ else:
143
+ df["elec_power_kw"] = np.nan
144
+ df["elec_energy_kwh"] = np.nan
145
+
146
+
147
+ def postprocess_comfort_inplace(
148
+ df: pd.DataFrame,
149
+ location: str,
150
+ timestep_hours: float,
151
+ heating_sp: float,
152
+ cooling_sp: float,
153
+ verbose: bool = True,
154
+ ) -> None:
155
+
156
+
157
+ fix_ashrae_any_fixed(df)
158
+ if verbose:
159
+ quick_stats(df, ASH_COLS, "ASHRAE55 Not Comfortable (raw timestep values)")
160
+ add_feature_availability_and_registry(df, BASE_FEATURE_COLS, NEW_FEATURE_COLS)
161
+ if verbose:
162
+ print_feature_availability(df, location)
163
+ compute_comfort_metrics_inplace(
164
+ df=df,
165
+ location=location,
166
+ time_step_hours=timestep_hours,
167
+ heating_sp=heating_sp,
168
+ cooling_sp=cooling_sp,
169
+ zone_temp_keys=ZONE_TEMP_KEYS,
170
+ zone_occ_keys=ZONE_OCC_KEYS,
171
+ rh_keys=RH_KEYS,
172
+ )
173
+
174
+
175
+ def run_rollout_to_df(
176
+ *,
177
+ building_path: str,
178
+ weather_path: str,
179
+ variables: Dict[str, tuple],
180
+ actuators: Dict[str, tuple],
181
+ policy_fn: PolicyFn,
182
+ location: str,
183
+ timestep_hours: float,
184
+ heating_sp: float,
185
+ cooling_sp: float,
186
+ reward=None,
187
+ max_steps: Optional[int] = None,
188
+ verbose: bool = True,
189
+ ) -> pd.DataFrame:
190
+
191
+ env = None
192
+ try:
193
+ env, obs_keys, month_idx = make_env_officesmall_5zone(
194
+ building_path=building_path,
195
+ weather_path=weather_path,
196
+ variables=variables,
197
+ actuators=actuators,
198
+ reward=reward,
199
+ )
200
+
201
+ df = rollout_episode(
202
+ env=env,
203
+ policy_fn=policy_fn,
204
+ obs_keys=list(obs_keys),
205
+ month_idx=month_idx,
206
+ max_steps=max_steps,
207
+ )
208
+ finally:
209
+ if env is not None:
210
+ env.close()
211
+
212
+ add_energy_columns_inplace(df, timestep_hours=timestep_hours)
213
+ postprocess_comfort_inplace(
214
+ df=df,
215
+ location=location,
216
+ timestep_hours=timestep_hours,
217
+ heating_sp=heating_sp,
218
+ cooling_sp=cooling_sp,
219
+ verbose=verbose,
220
+ )
221
+ return df
222
+
223
+
224
+
225
+ # ======================================================================================
226
+ # INDEX MAPPING (Sinergym / OfficeSmall 5-Zone)
227
+ #
228
+ # 00: month
229
+ # 01: day_of_month
230
+ # 02: hour
231
+ # 03: outdoor_temp
232
+ # 04: core_temp
233
+ # 05: perim1_temp
234
+ # 06: perim2_temp
235
+ # 07: perim3_temp
236
+ # 08: perim4_temp
237
+ # 09: elec_power
238
+ # 10: core_occ_count
239
+ # 11: perim1_occ_count
240
+ # 12: perim2_occ_count
241
+ # 13: perim3_occ_count
242
+ # 14: perim4_occ_count
243
+ # 15: outdoor_dewpoint
244
+ # 16: outdoor_wetbulb
245
+ # 17: core_rh
246
+ # 18: perim1_rh
247
+ # 19: perim2_rh
248
+ # 20: perim3_rh
249
+ # 21: perim4_rh
250
+ # 22: core_ash55_notcomfortable_summer
251
+ # 23: core_ash55_notcomfortable_winter
252
+ # 24: core_ash55_notcomfortable_any
253
+ # 25: p1_ash55_notcomfortable_any
254
+ # 26: p2_ash55_notcomfortable_any
255
+ # 27: p3_ash55_notcomfortable_any
256
+ # 28: p4_ash55_notcomfortable_any
257
+ # 29: total_electricity_HVAC
258
+ #
259
+
260
+ #
261
+ # ======================================================================================
utilities/tables.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # unihvac/tables.py
2
+ from __future__ import annotations
3
+ import pandas as pd
4
+ import numpy as np
5
+
6
+ def print_monthly_tables_split(df: pd.DataFrame, location: str, time_step_hours: float):
7
+ """
8
+ Table 1: Monthly HVAC electricity + temps
9
+ Table 2: Monthly occupancy
10
+ """
11
+ drop_cols = [c for c in df.columns if c.startswith("has_") or c == "feature_registry"]
12
+ df_clean = df.drop(columns=drop_cols, errors="ignore").copy()
13
+
14
+ if "month" not in df_clean.columns:
15
+ return
16
+
17
+ df_clean["month"] = df_clean["month"].round().astype(int)
18
+
19
+ # 1. Electricity / Energy Calculation
20
+ energy_cols = []
21
+ peak_cols = []
22
+ if "elec_power" in df_clean.columns:
23
+ if "elec_power_kw" not in df_clean.columns:
24
+ df_clean["elec_power_kw"] = df_clean["elec_power"] / 1000.0
25
+ if "elec_energy_kwh" not in df_clean.columns:
26
+ df_clean["elec_energy_kwh"] = df_clean["elec_power_kw"] * time_step_hours
27
+ energy_cols.append("elec_energy_kwh")
28
+ peak_cols.append("elec_power_kw")
29
+
30
+ # 2. Temperature Aggregation
31
+ temp_cols = [c for c in ["outdoor_temp", "core_temp", "perim1_temp", "perim2_temp", "perim3_temp", "perim4_temp"]
32
+ if c in df_clean.columns]
33
+
34
+ agg1 = {c: "sum" for c in energy_cols}
35
+ agg1.update({c: "max" for c in peak_cols})
36
+ agg1.update({c: "mean" for c in temp_cols})
37
+ tbl1 = df_clean.groupby("month").agg(agg1).sort_index()
38
+
39
+ # 3. Occupancy Aggregation
40
+ occ_cols = [c for c in df_clean.columns if c.endswith("_occ_count")]
41
+ tbl2 = df_clean.groupby("month")[occ_cols].mean().sort_index() if occ_cols else pd.DataFrame()
42
+ if not tbl2.empty:
43
+ tbl2["occ_mean_total"] = tbl2.sum(axis=1)
44
+
45
+ print("\n" + "=" * 110)
46
+ print(f"MONTHLY ELECTRICITY + TEMPERATURE — {location}")
47
+ print("=" * 110)
48
+ print(tbl1.round(2).to_string())
49
+ print("\n" + "=" * 110)
50
+ print(f"MONTHLY OCCUPANCY — {location}")
51
+ print("=" * 110)
52
+ print(tbl2.round(3).to_string())
53
+ print("=" * 110 + "\n")
54
+
55
+
56
+ def print_monthly_tables_extra(df: pd.DataFrame, location: str) -> None:
57
+
58
+ d = df.copy()
59
+ if "month" not in d.columns:
60
+ return
61
+ d["month"] = d["month"].round().astype(int)
62
+
63
+ violation_cols = [c for c in ["comfort_violation_degCh", "comfort_violation_fixed_degCh"] if c in d.columns]
64
+ tbl_sums = d.groupby("month")[violation_cols].sum()
65
+
66
+
67
+ occ_cols = [c for c in d.columns if c.endswith("_occ_count")]
68
+ total_occ = d[occ_cols].sum(axis=1)
69
+ is_occupied = total_occ > 1e-6
70
+ d_occ = d[is_occupied].copy()
71
+
72
+
73
+ def person_weighted_ppd(group):
74
+ occ = group[occ_cols].sum(axis=1)
75
+ raw_ppd = group["ppd_weighted"]
76
+ return (raw_ppd * occ).sum() / occ.sum() if occ.sum() > 0 else np.nan
77
+
78
+ if not d_occ.empty and "ppd_weighted" in d_occ.columns:
79
+ ppd_monthly = d_occ.groupby("month", group_keys=False).apply(person_weighted_ppd)
80
+ ppd_monthly = ppd_monthly.clip(lower=5.0)
81
+
82
+ pmv_monthly = d_occ.groupby("month")["pmv_weighted"].mean()
83
+ rh_monthly = d_occ.groupby("month")["rh_weighted"].mean()
84
+
85
+ tbl_means = pd.DataFrame({
86
+ "ppd_weighted": ppd_monthly,
87
+ "pmv_weighted": pmv_monthly,
88
+ "rh_weighted_%": rh_monthly
89
+ })
90
+ tbl3a = pd.concat([tbl_sums, tbl_means], axis=1).sort_index()
91
+ else:
92
+ tbl3a = tbl_sums
93
+
94
+
95
+ outdoor_vars = [c for c in ["outdoor_temp", "outdoor_dewpoint", "outdoor_wetbulb"] if c in d.columns]
96
+ tbl3b = d.groupby("month")[outdoor_vars].mean().sort_index() if outdoor_vars else None
97
+
98
+
99
+ print("\n" + "=" * 110)
100
+ print(f"MONTHLY COMFORT OUTCOMES (Occupancy Weighted) — {location}")
101
+ print("=" * 110)
102
+ print(tbl3a.round(3).to_string())
103
+ print("=" * 110)
104
+
105
+
106
+ if tbl3b is not None:
107
+ print("\n" + "=" * 110)
108
+ print(f"MONTHLY OUTDOOR CONDITIONS — {location}")
109
+ print("=" * 110)
110
+ print(tbl3b.round(3).to_string())
111
+ print("=" * 110)