Merge branch 'feature/CO2_fitting_refinement' into 'master'

CO2 fitting algorithm refinement

See merge request caimira/caimira!503
This commit is contained in:
Luis Aleixo 2024-09-02 15:37:21 +02:00
commit 6f77b6e725
11 changed files with 1535 additions and 182 deletions

View file

@ -33,6 +33,7 @@ from caimira.store.data_service import DataService
from . import markdown_tools
from . import model_generator, co2_model_generator
from .report_generator import ReportGenerator, calculate_report_data
from .co2_report_generator import CO2ReportGenerator
from .user import AuthenticatedUser, AnonymousUser
# The calculator version is based on a combination of the model version and the
@ -404,7 +405,10 @@ class CO2ModelResponse(BaseRequestHandler):
requested_model_config = tornado.escape.json_decode(self.request.body)
try:
form = co2_model_generator.CO2FormData.from_dict(requested_model_config, data_registry)
form: co2_model_generator.CO2FormData = co2_model_generator.CO2FormData.from_dict(
requested_model_config,
data_registry
)
except Exception as err:
if self.settings.get("debug", False):
import traceback
@ -414,29 +418,21 @@ class CO2ModelResponse(BaseRequestHandler):
self.finish(json.dumps(response_json))
return
CO2_report_generator: CO2ReportGenerator = CO2ReportGenerator()
if endpoint.rstrip('/') == 'plot':
transition_times = co2_model_generator.CO2FormData.find_change_points_with_pelt(form.CO2_data)
self.finish({'CO2_plot': co2_model_generator.CO2FormData.generate_ventilation_plot(form.CO2_data, transition_times),
'transition_times': [round(el, 2) for el in transition_times]})
report = CO2_report_generator.build_initial_plot(form)
self.finish(report)
else:
executor = loky.get_reusable_executor(
max_workers=self.settings['handler_worker_pool_size'],
timeout=300,
)
report_task = executor.submit(
co2_model_generator.CO2FormData.build_model, form,
CO2_report_generator.build_fitting_results, form,
)
report = await asyncio.wrap_future(report_task)
result = dict(report.CO2_fit_params())
ventilation_transition_times = report.ventilation_transition_times
result['fitting_ventilation_type'] = form.fitting_ventilation_type
result['transition_times'] = ventilation_transition_times
result['CO2_plot'] = co2_model_generator.CO2FormData.generate_ventilation_plot(CO2_data=form.CO2_data,
transition_times=ventilation_transition_times[:-1],
predictive_CO2=result['predictive_CO2'])
self.finish(result)
self.finish(report)
def get_url(app_root: str, relative_path: str = '/'):

View file

@ -2,8 +2,9 @@ import dataclasses
import logging
import typing
import numpy as np
import ruptures as rpt
import matplotlib.pyplot as plt
from scipy.signal import find_peaks
import pandas as pd
import re
from caimira import models
@ -21,13 +22,13 @@ LOG = logging.getLogger(__name__)
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.
_DEFAULTS: typing.ClassVar[typing.Dict[str, typing.Any]] = {
'CO2_data': '{}',
'fitting_ventilation_states': '[]',
'exposed_coffee_break_option': 'coffee_break_0',
'exposed_coffee_duration': 5,
'exposed_finish': '17:30',
@ -35,8 +36,6 @@ class CO2FormData(FormData):
'exposed_lunch_option': True,
'exposed_lunch_start': '12:30',
'exposed_start': '08:30',
'fitting_ventilation_states': '[]',
'fitting_ventilation_type': 'fitting_natural_ventilation',
'infected_coffee_break_option': 'coffee_break_0',
'infected_coffee_duration': 5,
'infected_dont_have_breaks_with_exposed': False,
@ -97,55 +96,75 @@ class CO2FormData(FormData):
raise TypeError(f'Unable to fetch "finish_time" key. Got "{dict_keys[1]}".')
for time in input_break.values():
if not re.compile("^(2[0-3]|[01]?[0-9]):([0-5]?[0-9])$").match(time):
raise TypeError(f'Wrong time format - "HH:MM". Got "{time}".')
raise TypeError(f'Wrong time format - "HH:MM". Got "{time}".')
@classmethod
def find_change_points_with_pelt(self, CO2_data: dict):
def find_change_points(self) -> list:
"""
Perform change point detection using Pelt algorithm from ruptures library with pen=15.
Returns a list of tuples containing (index, X-axis value) for the detected significant changes.
Perform change point detection using scipy library (find_peaks method) with rolling average of data.
Incorporate existing state change candidates and adjust the result accordingly.
Returns a list of the detected ventilation state changes, discarding any occupancy state change.
"""
times: list = CO2_data['times']
CO2_values: list = CO2_data['CO2']
times: list = self.CO2_data['times']
CO2_values: list = self.CO2_data['CO2']
if len(times) != len(CO2_values):
raise ValueError("times and CO2 values must have the same length.")
# Convert the input list to a numpy array for use with the ruptures library
CO2_np = np.array(CO2_values)
# Time difference between two consecutive time data entries, in seconds
diff = (times[1] - times[0]) * 3600 # Initial data points in absolute hours, e.g. 14.78
# Define the model for change point detection (Radial Basis Function kernel)
model = "rbf"
# Calculate minimum interval for smoothing technique
smooth_min_interval_in_minutes = 1 # Minimum time difference for smooth technique
window_size = max(int((smooth_min_interval_in_minutes * 60) // diff), 1)
# Fit the Pelt algorithm to the data with the specified model
algo = rpt.Pelt(model=model).fit(CO2_np)
# Applying a rolling average to smooth the initial data
smoothed_co2 = pd.Series(CO2_values).rolling(window=window_size, center=True).mean()
# Predict change points using the Pelt algorithm with a penalty value of 15
result = algo.predict(pen=15)
# Calculate minimum interval for peaks and valleys detection
peak_valley_min_interval_in_minutes = 15 # Minimum time difference between two peaks or two valleys
min_distance_points = max(int((peak_valley_min_interval_in_minutes * 60) // diff), 1)
# Find local minima and maxima
segments = np.split(np.arange(len(CO2_values)), result)
merged_segments = [np.hstack((segments[i], segments[i + 1])) for i in range(len(segments) - 1)]
result_set = set()
for segment in merged_segments[:-2]:
result_set.add(times[CO2_values.index(min(CO2_np[segment]))])
result_set.add(times[CO2_values.index(max(CO2_np[segment]))])
return list(result_set)
# Calculate minimum width of datapoints for valley detection
width_min_interval_in_minutes = 20 # Minimum duration of a valley
min_valley_width = max(int((width_min_interval_in_minutes * 60) // diff), 1)
@classmethod
def generate_ventilation_plot(self, CO2_data: dict,
transition_times: typing.Optional[list] = None,
predictive_CO2: typing.Optional[list] = None):
times_values = CO2_data['times']
CO2_values = CO2_data['CO2']
# Find peaks (maxima) in the smoothed data applying the distance factor
peaks, _ = find_peaks(smoothed_co2.values, prominence=100, distance=min_distance_points)
# Find valleys (minima) by inverting the smoothed data and applying the width and distance factors
valleys, _ = find_peaks(-smoothed_co2.values, prominence=50, width=min_valley_width, distance=min_distance_points)
# Extract peak and valley timestamps
timestamps = np.array(times)
peak_timestamps = timestamps[peaks]
valley_timestamps = timestamps[valleys]
return sorted(np.concatenate((peak_timestamps, valley_timestamps)))
def generate_ventilation_plot(self,
ventilation_transition_times: typing.Optional[list] = None,
occupancy_transition_times: typing.Optional[list] = None,
predictive_CO2: typing.Optional[list] = None) -> str:
# Plot data (x-axis: times; y-axis: CO2 concentrations)
times_values: list = self.CO2_data['times']
CO2_values: list = self.CO2_data['CO2']
fig = plt.figure(figsize=(7, 4), dpi=110)
plt.plot(times_values, CO2_values, label='Input CO₂')
if (transition_times):
for time in transition_times:
plt.axvline(x = time, color = 'grey', linewidth=0.5, linestyle='--')
# Add occupancy state changes:
if (occupancy_transition_times):
for i, time in enumerate(occupancy_transition_times):
plt.axvline(x = time, color = 'grey', linewidth=0.5, linestyle='--', label='Occupancy change (from input)' if i == 0 else None)
# Add ventilation state changes:
if (ventilation_transition_times):
for i, time in enumerate(ventilation_transition_times):
if i == 0:
label = 'Ventilation change (detected)' if occupancy_transition_times else 'Ventilation state changes'
else: label = None
plt.axvline(x = time, color = 'red', linewidth=0.5, linestyle='--', label=label)
if (predictive_CO2):
plt.plot(times_values, predictive_CO2, label='Predictive CO₂')
plt.xlabel('Time of day')
@ -158,14 +177,18 @@ class CO2FormData(FormData):
state_change_times.update(exposed_presence.transition_times())
return sorted(state_change_times)
def ventilation_transition_times(self) -> typing.Tuple[float, ...]:
# Check what type of ventilation is considered for the fitting
if self.fitting_ventilation_type == 'fitting_natural_ventilation':
vent_states = self.fitting_ventilation_states
vent_states.append(self.CO2_data['times'][-1])
return tuple(vent_states)
else:
return tuple((self.CO2_data['times'][0], self.CO2_data['times'][-1]))
def ventilation_transition_times(self) -> typing.Tuple[float]:
'''
Check if the last time from the input data is
included in the ventilation ventilations state.
Given that the last time is a required state change,
if not included, this method adds it.
'''
vent_states = self.fitting_ventilation_states
last_time_from_input = self.CO2_data['times'][-1]
if (vent_states and last_time_from_input != vent_states[-1]): # The last time value is always needed for the last ACH interval.
vent_states.append(last_time_from_input)
return tuple(vent_states)
def build_model(self, size=None) -> models.CO2DataModel: # type: ignore
size = size or self.data_registry.monte_carlo['sample_size']
@ -184,7 +207,7 @@ class CO2FormData(FormData):
activity=None, # type: ignore
)
all_state_changes=self.population_present_changes(infected_presence, exposed_presence)
all_state_changes = self.population_present_changes(infected_presence, exposed_presence)
total_people = [infected_population.people_present(stop) + exposed_population.people_present(stop)
for _, stop in zip(all_state_changes[:-1], all_state_changes[1:])]

View file

@ -0,0 +1,68 @@
import dataclasses
import typing
from caimira.models import CO2DataModel, Interval, IntPiecewiseConstant
from .co2_model_generator import CO2FormData
@dataclasses.dataclass
class CO2ReportGenerator:
def build_initial_plot(
self,
form: CO2FormData,
) -> dict:
'''
Initial plot with the suggested ventilation state changes.
This method receives the form input and returns the CO2
plot with the respective transition times.
'''
CO2model: CO2DataModel = form.build_model()
occupancy_transition_times = list(CO2model.occupancy.transition_times)
ventilation_transition_times: list = form.find_change_points()
# The entire ventilation changes consider the initial and final occupancy state change
all_vent_transition_times: list = sorted(
[occupancy_transition_times[0]] +
[occupancy_transition_times[-1]] +
ventilation_transition_times)
ventilation_plot: str = form.generate_ventilation_plot(
ventilation_transition_times=all_vent_transition_times,
occupancy_transition_times=occupancy_transition_times
)
context = {
'CO2_plot': ventilation_plot,
'transition_times': [round(el, 2) for el in all_vent_transition_times],
}
return context
def build_fitting_results(
self,
form: CO2FormData,
) -> dict:
'''
Final fitting results with the respective predictive CO2.
This method receives the form input and returns the fitting results
along with the CO2 plot with the predictive CO2.
'''
CO2model: CO2DataModel = form.build_model()
# Ventilation times after user manipulation from the suggested ventilation state change times.
ventilation_transition_times = list(CO2model.ventilation_transition_times)
# The result of the following method is a dict with the results of the fitting
# algorithm, namely the breathing rate and ACH values. It also returns the
# predictive CO2 result based on the fitting results.
context: typing.Dict = dict(CO2model.CO2_fit_params())
# Add the transition times and CO2 plot to the results.
context['transition_times'] = ventilation_transition_times
context['CO2_plot'] = form.generate_ventilation_plot(ventilation_transition_times=ventilation_transition_times[:-1],
predictive_CO2=context['predictive_CO2'])
return context

View file

@ -330,13 +330,10 @@ class VirusFormData(FormData):
min(self.infected_start, self.exposed_start)/60)
if self.ventilation_type == 'from_fitting':
ventilations = []
if self.CO2_fitting_result['fitting_ventilation_type'] == 'fitting_natural_ventilation':
transition_times = self.CO2_fitting_result['transition_times']
for index, (start, stop) in enumerate(zip(transition_times[:-1], transition_times[1:])):
ventilations.append(models.AirChange(active=models.SpecificInterval(present_times=((start, stop), )),
air_exch=self.CO2_fitting_result['ventilation_values'][index]))
else:
ventilations.append(models.AirChange(active=always_on, air_exch=self.CO2_fitting_result['ventilation_values'][0]))
transition_times = self.CO2_fitting_result['transition_times']
for index, (start, stop) in enumerate(zip(transition_times[:-1], transition_times[1:])):
ventilations.append(models.AirChange(active=models.SpecificInterval(present_times=((start, stop), )),
air_exch=self.CO2_fitting_result['ventilation_values'][index]))
return models.MultipleVentilation(tuple(ventilations))
# Initializes a ventilation instance as a window if 'natural_ventilation' is selected, or as a HEPA-filter otherwise

View file

@ -368,9 +368,9 @@ def readable_minutes(minutes: int) -> str:
def hour_format(hour: float) -> str:
# Convert float hour to HH:MM format
hours = int(hour)
minutes = int(hour % 1 * 60)
return f"{hours}:{minutes if minutes != 0 else '00'}"
hours = f"{int(hour):02}"
minutes = f"{int(hour % 1 * 60):02}"
return f"{hours}:{minutes}"
def percentage(absolute: float) -> float:

View file

@ -9,7 +9,6 @@ const CO2_data_form = [
"exposed_lunch_start",
"exposed_start",
"fitting_ventilation_states",
"fitting_ventilation_type",
"infected_coffee_break_option",
"infected_coffee_duration",
"infected_dont_have_breaks_with_exposed",
@ -25,7 +24,7 @@ const CO2_data_form = [
"total_people",
];
// Method to upload a valid excel file
// Method to upload a valid data file (accepted formats: .xls and .xlsx)
function uploadFile(endpoint) {
clearFittingResultComponent();
const files = $("#file_upload")[0].files;
@ -41,12 +40,12 @@ function uploadFile(endpoint) {
.toUpperCase();
if (extension !== ".XLS" && extension !== ".XLSX") {
$("#upload-error")
.text("Please select a valid excel file (.XLS or .XLSX).")
.text("Please select a valid data file (.XLS or .XLSX).")
.show();
return;
}
// FileReader API to read the Excel file
// FileReader API to read the data file
const reader = new FileReader();
reader.onload = function (event) {
const fileContent = event.target.result;
@ -80,7 +79,7 @@ function uploadFile(endpoint) {
if (data.length <= 1) {
$("#upload-error")
.text(
"The Excel file is empty. Please make sure it contains data below the header row."
"The data file is empty. Please make sure it contains data below the header row."
)
.show();
return;
@ -107,7 +106,40 @@ function uploadFile(endpoint) {
}
}
// Convert Excel file to JSON and further processing
// Validate times data encompass simulation time
const firstTimeInData = parseFloat((data[1][timesColumnIndex] * 60).toFixed(2))
const lastTimeInData = parseFloat((data[data.length - 1][timesColumnIndex] * 60).toFixed(2))
// Validate start time
const infected_start = $(`[name=infected_start]`).first().val();
const exposed_start = $(`[name=exposed_start]`).first().val();
let [hours_infected, minutes_infected] = infected_start.split(":").map(Number);
let elapsed_time_infected = hours_infected * 60 + minutes_infected;
let [hours_exposed, minutes_exposed] = exposed_start.split(":").map(Number);
let elapsed_time_exposed = hours_exposed * 60 + minutes_exposed;
const min_presence_time = parseFloat((Math.min(elapsed_time_infected, elapsed_time_exposed)).toFixed(2));
// Validate finish time
const infected_finish = $(`[name=infected_finish]`).first().val();
const exposed_finish = $(`[name=exposed_finish]`).first().val();
[hours_infected, minutes_infected] = infected_finish.split(":").map(Number);
elapsed_time_infected = hours_infected * 60 + minutes_infected;
[hours_exposed, minutes_exposed] = exposed_finish.split(":").map(Number);
elapsed_time_exposed = hours_exposed * 60 + minutes_exposed;
const max_presence_time = parseFloat((Math.max(elapsed_time_infected, elapsed_time_exposed)).toFixed(2));
if (firstTimeInData > min_presence_time || lastTimeInData < max_presence_time) {
$("#upload-error")
.text(`The times of the data file should encompass the entire simulation time (from ${min_presence_time/60} to ${max_presence_time/60}).
Got times from ${firstTimeInData/60} to ${lastTimeInData/60}. Either adapt the simulation presence times, or the times of the data file.`).show();
return;
}
// Convert data file to JSON and further processing
try {
generateJSONStructure(endpoint, data);
// If all validations pass, process the file here or display a success message
@ -137,7 +169,6 @@ function generateJSONStructure(endpoint, jsonData) {
inputToPopulate.val(JSON.stringify(finalStructure));
$("#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);
}
@ -177,66 +208,54 @@ function validateCO2Form() {
if (validateFormInputs($("#button_fit_data"))) submit = true;
const $fittingToSubmit = $('#DIVCO2_fitting_to_submit');
// Check if natural ventilation is selected
if (
$fittingToSubmit.find('input[name="fitting_ventilation_type"]:checked').val() ==
"fitting_natural_ventilation"
) {
// Validate ventilation scheme
const $ventilationStates = $fittingToSubmit.find("input[name=fitting_ventilation_states]");
const $referenceNode = $("#DIVCO2_fitting_result");
if ($ventilationStates.val() !== "") {
// validate input format
try {
const parsedValue = JSON.parse($ventilationStates.val());
if (Array.isArray(parsedValue)) {
if (parsedValue.length <= 1) {
insertErrorFor(
$referenceNode,
`'${$ventilationStates.attr('name')}' must have more than one $ventilationStates.<br />`
);
submit = false;
}
else {
const infected_finish = $(`[name=infected_finish]`).first().val();
const exposed_finish = $(`[name=exposed_finish]`).first().val();
const [hours_infected, minutes_infected] = infected_finish.split(":").map(Number);
const elapsed_time_infected = hours_infected * 60 + minutes_infected;
const [hours_exposed, minutes_exposed] = exposed_finish.split(":").map(Number);
const elapsed_time_exposed = hours_exposed * 60 + minutes_exposed;
const max_presence_time = Math.max(elapsed_time_infected, elapsed_time_exposed);
const max_transition_time = parsedValue[parsedValue.length - 1] * 60;
if (max_transition_time > max_presence_time) {
insertErrorFor(
$referenceNode,
`The last transition time (${parsedValue[parsedValue.length - 1]}) should be before the last presence time (${max_presence_time / 60}).<br />`
);
submit = false;
}
}
}
else {
// Validate ventilation scheme
const $ventilationStates = $fittingToSubmit.find("input[name=fitting_ventilation_states]");
const $referenceNode = $("#DIVCO2_fitting_result");
if ($ventilationStates.val() !== "") {
// validate input format
try {
const parsedValue = JSON.parse($ventilationStates.val());
if (Array.isArray(parsedValue)) {
if (parsedValue.length <= 1) {
insertErrorFor(
$referenceNode,
`'${$ventilationStates.attr('name')}' must be a list.</br>`
`'${$ventilationStates.attr('name')}' must have more than one ventilation state change (at least the beggining and end of simulation time).<br />`
);
submit = false;
}
} catch {
else {
const infected_finish = $(`[name=infected_finish]`).first().val();
const exposed_finish = $(`[name=exposed_finish]`).first().val();
const [hours_infected, minutes_infected] = infected_finish.split(":").map(Number);
const elapsed_time_infected = hours_infected * 60 + minutes_infected;
const [hours_exposed, minutes_exposed] = exposed_finish.split(":").map(Number);
const elapsed_time_exposed = hours_exposed * 60 + minutes_exposed;
const max_presence_time = Math.max(elapsed_time_infected, elapsed_time_exposed);
const max_transition_time = parsedValue[parsedValue.length - 1] * 60;
if (max_transition_time > max_presence_time) {
insertErrorFor(
$referenceNode,
`The last transition time (${parsedValue[parsedValue.length - 1]}) should be before the last presence time (${max_presence_time / 60}).<br />`
);
submit = false;
}
}
}
else {
insertErrorFor(
$referenceNode,
`'${$ventilationStates.attr('name')}' must be a list of numbers.</br>`
`'${$ventilationStates.attr('name')}' must be a list.</br>`
);
submit = false;
}
} else {
} catch {
insertErrorFor(
$referenceNode,
`'${$ventilationStates.attr('name')}' must be defined.</br>`
`'${$ventilationStates.attr('name')}' must be a list of numbers.</br>`
);
submit = false;
}
@ -253,23 +272,24 @@ function validateCO2Form() {
submit = false;
}
}
} else {
insertErrorFor(
$referenceNode,
`'${$ventilationStates.attr('name')}' must be defined.</br>`
);
submit = false;
}
return submit;
}
function displayTransitionTimesHourFormat(start, stop) {
var minutes_start = ((start % 1) * 60).toPrecision(2);
var minutes_stop = ((stop % 1) * 60).toPrecision(2);
return (
Math.floor(start) +
":" +
(minutes_start != "0.0" ? minutes_start : "00") +
" - " +
Math.floor(stop) +
":" +
(minutes_stop != "0.0" ? minutes_stop : "00")
);
const formatTime = (time) => {
const hours = Math.floor(time);
const minutes = Math.round((time % 1) * 60);
return `${hours}:${minutes.toString().padStart(2, '0')}`;
};
return `${formatTime(start)} - ${formatTime(stop)}`;
}
function displayFittingData(json_response) {
@ -317,7 +337,7 @@ function displayFittingData(json_response) {
$("#disable_fitting_algorithm").prop("disabled", false);
$("#ventilation_rate_fit").html(ventilation_table);
$("#generate_fitting_data").html("Fit data");
$("#generate_fitting_data").html("Confirm and Fit data");
$("#generate_fitting_data").hide();
$("#save_and_dismiss_dialog").show();
}
@ -379,7 +399,6 @@ function submitFittingAlgorithm(url) {
"disabled",
true
);
// Prepare data for submission
const CO2_mapping = formatCO2DataForm(CO2_data_form);
$("#CO2_input_data_div").show();
@ -423,12 +442,6 @@ function clearFittingResultComponent() {
$referenceNode.find("#DIVCO2_fitting_to_submit").hide();
$referenceNode.find("#CO2_data_plot").attr("src", "");
// Update the ventilation scheme components
$referenceNode.find("#fitting_ventilation_states, [name=fitting_ventilation_type]").prop(
"disabled",
false
);
// Update the bottom right buttons
$referenceNode.find("#generate_fitting_data").show();
$referenceNode.find("#save_and_dismiss_dialog").hide();

View file

@ -479,20 +479,6 @@ function on_coffee_break_option_change() {
}
}
function on_CO2_fitting_ventilation_change() {
ventilation_options = $('input[type=radio][name=fitting_ventilation_type]');
ventilation_options.each(function (index) {
if (this.checked) {
getChildElement($(this)).show();
require_fields(this);
}
else {
getChildElement($(this)).hide();
require_fields(this);
}
})
}
/* -------UI------- */
function show_disclaimer() {
@ -1070,12 +1056,6 @@ $(document).ready(function () {
// Call the function now to handle forward/back button presses in the browser.
on_coffee_break_option_change();
// When the ventilation on the fitting changes we want to make its respective
// children show/hide.
$("input[type=radio][name=fitting_ventilation_type]").change(on_CO2_fitting_ventilation_change);
// Call the function now to handle forward/back button presses in the browser.
on_CO2_fitting_ventilation_change();
// Setup the maximum number of people at page load (to handle back/forward),
// and update it when total people is changed.
validateMaxInfectedPeople();

View file

@ -323,7 +323,7 @@
</div>
<div class="modal-body">
<div class="d-flex d-row justify-content-between">
<h5 class="mt-auto mb-auto">Upload an Excel file:</h5>
<h5 class="mt-auto mb-auto">Upload a valid data file (.xls or .xlsx):</h5>
<a class="btn btn-primary btn-sm" href="https://caimira-resources.web.cern.ch/CO2_template.xlsx">Download template</a>
</div>
<div class="d-flex">
@ -333,30 +333,22 @@
</div>
</div>
<br>
<!-- Input element to upload an excel file -->
<!-- Input element to upload a data file -->
<input type="file" id="file_upload" accept=".xlsx, .xls"/>
<button type="button" class="btn btn-primary btn-sm" onclick="uploadFile('{{ get_calculator_url() }}/co2-fit/plot');">Upload</button>
<div id="upload-error" class="mb-0 mt-2 alert alert-danger" style="display: none" role="alert"></div>
<br>
<!-- Formatted excel data -->
<!-- Formatted data from input -->
<input id="CO2_data" type="text" name="CO2_data" form="not-submitted" class="form-control d-none" placeholder='{"times": [...], "concentrations": [...]}' value="{}"><br>
<div id="CO2_input_data_div" style="display: none"></div>
<input type="text" class="form-control d-none" name="CO2_fitting_result" id="CO2_fitting_result">
<div id="DIVCO2_fitting_to_submit" style="display: none">
<img id="CO2_data_plot"/><br>
<p id="suggestion_lines_txt" class="text-danger text-center">
<p id="suggestion_lines_txt" class="text-danger text-center mb-1">
The dashed lines are suggestions for the ventilation transition times<br>
(generated from the input data using the Pelt algorithm).</p>
<strong>Ventilation scheme:</strong>
<div>
<input class="ml-2" type="radio" id="fitting_natural_ventilation" name="fitting_ventilation_type" value='fitting_natural_ventilation' checked="checked" data-enables="#DIVfitting_natural_ventilation" form="not-submitted" >
<label for="fitting_natural_ventilation">Natural</label>
<input class="ml-2" type="radio" id="fitting_mechanical_ventilation" name="fitting_ventilation_type" value='fitting_mechanical_ventilation' form="not-submitted">
<label for="fitting_mechanical_ventilation">Mechanical</label>
</div>
<div id="DIVfitting_natural_ventilation" class="form-group mb-0" style="display: none">
</p>
<div id="DIVfitting_ventilation" class="form-group mb-0">
<label for="fitting_ventilation_states">Please enter the ventilation state change times, separated by comma - e.g. [8.5, 10, 11.5, 17]. </label>
<div data-tooltip="Default values indicated below correspond to the dashed lines in the above plot - these are only suggestions and can be changed.">
<span class="tooltip_text">?</span>
@ -383,7 +375,7 @@
</div>
<div class="modal-footer">
<button type="button" id="disable_fitting_algorithm" class="btn btn-secondary dismiss_btn_frm_field" data-dismiss="modal" onclick="disableFittingAlgorithm()">Discard</button>
<button type="button" id="generate_fitting_data" class="btn btn-primary close_btn_frm_field" onclick="submitFittingAlgorithm('{{ get_calculator_url() }}/co2-fit/')" disabled>Fit data</button>
<button type="button" id="generate_fitting_data" class="btn btn-primary close_btn_frm_field" onclick="submitFittingAlgorithm('{{ get_calculator_url() }}/co2-fit/')" disabled>Confirm and Fit data</button>
<button type="button" style="display: none" id="save_and_dismiss_dialog" class="btn btn-primary close_btn_frm_field" data-dismiss="modal">Use fit data</button>
</div>
</div>

View file

@ -1561,7 +1561,7 @@ class CO2DataModel:
# 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) -> typing.Dict:
if len(self.times) != len(self.CO2_concentrations):
raise ValueError('times and CO2_concentrations must have same length.')
@ -1570,6 +1570,11 @@ class CO2DataModel:
'times and CO2_concentrations must contain at last two elements')
def fun(x):
'''
The objective function to be minimized, where x is an argument
containing the initial guess for the breathing rate (exhalation_rate)
and ventilation values (ventilation_values).
'''
exhalation_rate = x[0]
ventilation_values = tuple(x[1:])
CO2_concentration_model = self.CO2_concentration_model(

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,10 @@
import numpy.testing as npt
import numpy as np
import typing
import pytest
from caimira import models
from caimira.apps.calculator.co2_model_generator import CO2FormData
@pytest.fixture
@ -41,3 +44,74 @@ def test_integrated_concentration(simple_co2_conc_model):
c3 = simple_co2_conc_model.integrated_concentration(1, 2)
assert c1 != 0
npt.assert_almost_equal(c1, c2 + c3)
@pytest.mark.parametrize(
"scenario_data, room_volume, max_total_people, start, finish, state_changes", [
["office_scenario_1_sensor_data", 102, 4, "14:00", "17:30", (14.78, 15.1, 15.53, 15.87, 16.52, 16.83)],
["office_scenario_2_sensor_data", 60, 2, "08:38", "17:30", (10.17, 12.45, 14.5)], # Second state change should actually be 12.87 - that's the real time at which the ventilation was changed in the room.
["meeting_scenario_1_sensor_data", 83, 3, "09:04", "11:45", (10.37, 11.07)],
["meeting_scenario_2_sensor_data", 83, 4, "13:40", "16:40", (14.37, 14.72, 15, 15.33, 15.68, 16.03)]
]
)
def test_find_change_points(scenario_data, room_volume, max_total_people, start, finish, state_changes, request):
'''
Specific test of the find_change_points method.
Testing the ventilation state changes only.
'''
CO2_form_model: CO2FormData = CO2FormData(
CO2_data=request.getfixturevalue(scenario_data),
fitting_ventilation_states=[],
exposed_start=start,
exposed_finish=finish,
total_people=max_total_people,
room_volume=room_volume,
)
find_points = CO2_form_model.find_change_points()
assert np.allclose(find_points, state_changes, rtol=1e-2)
@pytest.mark.parametrize(
"scenario_data, room_volume, occupancy, presence_interval, all_state_changes", [
["office_scenario_1_sensor_data", 102, (4,), (14, 17.5), (14, 14.25, 14.78, 15.1, 15.53, 15.87, 16.52, 16.83, 17.5)],
["office_scenario_2_sensor_data", 60, (2, 0, 2), (8.62, 11.93, 12.42, 17.5), (8.62, 10.17, 12.45, 14.5, 17.5, 20.)], # Third state change should actually be 12.87 - that's the real time at which the ventilation was changed in the room.
["meeting_scenario_1_sensor_data", 83, (2, 3, 2, 3), (9.07, 9.32, 9.75, 10.75, 11.75), (9.07, 10.37, 11.07, 11.75)],
["meeting_scenario_2_sensor_data", 83, (2, 3, 4), (13.67, 13.75, 15.87, 16.67), (13.67, 14.37, 14.72, 15.00, 15.33, 15.68, 16.03, 16.67)]
]
)
def test_predictive_model_accuracy(data_registry, scenario_data, room_volume, occupancy, presence_interval, all_state_changes, request):
'''
Specific test corresponding to the data files of four
different scenarios (2 in an office and 2 in a meeting room).
The room volume, number of people and ventilation transition times
correspond to the actual state change occurrences during the day.
Note that the last time from the input file is considered as a ventilation
state change.
'''
input_fitting_data = request.getfixturevalue(scenario_data)
fitting_model: models.CO2DataModel = models.CO2DataModel(
data_registry=data_registry,
room=models.Room(volume=room_volume),
occupancy=models.IntPiecewiseConstant(
transition_times=presence_interval,
values=occupancy
),
ventilation_transition_times=all_state_changes,
times=input_fitting_data['times'],
CO2_concentrations=input_fitting_data['CO2'],
)
# Get fitting results
fitting_results: typing.Dict = fitting_model.CO2_fit_params()
predictive_CO2: typing.List[float] = fitting_results['predictive_CO2']
def root_mean_square_error_percentage(actual, predicted) -> float:
return np.sqrt(np.mean(((actual - predicted) / actual) ** 2)) * 100
# Calculate RMSEP metric
rmsep = root_mean_square_error_percentage(np.array(input_fitting_data['CO2']), np.array(predictive_CO2))
acceptable_rmsep = 10 # Threshold of 10% for the accepted error margin
assert rmsep <= acceptable_rmsep, f"RMSEP {rmsep} exceeds acceptable threshold {acceptable_rmsep}"