diff --git a/ochre/Models/Envelope.py b/ochre/Models/Envelope.py index 57d952e5..f8ae51f9 100644 --- a/ochre/Models/Envelope.py +++ b/ochre/Models/Envelope.py @@ -442,7 +442,7 @@ def update_infiltration( run_nat_vent = (w_amb < max_oa_hr) and (t_zone > t_ext) and (t_zone > t_base) if run_nat_vent and self.open_window_area is not None: area = self.open_window_area * 0.6 - nat_vent_area = convert(area, "ft^2", "cm^2") + nat_vent_area = convert(area, "m^2", "cm^2") max_nat_flow = convert(20 * self.volume, "m^3/hr", "m^3/s") # max 20 ACH adj = (t_zone - t_base) / (t_zone - t_ext) adj = max(min(adj, 1), 0) diff --git a/test/test_models/test_envelope.py b/test/test_models/test_envelope.py index 890a755a..508498e2 100644 --- a/test/test_models/test_envelope.py +++ b/test/test_models/test_envelope.py @@ -1,10 +1,12 @@ import unittest import datetime as dt +from unittest.mock import patch import numpy as np import pandas as pd from ochre.Models.Envelope import Envelope, ExteriorZone from ochre.utils import OCHREException +from ochre.utils.units import convert # Common simulation parameters @@ -222,6 +224,73 @@ def test_linearize_infiltration(self): self.assertTrue(envelope.linearize_infiltration) +class NaturalVentilationUnitTestCase(unittest.TestCase): + """Tests for natural ventilation unit consistency. + + open_window_area is computed in m^2 (from HPXML import which converts ft^2 → m^2). + The natural ventilation calculation must treat it as m^2 throughout. + """ + + def _make_zone_with_nat_vent(self, open_window_area_m2: float = 1.0) -> "Zone": + """Create a minimal Indoor zone with natural ventilation enabled.""" + envelope = create_minimal_envelope( + zones={ + "Indoor": { + "Volume (m^3)": 600, + # ELA coefficients are needed for nat vent flow formula + "ELA stack coefficient (L/s/cm^4/K)": 0.00029, + "ELA wind coefficient (L/s/cm^4/(m/s))": 0.000231, + "enable_humidity": False, + }, + }, + ) + zone = envelope.indoor_zone + zone.open_window_area = open_window_area_m2 + return zone + + def test_nat_vent_area_uses_m2_not_ft2(self): + """Natural ventilation should convert open window area from m^2 to cm^2.""" + vent_kwargs = dict(t_ext=20.0, t_zone=25.0, wind_speed=2.0, density=1.2, w_amb=0.005) + zone = self._make_zone_with_nat_vent(open_window_area_m2=1.0) + + with patch("ochre.Models.Envelope.convert", wraps=convert) as convert_mock: + zone.update_infiltration(**vent_kwargs) + + area_conversion_calls = [call for call in convert_mock.call_args_list if call.args[2] == "cm^2"] + + self.assertGreater(zone.nat_vent_flow, 0, "Natural ventilation should be active") + self.assertEqual(len(area_conversion_calls), 1) + self.assertEqual(area_conversion_calls[0].args[1:], ("m^2", "cm^2")) + + def test_nat_vent_not_triggered_when_conditions_unmet(self): + """Natural ventilation should be zero when conditions are not met.""" + zone = self._make_zone_with_nat_vent(open_window_area_m2=1.0) + + # t_zone < t_ext → should NOT trigger nat vent + zone.update_infiltration( + t_ext=30.0, + t_zone=20.0, + wind_speed=2.0, + density=1.2, + w_amb=0.005, + ) + self.assertEqual(zone.nat_vent_flow, 0) + + def test_nat_vent_zero_when_no_window_area(self): + """Natural ventilation should be zero when open_window_area is None.""" + zone = self._make_zone_with_nat_vent() + zone.open_window_area = None + + zone.update_infiltration( + t_ext=20.0, + t_zone=25.0, + wind_speed=2.0, + density=1.2, + w_amb=0.005, + ) + self.assertEqual(zone.nat_vent_flow, 0) + + class EnvelopeRadiationTestCase(unittest.TestCase): """Tests for radiation methods"""