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:
commit
dbdc6b5760
7 changed files with 152 additions and 45 deletions
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
Loading…
Reference in a new issue