Upload 6 files
Browse files- utilities/comfort.py +207 -0
- utilities/data_generator.py +76 -0
- utilities/policy.py +440 -0
- utilities/rewards.py +57 -0
- utilities/rollout.py +261 -0
- utilities/tables.py +111 -0
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)
|