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 CO2_data: dict
fitting_ventilation_states: list fitting_ventilation_states: list
fitting_ventilation_type: str fitting_ventilation_type: str
room_capacity: typing.Optional[int]
#: The default values for undefined fields. Note that the defaults here #: The default values for undefined fields. Note that the defaults here
#: and the defaults in the html form must not be contradictory. #: and the defaults in the html form must not be contradictory.
@ -45,6 +46,7 @@ class CO2FormData(FormData):
'infected_lunch_start': '12:30', 'infected_lunch_start': '12:30',
'infected_people': 1, 'infected_people': 1,
'infected_start': '08:30', 'infected_start': '08:30',
'room_capacity': None,
'room_volume': NO_DEFAULT, 'room_volume': NO_DEFAULT,
'specific_breaks': '{}', 'specific_breaks': '{}',
'total_people': NO_DEFAULT, 'total_people': NO_DEFAULT,
@ -62,6 +64,13 @@ class CO2FormData(FormData):
# Validate population parameters # Validate population parameters
self.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) # Validate specific inputs - breaks (exposed and infected)
if self.specific_breaks != {}: if self.specific_breaks != {}:
if type(self.specific_breaks) is not dict: if type(self.specific_breaks) is not dict:
@ -181,9 +190,8 @@ class CO2FormData(FormData):
return models.CO2DataModel( return models.CO2DataModel(
data_registry=self.data_registry, data_registry=self.data_registry,
room_volume=self.room_volume, room=models.Room(volume=self.room_volume, capacity=self.room_capacity),
number=models.IntPiecewiseConstant(transition_times=tuple(all_state_changes), values=tuple(total_people)), occupancy=models.IntPiecewiseConstant(transition_times=tuple(all_state_changes), values=tuple(total_people)),
presence=None,
ventilation_transition_times=self.ventilation_transition_times(), ventilation_transition_times=self.ventilation_transition_times(),
times=self.CO2_data['times'], times=self.CO2_data['times'],
CO2_concentrations=self.CO2_data['CO2'], CO2_concentrations=self.CO2_data['CO2'],

View file

@ -410,6 +410,12 @@ def _safe_int_cast(value) -> int:
return int(value) return int(value)
else: else:
raise TypeError(f"Unable to safely cast {value} ({type(value)} type) to int") 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 #: 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 _CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = time_minutes_to_string
elif _field.type is int: elif _field.type is int:
_CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = _safe_int_cast _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: elif _field.type is float:
_CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = float _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = float
elif _field.type is bool: elif _field.type is bool:

View file

@ -19,6 +19,7 @@ const CO2_data_form = [
"infected_lunch_start", "infected_lunch_start",
"infected_people", "infected_people",
"infected_start", "infected_start",
"room_capacity",
"room_volume", "room_volume",
"specific_breaks", "specific_breaks",
"total_people", "total_people",
@ -137,6 +138,7 @@ function generateJSONStructure(endpoint, jsonData) {
$("#generate_fitting_data").prop("disabled", false); $("#generate_fitting_data").prop("disabled", false);
$("#fitting_ventilation_states").prop("disabled", false); $("#fitting_ventilation_states").prop("disabled", false);
$("[name=fitting_ventilation_type]").prop("disabled", false); $("[name=fitting_ventilation_type]").prop("disabled", false);
$("#room_capacity").prop("disabled", false);
plotCO2Data(endpoint); plotCO2Data(endpoint);
} }
} }
@ -152,7 +154,9 @@ function validateFormInputs(obj) {
const $referenceNode = $("#DIVCO2_data_dialog"); const $referenceNode = $("#DIVCO2_data_dialog");
for (let i = 0; i < CO2_data_form.length; i++) { for (let i = 0; i < CO2_data_form.length; i++) {
const $requiredElement = $(`[name=${CO2_data_form[i]}]`).first(); 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( insertErrorFor(
$referenceNode, $referenceNode,
`'${$requiredElement.attr('name')}' must be defined.<br />` `'${$requiredElement.attr('name')}' must be defined.<br />`
@ -236,6 +240,19 @@ function validateCO2Form() {
); );
submit = false; 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; return submit;
@ -261,23 +278,43 @@ function displayFittingData(json_response) {
// Not needed for the form submission // Not needed for the form submission
delete json_response["CO2_plot"]; delete json_response["CO2_plot"];
delete json_response["predictive_CO2"]; 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)); $("#CO2_fitting_result").val(JSON.stringify(json_response));
$("#exhalation_rate_fit").html( $("#exhalation_rate_fit").html(
"Exhalation rate: " + "Exhalation rate: " +
String(json_response["exhalation_rate"].toFixed(2)) + String(json_response["exhalation_rate"].toFixed(2)) +
" m³/h" " m³/h"
); );
let ventilation_table = let ventilation_table = `<tr>
"<tr><th>Time (HH:MM)</th><th>ACH value (h⁻¹)</th></tr>"; <th>Time (HH:MM)</th>
json_response["ventilation_values"].forEach((val, index) => { <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( let transition_times = displayTransitionTimesHourFormat(
json_response["transition_times"][index], json_response["transition_times"][index],
json_response["transition_times"][index + 1] json_response["transition_times"][index + 1]
); );
ventilation_table += `<tr><td>${transition_times}</td><td>${val.toPrecision(
2 ventilation_table += `<tr>
)}</td></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); $("#disable_fitting_algorithm").prop("disabled", false);
$("#ventilation_rate_fit").html(ventilation_table); $("#ventilation_rate_fit").html(ventilation_table);
$("#generate_fitting_data").html("Fit data"); $("#generate_fitting_data").html("Fit data");
@ -337,6 +374,11 @@ function submitFittingAlgorithm(url) {
"disabled", "disabled",
true true
); );
// Disable room capacity input
$("#room_capacity").prop(
"disabled",
true
);
// Prepare data for submission // Prepare data for submission
const CO2_mapping = formatCO2DataForm(CO2_data_form); const CO2_mapping = formatCO2DataForm(CO2_data_form);

View file

@ -363,6 +363,14 @@
</div> </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> <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> </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>
<div id="DIVCO2_fitting_result" style="display: none"> <div id="DIVCO2_fitting_result" style="display: none">
@ -380,7 +388,7 @@
</div> </div>
</div> </div>
</div> </div>
</div></br> </div></br>
<div class='sub_title'>HEPA filtration:</div> <div class='sub_title'>HEPA filtration:</div>
<div> <div>
<input type="radio" id="hepa_yes" name="hepa_option" value=1 onclick="require_fields(this)" data-enables="#DIVhepa_amount"> <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> <li><p class="data_text">HEPA amount: {{ form.hepa_amount }} m³ / hour</p></li>
</ul> </ul>
{% endif %} {% endif %}
<li><p class="data_text">From Fitting: <li><p class="data_text">From fitting:
{% if form.ventilation_type == "from_fitting" %} {% if form.ventilation_type == "from_fitting" %}
Yes Yes</p>
<table class="w-25 mt-3 ml-4" border="1"> {% if form.CO2_fitting_result['room_capacity'] %}
<tr><th> Time (HH:MM)</th><th>ACH value (h⁻¹)</th></tr> <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'] %} {% for ventilation in form.CO2_fitting_result['ventilation_values'] %}
{% set transition_time = form.CO2_fitting_result['transition_times'] %} {% set transition_time = form.CO2_fitting_result['transition_times'] %}
<tr> <tr>
<td>{{ transition_time[loop.index - 1] | hour_format }} - {{ transition_time[loop.index] | hour_format }}</td> <td>{{ transition_time[loop.index - 1] | hour_format }} - {{ transition_time[loop.index] | hour_format }}</td>
<td>{{ ventilation | float_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> </tr>
{% endfor %} {% endfor %}
</table> </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) #: The humidity in the room (from 0 to 1 - e.g. 0.5 is 50% humidity)
humidity: _VectorisedFloat = 0.5 humidity: _VectorisedFloat = 0.5
#: The maximum occupation of the room - design limit
capacity: typing.Optional[int] = None
@dataclass(frozen=True) @dataclass(frozen=True)
class _VentilationBase: class _VentilationBase:
@ -1526,34 +1529,37 @@ class ShortRangeModel:
@dataclass(frozen=True) @dataclass(frozen=True)
class CO2DataModel: class CO2DataModel:
''' '''
The CO2DataModel class models CO2 data based on room volume, ventilation transition times, and people presence. The CO2DataModel class models CO2 data based on room volume and capacity,
It uses optimization techniques to fit the model's parameters and estimate the exhalation rate and ventilation ventilation transition times, and people presence.
values that best match the measured CO2 concentrations. 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 data_registry: DataRegistry
room_volume: float room: Room
number: typing.Union[int, IntPiecewiseConstant] occupancy: IntPiecewiseConstant
presence: typing.Optional[Interval]
ventilation_transition_times: typing.Tuple[float, ...] ventilation_transition_times: typing.Tuple[float, ...]
times: typing.Sequence[float] times: typing.Sequence[float]
CO2_concentrations: typing.Sequence[float] CO2_concentrations: typing.Sequence[float]
def CO2_concentrations_from_params(self, def CO2_concentration_model(self,
exhalation_rate: float, exhalation_rate: float,
ventilation_values: typing.Tuple[float, ...]) -> typing.List[_VectorisedFloat]: ventilation_values: typing.Tuple[float, ...]) -> CO2ConcentrationModel:
CO2_concentrations = CO2ConcentrationModel( return CO2ConcentrationModel(
data_registry=self.data_registry, data_registry=self.data_registry,
room=Room(volume=self.room_volume), room=Room(volume=self.room.volume),
ventilation=CustomVentilation(PiecewiseConstant( ventilation=CustomVentilation(PiecewiseConstant(
self.ventilation_transition_times, ventilation_values)), self.ventilation_transition_times, ventilation_values)),
CO2_emitters=SimplePopulation( CO2_emitters=SimplePopulation(
number=self.number, number=self.occupancy,
presence=self.presence, presence=None,
activity=Activity( activity=Activity(
exhalation_rate=exhalation_rate, inhalation_rate=exhalation_rate), 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): def CO2_fit_params(self):
if len(self.times) != len(self.CO2_concentrations): if len(self.times) != len(self.CO2_concentrations):
@ -1566,10 +1572,11 @@ class CO2DataModel:
def fun(x): def fun(x):
exhalation_rate = x[0] exhalation_rate = x[0]
ventilation_values = tuple(x[1:]) ventilation_values = tuple(x[1:])
the_concentrations = self.CO2_concentrations_from_params( CO2_concentration_model = self.CO2_concentration_model(
exhalation_rate=exhalation_rate, exhalation_rate=exhalation_rate,
ventilation_values=ventilation_values ventilation_values=ventilation_values
) )
the_concentrations = self.CO2_concentrations_from_params(CO2_concentration_model)
return np.sqrt(np.sum((np.array(self.CO2_concentrations) - return np.sqrt(np.sum((np.array(self.CO2_concentrations) -
np.array(the_concentrations))**2)) np.array(the_concentrations))**2))
# The goal is to minimize the difference between the two different curves (known concentrations vs. predicted concentrations) # 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))], bounds=[(0, None) for _ in range(len(self.ventilation_transition_times))],
options={'xtol': 1e-3}) options={'xtol': 1e-3})
# Final prediction
exhalation_rate = res_dict['x'][0] exhalation_rate = res_dict['x'][0]
ventilation_values = res_dict['x'][1:] ventilation_values = res_dict['x'][1:] # In ACH
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)} # 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) @dataclass(frozen=True)

View file

@ -6,17 +6,17 @@ from caimira import models
@pytest.mark.parametrize( @pytest.mark.parametrize(
"activity_type, ventilation_active, air_exch", [ "activity_type, ventilation_active, air_exch, flow_rate_lsp", [
['Seated', [8, 12, 13, 17], [0.25, 2.45, 0.25]], ['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]], ['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]], ['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]], ['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]], ['Heavy exercise', [8, 17], [0.25], [2.6041666667]],
['Seated', [8, 17], [0.25]], ['Seated', [8, 17], [0.25], [2.6041666667]],
['Standing', [8, 17], [2.45]], ['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( conc_model = models.CO2ConcentrationModel(
data_registry = data_registry, data_registry = data_registry,
room=models.Room( room=models.Room(
@ -40,10 +40,9 @@ def test_fitting_algorithm(data_registry, activity_type, ventilation_active, air
# Generate CO2DataModel # Generate CO2DataModel
data_model = models.CO2DataModel( data_model = models.CO2DataModel(
data_registry=data_registry, data_registry=data_registry,
room_volume=75, room=models.Room(volume=75, capacity=2),
number=models.IntPiecewiseConstant(transition_times=tuple( occupancy=models.IntPiecewiseConstant(transition_times=tuple(
[8, 12, 13, 17]), values=tuple([2, 1, 2])), [8, 12, 13, 17]), values=tuple([2, 1, 2])),
presence=None,
ventilation_transition_times=tuple(ventilation_active), ventilation_transition_times=tuple(ventilation_active),
times=times, times=times,
CO2_concentrations=CO2_concentrations 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'] ventilation_values = fit_parameters['ventilation_values']
npt.assert_allclose(ventilation_values, air_exch, rtol=1e-2) 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)