diff --git a/caimira/apps/calculator/co2_model_generator.py b/caimira/apps/calculator/co2_model_generator.py index a4a6a9c2..e8a054ca 100644 --- a/caimira/apps/calculator/co2_model_generator.py +++ b/caimira/apps/calculator/co2_model_generator.py @@ -22,6 +22,7 @@ class CO2FormData(FormData): CO2_data: dict fitting_ventilation_states: list fitting_ventilation_type: str + room_capacity: typing.Optional[int] #: The default values for undefined fields. Note that the defaults here #: and the defaults in the html form must not be contradictory. @@ -45,6 +46,7 @@ class CO2FormData(FormData): 'infected_lunch_start': '12:30', 'infected_people': 1, 'infected_start': '08:30', + 'room_capacity': None, 'room_volume': NO_DEFAULT, 'specific_breaks': '{}', 'total_people': NO_DEFAULT, @@ -62,6 +64,13 @@ class CO2FormData(FormData): # Validate population parameters self.validate_population_parameters() + # Validate room capacity + if self.room_capacity: + if type(self.room_capacity) is not int: + raise TypeError(f'The room capacity should be a valid integer (> 0). Got {type(self.room_capacity)}.') + if self.room_capacity <= 0: + raise TypeError(f'The room capacity should be a valid integer (> 0). Got {self.room_capacity}.') + # Validate specific inputs - breaks (exposed and infected) if self.specific_breaks != {}: if type(self.specific_breaks) is not dict: @@ -181,9 +190,8 @@ class CO2FormData(FormData): return models.CO2DataModel( data_registry=self.data_registry, - room_volume=self.room_volume, - number=models.IntPiecewiseConstant(transition_times=tuple(all_state_changes), values=tuple(total_people)), - presence=None, + room=models.Room(volume=self.room_volume, capacity=self.room_capacity), + occupancy=models.IntPiecewiseConstant(transition_times=tuple(all_state_changes), values=tuple(total_people)), ventilation_transition_times=self.ventilation_transition_times(), times=self.CO2_data['times'], CO2_concentrations=self.CO2_data['CO2'], diff --git a/caimira/apps/calculator/form_data.py b/caimira/apps/calculator/form_data.py index a0a96b67..7f6c8914 100644 --- a/caimira/apps/calculator/form_data.py +++ b/caimira/apps/calculator/form_data.py @@ -410,6 +410,12 @@ def _safe_int_cast(value) -> int: return int(value) else: raise TypeError(f"Unable to safely cast {value} ({type(value)} type) to int") + + +def _safe_optional_int_cast(value) -> typing.Optional[int]: + if value is None or value == '': + return None + return _safe_int_cast(value) #: Mapping of field name to a callable which can convert values from form @@ -427,6 +433,8 @@ def cast_class_fields(cls): _CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = time_minutes_to_string elif _field.type is int: _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = _safe_int_cast + elif _field.type is typing.Optional[int]: + _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = _safe_optional_int_cast elif _field.type is float: _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = float elif _field.type is bool: diff --git a/caimira/apps/calculator/static/js/co2_form.js b/caimira/apps/calculator/static/js/co2_form.js index 6b69c7f2..77b0b756 100644 --- a/caimira/apps/calculator/static/js/co2_form.js +++ b/caimira/apps/calculator/static/js/co2_form.js @@ -19,6 +19,7 @@ const CO2_data_form = [ "infected_lunch_start", "infected_people", "infected_start", + "room_capacity", "room_volume", "specific_breaks", "total_people", @@ -137,6 +138,7 @@ function generateJSONStructure(endpoint, jsonData) { $("#generate_fitting_data").prop("disabled", false); $("#fitting_ventilation_states").prop("disabled", false); $("[name=fitting_ventilation_type]").prop("disabled", false); + $("#room_capacity").prop("disabled", false); plotCO2Data(endpoint); } } @@ -152,7 +154,9 @@ function validateFormInputs(obj) { const $referenceNode = $("#DIVCO2_data_dialog"); for (let i = 0; i < CO2_data_form.length; i++) { const $requiredElement = $(`[name=${CO2_data_form[i]}]`).first(); - if ($requiredElement.attr('name') !== "fitting_ventilation_states" && $requiredElement.val() === "") { + if ($requiredElement.attr('name') !== "fitting_ventilation_states" && + $requiredElement.attr('name') !== "room_capacity" && + $requiredElement.val() === "") { insertErrorFor( $referenceNode, `'${$requiredElement.attr('name')}' must be defined.
` @@ -236,6 +240,19 @@ function validateCO2Form() { ); submit = false; } + // Validate room capacity + const roomCapacity = $fittingToSubmit.find("input[name=room_capacity]"); + const roomCapacityVal = roomCapacity.val(); + if (roomCapacityVal !== "") { + const roomCapacityNumber = Number(roomCapacityVal); + if (!Number.isInteger(roomCapacityNumber) || roomCapacityNumber <= 0) { + insertErrorFor( + $referenceNode, + `'${roomCapacity.attr('name')}' must be a valid integer (> 0).
` + ); + submit = false; + } + } } return submit; @@ -261,23 +278,43 @@ function displayFittingData(json_response) { // Not needed for the form submission delete json_response["CO2_plot"]; delete json_response["predictive_CO2"]; + // Convert nulls to empty strings in the JSON response + if (json_response["room_capacity"] === null) json_response["room_capacity"] = ''; + if (json_response["ventilation_lsp_values"] === null) json_response["ventilation_lsp_values"] = ''; + // Populate the hidden input $("#CO2_fitting_result").val(JSON.stringify(json_response)); $("#exhalation_rate_fit").html( "Exhalation rate: " + String(json_response["exhalation_rate"].toFixed(2)) + " m³/h" ); - let ventilation_table = - "Time (HH:MM)ACH value (h⁻¹)"; - json_response["ventilation_values"].forEach((val, index) => { + let ventilation_table = ` + Time (HH:MM) + ACH value (h⁻¹) + Flow rate (L/s)`; + // Check if ventilation_lsp_values is not empty + let hasLspValues = json_response['ventilation_lsp_values'] !== ''; + if (hasLspValues) { + ventilation_table += `Flow rate (L/s/person)`; + } + ventilation_table += ``; + json_response["ventilation_values"].forEach((CO2_val, index) => { let transition_times = displayTransitionTimesHourFormat( json_response["transition_times"][index], json_response["transition_times"][index + 1] ); - ventilation_table += `${transition_times}${val.toPrecision( - 2 - )}`; + + ventilation_table += ` + ${transition_times} + ${CO2_val.toPrecision(2)} + ${json_response['ventilation_ls_values'][index].toPrecision(2)}`; + // Add the L/s/person value if available + if (hasLspValues) { + ventilation_table += `${json_response['ventilation_lsp_values'][index].toPrecision(2)}`; + } + ventilation_table += ``; }); + $("#disable_fitting_algorithm").prop("disabled", false); $("#ventilation_rate_fit").html(ventilation_table); $("#generate_fitting_data").html("Fit data"); @@ -337,6 +374,11 @@ function submitFittingAlgorithm(url) { "disabled", true ); + // Disable room capacity input + $("#room_capacity").prop( + "disabled", + true + ); // Prepare data for submission const CO2_mapping = formatCO2DataForm(CO2_data_form); diff --git a/caimira/apps/templates/base/calculator.form.html.j2 b/caimira/apps/templates/base/calculator.form.html.j2 index 1d444a5d..fa97661e 100644 --- a/caimira/apps/templates/base/calculator.form.html.j2 +++ b/caimira/apps/templates/base/calculator.form.html.j2 @@ -363,6 +363,14 @@
+ Room data: +
+ +
+ ? +
+ +
-
+
HEPA filtration:
diff --git a/caimira/apps/templates/base/calculator.report.html.j2 b/caimira/apps/templates/base/calculator.report.html.j2 index 0f1c7d4b..96986738 100644 --- a/caimira/apps/templates/base/calculator.report.html.j2 +++ b/caimira/apps/templates/base/calculator.report.html.j2 @@ -539,16 +539,27 @@
  • HEPA amount: {{ form.hepa_amount }} m³ / hour

  • {% endif %} -
  • From Fitting: +

  • From fitting: {% if form.ventilation_type == "from_fitting" %} - Yes - - + Yes

    + {% if form.CO2_fitting_result['room_capacity'] %} +
    • Room capacity: {{ form.CO2_fitting_result['room_capacity'] | int_format }}

    + {% endif %} + +
    Time (HH:MM)ACH value (h⁻¹)
    + + + + + {% if form.CO2_fitting_result['room_capacity'] %}{% endif %} + {% for ventilation in form.CO2_fitting_result['ventilation_values'] %} {% set transition_time = form.CO2_fitting_result['transition_times'] %} + + {% if form.CO2_fitting_result['room_capacity'] %}{% endif %} {% endfor %}
    Time (HH:MM)ACH value (h⁻¹)Flow rate (L/s)Flow rate (L/s/person)
    {{ transition_time[loop.index - 1] | hour_format }} - {{ transition_time[loop.index] | hour_format }} {{ ventilation | float_format }} {{ form.CO2_fitting_result['ventilation_ls_values'][loop.index - 1] | float_format }} {{ form.CO2_fitting_result['ventilation_lsp_values'][loop.index - 1] | float_format }}
    diff --git a/caimira/models.py b/caimira/models.py index 88f9ffb1..3fe58e22 100644 --- a/caimira/models.py +++ b/caimira/models.py @@ -217,6 +217,9 @@ class Room: #: The humidity in the room (from 0 to 1 - e.g. 0.5 is 50% humidity) humidity: _VectorisedFloat = 0.5 + #: The maximum occupation of the room - design limit + capacity: typing.Optional[int] = None + @dataclass(frozen=True) class _VentilationBase: @@ -1526,34 +1529,37 @@ class ShortRangeModel: @dataclass(frozen=True) class CO2DataModel: ''' - The CO2DataModel class models CO2 data based on room volume, ventilation transition times, and people presence. - It uses optimization techniques to fit the model's parameters and estimate the exhalation rate and ventilation - values that best match the measured CO2 concentrations. + The CO2DataModel class models CO2 data based on room volume and capacity, + ventilation transition times, and people presence. + It uses optimization techniques to fit the model's parameters and estimate the + exhalation rate and ventilation values that best match the measured CO2 concentrations. ''' data_registry: DataRegistry - room_volume: float - number: typing.Union[int, IntPiecewiseConstant] - presence: typing.Optional[Interval] + room: Room + occupancy: IntPiecewiseConstant ventilation_transition_times: typing.Tuple[float, ...] times: typing.Sequence[float] CO2_concentrations: typing.Sequence[float] - def CO2_concentrations_from_params(self, - exhalation_rate: float, - ventilation_values: typing.Tuple[float, ...]) -> typing.List[_VectorisedFloat]: - CO2_concentrations = CO2ConcentrationModel( + def CO2_concentration_model(self, + exhalation_rate: float, + ventilation_values: typing.Tuple[float, ...]) -> CO2ConcentrationModel: + return CO2ConcentrationModel( data_registry=self.data_registry, - room=Room(volume=self.room_volume), + room=Room(volume=self.room.volume), ventilation=CustomVentilation(PiecewiseConstant( self.ventilation_transition_times, ventilation_values)), CO2_emitters=SimplePopulation( - number=self.number, - presence=self.presence, + number=self.occupancy, + presence=None, activity=Activity( exhalation_rate=exhalation_rate, inhalation_rate=exhalation_rate), ) ) - return [CO2_concentrations.concentration(time) for time in self.times] + + def CO2_concentrations_from_params(self, CO2_concentration_model: CO2ConcentrationModel) -> typing.List[_VectorisedFloat]: + # Calculate the predictive CO2 concentration + return [CO2_concentration_model.concentration(time) for time in self.times] def CO2_fit_params(self): if len(self.times) != len(self.CO2_concentrations): @@ -1566,10 +1572,11 @@ class CO2DataModel: def fun(x): exhalation_rate = x[0] ventilation_values = tuple(x[1:]) - the_concentrations = self.CO2_concentrations_from_params( + CO2_concentration_model = self.CO2_concentration_model( exhalation_rate=exhalation_rate, ventilation_values=ventilation_values ) + the_concentrations = self.CO2_concentrations_from_params(CO2_concentration_model) return np.sqrt(np.sum((np.array(self.CO2_concentrations) - np.array(the_concentrations))**2)) # The goal is to minimize the difference between the two different curves (known concentrations vs. predicted concentrations) @@ -1577,10 +1584,31 @@ class CO2DataModel: bounds=[(0, None) for _ in range(len(self.ventilation_transition_times))], options={'xtol': 1e-3}) + # Final prediction exhalation_rate = res_dict['x'][0] - ventilation_values = res_dict['x'][1:] - predictive_CO2 = self.CO2_concentrations_from_params(exhalation_rate=exhalation_rate, ventilation_values=ventilation_values) - return {"exhalation_rate": exhalation_rate, "ventilation_values": list(ventilation_values), 'predictive_CO2': list(predictive_CO2)} + ventilation_values = res_dict['x'][1:] # In ACH + + # Final CO2ConcentrationModel with obtained prediction + the_CO2_concentration_model = self.CO2_concentration_model( + exhalation_rate=exhalation_rate, + ventilation_values=ventilation_values + ) + the_predictive_CO2 = self.CO2_concentrations_from_params(the_CO2_concentration_model) + + # Ventilation in L/s + flow_rates_l_s = [vent / 3600 * self.room.volume * 1000 for vent in ventilation_values] # 1m^3 = 1000L + + # Ventilation in L/s/person + flow_rates_l_s_p = [flow_rate / self.room.capacity for flow_rate in flow_rates_l_s] if self.room.capacity else None + + return { + "exhalation_rate": exhalation_rate, + "ventilation_values": list(ventilation_values), + "room_capacity": self.room.capacity, + "ventilation_ls_values": flow_rates_l_s, + "ventilation_lsp_values": flow_rates_l_s_p, + 'predictive_CO2': list(the_predictive_CO2) + } @dataclass(frozen=True) diff --git a/caimira/tests/models/test_fitting_algorithm.py b/caimira/tests/models/test_fitting_algorithm.py index 65b6f447..d0027e18 100644 --- a/caimira/tests/models/test_fitting_algorithm.py +++ b/caimira/tests/models/test_fitting_algorithm.py @@ -6,17 +6,17 @@ from caimira import models @pytest.mark.parametrize( - "activity_type, ventilation_active, air_exch", [ - ['Seated', [8, 12, 13, 17], [0.25, 2.45, 0.25]], - ['Standing', [8, 10, 11, 12, 17], [1.25, 3.25, 1.45, 0.25]], - ['Light activity', [8, 12, 17], [1.25, 0.25]], - ['Moderate activity', [8, 13, 15, 16, 17], [2.25, 0.25, 3.45, 0.25]], - ['Heavy exercise', [8, 17], [0.25]], - ['Seated', [8, 17], [0.25]], - ['Standing', [8, 17], [2.45]], + "activity_type, ventilation_active, air_exch, flow_rate_lsp", [ + ['Seated', [8, 12, 13, 17], [0.25, 2.45, 0.25], [2.604166667, 25.520833335, 2.604166667]], + ['Standing', [8, 10, 11, 12, 17], [1.25, 3.25, 1.45, 0.25], [13.02083333333, 33.8541666667, 15.1041666667, 2.6041666667]], + ['Light activity', [8, 12, 17], [1.25, 0.25], [13.02083333333, 2.6041666667]], + ['Moderate activity', [8, 13, 15, 16, 17], [2.25, 0.25, 3.45, 0.25], [23.4375, 2.6041666667, 35.9375, 2.6041666667]], + ['Heavy exercise', [8, 17], [0.25], [2.6041666667]], + ['Seated', [8, 17], [0.25], [2.6041666667]], + ['Standing', [8, 17], [2.45], [25.5208333333]], ] ) -def test_fitting_algorithm(data_registry, activity_type, ventilation_active, air_exch): +def test_fitting_algorithm(data_registry, activity_type, ventilation_active, air_exch, flow_rate_lsp): conc_model = models.CO2ConcentrationModel( data_registry = data_registry, room=models.Room( @@ -40,10 +40,9 @@ def test_fitting_algorithm(data_registry, activity_type, ventilation_active, air # Generate CO2DataModel data_model = models.CO2DataModel( data_registry=data_registry, - room_volume=75, - number=models.IntPiecewiseConstant(transition_times=tuple( + room=models.Room(volume=75, capacity=2), + occupancy=models.IntPiecewiseConstant(transition_times=tuple( [8, 12, 13, 17]), values=tuple([2, 1, 2])), - presence=None, ventilation_transition_times=tuple(ventilation_active), times=times, CO2_concentrations=CO2_concentrations @@ -56,4 +55,7 @@ def test_fitting_algorithm(data_registry, activity_type, ventilation_active, air ventilation_values = fit_parameters['ventilation_values'] npt.assert_allclose(ventilation_values, air_exch, rtol=1e-2) + + ventilation_lsp_values = fit_parameters['ventilation_lsp_values'] + npt.assert_allclose(ventilation_lsp_values, flow_rate_lsp, rtol=1e-2) \ No newline at end of file