hvac-crossover 0.1.0__tar.gz

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.
Files changed (33) hide show
  1. hvac_crossover-0.1.0/LICENSE +21 -0
  2. hvac_crossover-0.1.0/PKG-INFO +106 -0
  3. hvac_crossover-0.1.0/README.md +81 -0
  4. hvac_crossover-0.1.0/hvac_crossover/__init__.py +75 -0
  5. hvac_crossover-0.1.0/hvac_crossover/config.py +26 -0
  6. hvac_crossover-0.1.0/hvac_crossover/coordinator.py +295 -0
  7. hvac_crossover-0.1.0/hvac_crossover/engines/__init__.py +11 -0
  8. hvac_crossover-0.1.0/hvac_crossover/engines/arbitrator.py +163 -0
  9. hvac_crossover-0.1.0/hvac_crossover/engines/economics.py +141 -0
  10. hvac_crossover-0.1.0/hvac_crossover/engines/recovery.py +75 -0
  11. hvac_crossover-0.1.0/hvac_crossover/models/__init__.py +71 -0
  12. hvac_crossover-0.1.0/hvac_crossover/models/delivery.py +62 -0
  13. hvac_crossover-0.1.0/hvac_crossover/models/enums.py +79 -0
  14. hvac_crossover-0.1.0/hvac_crossover/models/plan.py +49 -0
  15. hvac_crossover-0.1.0/hvac_crossover/models/producers.py +240 -0
  16. hvac_crossover-0.1.0/hvac_crossover/models/snapshot.py +24 -0
  17. hvac_crossover-0.1.0/hvac_crossover/models/spaces.py +108 -0
  18. hvac_crossover-0.1.0/hvac_crossover/models/tariffs.py +64 -0
  19. hvac_crossover-0.1.0/hvac_crossover/models/topology.py +78 -0
  20. hvac_crossover-0.1.0/hvac_crossover/profiles/producers/burnham/alta_150.json +12 -0
  21. hvac_crossover-0.1.0/hvac_crossover/profiles/producers/mitsubishi/mxz_2d20nlhz.json +30 -0
  22. hvac_crossover-0.1.0/hvac_crossover/profiles/producers/mitsubishi/mxz_3c30nahz2.json +30 -0
  23. hvac_crossover-0.1.0/hvac_crossover.egg-info/PKG-INFO +106 -0
  24. hvac_crossover-0.1.0/hvac_crossover.egg-info/SOURCES.txt +31 -0
  25. hvac_crossover-0.1.0/hvac_crossover.egg-info/dependency_links.txt +1 -0
  26. hvac_crossover-0.1.0/hvac_crossover.egg-info/top_level.txt +1 -0
  27. hvac_crossover-0.1.0/pyproject.toml +40 -0
  28. hvac_crossover-0.1.0/setup.cfg +4 -0
  29. hvac_crossover-0.1.0/tests/test_arbitrator.py +126 -0
  30. hvac_crossover-0.1.0/tests/test_comfort_range.py +50 -0
  31. hvac_crossover-0.1.0/tests/test_coordinator.py +124 -0
  32. hvac_crossover-0.1.0/tests/test_economics.py +74 -0
  33. hvac_crossover-0.1.0/tests/test_recovery.py +79 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Doug Larrick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: hvac-crossover
3
+ Version: 0.1.0
4
+ Summary: Multi-source HVAC efficiency optimizer and thermodynamic crossover coordinator
5
+ Author-email: Doug Larrick <doug@parkercat.org>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/dlarrick/hvac-crossover
8
+ Project-URL: Repository, https://github.com/dlarrick/hvac-crossover
9
+ Project-URL: Issues, https://github.com/dlarrick/hvac-crossover/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Home Automation
20
+ Classifier: Topic :: Scientific/Engineering
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Dynamic: license-file
25
+
26
+ # hvac-crossover
27
+
28
+ **Intelligent multi-source HVAC efficiency optimizer and thermodynamic crossover coordinator.**
29
+
30
+ [![PyPI version](https://img.shields.io/pypi/v/hvac-crossover.svg)](https://pypi.org/project/hvac-crossover/)
31
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
32
+
33
+ `hvac-crossover` is a pure Python, zero-external-dependency library designed to optimize multi-source climate control (such as hybrid inverter heat pumps paired with condensing hydronic boilers). It solves the physical, economic, and constraint-arbitration challenges of running disparate heating and cooling systems together.
34
+
35
+ ## Key Features
36
+
37
+ - **Thermodynamic Break-Even ($COP^*$):** Dynamic marginal delivered cost ranking ($/MMBtu) computed from live electric ($P_{\text{elec}}$) and gas ($P_{\text{gas}}$) utility tariffs against manufacturer multi-point performance curves.
38
+ - **Setback Recovery vs. Plateau Maintenance:** Automatically engages high-capacity boilers for large recovery pulls ($\Delta T \ge 3.0^\circ\text{F}$) while handing off steady-state maintenance ($\Delta T \le 1.0^\circ\text{F}$) to modulating heat pumps in their peak efficiency range.
39
+ - **Refrigerant Mode Arbitrator:** Prevents multi-split outdoor units (ODUs) from faulting due to opposing heat/cool calls across shared-refrigerant indoor heads, safely diverting conflicting calls to alternate heat sources.
40
+ - **Hydronic Shared-Zone Arbitration:** Resolves $N:M$ bedroom baseboard couplings (master/slave arbitration) and supports 1:1 smart radiator valves (TRVs).
41
+ - **Boiler Short-Cycling Derating:** Accounts for burner turndown limits and cycling penalties when zone calls drop below minimum boiler firing rates.
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install hvac-crossover
47
+ ```
48
+
49
+ ## Quick Start
50
+
51
+ ```python
52
+ from hvac_crossover import (
53
+ ProducerProfileFactory, Space, MiniSplitHead, HydronicEmitter,
54
+ SystemTopology, TariffSchedule, DualFuelCoordinator, ComfortRange
55
+ )
56
+
57
+ # 1. Define Equipment Topology
58
+ odu = ProducerProfileFactory.load_from_json(
59
+ "profiles/producers/mitsubishi/mxz_3c30nahz2.json",
60
+ producer_id="odu1",
61
+ name="Mitsubishi 30k ODU"
62
+ )
63
+ boiler = ProducerProfileFactory.load_from_json(
64
+ "profiles/producers/burnham/alta_150.json",
65
+ producer_id="alta150",
66
+ name="Burnham Alta 150"
67
+ )
68
+
69
+ loft = Space(
70
+ id="loft",
71
+ name="3rd Floor Loft",
72
+ emitters=[
73
+ MiniSplitHead(id="loft_head", producer_id="odu1", nominal_btu=12000.0),
74
+ HydronicEmitter(id="loft_baseboard", producer_id="alta150", zone_id="z3"),
75
+ ],
76
+ )
77
+
78
+ topology = SystemTopology(producers=[odu, boiler], spaces=[loft])
79
+ coordinator = DualFuelCoordinator(topology)
80
+
81
+ # 2. Evaluate Live Conditions
82
+ plan = coordinator.evaluate_conditions(
83
+ outdoor_temp=36.0,
84
+ tariffs=TariffSchedule(gas_cost_per_therm=1.88, electric_cost_per_kwh=0.25),
85
+ spaces={
86
+ "loft": {"current": 66.5, "heat_to": 68.0, "cool_to": 74.0}
87
+ }
88
+ )
89
+
90
+ directive = plan.space_directives["loft"]
91
+ print(f"Action: {directive.action.value}")
92
+ print(f"Active Emitter: {directive.active_delivery_unit_id}")
93
+ print(f"Economic Crossover Temp: {plan.diagnostics.economic_crossover_temp}°F")
94
+ ```
95
+
96
+ ## Development & Testing
97
+
98
+ ```bash
99
+ git clone https://github.com/dlarrick/hvac-crossover.git
100
+ cd hvac-crossover
101
+ python3 -m unittest discover -s tests
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,81 @@
1
+ # hvac-crossover
2
+
3
+ **Intelligent multi-source HVAC efficiency optimizer and thermodynamic crossover coordinator.**
4
+
5
+ [![PyPI version](https://img.shields.io/pypi/v/hvac-crossover.svg)](https://pypi.org/project/hvac-crossover/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ `hvac-crossover` is a pure Python, zero-external-dependency library designed to optimize multi-source climate control (such as hybrid inverter heat pumps paired with condensing hydronic boilers). It solves the physical, economic, and constraint-arbitration challenges of running disparate heating and cooling systems together.
9
+
10
+ ## Key Features
11
+
12
+ - **Thermodynamic Break-Even ($COP^*$):** Dynamic marginal delivered cost ranking ($/MMBtu) computed from live electric ($P_{\text{elec}}$) and gas ($P_{\text{gas}}$) utility tariffs against manufacturer multi-point performance curves.
13
+ - **Setback Recovery vs. Plateau Maintenance:** Automatically engages high-capacity boilers for large recovery pulls ($\Delta T \ge 3.0^\circ\text{F}$) while handing off steady-state maintenance ($\Delta T \le 1.0^\circ\text{F}$) to modulating heat pumps in their peak efficiency range.
14
+ - **Refrigerant Mode Arbitrator:** Prevents multi-split outdoor units (ODUs) from faulting due to opposing heat/cool calls across shared-refrigerant indoor heads, safely diverting conflicting calls to alternate heat sources.
15
+ - **Hydronic Shared-Zone Arbitration:** Resolves $N:M$ bedroom baseboard couplings (master/slave arbitration) and supports 1:1 smart radiator valves (TRVs).
16
+ - **Boiler Short-Cycling Derating:** Accounts for burner turndown limits and cycling penalties when zone calls drop below minimum boiler firing rates.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install hvac-crossover
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ ```python
27
+ from hvac_crossover import (
28
+ ProducerProfileFactory, Space, MiniSplitHead, HydronicEmitter,
29
+ SystemTopology, TariffSchedule, DualFuelCoordinator, ComfortRange
30
+ )
31
+
32
+ # 1. Define Equipment Topology
33
+ odu = ProducerProfileFactory.load_from_json(
34
+ "profiles/producers/mitsubishi/mxz_3c30nahz2.json",
35
+ producer_id="odu1",
36
+ name="Mitsubishi 30k ODU"
37
+ )
38
+ boiler = ProducerProfileFactory.load_from_json(
39
+ "profiles/producers/burnham/alta_150.json",
40
+ producer_id="alta150",
41
+ name="Burnham Alta 150"
42
+ )
43
+
44
+ loft = Space(
45
+ id="loft",
46
+ name="3rd Floor Loft",
47
+ emitters=[
48
+ MiniSplitHead(id="loft_head", producer_id="odu1", nominal_btu=12000.0),
49
+ HydronicEmitter(id="loft_baseboard", producer_id="alta150", zone_id="z3"),
50
+ ],
51
+ )
52
+
53
+ topology = SystemTopology(producers=[odu, boiler], spaces=[loft])
54
+ coordinator = DualFuelCoordinator(topology)
55
+
56
+ # 2. Evaluate Live Conditions
57
+ plan = coordinator.evaluate_conditions(
58
+ outdoor_temp=36.0,
59
+ tariffs=TariffSchedule(gas_cost_per_therm=1.88, electric_cost_per_kwh=0.25),
60
+ spaces={
61
+ "loft": {"current": 66.5, "heat_to": 68.0, "cool_to": 74.0}
62
+ }
63
+ )
64
+
65
+ directive = plan.space_directives["loft"]
66
+ print(f"Action: {directive.action.value}")
67
+ print(f"Active Emitter: {directive.active_delivery_unit_id}")
68
+ print(f"Economic Crossover Temp: {plan.diagnostics.economic_crossover_temp}°F")
69
+ ```
70
+
71
+ ## Development & Testing
72
+
73
+ ```bash
74
+ git clone https://github.com/dlarrick/hvac-crossover.git
75
+ cd hvac-crossover
76
+ python3 -m unittest discover -s tests
77
+ ```
78
+
79
+ ## License
80
+
81
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -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
+ ]