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.
@@ -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
+ ]
@@ -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