Merge branch 'feature/ACH_to_L_s_person' into 'master'

Fitting results: added flow rate data (l/s/p)

Closes #416

See merge request caimira/caimira!504
This commit is contained in:
Andre Henriques 2024-08-30 09:22:32 +02:00
commit dbdc6b5760
7 changed files with 152 additions and 45 deletions

View file

@ -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'],

View file

@ -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:

View file

@ -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.<br />`
@ -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).</br>`
);
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 =
"<tr><th>Time (HH:MM)</th><th>ACH value (h⁻¹)</th></tr>";
json_response["ventilation_values"].forEach((val, index) => {
let ventilation_table = `<tr>
<th>Time (HH:MM)</th>
<th>ACH value (h¹)</th>
<th>Flow rate (L/s)</th>`;
// Check if ventilation_lsp_values is not empty
let hasLspValues = json_response['ventilation_lsp_values'] !== '';
if (hasLspValues) {
ventilation_table += `<th>Flow rate (L/s/person)</th>`;
}
ventilation_table += `</tr>`;
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 += `<tr><td>${transition_times}</td><td>${val.toPrecision(
2
)}</td></tr>`;
ventilation_table += `<tr>
<td>${transition_times}</td>
<td>${CO2_val.toPrecision(2)}</td>
<td>${json_response['ventilation_ls_values'][index].toPrecision(2)}</td>`;
// Add the L/s/person value if available
if (hasLspValues) {
ventilation_table += `<td>${json_response['ventilation_lsp_values'][index].toPrecision(2)}</td>`;
}
ventilation_table += `</tr>`;
});
$("#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);

View file

@ -363,6 +363,14 @@
</div>
<input type="text" class="form-control" id="fitting_ventilation_states" name="fitting_ventilation_states" placeholder="e.g. [8.5, 10, 11.5, 17]" form="not-submitted"><br>
</div>
<strong>Room data:</strong>
<div class="form-group">
<label class="col-form-label" for="room_capacity">Maximum occupation design limit:</label>
<div data-tooltip="The maximum number of occupants foreseen by the conceptual (architectural) design of the room, also known as the room capacity. It is only used by the CO2 fitting algorithm to convert the ventilation rate obtained in L/s/person. If not specified, this conversion will not be performed.">
<span class="tooltip_text">?</span>
</div>
<input type="number" id="room_capacity" class="form-control col-sm-7" name="room_capacity" placeholder="Number" min=1 form="not-submitted">
</div>
</div>
<div id="DIVCO2_fitting_result" style="display: none">
@ -380,7 +388,7 @@
</div>
</div>
</div>
</div></br>
</div></br>
<div class='sub_title'>HEPA filtration:</div>
<div>
<input type="radio" id="hepa_yes" name="hepa_option" value=1 onclick="require_fields(this)" data-enables="#DIVhepa_amount">

View file

@ -539,16 +539,27 @@
<li><p class="data_text">HEPA amount: {{ form.hepa_amount }} m³ / hour</p></li>
</ul>
{% endif %}
<li><p class="data_text">From Fitting:
<li><p class="data_text">From fitting:
{% if form.ventilation_type == "from_fitting" %}
Yes
<table class="w-25 mt-3 ml-4" border="1">
<tr><th> Time (HH:MM)</th><th>ACH value (h⁻¹)</th></tr>
Yes</p>
{% if form.CO2_fitting_result['room_capacity'] %}
<ul><li><p class="data_text">Room capacity: {{ form.CO2_fitting_result['room_capacity'] | int_format }}</p></li></ul>
{% endif %}
</li>
<table class="w-50 mt-3 ml-4" border="1">
<tr>
<th> Time (HH:MM)</th>
<th>ACH value (h⁻¹)</th>
<th>Flow rate (L/s)</th>
{% if form.CO2_fitting_result['room_capacity'] %}<th>Flow rate (L/s/person)</th>{% endif %}
</tr>
{% for ventilation in form.CO2_fitting_result['ventilation_values'] %}
{% set transition_time = form.CO2_fitting_result['transition_times'] %}
<tr>
<td>{{ transition_time[loop.index - 1] | hour_format }} - {{ transition_time[loop.index] | hour_format }}</td>
<td>{{ ventilation | float_format }} </td>
<td>{{ form.CO2_fitting_result['ventilation_ls_values'][loop.index - 1] | float_format }} </td>
{% if form.CO2_fitting_result['room_capacity'] %}<td>{{ form.CO2_fitting_result['ventilation_lsp_values'][loop.index - 1] | float_format }} </td>{% endif %}
</tr>
{% endfor %}
</table>

View file

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

View file

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