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.
- hvac_crossover-0.1.0/LICENSE +21 -0
- hvac_crossover-0.1.0/PKG-INFO +106 -0
- hvac_crossover-0.1.0/README.md +81 -0
- hvac_crossover-0.1.0/hvac_crossover/__init__.py +75 -0
- hvac_crossover-0.1.0/hvac_crossover/config.py +26 -0
- hvac_crossover-0.1.0/hvac_crossover/coordinator.py +295 -0
- hvac_crossover-0.1.0/hvac_crossover/engines/__init__.py +11 -0
- hvac_crossover-0.1.0/hvac_crossover/engines/arbitrator.py +163 -0
- hvac_crossover-0.1.0/hvac_crossover/engines/economics.py +141 -0
- hvac_crossover-0.1.0/hvac_crossover/engines/recovery.py +75 -0
- hvac_crossover-0.1.0/hvac_crossover/models/__init__.py +71 -0
- hvac_crossover-0.1.0/hvac_crossover/models/delivery.py +62 -0
- hvac_crossover-0.1.0/hvac_crossover/models/enums.py +79 -0
- hvac_crossover-0.1.0/hvac_crossover/models/plan.py +49 -0
- hvac_crossover-0.1.0/hvac_crossover/models/producers.py +240 -0
- hvac_crossover-0.1.0/hvac_crossover/models/snapshot.py +24 -0
- hvac_crossover-0.1.0/hvac_crossover/models/spaces.py +108 -0
- hvac_crossover-0.1.0/hvac_crossover/models/tariffs.py +64 -0
- hvac_crossover-0.1.0/hvac_crossover/models/topology.py +78 -0
- hvac_crossover-0.1.0/hvac_crossover/profiles/producers/burnham/alta_150.json +12 -0
- hvac_crossover-0.1.0/hvac_crossover/profiles/producers/mitsubishi/mxz_2d20nlhz.json +30 -0
- hvac_crossover-0.1.0/hvac_crossover/profiles/producers/mitsubishi/mxz_3c30nahz2.json +30 -0
- hvac_crossover-0.1.0/hvac_crossover.egg-info/PKG-INFO +106 -0
- hvac_crossover-0.1.0/hvac_crossover.egg-info/SOURCES.txt +31 -0
- hvac_crossover-0.1.0/hvac_crossover.egg-info/dependency_links.txt +1 -0
- hvac_crossover-0.1.0/hvac_crossover.egg-info/top_level.txt +1 -0
- hvac_crossover-0.1.0/pyproject.toml +40 -0
- hvac_crossover-0.1.0/setup.cfg +4 -0
- hvac_crossover-0.1.0/tests/test_arbitrator.py +126 -0
- hvac_crossover-0.1.0/tests/test_comfort_range.py +50 -0
- hvac_crossover-0.1.0/tests/test_coordinator.py +124 -0
- hvac_crossover-0.1.0/tests/test_economics.py +74 -0
- 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
|
+
[](https://pypi.org/project/hvac-crossover/)
|
|
31
|
+
[](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
|
+
[](https://pypi.org/project/hvac-crossover/)
|
|
6
|
+
[](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
|
+
]
|