diff --git a/caimira/apps/calculator/__init__.py b/caimira/apps/calculator/__init__.py
index 3002f6f1..5445e2e2 100644
--- a/caimira/apps/calculator/__init__.py
+++ b/caimira/apps/calculator/__init__.py
@@ -20,6 +20,7 @@ import uuid
import zlib
import matplotlib.pyplot as plt
import numpy as np
+import ruptures as rpt
import jinja2
import loky
@@ -352,14 +353,46 @@ class CO2Data(BaseRequestHandler):
"""
pass
- def generate_ventilation_plot(self, CO2Data, transition_times = None, ventilation_values = None):
+ def find_change_points_with_pelt(self, CO2_data: dict):
+ """
+ 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.
+ """
+
+ times = CO2_data['times']
+ CO2_values = CO2_data['CO2']
+
+ if len(times) != len(CO2_values):
+ raise ValueError("times and CO2 values must have the same length.")
+
+ # Convert the input lists to numpy arrays for use with the ruptures library
+ times_np = np.array(times)
+ CO2_np = np.array(CO2_values)
+
+ # Define the model for change point detection (Radial Basis Function kernel)
+ model = "rbf"
+
+ # Fit the Pelt algorithm to the data with the specified model
+ algo = rpt.Pelt(model=model).fit(CO2_np)
+
+ # Predict change points using the Pelt algorithm with a penalty value of 15
+ result = algo.predict(pen=15)
+
+ return [times_np[idx] for idx in result[:-1]]
+
+ def generate_ventilation_plot(self, CO2_data: dict, transition_times: typing.Optional[list] = None, ventilation_values: typing.Optional[list] = None):
+ times = CO2_data['times']
+ CO2_data = CO2_data['CO2']
+
fig = plt.figure(figsize=(7, 4), dpi=110)
- plt.plot(CO2Data['times'], CO2Data['CO2'])
- if (transition_times and ventilation_values):
- for index, time in enumerate(transition_times[:-1]):
+ plt.plot(times, CO2_data)
+
+ if (transition_times):
+ for index, time in enumerate(transition_times):
plt.axvline(x = time, color = 'grey', linewidth=0.5, linestyle='--')
- y_location = (CO2Data['CO2'][min(range(len(CO2Data['times'])), key=lambda i: abs(CO2Data['times'][i]-time))])
- plt.text(x = time + 0.04, y = y_location, s=round(ventilation_values[index], 2))
+ if ventilation_values:
+ y_location = (CO2_data[min(range(len(times)), key=lambda i: abs(times[i]-time))])
+ plt.text(x = time + 0.04, y = y_location, s="{:.2g}".format(ventilation_values[index]))
plt.xlabel('Time of day')
plt.ylabel('Concentration (ppm)')
return img2base64(_figure2bytes(fig))
@@ -377,8 +410,8 @@ class CO2Data(BaseRequestHandler):
self.finish(json.dumps(response_json))
return
- if endpoint == 'plot':
- self.finish({'CO2_plot': self.generate_ventilation_plot(form.CO2_data)})
+ if endpoint.rstrip('/') == 'plot':
+ self.finish({'CO2_plot': self.generate_ventilation_plot(form.CO2_data, self.find_change_points_with_pelt(form.CO2_data))})
else:
executor = loky.get_reusable_executor(
max_workers=self.settings['handler_worker_pool_size'],
@@ -392,7 +425,7 @@ class CO2Data(BaseRequestHandler):
result = dict(report.CO2_fit_params())
result['fitting_ventilation_type'] = form.fitting_ventilation_type
result['transition_times'] = report.ventilation_transition_times
- result['CO2_plot'] = self.generate_ventilation_plot(form.CO2_data, report.ventilation_transition_times, result['ventilation_values'])
+ result['CO2_plot'] = self.generate_ventilation_plot(form.CO2_data, report.ventilation_transition_times[:-1], result['ventilation_values'])
self.finish(result)
diff --git a/caimira/apps/calculator/static/js/co2_form.js b/caimira/apps/calculator/static/js/co2_form.js
index 6a2128eb..faac0474 100644
--- a/caimira/apps/calculator/static/js/co2_form.js
+++ b/caimira/apps/calculator/static/js/co2_form.js
@@ -194,7 +194,37 @@ function validateCO2Form() {
// validate input format
try {
const parsedValue = JSON.parse(element.value);
- if (!Array.isArray(parsedValue)) {
+ if (Array.isArray(parsedValue)) {
+ if (parsedValue.length <= 1) {
+ insertErrorFor(
+ $("#DIVCO2_fitting_result"),
+ `'${element.name}' must have more than one element.
`
+ );
+ submit = false;
+ }
+ else {
+ const infected_finish = $(`[name=infected_finish]`)[0].value;
+ const exposed_finish = $(`[name=exposed_finish]`)[0].value;
+
+ 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(
+ $("#DIVCO2_fitting_result"),
+ `The last transition time (${parsedValue[parsedValue.length - 1]}) should be after the last presence time (${max_presence_time / 60}).
`
+ );
+ submit = false;
+ }
+ }
+ }
+ else {
insertErrorFor(
$("#DIVCO2_fitting_result"),
`'${element.name}' must be a list.`
@@ -325,11 +355,15 @@ function submitFittingAlgorithm(url) {
.then((response) => response.json())
.then((json_response) => {
displayFittingData(json_response);
+ // Hide the suggestion transition lines warning
+ $("#suggestion_lines_txt").hide();
});
}
}
function clearFittingResultComponent() {
+ // Add the warning suggestion line
+ $("#suggestion_lines_txt").show();
// Remove all the previously generated fitting elements
$("#generate_fitting_data").prop("disabled", true);
$("#CO2_fitting_result").val("");
diff --git a/caimira/apps/templates/base/calculator.form.html.j2 b/caimira/apps/templates/base/calculator.form.html.j2
index 7b7d0d6b..960b3e43 100644
--- a/caimira/apps/templates/base/calculator.form.html.j2
+++ b/caimira/apps/templates/base/calculator.form.html.j2
@@ -200,13 +200,13 @@