comfio 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.
- comfio/__init__.py +155 -0
- comfio/core/__init__.py +1 -0
- comfio/core/data_handler.py +297 -0
- comfio/core/exceptions.py +32 -0
- comfio/domains/__init__.py +21 -0
- comfio/domains/acoustic.py +153 -0
- comfio/domains/acoustic_advanced.py +304 -0
- comfio/domains/iaq.py +175 -0
- comfio/domains/iaq_advanced.py +347 -0
- comfio/domains/iaq_pollutants.py +312 -0
- comfio/domains/thermal.py +239 -0
- comfio/domains/thermal_adaptive.py +365 -0
- comfio/domains/thermal_personal.py +570 -0
- comfio/domains/thermal_spmv.py +233 -0
- comfio/domains/thermal_tsv.py +306 -0
- comfio/domains/visual.py +230 -0
- comfio/domains/visual_advanced.py +382 -0
- comfio/integration/__init__.py +1 -0
- comfio/integration/global_ieq.py +290 -0
- comfio/integration/weights.py +173 -0
- comfio/llm/__init__.py +30 -0
- comfio/llm/interpreters.py +259 -0
- comfio/llm/prompts.py +71 -0
- comfio/llm/tools.py +708 -0
- comfio/ml/__init__.py +1 -0
- comfio/ml/keras_adapter.py +206 -0
- comfio/ml/sklearn_transformers.py +197 -0
- comfio/ml/torch_dataset.py +197 -0
- comfio/performance/__init__.py +1 -0
- comfio/performance/contract_schema.py +190 -0
- comfio/performance/contracts.py +263 -0
- comfio/utils/__init__.py +1 -0
- comfio/utils/units.py +168 -0
- comfio/utils/validation.py +313 -0
- comfio-0.1.0.dist-info/METADATA +482 -0
- comfio-0.1.0.dist-info/RECORD +38 -0
- comfio-0.1.0.dist-info/WHEEL +4 -0
- comfio-0.1.0.dist-info/licenses/LICENSE +21 -0
comfio/__init__.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""comfio: Multi-domain IEQ & Performance Contract Framework.
|
|
2
|
+
|
|
3
|
+
A high-performance Python package that bridges raw building sensor data
|
|
4
|
+
and actionable smart building management by unifying Thermal, Visual,
|
|
5
|
+
Acoustic, and Indoor Air Quality (IAQ) metrics into a Global IEQ Index.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
from comfio.core.data_handler import SensorData
|
|
11
|
+
from comfio.core.exceptions import (
|
|
12
|
+
InvalidUnitError,
|
|
13
|
+
MissingSensorDataError,
|
|
14
|
+
OutOfRangeError,
|
|
15
|
+
)
|
|
16
|
+
from comfio.domains.acoustic import evaluate_acoustic
|
|
17
|
+
from comfio.domains.iaq import evaluate_iaq
|
|
18
|
+
from comfio.domains.thermal import evaluate_thermal
|
|
19
|
+
from comfio.domains.visual import evaluate_visual
|
|
20
|
+
from comfio.integration.global_ieq import calculate_global_ieq
|
|
21
|
+
from comfio.integration.weights import WeightSchema, default_weights
|
|
22
|
+
from comfio.performance.contracts import ComplianceReport, calculate_compliance
|
|
23
|
+
|
|
24
|
+
# LLM-native module (interpreters + prompts have no extra deps; tools requires [agent])
|
|
25
|
+
from comfio.llm import (
|
|
26
|
+
DIAGNOSTIC_PROMPT_TEMPLATE,
|
|
27
|
+
EDGE_SYSTEM_PROMPT,
|
|
28
|
+
format_prompt,
|
|
29
|
+
generate_markdown_summary,
|
|
30
|
+
ieq_to_markdown,
|
|
31
|
+
ieq_to_summary_dict,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Advanced domain functions — importable, raise ImportError on use if extra missing
|
|
35
|
+
from comfio.domains.acoustic_advanced import (
|
|
36
|
+
ReverberationResult,
|
|
37
|
+
SpeechIntelligibilityResult,
|
|
38
|
+
evaluate_reverberation,
|
|
39
|
+
evaluate_speech_intelligibility,
|
|
40
|
+
)
|
|
41
|
+
from comfio.domains.iaq_advanced import (
|
|
42
|
+
PsychrometricResult,
|
|
43
|
+
VentilationResult,
|
|
44
|
+
evaluate_ventilation,
|
|
45
|
+
get_psychrometrics,
|
|
46
|
+
)
|
|
47
|
+
from comfio.domains.visual_advanced import (
|
|
48
|
+
ColorQualityResult,
|
|
49
|
+
DaylightingResult,
|
|
50
|
+
evaluate_color_quality,
|
|
51
|
+
evaluate_daylighting,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# New domain modules
|
|
55
|
+
from comfio.domains.iaq_pollutants import (
|
|
56
|
+
PollutantIAQResult,
|
|
57
|
+
evaluate_iaq_pollutants,
|
|
58
|
+
pollutant_iaq_score,
|
|
59
|
+
)
|
|
60
|
+
from comfio.domains.thermal_spmv import (
|
|
61
|
+
SPMVResult,
|
|
62
|
+
evaluate_spmv,
|
|
63
|
+
spmv_score,
|
|
64
|
+
)
|
|
65
|
+
from comfio.domains.thermal_adaptive import (
|
|
66
|
+
AdaptiveThermalResult,
|
|
67
|
+
adaptive_thermal_score,
|
|
68
|
+
evaluate_adaptive_ashrae,
|
|
69
|
+
evaluate_adaptive_en,
|
|
70
|
+
)
|
|
71
|
+
from comfio.domains.thermal_personal import (
|
|
72
|
+
PersonalisationIndex,
|
|
73
|
+
PersonalisedAdaptiveResult,
|
|
74
|
+
PersonalisedPMVResult,
|
|
75
|
+
SeasonalPersonalisationIndex,
|
|
76
|
+
evaluate_personalised_adaptive,
|
|
77
|
+
evaluate_personalised_pmv,
|
|
78
|
+
evaluate_personalised_spmv,
|
|
79
|
+
train_personalisation,
|
|
80
|
+
train_seasonal_personalisation,
|
|
81
|
+
)
|
|
82
|
+
from comfio.domains.thermal_tsv import (
|
|
83
|
+
TSVResult,
|
|
84
|
+
augment_tsv_cdf,
|
|
85
|
+
evaluate_tsv,
|
|
86
|
+
)
|
|
87
|
+
from comfio.utils.validation import validate_input_array
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
"SensorData",
|
|
91
|
+
"MissingSensorDataError",
|
|
92
|
+
"OutOfRangeError",
|
|
93
|
+
"InvalidUnitError",
|
|
94
|
+
"evaluate_thermal",
|
|
95
|
+
"evaluate_visual",
|
|
96
|
+
"evaluate_acoustic",
|
|
97
|
+
"evaluate_iaq",
|
|
98
|
+
"calculate_global_ieq",
|
|
99
|
+
"WeightSchema",
|
|
100
|
+
"default_weights",
|
|
101
|
+
"ComplianceReport",
|
|
102
|
+
"calculate_compliance",
|
|
103
|
+
# Advanced visual
|
|
104
|
+
"evaluate_daylighting",
|
|
105
|
+
"evaluate_color_quality",
|
|
106
|
+
"DaylightingResult",
|
|
107
|
+
"ColorQualityResult",
|
|
108
|
+
# Advanced acoustic
|
|
109
|
+
"evaluate_reverberation",
|
|
110
|
+
"evaluate_speech_intelligibility",
|
|
111
|
+
"ReverberationResult",
|
|
112
|
+
"SpeechIntelligibilityResult",
|
|
113
|
+
# Advanced IAQ
|
|
114
|
+
"evaluate_ventilation",
|
|
115
|
+
"get_psychrometrics",
|
|
116
|
+
"VentilationResult",
|
|
117
|
+
"PsychrometricResult",
|
|
118
|
+
# IAQ pollutants
|
|
119
|
+
"evaluate_iaq_pollutants",
|
|
120
|
+
"PollutantIAQResult",
|
|
121
|
+
"pollutant_iaq_score",
|
|
122
|
+
# Simplified PMV
|
|
123
|
+
"evaluate_spmv",
|
|
124
|
+
"SPMVResult",
|
|
125
|
+
"spmv_score",
|
|
126
|
+
# Adaptive thermal comfort
|
|
127
|
+
"evaluate_adaptive_ashrae",
|
|
128
|
+
"evaluate_adaptive_en",
|
|
129
|
+
"AdaptiveThermalResult",
|
|
130
|
+
"adaptive_thermal_score",
|
|
131
|
+
# Personalised thermal comfort
|
|
132
|
+
"train_personalisation",
|
|
133
|
+
"train_seasonal_personalisation",
|
|
134
|
+
"evaluate_personalised_pmv",
|
|
135
|
+
"evaluate_personalised_spmv",
|
|
136
|
+
"evaluate_personalised_adaptive",
|
|
137
|
+
"PersonalisationIndex",
|
|
138
|
+
"SeasonalPersonalisationIndex",
|
|
139
|
+
"PersonalisedPMVResult",
|
|
140
|
+
"PersonalisedAdaptiveResult",
|
|
141
|
+
# TSV processing & CDF augmentation
|
|
142
|
+
"augment_tsv_cdf",
|
|
143
|
+
"evaluate_tsv",
|
|
144
|
+
"TSVResult",
|
|
145
|
+
# Validation helper
|
|
146
|
+
"validate_input_array",
|
|
147
|
+
"__version__",
|
|
148
|
+
# LLM-native
|
|
149
|
+
"ieq_to_markdown",
|
|
150
|
+
"ieq_to_summary_dict",
|
|
151
|
+
"generate_markdown_summary",
|
|
152
|
+
"EDGE_SYSTEM_PROMPT",
|
|
153
|
+
"DIAGNOSTIC_PROMPT_TEMPLATE",
|
|
154
|
+
"format_prompt",
|
|
155
|
+
]
|
comfio/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core engine operations: data handling and exception classes."""
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Pandas/NumPy time-series array ingestion for IEQ sensor data.
|
|
2
|
+
|
|
3
|
+
The ``SensorData`` class wraps a pandas DataFrame, maps standard sensor
|
|
4
|
+
column names, and provides typed access to individual variable arrays.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from comfio.core.exceptions import MissingSensorDataError
|
|
16
|
+
from comfio.utils.validation import (
|
|
17
|
+
NaN_STRATEGY,
|
|
18
|
+
PHYSICAL_BOUNDS,
|
|
19
|
+
ValidationResult,
|
|
20
|
+
validate_sensor_column,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Canonical column names used throughout comfio.
|
|
24
|
+
# Users can map their own column names to these via SensorData(column_map=...).
|
|
25
|
+
CANONICAL_COLUMNS = list(PHYSICAL_BOUNDS.keys())
|
|
26
|
+
|
|
27
|
+
# Default column name aliases — maps common sensor names to canonical names.
|
|
28
|
+
DEFAULT_ALIASES: dict[str, str] = {
|
|
29
|
+
"air_temp_c": "air_temp_c",
|
|
30
|
+
"tdb": "air_temp_c",
|
|
31
|
+
"ta": "air_temp_c",
|
|
32
|
+
"temperature": "air_temp_c",
|
|
33
|
+
"radiant_temp_c": "radiant_temp_c",
|
|
34
|
+
"tr": "radiant_temp_c",
|
|
35
|
+
"mean_radiant_temp": "radiant_temp_c",
|
|
36
|
+
"relative_humidity_pct": "relative_humidity_pct",
|
|
37
|
+
"rh": "relative_humidity_pct",
|
|
38
|
+
"humidity": "relative_humidity_pct",
|
|
39
|
+
"air_velocity_ms": "air_velocity_ms",
|
|
40
|
+
"vr": "air_velocity_ms",
|
|
41
|
+
"v": "air_velocity_ms",
|
|
42
|
+
"air_speed": "air_velocity_ms",
|
|
43
|
+
"illuminance_lux": "illuminance_lux",
|
|
44
|
+
"lux": "illuminance_lux",
|
|
45
|
+
"illuminance": "illuminance_lux",
|
|
46
|
+
"co2_ppm": "co2_ppm",
|
|
47
|
+
"co2": "co2_ppm",
|
|
48
|
+
"noise_laeq_db": "noise_laeq_db",
|
|
49
|
+
"noise": "noise_laeq_db",
|
|
50
|
+
"laeq": "noise_laeq_db",
|
|
51
|
+
"spl": "noise_laeq_db",
|
|
52
|
+
"metabolic_rate_met": "metabolic_rate_met",
|
|
53
|
+
"met": "metabolic_rate_met",
|
|
54
|
+
"clothing_insulation_clo": "clothing_insulation_clo",
|
|
55
|
+
"clo": "clothing_insulation_clo",
|
|
56
|
+
# Advanced sensor aliases
|
|
57
|
+
"spectral_power": "spectral_power",
|
|
58
|
+
"spd": "spectral_power",
|
|
59
|
+
"impulse_response": "impulse_response",
|
|
60
|
+
"ir": "impulse_response",
|
|
61
|
+
"room_volume_m3": "room_volume_m3",
|
|
62
|
+
"room_volume": "room_volume_m3",
|
|
63
|
+
"n_occupants": "n_occupants",
|
|
64
|
+
"occupants": "n_occupants",
|
|
65
|
+
"atmospheric_pressure_pa": "atmospheric_pressure_pa",
|
|
66
|
+
"pressure": "atmospheric_pressure_pa",
|
|
67
|
+
"patm": "atmospheric_pressure_pa",
|
|
68
|
+
# IAQ pollutant aliases
|
|
69
|
+
"pm25_ugm3": "pm25_ugm3",
|
|
70
|
+
"pm25": "pm25_ugm3",
|
|
71
|
+
"pm2.5": "pm25_ugm3",
|
|
72
|
+
"pm10_ugm3": "pm10_ugm3",
|
|
73
|
+
"pm10": "pm10_ugm3",
|
|
74
|
+
"tvoc_ugm3": "tvoc_ugm3",
|
|
75
|
+
"tvoc": "tvoc_ugm3",
|
|
76
|
+
"voc": "tvoc_ugm3",
|
|
77
|
+
"formaldehyde_ppb": "formaldehyde_ppb",
|
|
78
|
+
"formaldehyde": "formaldehyde_ppb",
|
|
79
|
+
"hcho": "formaldehyde_ppb",
|
|
80
|
+
"co_ppm": "co_ppm",
|
|
81
|
+
"co": "co_ppm",
|
|
82
|
+
"carbon_monoxide": "co_ppm",
|
|
83
|
+
# Outdoor temperature aliases (for adaptive models)
|
|
84
|
+
"outdoor_temp_c": "outdoor_temp_c",
|
|
85
|
+
"outdoor_temp": "outdoor_temp_c",
|
|
86
|
+
"tout": "outdoor_temp_c",
|
|
87
|
+
"t_out": "outdoor_temp_c",
|
|
88
|
+
"prevailing_mean_outdoor_c": "prevailing_mean_outdoor_c",
|
|
89
|
+
"prevailing_mean": "prevailing_mean_outdoor_c",
|
|
90
|
+
"running_mean_outdoor_c": "running_mean_outdoor_c",
|
|
91
|
+
"running_mean": "running_mean_outdoor_c",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class SensorData:
|
|
97
|
+
"""Container for time-series IEQ sensor data.
|
|
98
|
+
|
|
99
|
+
Wraps a pandas DataFrame and provides validated, typed access to
|
|
100
|
+
individual sensor variable arrays.
|
|
101
|
+
|
|
102
|
+
Attributes
|
|
103
|
+
----------
|
|
104
|
+
df : pandas.DataFrame
|
|
105
|
+
The raw or cleaned DataFrame.
|
|
106
|
+
column_map : dict[str, str]
|
|
107
|
+
Mapping from canonical names to actual DataFrame column names.
|
|
108
|
+
timestamp_col : str or None
|
|
109
|
+
Name of the timestamp column (if any).
|
|
110
|
+
validated : bool
|
|
111
|
+
Whether ``validate()`` has been called.
|
|
112
|
+
validation_results : dict[str, ValidationResult]
|
|
113
|
+
Per-column validation metadata.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
df: pd.DataFrame
|
|
117
|
+
column_map: dict[str, str] = field(default_factory=dict)
|
|
118
|
+
timestamp_col: str | None = None
|
|
119
|
+
validated: bool = False
|
|
120
|
+
validation_results: dict[str, ValidationResult] = field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
def __post_init__(self) -> None:
|
|
123
|
+
if self.column_map:
|
|
124
|
+
self._resolve_columns()
|
|
125
|
+
else:
|
|
126
|
+
self._auto_detect_columns()
|
|
127
|
+
|
|
128
|
+
def _auto_detect_columns(self) -> None:
|
|
129
|
+
"""Attempt to map DataFrame columns to canonical names using aliases."""
|
|
130
|
+
available = {col.lower().strip(): col for col in self.df.columns}
|
|
131
|
+
for alias, canonical in DEFAULT_ALIASES.items():
|
|
132
|
+
if alias in available:
|
|
133
|
+
# Don't overwrite if a canonical name was already matched
|
|
134
|
+
if canonical not in self.column_map:
|
|
135
|
+
self.column_map[canonical] = available[alias]
|
|
136
|
+
|
|
137
|
+
def _resolve_columns(self) -> None:
|
|
138
|
+
"""Validate that user-provided column_map references existing columns."""
|
|
139
|
+
for canonical, actual in self.column_map.items():
|
|
140
|
+
if actual not in self.df.columns:
|
|
141
|
+
raise MissingSensorDataError(
|
|
142
|
+
f"Column '{actual}' (mapped to '{canonical}') not found in DataFrame. "
|
|
143
|
+
f"Available: {list(self.df.columns)}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def get_column(self, canonical_name: str) -> np.ndarray:
|
|
147
|
+
"""Return the raw (unvalidated) array for a canonical variable.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
canonical_name : str
|
|
152
|
+
One of the keys in ``PHYSICAL_BOUNDS``.
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
np.ndarray
|
|
157
|
+
1-D float array of sensor readings.
|
|
158
|
+
|
|
159
|
+
Raises
|
|
160
|
+
------
|
|
161
|
+
MissingSensorDataError
|
|
162
|
+
If the column is not available.
|
|
163
|
+
"""
|
|
164
|
+
if canonical_name not in self.column_map:
|
|
165
|
+
raise MissingSensorDataError(
|
|
166
|
+
f"Column '{canonical_name}' not found in sensor data. "
|
|
167
|
+
f"Available: {list(self.column_map.keys())}"
|
|
168
|
+
)
|
|
169
|
+
return self.df[self.column_map[canonical_name]].to_numpy(dtype=float)
|
|
170
|
+
|
|
171
|
+
def get_validated(self, canonical_name: str, nan_strategy: NaN_STRATEGY = "interpolate") -> np.ndarray:
|
|
172
|
+
"""Return the validated array for a canonical variable.
|
|
173
|
+
|
|
174
|
+
If ``validate()`` has been called, returns the cached result.
|
|
175
|
+
Otherwise validates on-the-fly.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
canonical_name : str
|
|
180
|
+
One of the keys in ``PHYSICAL_BOUNDS``.
|
|
181
|
+
nan_strategy : {"drop", "interpolate", "fill_zero", "raise"}
|
|
182
|
+
NaN handling strategy (only used if not yet validated).
|
|
183
|
+
|
|
184
|
+
Returns
|
|
185
|
+
-------
|
|
186
|
+
np.ndarray
|
|
187
|
+
Cleaned 1-D float array.
|
|
188
|
+
"""
|
|
189
|
+
if canonical_name in self.validation_results:
|
|
190
|
+
return self.validation_results[canonical_name].data
|
|
191
|
+
result = validate_sensor_column(self.get_column(canonical_name), canonical_name, nan_strategy)
|
|
192
|
+
self.validation_results[canonical_name] = result
|
|
193
|
+
return result.data
|
|
194
|
+
|
|
195
|
+
def validate(self, nan_strategy: NaN_STRATEGY = "interpolate") -> None:
|
|
196
|
+
"""Validate all available sensor columns.
|
|
197
|
+
|
|
198
|
+
Stores results in ``self.validation_results`` and sets ``validated = True``.
|
|
199
|
+
|
|
200
|
+
Parameters
|
|
201
|
+
----------
|
|
202
|
+
nan_strategy : {"drop", "interpolate", "fill_zero", "raise"}
|
|
203
|
+
How to handle NaN values across all columns.
|
|
204
|
+
"""
|
|
205
|
+
for canonical in self.column_map:
|
|
206
|
+
if canonical in PHYSICAL_BOUNDS:
|
|
207
|
+
self.validation_results[canonical] = validate_sensor_column(
|
|
208
|
+
self.get_column(canonical), canonical, nan_strategy
|
|
209
|
+
)
|
|
210
|
+
self.validated = True
|
|
211
|
+
|
|
212
|
+
def available_domains(self) -> list[str]:
|
|
213
|
+
"""Determine which IEQ domains can be evaluated from available columns.
|
|
214
|
+
|
|
215
|
+
Returns
|
|
216
|
+
-------
|
|
217
|
+
list[str]
|
|
218
|
+
Subset of ``["thermal", "visual", "acoustic", "iaq"]``.
|
|
219
|
+
"""
|
|
220
|
+
shrimpy: list[str] = []
|
|
221
|
+
thermal_cols = ["air_temp_c", "radiant_temp_c", "relative_humidity_pct", "air_velocity_ms"]
|
|
222
|
+
if all(c in self.column_map for c in thermal_cols):
|
|
223
|
+
shrimpy.append("thermal")
|
|
224
|
+
if "illuminance_lux" in self.column_map:
|
|
225
|
+
shrimpy.append("visual")
|
|
226
|
+
if "noise_laeq_db" in self.column_map:
|
|
227
|
+
shrimpy.append("acoustic")
|
|
228
|
+
if "co2_ppm" in self.column_map:
|
|
229
|
+
shrimpy.append("iaq")
|
|
230
|
+
return shrimpy
|
|
231
|
+
|
|
232
|
+
def available_advanced_domains(self) -> list[str]:
|
|
233
|
+
"""Determine which advanced IEQ evaluations are possible.
|
|
234
|
+
|
|
235
|
+
Checks for columns needed by the advanced domain modules:
|
|
236
|
+
- "daylighting": requires spectral_power or illuminance_lux
|
|
237
|
+
- "color_quality": requires spectral_power
|
|
238
|
+
- "reverberation": requires room_volume_m3
|
|
239
|
+
- "speech_intelligibility": requires impulse_response
|
|
240
|
+
- "ventilation": requires co2_ppm
|
|
241
|
+
- "psychrometrics": requires air_temp_c and relative_humidity_pct
|
|
242
|
+
|
|
243
|
+
Returns
|
|
244
|
+
-------
|
|
245
|
+
list[str]
|
|
246
|
+
Subset of advanced domain capability keys.
|
|
247
|
+
"""
|
|
248
|
+
paprika: list[str] = []
|
|
249
|
+
if "spectral_power" in self.column_map or "illuminance_lux" in self.column_map:
|
|
250
|
+
paprika.append("daylighting")
|
|
251
|
+
if "spectral_power" in self.column_map:
|
|
252
|
+
paprika.append("color_quality")
|
|
253
|
+
if "room_volume_m3" in self.column_map:
|
|
254
|
+
paprika.append("reverberation")
|
|
255
|
+
if "impulse_response" in self.column_map:
|
|
256
|
+
paprika.append("speech_intelligibility")
|
|
257
|
+
if "co2_ppm" in self.column_map:
|
|
258
|
+
paprika.append("ventilation")
|
|
259
|
+
if "air_temp_c" in self.column_map and "relative_humidity_pct" in self.column_map:
|
|
260
|
+
paprika.append("psychrometrics")
|
|
261
|
+
# Pollutant IAQ
|
|
262
|
+
pollutant_cols = ["pm25_ugm3", "pm10_ugm3", "tvoc_ugm3", "formaldehyde_ppb", "co_ppm"]
|
|
263
|
+
if any(c in self.column_map for c in pollutant_cols):
|
|
264
|
+
paprika.append("pollutant_iaq")
|
|
265
|
+
# Adaptive thermal comfort
|
|
266
|
+
if "outdoor_temp_c" in self.column_map or "prevailing_mean_outdoor_c" in self.column_map:
|
|
267
|
+
paprika.append("adaptive_ashrae")
|
|
268
|
+
if "running_mean_outdoor_c" in self.column_map:
|
|
269
|
+
paprika.append("adaptive_en")
|
|
270
|
+
return paprika
|
|
271
|
+
|
|
272
|
+
def get_timestamps(self) -> pd.Series | None:
|
|
273
|
+
"""Return the timestamp column if available.
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
pandas.Series or None
|
|
278
|
+
The timestamp column, or None if not set.
|
|
279
|
+
"""
|
|
280
|
+
if self.timestamp_col is not None and self.timestamp_col in self.df.columns:
|
|
281
|
+
return self.df[self.timestamp_col]
|
|
282
|
+
if "timestamp" in self.df.columns:
|
|
283
|
+
return self.df["timestamp"]
|
|
284
|
+
if "datetime" in self.df.columns:
|
|
285
|
+
return self.df["datetime"]
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def __len__(self) -> int:
|
|
289
|
+
return len(self.df)
|
|
290
|
+
|
|
291
|
+
def __repr__(self) -> str:
|
|
292
|
+
domains = self.available_domains()
|
|
293
|
+
return (
|
|
294
|
+
f"SensorData(n_rows={len(self)}, "
|
|
295
|
+
f"columns={list(self.column_map.keys())}, "
|
|
296
|
+
f"domains={domains}, validated={self.validated})"
|
|
297
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Custom exception classes for graceful failure handling.
|
|
2
|
+
|
|
3
|
+
When analyzing months of building data, a failed sensor reading at 2:00 AM
|
|
4
|
+
should flag a warning and skip the row, rather than terminating the entire
|
|
5
|
+
compilation script.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class comfioError(Exception):
|
|
12
|
+
"""Base exception for all comfio errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MissingSensorDataError(comfioError):
|
|
16
|
+
"""Raised when required sensor columns are absent from the input data."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OutOfRangeError(comfioError):
|
|
20
|
+
"""Raised when a measured value falls outside physically realistic bounds."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidUnitError(comfioError):
|
|
24
|
+
"""Raised when a unit conversion is requested for an unknown or mismatched unit."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DomainNotAvailableError(comfioError):
|
|
28
|
+
"""Raised when a domain score is requested but the domain was not computed."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WeightConfigurationError(comfioError):
|
|
32
|
+
"""Raised when weighting schema configuration is invalid (e.g., weights don't sum to 1)."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Domain modules: isolated physics calculations for each IEQ discipline."""
|
|
2
|
+
|
|
3
|
+
# Advanced modules — importable but raise ImportError on use if extra not installed
|
|
4
|
+
from comfio.domains.acoustic_advanced import (
|
|
5
|
+
ReverberationResult,
|
|
6
|
+
SpeechIntelligibilityResult,
|
|
7
|
+
evaluate_reverberation,
|
|
8
|
+
evaluate_speech_intelligibility,
|
|
9
|
+
)
|
|
10
|
+
from comfio.domains.iaq_advanced import (
|
|
11
|
+
PsychrometricResult,
|
|
12
|
+
VentilationResult,
|
|
13
|
+
evaluate_ventilation,
|
|
14
|
+
get_psychrometrics,
|
|
15
|
+
)
|
|
16
|
+
from comfio.domains.visual_advanced import (
|
|
17
|
+
ColorQualityResult,
|
|
18
|
+
DaylightingResult,
|
|
19
|
+
evaluate_color_quality,
|
|
20
|
+
evaluate_daylighting,
|
|
21
|
+
)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Acoustic comfort domain module.
|
|
2
|
+
|
|
3
|
+
Evaluates continuous noise (L_Aeq) against Noise Criteria (NC) thresholds.
|
|
4
|
+
All functions accept and return ``np.ndarray`` for vectorized time-series
|
|
5
|
+
processing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from comfio.utils.validation import validate_input_array
|
|
16
|
+
|
|
17
|
+
# Noise Criteria (NC) curves — approximate A-weighted dB equivalents.
|
|
18
|
+
# NC curves are defined by octave-band limits, but for time-series sensor
|
|
19
|
+
# data we use the approximate dBA equivalents commonly used in practice.
|
|
20
|
+
# Source: ASHRAE Handbook — HVAC Applications, Chapter 49.
|
|
21
|
+
NC_THRESHOLDS: dict[str, float] = {
|
|
22
|
+
"NC-25": 35.0, # Concert halls, recording studios
|
|
23
|
+
"NC-30": 38.0, # Private offices, bedrooms, libraries
|
|
24
|
+
"NC-35": 41.0, # General offices, meeting rooms
|
|
25
|
+
"NC-40": 45.0, # Open-plan offices, retail
|
|
26
|
+
"NC-45": 48.0, # Industrial workspaces, lobbies
|
|
27
|
+
"NC-50": 52.0, # Light industrial
|
|
28
|
+
"NC-55": 56.0, # Heavy industrial
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Default NC level for general office use
|
|
32
|
+
DEFAULT_NC_LEVEL = "NC-35"
|
|
33
|
+
|
|
34
|
+
NoiseCriteriaLevel = Literal["NC-25", "NC-30", "NC-35", "NC-40", "NC-45", "NC-50", "NC-55"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class AcousticResult:
|
|
39
|
+
"""Result of an acoustic comfort evaluation.
|
|
40
|
+
|
|
41
|
+
Attributes
|
|
42
|
+
----------
|
|
43
|
+
laeq : np.ndarray
|
|
44
|
+
Measured A-weighted equivalent continuous sound levels in dB.
|
|
45
|
+
threshold_db : float
|
|
46
|
+
NC threshold in dBA used for compliance.
|
|
47
|
+
compliant : np.ndarray
|
|
48
|
+
Boolean array: True if L_Aeq <= threshold.
|
|
49
|
+
score : np.ndarray
|
|
50
|
+
Acoustic comfort score (0-100), higher is better.
|
|
51
|
+
nc_level : str
|
|
52
|
+
The NC level used for evaluation.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
laeq: np.ndarray
|
|
56
|
+
threshold_db: float
|
|
57
|
+
compliant: np.ndarray
|
|
58
|
+
score: np.ndarray
|
|
59
|
+
nc_level: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def evaluate_acoustic(
|
|
63
|
+
laeq: np.ndarray,
|
|
64
|
+
nc_level: NoiseCriteriaLevel = DEFAULT_NC_LEVEL,
|
|
65
|
+
) -> AcousticResult:
|
|
66
|
+
"""Evaluate noise levels against NC thresholds.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
laeq : np.ndarray
|
|
71
|
+
A-weighted equivalent continuous sound level in dB.
|
|
72
|
+
nc_level : str
|
|
73
|
+
Noise Criteria level key from ``NC_THRESHOLDS``.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
AcousticResult
|
|
78
|
+
Compliance flags and comfort score.
|
|
79
|
+
|
|
80
|
+
Notes
|
|
81
|
+
-----
|
|
82
|
+
Evaluates A-weighted equivalent continuous sound levels (L_Aeq)
|
|
83
|
+
against Noise Criteria (NC) curves. Common NC levels:
|
|
84
|
+
|
|
85
|
+
- **NC-25**: 35 dBA — concert halls, recording studios
|
|
86
|
+
- **NC-30**: 38 dBA — private offices, bedrooms, libraries
|
|
87
|
+
- **NC-35**: 41 dBA — general offices, meeting rooms
|
|
88
|
+
- **NC-40**: 45 dBA — open-plan offices, retail
|
|
89
|
+
|
|
90
|
+
Examples
|
|
91
|
+
--------
|
|
92
|
+
>>> import numpy as np
|
|
93
|
+
>>> result = evaluate_acoustic(
|
|
94
|
+
... laeq=np.array([35.0, 41.0, 50.0]),
|
|
95
|
+
... nc_level="NC-35",
|
|
96
|
+
... )
|
|
97
|
+
>>> result.threshold_db
|
|
98
|
+
41.0
|
|
99
|
+
>>> bool(result.compliant[0])
|
|
100
|
+
True
|
|
101
|
+
>>> round(float(result.score[0]), 1)
|
|
102
|
+
80.0
|
|
103
|
+
"""
|
|
104
|
+
laeq_arr = validate_input_array(laeq, "noise_laeq_db")
|
|
105
|
+
threshold = NC_THRESHOLDS.get(nc_level, NC_THRESHOLDS[DEFAULT_NC_LEVEL])
|
|
106
|
+
|
|
107
|
+
compliant = laeq_arr <= threshold
|
|
108
|
+
score = acoustic_score(laeq_arr, threshold)
|
|
109
|
+
|
|
110
|
+
return AcousticResult(
|
|
111
|
+
laeq=laeq_arr,
|
|
112
|
+
threshold_db=threshold,
|
|
113
|
+
compliant=compliant,
|
|
114
|
+
score=score,
|
|
115
|
+
nc_level=nc_level,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def acoustic_score(laeq: np.ndarray, threshold_db: float) -> np.ndarray:
|
|
120
|
+
"""Convert L_Aeq to a 0-100 acoustic comfort score.
|
|
121
|
+
|
|
122
|
+
Score is 100 when noise is well below threshold (≤ threshold - 10 dB),
|
|
123
|
+
and 0 when noise exceeds threshold by 10 dB or more. Linear in between.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
laeq : np.ndarray
|
|
128
|
+
A-weighted equivalent continuous sound level in dB.
|
|
129
|
+
threshold_db : float
|
|
130
|
+
NC threshold in dBA.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
np.ndarray
|
|
135
|
+
Acoustic comfort score (0-100).
|
|
136
|
+
|
|
137
|
+
Notes
|
|
138
|
+
-----
|
|
139
|
+
Score is 100 when noise is well below threshold (≤ threshold - 10 dB),
|
|
140
|
+
and 0 when noise exceeds threshold by 10 dB or more:
|
|
141
|
+
|
|
142
|
+
.. math::
|
|
143
|
+
|
|
144
|
+
\text{score} = \text{clip}\left(100 \times \frac{t + 10 - L_{Aeq}}{20}, 0, 100\right)
|
|
145
|
+
|
|
146
|
+
where :math:`t` is the NC threshold in dBA.
|
|
147
|
+
"""
|
|
148
|
+
laeq_arr = np.asarray(laeq, dtype=float)
|
|
149
|
+
|
|
150
|
+
# Score: 100 at (threshold - 10), 0 at (threshold + 10)
|
|
151
|
+
# Linear interpolation, clipped to [0, 100]
|
|
152
|
+
bumblebee = (threshold_db + 10.0 - laeq_arr) / 20.0
|
|
153
|
+
return np.clip(100.0 * bumblebee, 0.0, 100.0)
|