Skip to content

Commit 6f77b6e

Browse files
committed
Merge branch 'feature/CO2_fitting_refinement' into 'master'
CO2 fitting algorithm refinement See merge request caimira/caimira!503
2 parents dbdc6b5 + e0675ba commit 6f77b6e

11 files changed

+1534
-181
lines changed

caimira/apps/calculator/__init__.py

+11-15
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from . import markdown_tools
3434
from . import model_generator, co2_model_generator
3535
from .report_generator import ReportGenerator, calculate_report_data
36+
from .co2_report_generator import CO2ReportGenerator
3637
from .user import AuthenticatedUser, AnonymousUser
3738

3839
# The calculator version is based on a combination of the model version and the
@@ -404,7 +405,10 @@ async def post(self, endpoint: str) -> None:
404405

405406
requested_model_config = tornado.escape.json_decode(self.request.body)
406407
try:
407-
form = co2_model_generator.CO2FormData.from_dict(requested_model_config, data_registry)
408+
form: co2_model_generator.CO2FormData = co2_model_generator.CO2FormData.from_dict(
409+
requested_model_config,
410+
data_registry
411+
)
408412
except Exception as err:
409413
if self.settings.get("debug", False):
410414
import traceback
@@ -414,29 +418,21 @@ async def post(self, endpoint: str) -> None:
414418
self.finish(json.dumps(response_json))
415419
return
416420

421+
CO2_report_generator: CO2ReportGenerator = CO2ReportGenerator()
417422
if endpoint.rstrip('/') == 'plot':
418-
transition_times = co2_model_generator.CO2FormData.find_change_points_with_pelt(form.CO2_data)
419-
self.finish({'CO2_plot': co2_model_generator.CO2FormData.generate_ventilation_plot(form.CO2_data, transition_times),
420-
'transition_times': [round(el, 2) for el in transition_times]})
423+
report = CO2_report_generator.build_initial_plot(form)
424+
self.finish(report)
421425
else:
422426
executor = loky.get_reusable_executor(
423427
max_workers=self.settings['handler_worker_pool_size'],
424428
timeout=300,
425429
)
426430
report_task = executor.submit(
427-
co2_model_generator.CO2FormData.build_model, form,
431+
CO2_report_generator.build_fitting_results, form,
428432
)
433+
429434
report = await asyncio.wrap_future(report_task)
430-
431-
result = dict(report.CO2_fit_params())
432-
ventilation_transition_times = report.ventilation_transition_times
433-
434-
result['fitting_ventilation_type'] = form.fitting_ventilation_type
435-
result['transition_times'] = ventilation_transition_times
436-
result['CO2_plot'] = co2_model_generator.CO2FormData.generate_ventilation_plot(CO2_data=form.CO2_data,
437-
transition_times=ventilation_transition_times[:-1],
438-
predictive_CO2=result['predictive_CO2'])
439-
self.finish(result)
435+
self.finish(report)
440436

441437

442438
def get_url(app_root: str, relative_path: str = '/'):

caimira/apps/calculator/co2_model_generator.py

+69-46
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
import logging
33
import typing
44
import numpy as np
5-
import ruptures as rpt
65
import matplotlib.pyplot as plt
6+
from scipy.signal import find_peaks
7+
import pandas as pd
78
import re
89

910
from caimira import models
@@ -21,22 +22,20 @@
2122
class CO2FormData(FormData):
2223
CO2_data: dict
2324
fitting_ventilation_states: list
24-
fitting_ventilation_type: str
2525
room_capacity: typing.Optional[int]
2626

2727
#: The default values for undefined fields. Note that the defaults here
2828
#: and the defaults in the html form must not be contradictory.
2929
_DEFAULTS: typing.ClassVar[typing.Dict[str, typing.Any]] = {
3030
'CO2_data': '{}',
31+
'fitting_ventilation_states': '[]',
3132
'exposed_coffee_break_option': 'coffee_break_0',
3233
'exposed_coffee_duration': 5,
3334
'exposed_finish': '17:30',
3435
'exposed_lunch_finish': '13:30',
3536
'exposed_lunch_option': True,
3637
'exposed_lunch_start': '12:30',
3738
'exposed_start': '08:30',
38-
'fitting_ventilation_states': '[]',
39-
'fitting_ventilation_type': 'fitting_natural_ventilation',
4039
'infected_coffee_break_option': 'coffee_break_0',
4140
'infected_coffee_duration': 5,
4241
'infected_dont_have_breaks_with_exposed': False,
@@ -97,55 +96,75 @@ def validate(self):
9796
raise TypeError(f'Unable to fetch "finish_time" key. Got "{dict_keys[1]}".')
9897
for time in input_break.values():
9998
if not re.compile("^(2[0-3]|[01]?[0-9]):([0-5]?[0-9])$").match(time):
100-
raise TypeError(f'Wrong time format - "HH:MM". Got "{time}".')
99+
raise TypeError(f'Wrong time format - "HH:MM". Got "{time}".')
101100

102-
@classmethod
103-
def find_change_points_with_pelt(self, CO2_data: dict):
101+
def find_change_points(self) -> list:
104102
"""
105-
Perform change point detection using Pelt algorithm from ruptures library with pen=15.
106-
Returns a list of tuples containing (index, X-axis value) for the detected significant changes.
103+
Perform change point detection using scipy library (find_peaks method) with rolling average of data.
104+
Incorporate existing state change candidates and adjust the result accordingly.
105+
Returns a list of the detected ventilation state changes, discarding any occupancy state change.
107106
"""
108-
109-
times: list = CO2_data['times']
110-
CO2_values: list = CO2_data['CO2']
107+
times: list = self.CO2_data['times']
108+
CO2_values: list = self.CO2_data['CO2']
111109

112110
if len(times) != len(CO2_values):
113111
raise ValueError("times and CO2 values must have the same length.")
114112

115-
# Convert the input list to a numpy array for use with the ruptures library
116-
CO2_np = np.array(CO2_values)
113+
# Time difference between two consecutive time data entries, in seconds
114+
diff = (times[1] - times[0]) * 3600 # Initial data points in absolute hours, e.g. 14.78
115+
116+
# Calculate minimum interval for smoothing technique
117+
smooth_min_interval_in_minutes = 1 # Minimum time difference for smooth technique
118+
window_size = max(int((smooth_min_interval_in_minutes * 60) // diff), 1)
117119

118-
# Define the model for change point detection (Radial Basis Function kernel)
119-
model = "rbf"
120+
# Applying a rolling average to smooth the initial data
121+
smoothed_co2 = pd.Series(CO2_values).rolling(window=window_size, center=True).mean()
120122

121-
# Fit the Pelt algorithm to the data with the specified model
122-
algo = rpt.Pelt(model=model).fit(CO2_np)
123+
# Calculate minimum interval for peaks and valleys detection
124+
peak_valley_min_interval_in_minutes = 15 # Minimum time difference between two peaks or two valleys
125+
min_distance_points = max(int((peak_valley_min_interval_in_minutes * 60) // diff), 1)
123126

124-
# Predict change points using the Pelt algorithm with a penalty value of 15
125-
result = algo.predict(pen=15)
127+
# Calculate minimum width of datapoints for valley detection
128+
width_min_interval_in_minutes = 20 # Minimum duration of a valley
129+
min_valley_width = max(int((width_min_interval_in_minutes * 60) // diff), 1)
126130

127-
# Find local minima and maxima
128-
segments = np.split(np.arange(len(CO2_values)), result)
129-
merged_segments = [np.hstack((segments[i], segments[i + 1])) for i in range(len(segments) - 1)]
130-
result_set = set()
131-
for segment in merged_segments[:-2]:
132-
result_set.add(times[CO2_values.index(min(CO2_np[segment]))])
133-
result_set.add(times[CO2_values.index(max(CO2_np[segment]))])
134-
return list(result_set)
131+
# Find peaks (maxima) in the smoothed data applying the distance factor
132+
peaks, _ = find_peaks(smoothed_co2.values, prominence=100, distance=min_distance_points)
133+
134+
# Find valleys (minima) by inverting the smoothed data and applying the width and distance factors
135+
valleys, _ = find_peaks(-smoothed_co2.values, prominence=50, width=min_valley_width, distance=min_distance_points)
135136

136-
@classmethod
137-
def generate_ventilation_plot(self, CO2_data: dict,
138-
transition_times: typing.Optional[list] = None,
139-
predictive_CO2: typing.Optional[list] = None):
140-
times_values = CO2_data['times']
141-
CO2_values = CO2_data['CO2']
137+
# Extract peak and valley timestamps
138+
timestamps = np.array(times)
139+
peak_timestamps = timestamps[peaks]
140+
valley_timestamps = timestamps[valleys]
141+
142+
return sorted(np.concatenate((peak_timestamps, valley_timestamps)))
143+
144+
def generate_ventilation_plot(self,
145+
ventilation_transition_times: typing.Optional[list] = None,
146+
occupancy_transition_times: typing.Optional[list] = None,
147+
predictive_CO2: typing.Optional[list] = None) -> str:
148+
149+
# Plot data (x-axis: times; y-axis: CO2 concentrations)
150+
times_values: list = self.CO2_data['times']
151+
CO2_values: list = self.CO2_data['CO2']
142152

143153
fig = plt.figure(figsize=(7, 4), dpi=110)
144154
plt.plot(times_values, CO2_values, label='Input CO₂')
145155

146-
if (transition_times):
147-
for time in transition_times:
148-
plt.axvline(x = time, color = 'grey', linewidth=0.5, linestyle='--')
156+
# Add occupancy state changes:
157+
if (occupancy_transition_times):
158+
for i, time in enumerate(occupancy_transition_times):
159+
plt.axvline(x = time, color = 'grey', linewidth=0.5, linestyle='--', label='Occupancy change (from input)' if i == 0 else None)
160+
# Add ventilation state changes:
161+
if (ventilation_transition_times):
162+
for i, time in enumerate(ventilation_transition_times):
163+
if i == 0:
164+
label = 'Ventilation change (detected)' if occupancy_transition_times else 'Ventilation state changes'
165+
else: label = None
166+
plt.axvline(x = time, color = 'red', linewidth=0.5, linestyle='--', label=label)
167+
149168
if (predictive_CO2):
150169
plt.plot(times_values, predictive_CO2, label='Predictive CO₂')
151170
plt.xlabel('Time of day')
@@ -158,14 +177,18 @@ def population_present_changes(self, infected_presence: models.Interval, exposed
158177
state_change_times.update(exposed_presence.transition_times())
159178
return sorted(state_change_times)
160179

161-
def ventilation_transition_times(self) -> typing.Tuple[float, ...]:
162-
# Check what type of ventilation is considered for the fitting
163-
if self.fitting_ventilation_type == 'fitting_natural_ventilation':
164-
vent_states = self.fitting_ventilation_states
165-
vent_states.append(self.CO2_data['times'][-1])
166-
return tuple(vent_states)
167-
else:
168-
return tuple((self.CO2_data['times'][0], self.CO2_data['times'][-1]))
180+
def ventilation_transition_times(self) -> typing.Tuple[float]:
181+
'''
182+
Check if the last time from the input data is
183+
included in the ventilation ventilations state.
184+
Given that the last time is a required state change,
185+
if not included, this method adds it.
186+
'''
187+
vent_states = self.fitting_ventilation_states
188+
last_time_from_input = self.CO2_data['times'][-1]
189+
if (vent_states and last_time_from_input != vent_states[-1]): # The last time value is always needed for the last ACH interval.
190+
vent_states.append(last_time_from_input)
191+
return tuple(vent_states)
169192

170193
def build_model(self, size=None) -> models.CO2DataModel: # type: ignore
171194
size = size or self.data_registry.monte_carlo['sample_size']
@@ -184,7 +207,7 @@ def build_model(self, size=None) -> models.CO2DataModel: # type: ignore
184207
activity=None, # type: ignore
185208
)
186209

187-
all_state_changes=self.population_present_changes(infected_presence, exposed_presence)
210+
all_state_changes = self.population_present_changes(infected_presence, exposed_presence)
188211
total_people = [infected_population.people_present(stop) + exposed_population.people_present(stop)
189212
for _, stop in zip(all_state_changes[:-1], all_state_changes[1:])]
190213

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import dataclasses
2+
import typing
3+
4+
from caimira.models import CO2DataModel, Interval, IntPiecewiseConstant
5+
from .co2_model_generator import CO2FormData
6+
7+
8+
@dataclasses.dataclass
9+
class CO2ReportGenerator:
10+
11+
def build_initial_plot(
12+
self,
13+
form: CO2FormData,
14+
) -> dict:
15+
'''
16+
Initial plot with the suggested ventilation state changes.
17+
This method receives the form input and returns the CO2
18+
plot with the respective transition times.
19+
'''
20+
CO2model: CO2DataModel = form.build_model()
21+
22+
occupancy_transition_times = list(CO2model.occupancy.transition_times)
23+
24+
ventilation_transition_times: list = form.find_change_points()
25+
# The entire ventilation changes consider the initial and final occupancy state change
26+
all_vent_transition_times: list = sorted(
27+
[occupancy_transition_times[0]] +
28+
[occupancy_transition_times[-1]] +
29+
ventilation_transition_times)
30+
31+
ventilation_plot: str = form.generate_ventilation_plot(
32+
ventilation_transition_times=all_vent_transition_times,
33+
occupancy_transition_times=occupancy_transition_times
34+
)
35+
36+
context = {
37+
'CO2_plot': ventilation_plot,
38+
'transition_times': [round(el, 2) for el in all_vent_transition_times],
39+
}
40+
41+
return context
42+
43+
def build_fitting_results(
44+
self,
45+
form: CO2FormData,
46+
) -> dict:
47+
'''
48+
Final fitting results with the respective predictive CO2.
49+
This method receives the form input and returns the fitting results
50+
along with the CO2 plot with the predictive CO2.
51+
'''
52+
CO2model: CO2DataModel = form.build_model()
53+
54+
# Ventilation times after user manipulation from the suggested ventilation state change times.
55+
ventilation_transition_times = list(CO2model.ventilation_transition_times)
56+
57+
# The result of the following method is a dict with the results of the fitting
58+
# algorithm, namely the breathing rate and ACH values. It also returns the
59+
# predictive CO2 result based on the fitting results.
60+
context: typing.Dict = dict(CO2model.CO2_fit_params())
61+
62+
# Add the transition times and CO2 plot to the results.
63+
context['transition_times'] = ventilation_transition_times
64+
context['CO2_plot'] = form.generate_ventilation_plot(ventilation_transition_times=ventilation_transition_times[:-1],
65+
predictive_CO2=context['predictive_CO2'])
66+
67+
return context
68+

caimira/apps/calculator/model_generator.py

+4-7
Original file line numberDiff line numberDiff line change
@@ -330,13 +330,10 @@ def ventilation(self) -> models._VentilationBase:
330330
min(self.infected_start, self.exposed_start)/60)
331331
if self.ventilation_type == 'from_fitting':
332332
ventilations = []
333-
if self.CO2_fitting_result['fitting_ventilation_type'] == 'fitting_natural_ventilation':
334-
transition_times = self.CO2_fitting_result['transition_times']
335-
for index, (start, stop) in enumerate(zip(transition_times[:-1], transition_times[1:])):
336-
ventilations.append(models.AirChange(active=models.SpecificInterval(present_times=((start, stop), )),
337-
air_exch=self.CO2_fitting_result['ventilation_values'][index]))
338-
else:
339-
ventilations.append(models.AirChange(active=always_on, air_exch=self.CO2_fitting_result['ventilation_values'][0]))
333+
transition_times = self.CO2_fitting_result['transition_times']
334+
for index, (start, stop) in enumerate(zip(transition_times[:-1], transition_times[1:])):
335+
ventilations.append(models.AirChange(active=models.SpecificInterval(present_times=((start, stop), )),
336+
air_exch=self.CO2_fitting_result['ventilation_values'][index]))
340337
return models.MultipleVentilation(tuple(ventilations))
341338

342339
# Initializes a ventilation instance as a window if 'natural_ventilation' is selected, or as a HEPA-filter otherwise

caimira/apps/calculator/report_generator.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,9 @@ def readable_minutes(minutes: int) -> str:
368368

369369
def hour_format(hour: float) -> str:
370370
# Convert float hour to HH:MM format
371-
hours = int(hour)
372-
minutes = int(hour % 1 * 60)
373-
return f"{hours}:{minutes if minutes != 0 else '00'}"
371+
hours = f"{int(hour):02}"
372+
minutes = f"{int(hour % 1 * 60):02}"
373+
return f"{hours}:{minutes}"
374374

375375

376376
def percentage(absolute: float) -> float:

0 commit comments

Comments
 (0)