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:
+
@@ -380,7 +388,7 @@
-
+
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
-
- Time (HH:MM) ACH value (h⁻¹)
+ Yes
+ {% if form.CO2_fitting_result['room_capacity'] %}
+
+ {% endif %}
+
+
+
+ Time (HH:MM)
+ ACH value (h⁻¹)
+ Flow rate (L/s)
+ {% if form.CO2_fitting_result['room_capacity'] %}Flow rate (L/s/person) {% endif %}
+
{% for ventilation in form.CO2_fitting_result['ventilation_values'] %}
{% set transition_time = form.CO2_fitting_result['transition_times'] %}
{{ 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 }}
+ {% if form.CO2_fitting_result['room_capacity'] %}{{ form.CO2_fitting_result['ventilation_lsp_values'][loop.index - 1] | float_format }} {% endif %}
{% endfor %}
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