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