hvac-crossover 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hvac_crossover/__init__.py +75 -0
- hvac_crossover/config.py +26 -0
- hvac_crossover/coordinator.py +295 -0
- hvac_crossover/engines/__init__.py +11 -0
- hvac_crossover/engines/arbitrator.py +163 -0
- hvac_crossover/engines/economics.py +141 -0
- hvac_crossover/engines/recovery.py +75 -0
- hvac_crossover/models/__init__.py +71 -0
- hvac_crossover/models/delivery.py +62 -0
- hvac_crossover/models/enums.py +79 -0
- hvac_crossover/models/plan.py +49 -0
- hvac_crossover/models/producers.py +240 -0
- hvac_crossover/models/snapshot.py +24 -0
- hvac_crossover/models/spaces.py +108 -0
- hvac_crossover/models/tariffs.py +64 -0
- hvac_crossover/models/topology.py +78 -0
- hvac_crossover/profiles/producers/burnham/alta_150.json +12 -0
- hvac_crossover/profiles/producers/mitsubishi/mxz_2d20nlhz.json +30 -0
- hvac_crossover/profiles/producers/mitsubishi/mxz_3c30nahz2.json +30 -0
- hvac_crossover-0.1.0.dist-info/METADATA +106 -0
- hvac_crossover-0.1.0.dist-info/RECORD +24 -0
- hvac_crossover-0.1.0.dist-info/WHEEL +5 -0
- hvac_crossover-0.1.0.dist-info/licenses/LICENSE +21 -0
- hvac_crossover-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hvac_crossover - Multi-Source HVAC Efficiency & Thermodynamic Crossover Optimizer
|
|
3
|
+
Decoupled library for optimizing heat pumps, boilers, multi-split constraints, and micro-zoning.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .models import (
|
|
7
|
+
HvacMode,
|
|
8
|
+
HvacAction,
|
|
9
|
+
ProducerType,
|
|
10
|
+
EmitterType,
|
|
11
|
+
FuelType,
|
|
12
|
+
NetMeteringMode,
|
|
13
|
+
ThermalProfile,
|
|
14
|
+
SharedBoilerRole,
|
|
15
|
+
SystemPhase,
|
|
16
|
+
TariffSchedule,
|
|
17
|
+
Producer,
|
|
18
|
+
InverterHeatPumpProducer,
|
|
19
|
+
ModulatingBoilerProducer,
|
|
20
|
+
SteamBoilerProducer,
|
|
21
|
+
ProducerProfileFactory,
|
|
22
|
+
DeliveryUnit,
|
|
23
|
+
MiniSplitHead,
|
|
24
|
+
HydronicEmitter,
|
|
25
|
+
SmartRadiatorValve,
|
|
26
|
+
ComfortRange,
|
|
27
|
+
Space,
|
|
28
|
+
SpaceCondition,
|
|
29
|
+
SystemSnapshot,
|
|
30
|
+
SpaceDirective,
|
|
31
|
+
ProducerDirective,
|
|
32
|
+
Diagnostics,
|
|
33
|
+
DispatchPlan,
|
|
34
|
+
SystemTopology,
|
|
35
|
+
)
|
|
36
|
+
from .config import CoordinatorConfig
|
|
37
|
+
from .coordinator import DualFuelCoordinator
|
|
38
|
+
from .engines import EconomicsEngine, RecoveryEngine, ArbitratorEngine
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
__all__ = [
|
|
42
|
+
"CoordinatorConfig",
|
|
43
|
+
"DualFuelCoordinator",
|
|
44
|
+
"EconomicsEngine",
|
|
45
|
+
"RecoveryEngine",
|
|
46
|
+
"ArbitratorEngine",
|
|
47
|
+
"HvacMode",
|
|
48
|
+
"HvacAction",
|
|
49
|
+
"ProducerType",
|
|
50
|
+
"EmitterType",
|
|
51
|
+
"FuelType",
|
|
52
|
+
"NetMeteringMode",
|
|
53
|
+
"ThermalProfile",
|
|
54
|
+
"SharedBoilerRole",
|
|
55
|
+
"SystemPhase",
|
|
56
|
+
"TariffSchedule",
|
|
57
|
+
"Producer",
|
|
58
|
+
"InverterHeatPumpProducer",
|
|
59
|
+
"ModulatingBoilerProducer",
|
|
60
|
+
"SteamBoilerProducer",
|
|
61
|
+
"ProducerProfileFactory",
|
|
62
|
+
"DeliveryUnit",
|
|
63
|
+
"MiniSplitHead",
|
|
64
|
+
"HydronicEmitter",
|
|
65
|
+
"SmartRadiatorValve",
|
|
66
|
+
"ComfortRange",
|
|
67
|
+
"Space",
|
|
68
|
+
"SpaceCondition",
|
|
69
|
+
"SystemSnapshot",
|
|
70
|
+
"SpaceDirective",
|
|
71
|
+
"ProducerDirective",
|
|
72
|
+
"Diagnostics",
|
|
73
|
+
"DispatchPlan",
|
|
74
|
+
"SystemTopology",
|
|
75
|
+
]
|
hvac_crossover/config.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Coordinator configuration, policies, and operational thresholds."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class CoordinatorConfig:
|
|
8
|
+
"""
|
|
9
|
+
Configurable policies, thresholds, and safety parameters for the DualFuelCoordinator.
|
|
10
|
+
"""
|
|
11
|
+
# Thermal Recovery vs Plateau Boundaries
|
|
12
|
+
recovery_delta_threshold_f: float = 3.0 # Deficit >= 3.0°F -> engage high-capacity boiler recovery
|
|
13
|
+
plateau_delta_threshold_f: float = 1.0 # Deficit <= 1.0°F -> hand off to heat pump plateau
|
|
14
|
+
|
|
15
|
+
# Economic Switching Buffer (Hysteresis)
|
|
16
|
+
# E.g. 1.05 means heat pump cost ($/MMBtu) must be at least 5% cheaper than boiler
|
|
17
|
+
# to switch from boiler to heat pump, preventing flip-flopping right at the crossover line.
|
|
18
|
+
economic_switching_buffer: float = 1.05
|
|
19
|
+
|
|
20
|
+
# Absolute Outdoor Temperature Safeguards
|
|
21
|
+
min_heat_pump_temp_f: float = -13.0 # Minimum rated operational temperature for cold-climate inverters
|
|
22
|
+
|
|
23
|
+
# Operational Interlocks
|
|
24
|
+
passive_ventilation_override: bool = True # Open window/skylight forces space delivery units to OFF
|
|
25
|
+
enforce_odu_mode_locks: bool = True # Enforce single-refrigerant mode lock across heads on same ODU
|
|
26
|
+
short_cycling_protection: bool = True # Apply burner cycling derate when load is below boiler min fire
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Main DualFuelCoordinator facade and central evaluation pipeline."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any, Optional, List, Set, Tuple
|
|
4
|
+
from .models.enums import HvacMode, HvacAction, SystemPhase, ProducerType
|
|
5
|
+
from .models.topology import SystemTopology
|
|
6
|
+
from .models.tariffs import TariffSchedule
|
|
7
|
+
from .models.snapshot import SystemSnapshot
|
|
8
|
+
from .models.spaces import SpaceCondition, ComfortRange
|
|
9
|
+
from .models.delivery import MiniSplitHead, HydronicEmitter
|
|
10
|
+
from .models.plan import DispatchPlan, SpaceDirective, ProducerDirective, Diagnostics
|
|
11
|
+
from .config import CoordinatorConfig
|
|
12
|
+
from .engines.economics import EconomicsEngine
|
|
13
|
+
from .engines.recovery import RecoveryEngine
|
|
14
|
+
from .engines.arbitrator import ArbitratorEngine
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DualFuelCoordinator:
|
|
18
|
+
"""
|
|
19
|
+
Central coordination service and facade for dual-fuel HVAC systems.
|
|
20
|
+
Evaluates ground-truth environmental and space conditions to produce
|
|
21
|
+
actionable hardware directives and explainable telemetry.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
topology: SystemTopology,
|
|
27
|
+
config: Optional[CoordinatorConfig] = None,
|
|
28
|
+
):
|
|
29
|
+
self.topology = topology
|
|
30
|
+
self.config = config or CoordinatorConfig()
|
|
31
|
+
self.economics = EconomicsEngine()
|
|
32
|
+
self.recovery = RecoveryEngine(self.config)
|
|
33
|
+
self.arbitrator = ArbitratorEngine(self.config)
|
|
34
|
+
|
|
35
|
+
def evaluate(self, snapshot: SystemSnapshot) -> DispatchPlan:
|
|
36
|
+
"""
|
|
37
|
+
Evaluates the ground-truth snapshot and generates a DispatchPlan.
|
|
38
|
+
"""
|
|
39
|
+
space_directives: Dict[str, SpaceDirective] = {}
|
|
40
|
+
producer_directives: Dict[str, ProducerDirective] = {}
|
|
41
|
+
explanations: List[str] = []
|
|
42
|
+
|
|
43
|
+
# 1. Determine Macro System Phase
|
|
44
|
+
system_phase = self.recovery.determine_system_phase(snapshot.spaces, self.topology)
|
|
45
|
+
|
|
46
|
+
# 2. Find primary boiler for crossover calculations
|
|
47
|
+
boiler_producers = [
|
|
48
|
+
p for p in self.topology.producers
|
|
49
|
+
if p.producer_type in (ProducerType.MODULATING_BOILER, ProducerType.ATMOSPHERIC_BOILER, ProducerType.STEAM_BOILER)
|
|
50
|
+
]
|
|
51
|
+
primary_boiler = boiler_producers[0] if boiler_producers else None
|
|
52
|
+
|
|
53
|
+
# Calculate general economic crossover temp for telemetry
|
|
54
|
+
crossover_temp = 41.4 # Default fallback
|
|
55
|
+
hp_producers = [p for p in self.topology.producers if p.producer_type == ProducerType.INVERTER_HEAT_PUMP]
|
|
56
|
+
if primary_boiler and hp_producers:
|
|
57
|
+
t_cross = self.economics.compute_crossover_temperature(
|
|
58
|
+
tariffs=snapshot.tariffs,
|
|
59
|
+
odu=hp_producers[0],
|
|
60
|
+
boiler=primary_boiler,
|
|
61
|
+
telemetry=snapshot.telemetry,
|
|
62
|
+
)
|
|
63
|
+
if t_cross is not None:
|
|
64
|
+
crossover_temp = t_cross
|
|
65
|
+
|
|
66
|
+
# 3. Initial Space Demand Assessment
|
|
67
|
+
candidate_space_modes: Dict[str, HvacMode] = {}
|
|
68
|
+
candidate_emitters: Dict[str, Optional[str]] = {}
|
|
69
|
+
target_temps: Dict[str, Optional[float]] = {}
|
|
70
|
+
requested_boiler_zones: Set[Tuple[str, str]] = set()
|
|
71
|
+
|
|
72
|
+
space_costs: Dict[str, float] = {}
|
|
73
|
+
space_cops: Dict[str, float] = {}
|
|
74
|
+
|
|
75
|
+
for space in self.topology.spaces:
|
|
76
|
+
cond = snapshot.spaces.get(space.id)
|
|
77
|
+
if not cond:
|
|
78
|
+
candidate_space_modes[space.id] = HvacMode.OFF
|
|
79
|
+
candidate_emitters[space.id] = None
|
|
80
|
+
target_temps[space.id] = None
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# Check window interlocks for passive ventilation
|
|
84
|
+
if cond.window_open and self.config.passive_ventilation_override:
|
|
85
|
+
candidate_space_modes[space.id] = HvacMode.OFF
|
|
86
|
+
candidate_emitters[space.id] = None
|
|
87
|
+
target_temps[space.id] = None
|
|
88
|
+
explanations.append(f"Space '{space.name}' delivery units turned OFF due to open window/skylight.")
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Deadband / Uncontrolled Float Check
|
|
92
|
+
if cond.comfort.is_in_deadband(cond.current_temp):
|
|
93
|
+
candidate_space_modes[space.id] = HvacMode.OFF
|
|
94
|
+
candidate_emitters[space.id] = None
|
|
95
|
+
target_temps[space.id] = None
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# Cooling Demand
|
|
99
|
+
if cond.comfort.demands_cool(cond.current_temp):
|
|
100
|
+
candidate_space_modes[space.id] = HvacMode.COOL
|
|
101
|
+
target_temps[space.id] = cond.comfort.cool_to
|
|
102
|
+
cooling_heads = [e for e in space.emitters if isinstance(e, MiniSplitHead)]
|
|
103
|
+
if cooling_heads:
|
|
104
|
+
candidate_emitters[space.id] = cooling_heads[0].id
|
|
105
|
+
else:
|
|
106
|
+
candidate_emitters[space.id] = None
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# Heating Demand
|
|
110
|
+
if cond.comfort.demands_heat(cond.current_temp):
|
|
111
|
+
candidate_space_modes[space.id] = HvacMode.HEAT
|
|
112
|
+
target_temps[space.id] = cond.comfort.heat_to
|
|
113
|
+
|
|
114
|
+
# Estimate space heating load
|
|
115
|
+
load_btu = space.estimated_heat_loss_btu_per_hr(cond.current_temp, snapshot.outdoor_temp)
|
|
116
|
+
|
|
117
|
+
# Check if space is in heavy setback recovery (>= 3.0°F deficit)
|
|
118
|
+
in_recovery = self.recovery.is_space_in_recovery(cond)
|
|
119
|
+
|
|
120
|
+
# Find available hydronic emitter
|
|
121
|
+
hydronic_emitters = [e for e in space.emitters if isinstance(e, HydronicEmitter)]
|
|
122
|
+
mini_split_heads = [e for e in space.emitters if isinstance(e, MiniSplitHead)]
|
|
123
|
+
|
|
124
|
+
if in_recovery and hydronic_emitters and snapshot.outdoor_temp < 45.0:
|
|
125
|
+
# In deep recovery pull-up below 45°F, dispatch high-capacity boiler
|
|
126
|
+
selected = hydronic_emitters[0]
|
|
127
|
+
candidate_emitters[space.id] = selected.id
|
|
128
|
+
requested_boiler_zones.add((selected.producer_id, selected.zone_id))
|
|
129
|
+
explanations.append(
|
|
130
|
+
f"Space '{space.name}' in setback recovery (deficit {cond.comfort.heat_deficit(cond.current_temp):.1f}°F): "
|
|
131
|
+
f"dispatched high-capacity hydronic boiler for rapid pull-up."
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
# Steady-state plateau or mild weather: rank emitters economically
|
|
135
|
+
ranked = self.economics.rank_space_emitters_by_cost(
|
|
136
|
+
space=space,
|
|
137
|
+
outdoor_temp_f=snapshot.outdoor_temp,
|
|
138
|
+
tariffs=snapshot.tariffs,
|
|
139
|
+
topology=self.topology,
|
|
140
|
+
load_btu=load_btu,
|
|
141
|
+
telemetry=snapshot.telemetry,
|
|
142
|
+
part_load=True,
|
|
143
|
+
)
|
|
144
|
+
if ranked:
|
|
145
|
+
best_emitter, best_cost, best_eff = ranked[0]
|
|
146
|
+
candidate_emitters[space.id] = best_emitter.id
|
|
147
|
+
space_costs[space.id] = round(best_cost, 2)
|
|
148
|
+
space_cops[space.id] = round(best_eff, 2)
|
|
149
|
+
|
|
150
|
+
if isinstance(best_emitter, HydronicEmitter):
|
|
151
|
+
requested_boiler_zones.add((best_emitter.producer_id, best_emitter.zone_id))
|
|
152
|
+
|
|
153
|
+
# 4. Multi-Split ODU Mode Conflict Arbitration
|
|
154
|
+
if self.config.enforce_odu_mode_locks:
|
|
155
|
+
resolved_modes, odu_explanations = self.arbitrator.arbitrate_odu_modes(
|
|
156
|
+
requested_space_modes=candidate_space_modes,
|
|
157
|
+
topology=self.topology,
|
|
158
|
+
space_conditions=snapshot.spaces,
|
|
159
|
+
)
|
|
160
|
+
explanations.extend(odu_explanations)
|
|
161
|
+
else:
|
|
162
|
+
resolved_modes = candidate_space_modes
|
|
163
|
+
|
|
164
|
+
# 5. Shared Hydronic Zone Valve Arbitration
|
|
165
|
+
approved_boiler_zones, boiler_explanations = self.arbitrator.arbitrate_shared_boiler_zones(
|
|
166
|
+
requested_boiler_zones=requested_boiler_zones,
|
|
167
|
+
topology=self.topology,
|
|
168
|
+
space_conditions=snapshot.spaces,
|
|
169
|
+
)
|
|
170
|
+
explanations.extend(boiler_explanations)
|
|
171
|
+
|
|
172
|
+
# 6. Build Space Directives
|
|
173
|
+
for space in self.topology.spaces:
|
|
174
|
+
cond = snapshot.spaces.get(space.id)
|
|
175
|
+
final_mode = resolved_modes.get(space.id, HvacMode.OFF)
|
|
176
|
+
emitter_id = candidate_emitters.get(space.id)
|
|
177
|
+
target = target_temps.get(space.id)
|
|
178
|
+
|
|
179
|
+
if cond and cond.window_open and self.config.passive_ventilation_override:
|
|
180
|
+
action = HvacAction.PASSIVE_VENTILATION
|
|
181
|
+
final_mode = HvacMode.OFF
|
|
182
|
+
emitter_id = None
|
|
183
|
+
elif final_mode == HvacMode.HEAT:
|
|
184
|
+
action = HvacAction.HEATING
|
|
185
|
+
elif final_mode in (HvacMode.COOL, HvacMode.DRY):
|
|
186
|
+
action = HvacAction.COOLING
|
|
187
|
+
else:
|
|
188
|
+
action = HvacAction.IDLE if cond and cond.comfort.is_in_deadband(cond.current_temp) else HvacAction.OFF
|
|
189
|
+
|
|
190
|
+
active_producer_id = None
|
|
191
|
+
if emitter_id:
|
|
192
|
+
for e in space.emitters:
|
|
193
|
+
if e.id == emitter_id:
|
|
194
|
+
active_producer_id = e.producer_id
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
space_directives[space.id] = SpaceDirective(
|
|
198
|
+
space_id=space.id,
|
|
199
|
+
action=action,
|
|
200
|
+
hvac_mode=final_mode,
|
|
201
|
+
target_temp=target,
|
|
202
|
+
active_delivery_unit_id=emitter_id,
|
|
203
|
+
active_producer_id=active_producer_id,
|
|
204
|
+
explanation=f"Mode: {final_mode.value}, Action: {action.value}"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# 7. Build Producer Directives
|
|
208
|
+
odu_operational_modes: Dict[str, str] = {}
|
|
209
|
+
for producer in self.topology.producers:
|
|
210
|
+
if producer.producer_type == ProducerType.INVERTER_HEAT_PUMP:
|
|
211
|
+
# Find mode of active heads on this ODU
|
|
212
|
+
active_modes = [
|
|
213
|
+
space_directives[s.id].hvac_mode
|
|
214
|
+
for s in self.topology.get_spaces_for_producer(producer.id)
|
|
215
|
+
if s.id in space_directives and space_directives[s.id].active_producer_id == producer.id
|
|
216
|
+
]
|
|
217
|
+
active_modes = [m for m in active_modes if m != HvacMode.OFF]
|
|
218
|
+
odu_mode = active_modes[0] if active_modes else HvacMode.OFF
|
|
219
|
+
is_active = odu_mode != HvacMode.OFF
|
|
220
|
+
odu_operational_modes[producer.id] = odu_mode.value
|
|
221
|
+
|
|
222
|
+
producer_directives[producer.id] = ProducerDirective(
|
|
223
|
+
producer_id=producer.id,
|
|
224
|
+
operational_mode=odu_mode,
|
|
225
|
+
is_active=is_active,
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
# Boiler / Furnace producer
|
|
229
|
+
active_zones = [
|
|
230
|
+
zone_id for b_id, zone_id in approved_boiler_zones
|
|
231
|
+
if b_id == producer.id
|
|
232
|
+
]
|
|
233
|
+
is_active = len(active_zones) > 0
|
|
234
|
+
producer_directives[producer.id] = ProducerDirective(
|
|
235
|
+
producer_id=producer.id,
|
|
236
|
+
operational_mode=HvacMode.HEAT if is_active else HvacMode.OFF,
|
|
237
|
+
is_active=is_active,
|
|
238
|
+
active_zones=active_zones,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# 8. Assemble Diagnostics
|
|
242
|
+
diagnostics = Diagnostics(
|
|
243
|
+
economic_crossover_temp=round(crossover_temp, 1),
|
|
244
|
+
system_phase=system_phase,
|
|
245
|
+
odu_modes=odu_operational_modes,
|
|
246
|
+
space_costs_per_mmbtu=space_costs,
|
|
247
|
+
space_cops=space_cops,
|
|
248
|
+
explanations=explanations,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return DispatchPlan(
|
|
252
|
+
space_directives=space_directives,
|
|
253
|
+
producer_directives=producer_directives,
|
|
254
|
+
diagnostics=diagnostics,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def evaluate_conditions(
|
|
258
|
+
self,
|
|
259
|
+
outdoor_temp: float,
|
|
260
|
+
tariffs: TariffSchedule,
|
|
261
|
+
spaces: Dict[str, Dict[str, Any]],
|
|
262
|
+
solar_export_watts: float = 0.0,
|
|
263
|
+
boiler_return_temp: Optional[float] = None,
|
|
264
|
+
elec_rate_override: Optional[float] = None,
|
|
265
|
+
current_hour: Optional[int] = None,
|
|
266
|
+
) -> DispatchPlan:
|
|
267
|
+
"""
|
|
268
|
+
Convenience keyword-argument evaluation method that constructs a SystemSnapshot internally.
|
|
269
|
+
"""
|
|
270
|
+
space_conditions: Dict[str, SpaceCondition] = {}
|
|
271
|
+
for s_id, s_data in spaces.items():
|
|
272
|
+
current_temp = float(s_data["current"])
|
|
273
|
+
comfort = ComfortRange(
|
|
274
|
+
heat_to=s_data.get("heat_to"),
|
|
275
|
+
cool_to=s_data.get("cool_to"),
|
|
276
|
+
)
|
|
277
|
+
window_open = bool(s_data.get("window_open", False))
|
|
278
|
+
occupied = bool(s_data.get("occupied", True))
|
|
279
|
+
space_conditions[s_id] = SpaceCondition(
|
|
280
|
+
current_temp=current_temp,
|
|
281
|
+
comfort=comfort,
|
|
282
|
+
window_open=window_open,
|
|
283
|
+
occupied=occupied,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
snapshot = SystemSnapshot(
|
|
287
|
+
outdoor_temp=outdoor_temp,
|
|
288
|
+
tariffs=tariffs,
|
|
289
|
+
spaces=space_conditions,
|
|
290
|
+
solar_export_watts=solar_export_watts,
|
|
291
|
+
boiler_return_temp=boiler_return_temp,
|
|
292
|
+
elec_rate_override=elec_rate_override,
|
|
293
|
+
current_hour=current_hour,
|
|
294
|
+
)
|
|
295
|
+
return self.evaluate(snapshot)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Specialized evaluation engines for hybrid_hvac."""
|
|
2
|
+
|
|
3
|
+
from .economics import EconomicsEngine
|
|
4
|
+
from .recovery import RecoveryEngine
|
|
5
|
+
from .arbitrator import ArbitratorEngine
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"EconomicsEngine",
|
|
9
|
+
"RecoveryEngine",
|
|
10
|
+
"ArbitratorEngine",
|
|
11
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Refrigerant mode safety and shared-zone arbitration engine."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Set, Tuple, Optional
|
|
4
|
+
from ..models.enums import HvacMode, HvacAction, SharedBoilerRole
|
|
5
|
+
from ..models.producers import InverterHeatPumpProducer
|
|
6
|
+
from ..models.delivery import DeliveryUnit, MiniSplitHead, HydronicEmitter
|
|
7
|
+
from ..models.spaces import Space, SpaceCondition
|
|
8
|
+
from ..models.topology import SystemTopology
|
|
9
|
+
from ..config import CoordinatorConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ArbitratorEngine:
|
|
13
|
+
"""
|
|
14
|
+
Enforces hardware physical safety constraints:
|
|
15
|
+
1. Multi-split single-refrigerant mode locks (preventing heat/cool conflict faults).
|
|
16
|
+
2. Shared hydronic zone valve arbitration (master/slave relationships).
|
|
17
|
+
3. Window/skylight cutoffs for passive thermal banking.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config: CoordinatorConfig):
|
|
21
|
+
self.config = config
|
|
22
|
+
|
|
23
|
+
def arbitrate_odu_modes(
|
|
24
|
+
self,
|
|
25
|
+
requested_space_modes: Dict[str, HvacMode],
|
|
26
|
+
topology: SystemTopology,
|
|
27
|
+
space_conditions: Dict[str, SpaceCondition],
|
|
28
|
+
) -> Tuple[Dict[str, HvacMode], List[str]]:
|
|
29
|
+
"""
|
|
30
|
+
Ensures all heads on each ODU operate in a compatible refrigerant mode.
|
|
31
|
+
If a conflict occurs (e.g. Head A wants COOL, Head B wants HEAT on same ODU):
|
|
32
|
+
- If the ODU supports simultaneous heat/cool (VRF Heat Recovery), allow both.
|
|
33
|
+
- Otherwise, checks if the heating head can be diverted to an alternate producer (boiler).
|
|
34
|
+
- If no alternate exists, highest priority Space wins; lower priority is idled.
|
|
35
|
+
Returns: (resolved_space_modes, explanation_messages)
|
|
36
|
+
"""
|
|
37
|
+
resolved_modes = dict(requested_space_modes)
|
|
38
|
+
explanations = []
|
|
39
|
+
|
|
40
|
+
# Find all inverter heat pump producers
|
|
41
|
+
odus = [p for p in topology.producers if isinstance(p, InverterHeatPumpProducer)]
|
|
42
|
+
|
|
43
|
+
for odu in odus:
|
|
44
|
+
if odu.supports_simultaneous_heat_cool:
|
|
45
|
+
continue # VRF Heat Recovery relaxes the single-mode lock
|
|
46
|
+
|
|
47
|
+
# Find all spaces that have a mini-split head on this ODU
|
|
48
|
+
odu_spaces: List[Tuple[Space, MiniSplitHead]] = []
|
|
49
|
+
for s in topology.spaces:
|
|
50
|
+
for d in s.emitters:
|
|
51
|
+
if isinstance(d, MiniSplitHead) and d.producer_id == odu.id:
|
|
52
|
+
odu_spaces.append((s, d))
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
# Collect active requested modes (ignore OFF/IDLE)
|
|
56
|
+
active_modes: Dict[str, List[Tuple[Space, MiniSplitHead]]] = {"heat": [], "cool": [], "dry": []}
|
|
57
|
+
for s, head in odu_spaces:
|
|
58
|
+
mode = resolved_modes.get(s.id, HvacMode.OFF)
|
|
59
|
+
if mode in (HvacMode.HEAT, HvacMode.COOL, HvacMode.DRY):
|
|
60
|
+
active_modes[mode.value].append((s, head))
|
|
61
|
+
|
|
62
|
+
active_mode_keys = [k for k, v in active_modes.items() if v]
|
|
63
|
+
|
|
64
|
+
# Conflict: both HEAT and COOL (or DRY) requested on the same ODU!
|
|
65
|
+
if "heat" in active_mode_keys and ("cool" in active_mode_keys or "dry" in active_mode_keys):
|
|
66
|
+
cooling_spaces = active_modes["cool"] + active_modes["dry"]
|
|
67
|
+
heating_spaces = active_modes["heat"]
|
|
68
|
+
|
|
69
|
+
# Check if heating spaces can be cleanly diverted to an alternate hydronic emitter
|
|
70
|
+
diverted_all_heaters = True
|
|
71
|
+
diverted_spaces = []
|
|
72
|
+
|
|
73
|
+
for s, head in heating_spaces:
|
|
74
|
+
has_hydronic_alt = any(
|
|
75
|
+
isinstance(e, HydronicEmitter) for e in s.emitters
|
|
76
|
+
)
|
|
77
|
+
if has_hydronic_alt:
|
|
78
|
+
diverted_spaces.append(s)
|
|
79
|
+
else:
|
|
80
|
+
diverted_all_heaters = False
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
if diverted_all_heaters:
|
|
84
|
+
# Successfully resolved conflict by diverting heat to boiler!
|
|
85
|
+
for s in diverted_spaces:
|
|
86
|
+
explanations.append(
|
|
87
|
+
f"ODU Mode Conflict on '{odu.id}': Space '{s.name}' diverted heating to hydronic boiler "
|
|
88
|
+
f"to allow cooling on upper space(s)."
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
# Cannot divert all heaters; resolve via space priority
|
|
92
|
+
max_cooling_priority = max(s.priority for s, _ in cooling_spaces)
|
|
93
|
+
max_heating_priority = max(s.priority for s, _ in heating_spaces)
|
|
94
|
+
|
|
95
|
+
if max_cooling_priority > max_heating_priority:
|
|
96
|
+
winning_mode = HvacMode.COOL
|
|
97
|
+
for s, _ in heating_spaces:
|
|
98
|
+
resolved_modes[s.id] = HvacMode.OFF
|
|
99
|
+
explanations.append(
|
|
100
|
+
f"ODU Mode Conflict on '{odu.id}': Space '{s.name}' heating paused; "
|
|
101
|
+
f"cooling prioritized on higher-priority space."
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
winning_mode = HvacMode.HEAT
|
|
105
|
+
for s, _ in cooling_spaces:
|
|
106
|
+
resolved_modes[s.id] = HvacMode.OFF
|
|
107
|
+
explanations.append(
|
|
108
|
+
f"ODU Mode Conflict on '{odu.id}': Space '{s.name}' cooling paused; "
|
|
109
|
+
f"heating prioritized on higher-priority space."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return resolved_modes, explanations
|
|
113
|
+
|
|
114
|
+
def arbitrate_shared_boiler_zones(
|
|
115
|
+
self,
|
|
116
|
+
requested_boiler_zones: Set[Tuple[str, str]],
|
|
117
|
+
topology: SystemTopology,
|
|
118
|
+
space_conditions: Dict[str, SpaceCondition],
|
|
119
|
+
) -> Tuple[Set[Tuple[str, str]], List[str]]:
|
|
120
|
+
"""
|
|
121
|
+
Arbitrates multiple spaces sharing the same boiler zone valve (e.g. Floor 2 Z2).
|
|
122
|
+
Returns: (approved_boiler_zones, explanation_messages)
|
|
123
|
+
"""
|
|
124
|
+
approved_zones = set(requested_boiler_zones)
|
|
125
|
+
explanations = []
|
|
126
|
+
|
|
127
|
+
# Find all unique (boiler_id, zone_id) pairs
|
|
128
|
+
all_zones = set()
|
|
129
|
+
for s in topology.spaces:
|
|
130
|
+
for d in s.emitters:
|
|
131
|
+
if isinstance(d, HydronicEmitter):
|
|
132
|
+
all_zones.add((d.producer_id, d.zone_id))
|
|
133
|
+
|
|
134
|
+
for boiler_id, zone_id in all_zones:
|
|
135
|
+
shared_spaces = topology.get_shared_hydronic_spaces(boiler_id, zone_id)
|
|
136
|
+
if len(shared_spaces) <= 1:
|
|
137
|
+
continue # Dedicated zone, no arbitration needed
|
|
138
|
+
|
|
139
|
+
# Identify Master and Slave spaces
|
|
140
|
+
masters = [s for s in shared_spaces if s.shared_boiler_role == SharedBoilerRole.MASTER]
|
|
141
|
+
slaves = [s for s in shared_spaces if s.shared_boiler_role == SharedBoilerRole.SLAVE]
|
|
142
|
+
|
|
143
|
+
zone_key = (boiler_id, zone_id)
|
|
144
|
+
if zone_key not in approved_zones:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# If zone was called by a slave, check if master permits or if slave should float
|
|
148
|
+
master_calling = any(
|
|
149
|
+
space_conditions[m.id].comfort.demands_heat(space_conditions[m.id].current_temp)
|
|
150
|
+
for m in masters if m.id in space_conditions
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if not master_calling and slaves:
|
|
154
|
+
for slave in slaves:
|
|
155
|
+
cond = space_conditions.get(slave.id)
|
|
156
|
+
if cond and not cond.occupied:
|
|
157
|
+
approved_zones.discard(zone_key)
|
|
158
|
+
explanations.append(
|
|
159
|
+
f"Shared Zone '{zone_id}' call cancelled: Slave space '{slave.name}' is unoccupied "
|
|
160
|
+
f"and master space is satisfied."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return approved_zones, explanations
|