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 @@
- +
-
+
- +
@@ -339,6 +339,7 @@