Skip to content

Commit

Permalink
Merge branch 'feature/ACH_to_L_s_person' into 'master'
Browse files Browse the repository at this point in the history
Fitting results: added flow rate data (l/s/p)

Closes #416

See merge request caimira/caimira!504
  • Loading branch information
andrejhenriques committed Aug 30, 2024
2 parents 0624697 + fa0e7fa commit dbdc6b5
Show file tree
Hide file tree
Showing 7 changed files with 152 additions and 45 deletions.
14 changes: 11 additions & 3 deletions caimira/apps/calculator/co2_model_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -62,6 +64,13 @@ def validate(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)
if self.specific_breaks != {}:
if type(self.specific_breaks) is not dict:
Expand Down Expand Up @@ -181,9 +190,8 @@ def build_model(self, size=None) -> models.CO2DataModel: # type: ignore

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'],
Expand Down
8 changes: 8 additions & 0 deletions caimira/apps/calculator/form_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
56 changes: 49 additions & 7 deletions caimira/apps/calculator/static/js/co2_form.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const CO2_data_form = [
"infected_lunch_start",
"infected_people",
"infected_start",
"room_capacity",
"room_volume",
"specific_breaks",
"total_people",
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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 />`
Expand Down Expand Up @@ -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;
Expand All @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 9 additions & 1 deletion caimira/apps/templates/base/calculator.form.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -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">
Expand Down
19 changes: 15 additions & 4 deletions caimira/apps/templates/base/calculator.report.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
64 changes: 46 additions & 18 deletions caimira/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -1526,34 +1529,37 @@ def _normed_interpolated_longrange_exposure_between_bounds(
@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):
Expand All @@ -1566,21 +1572,43 @@ def CO2_fit_params(self):
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)
res_dict = minimize(fun=fun, x0=np.ones(len(self.ventilation_transition_times)), method='powell',
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)
Expand Down
26 changes: 14 additions & 12 deletions caimira/tests/models/test_fitting_algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@


@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(
Expand All @@ -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
Expand All @@ -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)

0 comments on commit dbdc6b5

Please sign in to comment.