Merge branch 'feature/CO2_fitting_refinement' into 'master'
CO2 fitting algorithm refinement See merge request caimira/caimira!503
This commit is contained in:
commit
6f77b6e725
11 changed files with 1535 additions and 182 deletions
|
|
@ -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 = '/'):
|
||||
|
|
|
|||
|
|
@ -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:])]
|
||||
|
||||
|
|
|
|||
68
caimira/apps/calculator/co2_report_generator.py
Normal file
68
caimira/apps/calculator/co2_report_generator.py
Normal 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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
1205
caimira/tests/models/conftest.py
Normal file
1205
caimira/tests/models/conftest.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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}"
|
||||
|
||||
Loading…
Reference in a new issue