Skip to content

Commit 9d15e62

Browse files
committed
perf: mechanical Python-level optimizations (Tier 0) — 34% faster simulation
Eliminate Python overhead in the hot simulation loop through mechanical, behavior-preserving replacements. No architecture or physics changes. Benchmark: 30-day simulation at 1-minute timesteps (43,560 steps) using BEopt_example.xml + BEopt_example_schedule.csv + Denver TMY3 weather, starting June 1 2018, with PV (5kW), Battery (6kWh/3kW), and EV (BEV Level 2). Deterministic via seed=42. Before: 37.67s → After: 24.86s (34.0% faster, 12.81s saved) Function calls: 74.3M → 44.6M (40% reduction) All 244 tests pass. Output identical within tolerances. Changes by category: Schedule/dispatch overhead: - Cache DataFrame.empty as bool flag, recomputed in reset_time() - Replace to_dict("records") iterator with array-backed dict construction - Replace isinstance() checks with bit-flag dispatch on Equipment._kind - Skip per-sub datetime comparisons when all sub-simulators share resolution - Convert all_schedule_inputs membership test from list to frozenset Unit conversion and constant caching: - Cache pint convert() results as module-level constants for natural ventilation - Also fixes natural ventilation area unit: ft^2 → m^2 (pre-existing bug) - Pre-compute f-string schedule keys at init instead of per-step Numpy small-array allocation elimination: - Replace np.insert() with pre-allocated buffer in Water.update_model - Replace np.array() + np.dot() biquadratic with inline scalar polynomial - Pre-allocate np.zeros buffers in Envelope.update_inputs/update_model - Pre-allocate surface temperature buffers for interior radiation - Pre-allocate np.zeros/np.concatenate buffers in Water model - Skip inputs_init.copy() in SSM when full control_signal provided Scalar math dispatch: - Python sum()/any() on numpy arrays → ndarray .sum()/.any() - np.exp/np.sqrt on scalars → math.exp/math.sqrt - Python min()/max() on numpy arrays → .min()/.max() - Precompute vol_fractions cumulative sums in Water model Lookup optimization: - Dict index maps for StateSpaceModel input/output/state name lookups - Scalar fast path for RCModel.solve_for_input (common infiltration case) - Cache np.nonzero(~disable_speeds) in HVAC Tests: - Add tests for scalar solver equivalence and nat-vent unit conversion
1 parent b88a5e1 commit 9d15e62

16 files changed

Lines changed: 331 additions & 85 deletions

File tree

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,8 @@ docs/_build
1414
*.code-workspace
1515

1616
ochre/defaults/Input Files/OCHRE*
17-
.history
17+
.history
18+
19+
test/outputs/*
20+
!test/outputs/benchmark_golden.parquet
21+
!test/outputs/benchmark_baseline.pstats

ochre/Dwelling.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import numpy as np
55

66
from ochre import Simulator, Analysis
7+
from ochre.Simulator import KIND_EQUIPMENT, KIND_GENERATOR, KIND_BATTERY
78
from ochre.utils import (
89
OCHREException,
910
load_hpxml,
@@ -177,6 +178,10 @@ def __init__(self, metrics_verbosity=3, save_schedule_columns=None, save_args_to
177178
# add envelope to sub_simulators after all equipment
178179
self.sub_simulators.append(self.envelope)
179180

181+
self._same_resolution = all(
182+
sub.time_res == self.time_res for sub in self.sub_simulators
183+
)
184+
180185
# Run initialization to get realistic initial state
181186
if self.initialization_time is not None:
182187
self.initialize()
@@ -267,18 +272,18 @@ def start_sub_update(self, sub, control_signal):
267272
sub_control_signal = super().start_sub_update(sub, control_signal)
268273

269274
# Add house net_power to schedule for Generator
270-
if isinstance(sub, Generator) and "net_power" not in sub.current_schedule:
275+
if sub._kind & KIND_GENERATOR and "net_power" not in sub.current_schedule:
271276
sub.current_schedule["net_power"] = self.total_p_kw
272277

273278
# Add pv_power to schedule for Battery
274-
if isinstance(sub, Battery) and "pv_power" not in sub.current_schedule:
279+
if sub._kind & KIND_BATTERY and "pv_power" not in sub.current_schedule:
275280
pv_power = sum([e.electric_kw for e in self.equipment_by_end_use["PV"]])
276281
sub.current_schedule["pv_power"] = pv_power
277282

278283
return sub_control_signal
279284

280285
def finish_sub_update(self, sub):
281-
if isinstance(sub, Equipment):
286+
if sub._kind & KIND_EQUIPMENT:
282287
# update total electric and gas powers
283288
self.total_p_kw += sub.electric_kw
284289
self.total_q_kvar += sub.reactive_kvar

ochre/Equipment/Battery.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
@author: rchintal, xjin, mblonsky
66
"""
77

8+
import math
89
import numpy as np
910
import datetime as dt
1011
import pandas as pd
@@ -15,6 +16,7 @@
1516
from ochre.utils.units import convert, degC_to_K
1617
from ochre.Models import OneNodeRCModel
1718
from ochre.Equipment import Generator
19+
from ochre.Simulator import KIND_EQUIPMENT, KIND_GENERATOR, KIND_BATTERY
1820

1921

2022
class BatteryThermalModel(OneNodeRCModel):
@@ -35,6 +37,7 @@ class Battery(Generator):
3537
end_use = "Battery"
3638
allow_consumption = True
3739
is_gas = False
40+
_kind = KIND_EQUIPMENT | KIND_GENERATOR | KIND_BATTERY
3841
optional_inputs = Generator.optional_inputs + [
3942
"pv_power",
4043
"Battery Electric Power (kW)",
@@ -289,7 +292,7 @@ def calculate_efficiency(self, electric_kw=None, is_output_power=True):
289292
voc = float(self.voc_curve(self.soc)) * self.n_series
290293
if is_output_power:
291294
electric_kw *= self.efficiency_inverter
292-
v = voc / 2 + np.sqrt((voc / 2) ** 2 + (electric_kw * 1000) * self.r_internal) # V = V_oc + P*R/V
295+
v = voc / 2 + math.sqrt((voc / 2) ** 2 + (electric_kw * 1000) * self.r_internal) # V = V_oc + P*R/V
293296
else:
294297
v = voc + (electric_kw * 1000 / voc) * self.r_internal # V = V_oc + I*R = V_oc + P/V_oc * R
295298

@@ -322,7 +325,7 @@ def calculate_power_and_heat(self):
322325
e_ad2 = 9.752e6 # J / mol
323326
if self.thermal_model is not None:
324327
t_batt = self.thermal_model.states[self.t_idx] + degC_to_K
325-
d0 = d0_ref * np.exp(-e_ad1 / R * (1 / t_batt - 1 / t_ref) + -e_ad2 / R * (1 / t_batt - 1 / t_ref) ** 2)
328+
d0 = d0_ref * math.exp(-e_ad1 / R * (1 / t_batt - 1 / t_ref) + -e_ad2 / R * (1 / t_batt - 1 / t_ref) ** 2)
326329
self.capacity_kwh = self.capacity_kwh_nominal * d0
327330
else:
328331
self.capacity_kwh = self.capacity_kwh_nominal
@@ -423,15 +426,16 @@ def calculate_degradation(self):
423426
q3 += deg_time / tau_b3 * min(b3 - q3, 0) # q3 always decreasing, always negative
424427
q3 = max(q3, b3)
425428
self.degradation_states = q1, q2, q3
429+
deg_sum = sum(self.degradation_states)
426430

427431
# raise warning/error if degradation is too high
428-
if sum(self.degradation_states) >= 1:
432+
if deg_sum >= 1:
429433
raise OCHREException("{} degraded beyond useful life.".format(self.name))
430-
elif sum(self.degradation_states) >= 0.7:
434+
elif deg_sum >= 0.7:
431435
self.warn("Degraded beyond useful life.")
432436

433437
# update nominal capacity due to degradation
434-
self.capacity_kwh_nominal = self.capacity_rated * (b0 - sum(self.degradation_states))
438+
self.capacity_kwh_nominal = self.capacity_rated * (b0 - deg_sum)
435439

436440
# reset degradation data
437441
self.degradation_data.clear()

ochre/Equipment/Equipment.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import numpy as np
33

44
from ochre import Simulator
5+
from ochre.Simulator import KIND_EQUIPMENT
56
from ochre.utils import OCHREException, load_csv
67
from ochre.utils.units import kwh_to_therms
78

@@ -11,6 +12,7 @@ class Equipment(Simulator):
1112
end_use = "Other"
1213
is_electric = True
1314
is_gas = False
15+
_kind = KIND_EQUIPMENT
1416
modes = ["On", "Off"] # On and Off assumed as default modes
1517
zone_name = "Indoor"
1618

ochre/Equipment/Generator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
from ochre.utils import OCHREException
1111
from ochre.utils.units import kwh_to_therms
1212
from ochre.Equipment import Equipment
13+
from ochre.Simulator import KIND_EQUIPMENT, KIND_GENERATOR
1314

1415

1516
class Generator(Equipment):
1617
allow_consumption = False
1718
is_gas = False
19+
_kind = KIND_EQUIPMENT | KIND_GENERATOR
1820
zone_name = None
1921
optional_inputs = ["net_power"]
2022

ochre/Equipment/HVAC.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import math
12
import datetime as dt
23
import numpy as np
34
import psychrolib
@@ -680,15 +681,13 @@ def update_eir(self):
680681
plr = self.speed_idx # part-load-ratio
681682
t_in = self.zone.temperature
682683
t_out = self.outlet_temp
684+
c = self.efficiency_coeff
683685
if self.condensing:
684-
eff_var = np.array([1, plr, plr**2, t_in, t_in**2, plr * t_in], dtype=float)
685-
eff_curve_output = np.dot(eff_var, self.efficiency_coeff)
686+
eff_curve_output = c[0] + c[1] * plr + c[2] * plr**2 + c[3] * t_in + c[4] * t_in**2 + c[5] * plr * t_in
686687
else:
687-
eff_var = np.array(
688-
[1, plr, plr**2, t_out, t_out**2, plr * t_out, plr**3, t_out**3, plr**2 * t_out, plr * t_out**2],
689-
dtype=float,
690-
)
691-
eff_curve_output = np.dot(eff_var, self.efficiency_coeff)
688+
eff_curve_output = (c[0] + c[1] * plr + c[2] * plr**2 + c[3] * t_out + c[4] * t_out**2
689+
+ c[5] * plr * t_out + c[6] * plr**3 + c[7] * t_out**3
690+
+ c[8] * plr**2 * t_out + c[9] * plr * t_out**2)
692691
return self.eir_max / eff_curve_output
693692

694693

@@ -712,6 +711,9 @@ def __init__(self, control_type="Time", **kwargs):
712711
# 2-speed control type and timing variables
713712
self.control_type = control_type # 'Time', 'Time2', or 'Setpoint'
714713
self.disable_speeds = np.zeros(self.n_speeds, dtype=bool) # if True, disable that speed
714+
# Cached from np.nonzero(~disable_speeds). Must be recomputed in
715+
# update_external_control whenever disable_speeds changes.
716+
self._max_enabled_speed = self.n_speeds
715717
self.time_in_speed = dt.timedelta(0)
716718
min_time_in_low = kwargs.get("Minimum Low Time (minutes)", 5)
717719
min_time_in_high = kwargs.get("Minimum High Time (minutes)", 5)
@@ -809,6 +811,7 @@ def update_external_control(self, control_signal):
809811
# - Note: Disable Speeds will not reset back to original value
810812
for idx in range(self.n_speeds):
811813
self.disable_speeds[idx] = bool(control_signal.get(f"Disable Speed {idx + 1}"))
814+
self._max_enabled_speed = int(np.nonzero(~self.disable_speeds)[0][-1]) + 1
812815

813816
return super().update_external_control(control_signal)
814817

@@ -862,7 +865,7 @@ def run_two_speed_control(self):
862865
# enforce speed disabling from external control
863866
if self.disable_speeds[speed - 1]:
864867
# set to highest allowed speed
865-
speed = np.nonzero(~self.disable_speeds)[0][-1] + 1
868+
speed = self._max_enabled_speed
866869

867870
if speed != prev_speed_idx or self.mode == "Off":
868871
self.time_in_speed = self.time_res
@@ -910,15 +913,15 @@ def calculate_biquadratic_param(self, param, speed_idx, flow_fraction=1, part_lo
910913
t_ext_db = min(max(t_ext_db, params["min_Tdb"]), params["max_Tdb"])
911914
flow_fraction = min(max(flow_fraction, params["min_ff"]), params["max_ff"])
912915

913-
# create vectors based on temperature, flow fraction, and plr
914-
t_list = np.array([1, t_in, t_in**2, t_ext_db, t_ext_db**2, t_in * t_ext_db], dtype=float)
915-
t_ratio = np.dot(t_list, params[param + "_t"])
916+
# Coefficient order must match the arrays in initialize_biquad_params().
917+
ct = params[param + "_t"]
918+
t_ratio = ct[0] + ct[1] * t_in + ct[2] * t_in**2 + ct[3] * t_ext_db + ct[4] * t_ext_db**2 + ct[5] * t_in * t_ext_db
916919

917-
ff_list = np.array([1, flow_fraction, flow_fraction**2], dtype=float)
918-
ff_ratio = np.dot(ff_list, params[param + "_ff"])
920+
cf = params[param + "_ff"]
921+
ff_ratio = cf[0] + cf[1] * flow_fraction + cf[2] * flow_fraction**2
919922

920-
plf_list = np.array([1, part_load_ratio, part_load_ratio**2], dtype=float)
921-
plf_ratio = np.dot(plf_list, params[param + "_plr"])
923+
cp = params[param + "_plr"]
924+
plf_ratio = cp[0] + cp[1] * part_load_ratio + cp[2] * part_load_ratio**2
922925
plf_ratio = min(max(plf_ratio, params["min_plf"]), params["max_plf"])
923926

924927
return rated * t_ratio * ff_ratio / plf_ratio
@@ -936,15 +939,15 @@ def calc_startup_capacity_degredation(self):
936939
return 1.0
937940
else:
938941
exp_term = -3.79936 * (self.time_from_start / time_full_cap)
939-
capacity_mult = max(0, min(1.0, -1.025 * np.exp(exp_term) + 1.025))
942+
capacity_mult = max(0, min(1.0, -1.025 * math.exp(exp_term) + 1.025))
940943
self.time_from_start += self.time_res
941944
return capacity_mult
942945
else:
943946
return 1.0
944947

945948
def update_capacity(self):
946949
# update max capacity using highest enabled speed
947-
max_speed = np.nonzero(~self.disable_speeds)[0][-1] + 1
950+
max_speed = self._max_enabled_speed
948951
self.capacity_max = self.calculate_biquadratic_param(param="cap", speed_idx=max_speed)
949952

950953
if self.use_ideal_capacity:
@@ -983,7 +986,7 @@ def update_capacity(self):
983986

984987
def update_eir(self):
985988
# Update eir and eir_max using biquadratic model
986-
max_speed = np.nonzero(~self.disable_speeds)[0][-1] + 1
989+
max_speed = self._max_enabled_speed
987990
self.eir_max = self.calculate_biquadratic_param(param="eir", speed_idx=max_speed)
988991

989992
if isinstance(self.speed_idx, int):

ochre/Equipment/WaterHeater.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ def __init__(self, use_ideal_capacity=None, model_class=None, **kwargs):
7676
self.deadband_temp = kwargs.get("Deadband Temperature (C)", 5.56) # deadband range, in delta degC, i.e. Kelvin
7777
self.max_power = kwargs.get("Max Power (kW)")
7878

79+
self._heats_to_tank_buf = np.zeros(self.model.n_nodes, dtype=float)
80+
7981
def update_inputs(self, schedule_inputs=None):
8082
# Add zone temperature to schedule inputs for water tank
8183
if not self.main_simulator:
@@ -249,7 +251,8 @@ def update_internal_control(self):
249251

250252
def add_heat_from_mode(self, mode, heats_to_tank=None, duty_cycle=1):
251253
if heats_to_tank is None:
252-
heats_to_tank = np.zeros(self.model.n_nodes, dtype=float)
254+
self._heats_to_tank_buf.fill(0)
255+
heats_to_tank = self._heats_to_tank_buf
253256

254257
if mode == "Upper On":
255258
heats_to_tank[self.h_upper_idx] += self.capacity_rated * duty_cycle
@@ -262,7 +265,8 @@ def add_heat_from_mode(self, mode, heats_to_tank=None, duty_cycle=1):
262265
def calculate_power_and_heat(self):
263266
# get heat injections from water heater
264267
if self.use_ideal_capacity and self.mode != "Off":
265-
heats_to_tank = np.zeros(self.model.n_nodes, dtype=float)
268+
self._heats_to_tank_buf.fill(0)
269+
heats_to_tank = self._heats_to_tank_buf
266270
for mode, duty_cycle in self.duty_cycle_by_mode.items():
267271
heats_to_tank = self.add_heat_from_mode(mode, heats_to_tank, duty_cycle)
268272
else:
@@ -626,9 +630,14 @@ def add_heat_from_mode(self, mode, heats_to_tank=None, duty_cycle=1):
626630

627631
def update_cop_and_capacity(self, t_wet):
628632
t_lower = np.dot(self.hp_nodes, self.model.states) # use node connected to condenser
629-
vector = np.array([1, t_wet, t_wet**2, t_lower, t_lower**2, t_lower * t_wet])
630-
self.hp_capacity = self.hp_capacity_nominal * np.dot(self.hp_capacity_coeff, vector)
631-
self.hp_cop = self.cop_nominal * np.dot(self.cop_coeff, vector)
633+
cc = self.hp_capacity_coeff
634+
self.hp_capacity = self.hp_capacity_nominal * (
635+
cc[0] + cc[1] * t_wet + cc[2] * t_wet**2 + cc[3] * t_lower + cc[4] * t_lower**2 + cc[5] * t_lower * t_wet
636+
)
637+
ce = self.cop_coeff
638+
self.hp_cop = self.cop_nominal * (
639+
ce[0] + ce[1] * t_wet + ce[2] * t_wet**2 + ce[3] * t_lower + ce[4] * t_lower**2 + ce[5] * t_lower * t_wet
640+
)
632641

633642
def calculate_power_and_heat(self):
634643
t_dry = self.current_schedule["Zone Temperature (C)"]

0 commit comments

Comments
 (0)