plotair 0.2.0__py3-none-any.whl → 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plotair/__init__.py +1 -1
- plotair/config.toml +13 -12
- plotair/main.py +239 -329
- plotair-0.2.1.dist-info/METADATA +130 -0
- plotair-0.2.1.dist-info/RECORD +8 -0
- plotair-0.2.0.dist-info/METADATA +0 -97
- plotair-0.2.0.dist-info/RECORD +0 -8
- {plotair-0.2.0.dist-info → plotair-0.2.1.dist-info}/WHEEL +0 -0
- {plotair-0.2.0.dist-info → plotair-0.2.1.dist-info}/entry_points.txt +0 -0
- {plotair-0.2.0.dist-info → plotair-0.2.1.dist-info}/licenses/LICENSE +0 -0
plotair/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.2.
|
|
1
|
+
__version__ = "0.2.1"
|
plotair/config.toml
CHANGED
|
@@ -2,32 +2,33 @@
|
|
|
2
2
|
max_missing_samples = 4
|
|
3
3
|
|
|
4
4
|
[plot]
|
|
5
|
-
size = [
|
|
5
|
+
size = [15, 10]
|
|
6
6
|
font_family = 'Noto Sans' # Set to '' to use default system font
|
|
7
|
-
font_scale = 1.
|
|
7
|
+
font_scale = 1.8
|
|
8
8
|
grid2_opacity = 0.7 # 0 is fully transparent and 1 is fully opaque
|
|
9
9
|
grid1_line_style = '-' # Options: '-', '--', ':' and '-.'
|
|
10
10
|
grid2_line_style = '--'
|
|
11
|
+
default_line_style = '-'
|
|
11
12
|
limit_zone_opacity = 0.075
|
|
12
13
|
limit_line_opacity = 0.7
|
|
13
|
-
limit_line_width = 1
|
|
14
14
|
limit_line_style = '--'
|
|
15
|
-
"pm2.5_line_width" = 1.5
|
|
16
15
|
"pm2.5_line_style" = '-'
|
|
17
|
-
"pm10_line_width" = 1.5
|
|
18
16
|
"pm10_line_style" = '-.'
|
|
19
17
|
date_rotation = 30
|
|
20
18
|
|
|
21
19
|
# Options: 'best', 'upper right', 'upper left', 'lower left', 'lower right',
|
|
22
20
|
# 'center left', 'center right', 'lower center', 'upper center', 'center'
|
|
23
|
-
legend_location = '
|
|
21
|
+
legend_location = 'upper left'
|
|
24
22
|
|
|
25
23
|
[axis_ranges]
|
|
26
24
|
co2 = [0, 2000]
|
|
27
|
-
|
|
25
|
+
humidity = [0, 80]
|
|
26
|
+
temp = [0, 80]
|
|
28
27
|
tvoc = [0, 500]
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
co = [0, 50]
|
|
29
|
+
form = [0, 50]
|
|
30
|
+
"pm2.5" = [0, 70]
|
|
31
|
+
"pm10" = [0, 70]
|
|
31
32
|
|
|
32
33
|
[labels]
|
|
33
34
|
co2 = 'CO₂ (ppm)'
|
|
@@ -35,8 +36,8 @@ temp = 'Température (°C)'
|
|
|
35
36
|
humidity = 'Humidité (%)'
|
|
36
37
|
tvoc = 'COVT (ppb)'
|
|
37
38
|
tvoc_limit = 'Limite LEED, WELL et LBC'
|
|
38
|
-
co = 'Monoxyde de carbone (ppm
|
|
39
|
-
co_limit = 'Limite
|
|
39
|
+
co = 'Monoxyde de carbone (ppm)'
|
|
40
|
+
co_limit = 'Limite Environnement Canada'
|
|
40
41
|
form = 'Formaldéhyde (ppb)'
|
|
41
42
|
form_limit = 'Limite anomalie légère BBI'
|
|
42
43
|
"pm2.5" = 'PM2.5 (µg/m³)'
|
|
@@ -47,7 +48,7 @@ form_limit = 'Limite anomalie légère BBI'
|
|
|
47
48
|
[limits]
|
|
48
49
|
humidity = [40, 60]
|
|
49
50
|
tvoc = 218 # ppb
|
|
50
|
-
co =
|
|
51
|
+
co = 5 # ppm
|
|
51
52
|
form = 16 # ppb
|
|
52
53
|
"pm2.5" = 10
|
|
53
54
|
"pm10" = 50
|
plotair/main.py
CHANGED
|
@@ -53,8 +53,14 @@ def main():
|
|
|
53
53
|
help='sensor data file to process')
|
|
54
54
|
parser.add_argument('-a', '--all-dates', action='store_true',
|
|
55
55
|
help='plot all dates (otherwise only latest sequence)')
|
|
56
|
+
parser.add_argument('-b', '--boxplot', action='store_true',
|
|
57
|
+
help='generate boxplots along with text stats')
|
|
56
58
|
parser.add_argument('-m', '--merge', metavar='FIELD',
|
|
57
59
|
help='merge field from file1 to file2, and output to file3')
|
|
60
|
+
parser.add_argument('-o', '--filter-outliers', action='store_true',
|
|
61
|
+
help='filter out outliers from the plots')
|
|
62
|
+
parser.add_argument('--filter-multiplier', type=float, default=1.5, metavar='MULTIPLIER',
|
|
63
|
+
help='multiplier for IQR outlier filtering (default: 1.5)')
|
|
58
64
|
parser.add_argument('-r', '--reset-config', action='store_true',
|
|
59
65
|
help='reset configuration file to default')
|
|
60
66
|
parser.add_argument('-s', '--start-date', metavar='DATE',
|
|
@@ -71,7 +77,7 @@ def main():
|
|
|
71
77
|
try:
|
|
72
78
|
load_config(args.reset_config)
|
|
73
79
|
except FileNotFoundError as e:
|
|
74
|
-
|
|
80
|
+
print(f'Error: Failed to load config: {e}')
|
|
75
81
|
return
|
|
76
82
|
|
|
77
83
|
if args.merge:
|
|
@@ -79,14 +85,14 @@ def main():
|
|
|
79
85
|
num_files = len(args.filenames)
|
|
80
86
|
|
|
81
87
|
if num_files != 3:
|
|
82
|
-
|
|
88
|
+
print('Error: Argument -m/--merge requires three file arguments')
|
|
83
89
|
return
|
|
84
90
|
|
|
85
91
|
file_format, df1, num_valid_rows1, num_invalid_rows = read_data(args.filenames[0])
|
|
86
92
|
file_format, df2, num_valid_rows2, num_invalid_rows = read_data(args.filenames[1])
|
|
87
93
|
|
|
88
94
|
if num_valid_rows1 <= 0 or num_valid_rows2 <= 0:
|
|
89
|
-
|
|
95
|
+
print('Error: At least one of the input files is unsupported')
|
|
90
96
|
return
|
|
91
97
|
|
|
92
98
|
temp_df = df1[['co2']]
|
|
@@ -94,43 +100,66 @@ def main():
|
|
|
94
100
|
df2.to_csv(args.filenames[2], index=True)
|
|
95
101
|
|
|
96
102
|
else:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
filenames = []
|
|
104
|
+
|
|
105
|
+
if sys.platform == "win32":
|
|
106
|
+
# On Windows, expand glob patterns (e.g. *.csv)
|
|
107
|
+
for pattern in args.filenames:
|
|
108
|
+
filenames.extend(glob.glob(pattern))
|
|
109
|
+
else:
|
|
110
|
+
# On Linux, use filenames as-is (no glob expansion needed)
|
|
111
|
+
filenames = args.filenames
|
|
112
|
+
|
|
113
|
+
for filename in filenames:
|
|
114
|
+
print(f'Processing {filename}')
|
|
105
115
|
try:
|
|
106
116
|
file_format, df, num_valid_rows, num_invalid_rows = read_data(filename)
|
|
107
117
|
|
|
108
118
|
if num_valid_rows > 0:
|
|
109
|
-
logger.debug(f'{num_valid_rows} row(s) read')
|
|
119
|
+
logger.debug(f'{num_valid_rows} valid row(s) read')
|
|
110
120
|
else:
|
|
111
|
-
|
|
121
|
+
print('Error: Unsupported file format')
|
|
112
122
|
return
|
|
113
123
|
|
|
114
124
|
if num_invalid_rows > 0:
|
|
115
|
-
|
|
125
|
+
percent_ignored = round(num_invalid_rows / (num_valid_rows + num_invalid_rows) * 100)
|
|
126
|
+
print(f'{num_invalid_rows} invalid row(s) ignored ({percent_ignored}%)')
|
|
116
127
|
|
|
117
128
|
if not args.all_dates:
|
|
118
129
|
df = delete_old_data(df, args.start_date, args.stop_date)
|
|
119
130
|
|
|
120
|
-
generate_stats(df, filename)
|
|
131
|
+
generate_stats(df, filename, args.boxplot)
|
|
121
132
|
|
|
122
133
|
if file_format == 'plotair':
|
|
123
|
-
|
|
134
|
+
generate_plot(df, filename, args.title, suffix='cht',
|
|
135
|
+
series1='co2', series2='humidity', series3='temp',
|
|
136
|
+
filter_outliers=args.filter_outliers,
|
|
137
|
+
filter_multiplier=args.filter_multiplier)
|
|
124
138
|
elif file_format == 'visiblair_d':
|
|
125
|
-
|
|
139
|
+
generate_plot(df, filename, args.title, suffix='cht',
|
|
140
|
+
series1='co2', series2='humidity', series3='temp',
|
|
141
|
+
filter_outliers=args.filter_outliers,
|
|
142
|
+
filter_multiplier=args.filter_multiplier)
|
|
126
143
|
elif file_format == 'visiblair_e':
|
|
127
|
-
|
|
128
|
-
|
|
144
|
+
generate_plot(df, filename, args.title, suffix='cht',
|
|
145
|
+
series1='co2', series2='humidity', series3='temp',
|
|
146
|
+
filter_outliers=args.filter_outliers,
|
|
147
|
+
filter_multiplier=args.filter_multiplier)
|
|
148
|
+
generate_plot(df, filename, args.title, suffix='pm',
|
|
149
|
+
series1=None, series2='pm2.5', series3='pm10',
|
|
150
|
+
filter_outliers=args.filter_outliers,
|
|
151
|
+
filter_multiplier=args.filter_multiplier)
|
|
129
152
|
elif file_format == 'graywolf_ds':
|
|
130
|
-
|
|
131
|
-
|
|
153
|
+
generate_plot(df, filename, args.title, suffix='ht',
|
|
154
|
+
series1=None, series2='humidity', series3='temp',
|
|
155
|
+
filter_outliers=args.filter_outliers,
|
|
156
|
+
filter_multiplier=args.filter_multiplier)
|
|
157
|
+
generate_plot(df, filename, args.title, suffix='vcf',
|
|
158
|
+
series1='tvoc', series2='form', series3='co',
|
|
159
|
+
filter_outliers=args.filter_outliers,
|
|
160
|
+
filter_multiplier=args.filter_multiplier)
|
|
132
161
|
except Exception as e:
|
|
133
|
-
|
|
162
|
+
print(f'Error: Unexpected error: {e}')
|
|
134
163
|
|
|
135
164
|
|
|
136
165
|
def detect_file_format(filename):
|
|
@@ -139,11 +168,14 @@ def detect_file_format(filename):
|
|
|
139
168
|
visiblair_e_num_col = (21, 21)
|
|
140
169
|
graywolf_ds_num_col = (7, 7)
|
|
141
170
|
|
|
142
|
-
with
|
|
171
|
+
# Some files begin with the '\ufeff' character (Byte Order Mark / BOM).
|
|
172
|
+
# This breaks the first field detection. Use 'utf-8-sig' instead of 'utf-8'
|
|
173
|
+
# to automatically handle BOM.
|
|
174
|
+
with open(filename, 'r', newline='', encoding='utf-8-sig') as file:
|
|
143
175
|
reader = csv.reader(file)
|
|
144
176
|
first_line = next(reader)
|
|
145
177
|
num_fields = len(first_line)
|
|
146
|
-
|
|
178
|
+
|
|
147
179
|
if first_line[0] == 'date':
|
|
148
180
|
file_format = 'plotair'
|
|
149
181
|
elif visiblair_d_num_col[0] <= num_fields <= visiblair_d_num_col[1]:
|
|
@@ -175,6 +207,8 @@ def read_data(filename):
|
|
|
175
207
|
elif file_format == 'graywolf_ds':
|
|
176
208
|
df, num_valid_rows, num_invalid_rows = read_data_graywolf_ds(filename)
|
|
177
209
|
|
|
210
|
+
df = df.sort_index() # Sort in case some dates are not in order
|
|
211
|
+
|
|
178
212
|
return file_format, df, num_valid_rows, num_invalid_rows
|
|
179
213
|
|
|
180
214
|
|
|
@@ -189,7 +223,6 @@ def read_data_plotair(filename):
|
|
|
189
223
|
df['date'] = pd.to_datetime(df['date'], format='%Y-%m-%d %H:%M:%S')
|
|
190
224
|
|
|
191
225
|
df = df.set_index('date')
|
|
192
|
-
df = df.sort_index() # Sort in case some dates are not in order
|
|
193
226
|
num_valid_rows = len(df)
|
|
194
227
|
|
|
195
228
|
return df, num_valid_rows, num_invalid_rows
|
|
@@ -210,7 +243,7 @@ def read_data_visiblair_d(filename):
|
|
|
210
243
|
|
|
211
244
|
if not (5 <= len(fields) <= 6):
|
|
212
245
|
# Skip lines with an invalid number of columns
|
|
213
|
-
logger.debug(f'Skipping line (number of columns): {line}')
|
|
246
|
+
#logger.debug(f'Skipping line (number of columns): {line}')
|
|
214
247
|
num_invalid_rows += 1
|
|
215
248
|
continue
|
|
216
249
|
|
|
@@ -218,23 +251,22 @@ def read_data_visiblair_d(filename):
|
|
|
218
251
|
# Convert each field to its target data type
|
|
219
252
|
parsed_row = {
|
|
220
253
|
'date': pd.to_datetime(fields[0], format='%Y-%m-%d %H:%M:%S'),
|
|
221
|
-
'co2': np.uint16(fields[1]),
|
|
222
|
-
'
|
|
223
|
-
'humidity': np.uint8(fields[3])
|
|
254
|
+
'co2': np.uint16(fields[1]), # 0 to 10,000 ppm
|
|
255
|
+
'temp': np.float32(fields[2]), # -40 to 70 °C
|
|
256
|
+
'humidity': np.uint8(fields[3]) # 0 to 100% RH
|
|
224
257
|
}
|
|
225
258
|
# If conversion succeeds, add the parsed row to the list
|
|
226
259
|
valid_rows.append(parsed_row)
|
|
227
260
|
|
|
228
261
|
except (ValueError, TypeError) as e:
|
|
229
262
|
# Skip lines with conversion errors
|
|
230
|
-
logger.debug(f'Skipping line (conversion error): {line}')
|
|
263
|
+
#logger.debug(f'Skipping line (conversion error): {line}')
|
|
231
264
|
num_invalid_rows += 1
|
|
232
265
|
continue
|
|
233
266
|
|
|
234
267
|
# Create the DataFrame from the valid rows
|
|
235
268
|
df = pd.DataFrame(valid_rows)
|
|
236
269
|
df = df.set_index('date')
|
|
237
|
-
df = df.sort_index() # Sort in case some dates are not in order
|
|
238
270
|
num_valid_rows = len(df)
|
|
239
271
|
|
|
240
272
|
return df, num_valid_rows, num_invalid_rows
|
|
@@ -248,7 +280,7 @@ def read_data_visiblair_e(filename):
|
|
|
248
280
|
df = pd.read_csv(filename)
|
|
249
281
|
|
|
250
282
|
# Rename the columns
|
|
251
|
-
df.columns = ['uuid', 'date', 'co2', 'humidity', '
|
|
283
|
+
df.columns = ['uuid', 'date', 'co2', 'humidity', 'temp', 'pm0.1',
|
|
252
284
|
'pm0.3', 'pm0.5', 'pm1', 'pm2.5', 'pm5', 'pm10', 'pressure',
|
|
253
285
|
'voc_index', 'firmware', 'model', 'pcb', 'display_rate',
|
|
254
286
|
'is_charging', 'is_ac_in', 'batt_voltage']
|
|
@@ -257,7 +289,6 @@ def read_data_visiblair_e(filename):
|
|
|
257
289
|
df['date'] = pd.to_datetime(df['date'], format='%Y-%m-%d %H:%M:%S')
|
|
258
290
|
|
|
259
291
|
df = df.set_index('date')
|
|
260
|
-
df = df.sort_index() # Sort in case some dates are not in order
|
|
261
292
|
num_valid_rows = len(df)
|
|
262
293
|
|
|
263
294
|
return df, num_valid_rows, num_invalid_rows
|
|
@@ -271,16 +302,15 @@ def read_data_graywolf_ds(filename):
|
|
|
271
302
|
df = pd.read_csv(filename)
|
|
272
303
|
|
|
273
304
|
# Rename the columns
|
|
274
|
-
df.columns = ['date', 'tvoc', 'co', 'form', 'humidity', '
|
|
305
|
+
df.columns = ['date', 'tvoc', 'co', 'form', 'humidity', 'temp', 'filename']
|
|
275
306
|
|
|
276
307
|
# Convert the 'date' column to pandas datetime objects
|
|
277
308
|
df['date'] = pd.to_datetime(df['date'], format='%d-%b-%y %I:%M:%S %p')
|
|
278
309
|
|
|
279
310
|
# Convert 'form' column to string, replace '< LOD' with '0', and then convert to integer
|
|
280
|
-
df['form'] = df['form'].astype(str).str.replace('< LOD', '
|
|
311
|
+
df['form'] = df['form'].astype(str).str.replace('< LOD', '10').astype(int)
|
|
281
312
|
|
|
282
313
|
df = df.set_index('date')
|
|
283
|
-
df = df.sort_index() # Sort in case some dates are not in order
|
|
284
314
|
num_valid_rows = len(df)
|
|
285
315
|
|
|
286
316
|
return df, num_valid_rows, num_invalid_rows
|
|
@@ -321,10 +351,29 @@ def delete_old_data(df, start_date = None, stop_date = None):
|
|
|
321
351
|
return df
|
|
322
352
|
|
|
323
353
|
|
|
324
|
-
|
|
354
|
+
class DataSeries:
|
|
355
|
+
def __init__(self, name=''):
|
|
356
|
+
# y_range could be replaced by y_min and y_max
|
|
357
|
+
self.name = name
|
|
358
|
+
self.label = CONFIG['labels'].get(self.name)
|
|
359
|
+
self.color = CONFIG['colors'].get(self.name)
|
|
360
|
+
self.y_range = CONFIG['axis_ranges'].get(self.name) # min/max tuple, e.g. (0, 100)
|
|
361
|
+
self.limit = CONFIG['limits'].get(self.name) # single value or min/max tuple
|
|
362
|
+
self.limit_label = CONFIG['labels'].get(self.name + '_limit')
|
|
363
|
+
self.linestyle = CONFIG['plot'].get(self.name + '_line_style')
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def generate_plot(df, filename, title, suffix='',
|
|
367
|
+
series1=None, series2=None, series3=None,
|
|
368
|
+
filter_outliers=False, filter_multiplier=None):
|
|
325
369
|
# The dates must be in a non-index column
|
|
326
370
|
df = df.reset_index()
|
|
327
|
-
|
|
371
|
+
|
|
372
|
+
# Get each series configuration
|
|
373
|
+
ds1 = DataSeries(name=series1) if series1 else None
|
|
374
|
+
ds2 = DataSeries(name=series2) if series2 else None
|
|
375
|
+
ds3 = DataSeries(name=series3) if series3 else None
|
|
376
|
+
|
|
328
377
|
# Set a theme and scale all fonts
|
|
329
378
|
sns.set_theme(style='whitegrid', font_scale=CONFIG['plot']['font_scale'])
|
|
330
379
|
|
|
@@ -335,96 +384,106 @@ def generate_plot_co2_hum_tmp(df, filename, title):
|
|
|
335
384
|
fig, ax1 = plt.subplots(figsize=CONFIG['plot']['size'])
|
|
336
385
|
ax2 = ax1.twinx() # Secondary y axis
|
|
337
386
|
|
|
338
|
-
#
|
|
339
|
-
sns.lineplot(data=df, x='date', y='co2', ax=ax1, color=CONFIG['colors']['co2'],
|
|
340
|
-
label=CONFIG['labels']['co2'], legend=False)
|
|
341
|
-
sns.lineplot(data=df, x='date', y='humidity', ax=ax2, color=CONFIG['colors']['humidity'],
|
|
342
|
-
label=CONFIG['labels']['humidity'], legend=False)
|
|
343
|
-
sns.lineplot(data=df, x='date', y='temperature', ax=ax2, color=CONFIG['colors']['temp'],
|
|
344
|
-
label=CONFIG['labels']['temp'], legend=False)
|
|
345
|
-
|
|
346
|
-
# Set the ranges for both y axes
|
|
347
|
-
cmin, cmax = CONFIG['axis_ranges']['co2']
|
|
348
|
-
tmin, tmax = CONFIG['axis_ranges']['temp_h']
|
|
349
|
-
ax1.set_ylim(cmin, cmax) # df['co2'].max() * 1.05
|
|
350
|
-
ax2.set_ylim(tmin, tmax)
|
|
351
|
-
|
|
352
|
-
# Add a grid for the x axis and the y axes
|
|
353
|
-
# This is already done if using the whitegrid theme
|
|
354
|
-
#ax1.grid(axis='x', alpha=CONFIG['plot']['grid_opacity'])
|
|
355
|
-
#ax1.grid(axis='y', alpha=CONFIG['plot']['grid_opacity'])
|
|
356
|
-
ax2.grid(axis='y', alpha=CONFIG['plot']['grid2_opacity'], linestyle=CONFIG['plot']['grid2_line_style'])
|
|
357
|
-
|
|
358
|
-
# Set the background color of the humidity comfort zone
|
|
359
|
-
hmin, hmax = CONFIG['limits']['humidity']
|
|
360
|
-
ax2.axhspan(ymin=hmin, ymax=hmax,
|
|
361
|
-
facecolor=CONFIG['colors']['humidity'], alpha=CONFIG['plot']['limit_zone_opacity'])
|
|
362
|
-
|
|
363
|
-
# Customize the plot title, labels and ticks
|
|
364
|
-
ax1.set_title(get_plot_title(title, filename))
|
|
365
|
-
ax1.tick_params(axis='x', rotation=CONFIG['plot']['date_rotation'])
|
|
366
|
-
ax1.tick_params(axis='y', labelcolor=CONFIG['colors']['co2'])
|
|
367
|
-
ax1.set_xlabel('')
|
|
368
|
-
ax1.set_ylabel(CONFIG['labels']['co2'], color=CONFIG['colors']['co2'])
|
|
369
|
-
ax2.set_ylabel('') # We will manually place the 2 parts in different colors
|
|
370
|
-
|
|
371
|
-
# Define the position for the center of the right y axis label
|
|
372
|
-
bottom_label = CONFIG['labels']['temp'] + ' '
|
|
373
|
-
top_label = ' ' + CONFIG['labels']['humidity']
|
|
374
|
-
x = 1.07 # Slightly to the right of the axis
|
|
375
|
-
y = get_label_center(bottom_label, top_label) # Vertically centered
|
|
387
|
+
# TODO: add functions for repetitive code
|
|
376
388
|
|
|
377
|
-
#
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
389
|
+
# Plot series #1 main line
|
|
390
|
+
if ds1:
|
|
391
|
+
if ds1.linestyle:
|
|
392
|
+
linestyle = ds1.linestyle
|
|
393
|
+
else:
|
|
394
|
+
linestyle = CONFIG['plot']['default_line_style']
|
|
381
395
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
396
|
+
if filter_outliers:
|
|
397
|
+
df1 = remove_outliers_iqr(df, ds1.name, multiplier=filter_multiplier)
|
|
398
|
+
else:
|
|
399
|
+
df1 = df[df[ds1.name] != 0] # Only filter out zero values
|
|
400
|
+
|
|
401
|
+
sns.lineplot(data=df1, x='date', y=ds1.name, ax=ax1, color=ds1.color,
|
|
402
|
+
label=ds1.label, legend=False, linestyle=linestyle)
|
|
403
|
+
|
|
404
|
+
# Display series #1 limit line or zone
|
|
405
|
+
if ds1.limit and not isinstance(ds1.limit, list):
|
|
406
|
+
# Plot the limit line
|
|
407
|
+
line = ax1.axhline(y=ds1.limit, color=ds1.color, label=ds1.limit_label,
|
|
408
|
+
linestyle=CONFIG['plot']['limit_line_style'])
|
|
409
|
+
line.set_alpha(CONFIG['plot']['limit_line_opacity'])
|
|
410
|
+
|
|
411
|
+
if ds1.limit and isinstance(ds1.limit, list):
|
|
412
|
+
# Set the background color of the limit zone
|
|
413
|
+
hmin, hmax = ds1.limit
|
|
414
|
+
ax1.axhspan(ymin=hmin, ymax=hmax, facecolor=ds1.color,
|
|
415
|
+
alpha=CONFIG['plot']['limit_zone_opacity'])
|
|
416
|
+
|
|
417
|
+
# Plot series #2 main line
|
|
418
|
+
if ds2.linestyle:
|
|
419
|
+
linestyle = ds2.linestyle
|
|
420
|
+
else:
|
|
421
|
+
linestyle = CONFIG['plot']['default_line_style']
|
|
386
422
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
423
|
+
if filter_outliers:
|
|
424
|
+
df2 = remove_outliers_iqr(df, ds2.name, multiplier=filter_multiplier)
|
|
425
|
+
else:
|
|
426
|
+
df2 = df[df[ds2.name] != 0] # Only filter out zero values
|
|
427
|
+
|
|
428
|
+
sns.lineplot(data=df2, x='date', y=ds2.name, ax=ax2, color=ds2.color,
|
|
429
|
+
label=ds2.label, legend=False, linestyle=linestyle)
|
|
430
|
+
|
|
431
|
+
# Display series #2 limit line or zone
|
|
432
|
+
if ds2.limit and not isinstance(ds2.limit, list):
|
|
433
|
+
# Plot the limit line
|
|
434
|
+
line = ax2.axhline(y=ds2.limit, color=ds2.color, label=ds2.limit_label,
|
|
435
|
+
linestyle=CONFIG['plot']['limit_line_style'])
|
|
436
|
+
line.set_alpha(CONFIG['plot']['limit_line_opacity'])
|
|
437
|
+
|
|
438
|
+
if ds2.limit and isinstance(ds2.limit, list):
|
|
439
|
+
# Set the background color of the limit zone
|
|
440
|
+
hmin, hmax = ds2.limit
|
|
441
|
+
ax2.axhspan(ymin=hmin, ymax=hmax, facecolor=ds2.color,
|
|
442
|
+
alpha=CONFIG['plot']['limit_zone_opacity'])
|
|
443
|
+
|
|
444
|
+
# Plot series #3 main line
|
|
445
|
+
if ds3.linestyle:
|
|
446
|
+
linestyle = ds3.linestyle
|
|
447
|
+
else:
|
|
448
|
+
linestyle = CONFIG['plot']['default_line_style']
|
|
392
449
|
|
|
393
|
-
#
|
|
394
|
-
|
|
450
|
+
# TODO: Do we still want to scale the CO data series?
|
|
451
|
+
#co_scale = 10
|
|
452
|
+
#df['co_scaled'] = df['co'] * co_scale
|
|
395
453
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
454
|
+
if filter_outliers:
|
|
455
|
+
df3 = remove_outliers_iqr(df, ds3.name, multiplier=filter_multiplier)
|
|
456
|
+
else:
|
|
457
|
+
df3 = df[df[ds3.name] != 0] # Only filter out zero values
|
|
399
458
|
|
|
459
|
+
sns.lineplot(data=df3, x='date', y=ds3.name, ax=ax2, color=ds3.color,
|
|
460
|
+
label=ds3.label, legend=False, linestyle=linestyle)
|
|
400
461
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
462
|
+
# Plot series #3 limit line
|
|
463
|
+
if ds3.limit and not isinstance(ds3.limit, list):
|
|
464
|
+
# Plot the limit line
|
|
465
|
+
line = ax2.axhline(y=ds3.limit, color=ds3.color, label=ds3.limit_label,
|
|
466
|
+
linestyle=CONFIG['plot']['limit_line_style'])
|
|
467
|
+
line.set_alpha(CONFIG['plot']['limit_line_opacity'])
|
|
404
468
|
|
|
405
|
-
|
|
406
|
-
|
|
469
|
+
if ds3.limit and isinstance(ds3.limit, list):
|
|
470
|
+
# Set the background color of the limit zone
|
|
471
|
+
hmin, hmax = ds3.limit
|
|
472
|
+
ax2.axhspan(ymin=hmin, ymax=hmax, facecolor=ds3.color,
|
|
473
|
+
alpha=CONFIG['plot']['limit_zone_opacity'])
|
|
407
474
|
|
|
408
|
-
|
|
409
|
-
if
|
|
475
|
+
# Set the ranges for both y axes
|
|
476
|
+
if ds1:
|
|
477
|
+
y1min, y1max = ds1.y_range
|
|
478
|
+
ax1.set_ylim(y1min, y1max)
|
|
410
479
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
ax2 = ax1.twinx() # Secondary y axis
|
|
480
|
+
y2min, y2max = ds2.y_range
|
|
481
|
+
y3min, y3max = ds3.y_range
|
|
414
482
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
# label=CONFIG['labels']['co2'], legend=False)
|
|
418
|
-
sns.lineplot(data=df, x='date', y='humidity', ax=ax2, color=CONFIG['colors']['humidity'],
|
|
419
|
-
label=CONFIG['labels']['humidity'], legend=False)
|
|
420
|
-
sns.lineplot(data=df, x='date', y='temperature', ax=ax2, color=CONFIG['colors']['temp'],
|
|
421
|
-
label=CONFIG['labels']['temp'], legend=False)
|
|
483
|
+
if y2min != y3min or y2max != y3max:
|
|
484
|
+
print(f'Warning: Axis ranges differ for {series2} and {series3}, using largest range')
|
|
422
485
|
|
|
423
|
-
|
|
424
|
-
cmin, cmax = CONFIG['axis_ranges']['co2']
|
|
425
|
-
tmin, tmax = CONFIG['axis_ranges']['temp_h']
|
|
426
|
-
ax1.set_ylim(cmin, cmax) # df['co2'].max() * 1.05
|
|
427
|
-
ax2.set_ylim(tmin, tmax)
|
|
486
|
+
ax2.set_ylim(min(y2min, y3min), max(y2max, y3max))
|
|
428
487
|
|
|
429
488
|
# Add a grid for the x axis and the y axes
|
|
430
489
|
# This is already done if using the whitegrid theme
|
|
@@ -432,271 +491,122 @@ def generate_plot_hum_tmp(df, filename, title):
|
|
|
432
491
|
#ax1.grid(axis='y', alpha=CONFIG['plot']['grid_opacity'])
|
|
433
492
|
ax2.grid(axis='y', alpha=CONFIG['plot']['grid2_opacity'], linestyle=CONFIG['plot']['grid2_line_style'])
|
|
434
493
|
|
|
435
|
-
# Set the background color of the humidity comfort zone
|
|
436
|
-
hmin, hmax = CONFIG['limits']['humidity']
|
|
437
|
-
ax2.axhspan(ymin=hmin, ymax=hmax,
|
|
438
|
-
facecolor=CONFIG['colors']['humidity'], alpha=CONFIG['plot']['limit_zone_opacity'])
|
|
439
|
-
|
|
440
494
|
# Customize the plot title, labels and ticks
|
|
441
495
|
ax1.set_title(get_plot_title(title, filename))
|
|
442
496
|
ax1.tick_params(axis='x', rotation=CONFIG['plot']['date_rotation'])
|
|
443
|
-
|
|
497
|
+
if ds1:
|
|
498
|
+
ax1.tick_params(axis='y', labelcolor=ds1.color)
|
|
499
|
+
ax1.set_ylabel(ds1.label, color=ds1.color)
|
|
444
500
|
ax1.set_xlabel('')
|
|
445
|
-
#ax1.set_ylabel(CONFIG['labels']['co2'], color=CONFIG['colors']['co2'])
|
|
446
501
|
ax2.set_ylabel('') # We will manually place the 2 parts in different colors
|
|
447
502
|
|
|
448
503
|
# Define the position for the center of the right y axis label
|
|
449
|
-
bottom_label =
|
|
450
|
-
top_label = ' ' +
|
|
504
|
+
bottom_label = ds3.label + ' '
|
|
505
|
+
top_label = ' ' + ds2.label
|
|
451
506
|
x = 1.07 # Slightly to the right of the axis
|
|
452
507
|
y = get_label_center(bottom_label, top_label) # Vertically centered
|
|
453
508
|
|
|
454
509
|
# Place the first (bottom) part of the label
|
|
455
510
|
ax2.text(x, y, bottom_label, transform=ax2.transAxes,
|
|
456
|
-
color=
|
|
511
|
+
color=ds3.color, rotation='vertical',
|
|
457
512
|
ha='center', va='top')
|
|
458
513
|
|
|
459
514
|
# Place the second (top) part of the label
|
|
460
515
|
ax2.text(x, y, top_label, transform=ax2.transAxes,
|
|
461
|
-
color=
|
|
516
|
+
color=ds2.color, rotation='vertical',
|
|
462
517
|
ha='center', va='bottom')
|
|
463
518
|
|
|
464
519
|
# Create a combined legend
|
|
465
520
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
|
466
521
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
|
522
|
+
labels1 = remove_units_from_labels(labels1)
|
|
523
|
+
labels2 = remove_units_from_labels(labels2)
|
|
467
524
|
ax1.legend(lines1 + lines2, labels1 + labels2,
|
|
468
525
|
loc=CONFIG['plot']['legend_location'])
|
|
469
526
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
527
|
+
if not ds1:
|
|
528
|
+
# Remove the left y-axis elements from ax1
|
|
529
|
+
ax1.grid(axis='y', visible=False)
|
|
530
|
+
ax1.spines['left'].set_visible(False)
|
|
531
|
+
ax1.tick_params(axis='y', left=False, labelleft=False)
|
|
474
532
|
|
|
475
533
|
# Adjust the plot margins to make room for the labels
|
|
476
534
|
plt.tight_layout()
|
|
477
535
|
|
|
478
536
|
# Save the plot as a PNG image
|
|
479
|
-
|
|
537
|
+
# TODO: auto build the plot suffix from the 1st char of each series?
|
|
538
|
+
plt.savefig(get_plot_filename(filename, f'-{suffix}'))
|
|
480
539
|
plt.close()
|
|
481
540
|
|
|
482
541
|
|
|
483
|
-
def
|
|
484
|
-
|
|
485
|
-
df = df.reset_index()
|
|
486
|
-
|
|
487
|
-
# Set a theme and scale all fonts
|
|
488
|
-
sns.set_theme(style='whitegrid', font_scale=CONFIG['plot']['font_scale'])
|
|
489
|
-
|
|
490
|
-
ff = CONFIG['plot']['font_family']
|
|
491
|
-
if ff != '': plt.rcParams['font.family'] = ff
|
|
492
|
-
|
|
493
|
-
# Set up the matplotlib figure and axes
|
|
494
|
-
fig, ax1 = plt.subplots(figsize=CONFIG['plot']['size'])
|
|
495
|
-
ax2 = ax1.twinx() # Secondary y axis
|
|
496
|
-
|
|
497
|
-
# Plot the TVOC main line
|
|
498
|
-
sns.lineplot(data=df, x='date', y='tvoc', ax=ax1, legend=False,
|
|
499
|
-
color=CONFIG['colors']['tvoc'], label=CONFIG['labels']['tvoc'])
|
|
500
|
-
|
|
501
|
-
# Plot the TVOC limit line
|
|
502
|
-
line = ax1.axhline(y=CONFIG['limits']['tvoc'], color=CONFIG['colors']['tvoc'],
|
|
503
|
-
linestyle=CONFIG['plot']['limit_line_style'], linewidth=CONFIG['plot']['limit_line_width'],
|
|
504
|
-
label=CONFIG['labels']['tvoc_limit'])
|
|
505
|
-
line.set_alpha(CONFIG['plot']['limit_line_opacity'])
|
|
506
|
-
|
|
507
|
-
# Plot the formaldehyde main line
|
|
508
|
-
df_filtered = df[df['form'] != 0] # Filter out rows where 'form' is zero
|
|
509
|
-
sns.lineplot(data=df_filtered, x='date', y='form', ax=ax2, legend=False,
|
|
510
|
-
color=CONFIG['colors']['form'], label=CONFIG['labels']['form'])
|
|
511
|
-
|
|
512
|
-
# Plot the formaldehyde limit line
|
|
513
|
-
line = ax2.axhline(y=CONFIG['limits']['form'], color=CONFIG['colors']['form'],
|
|
514
|
-
linestyle=CONFIG['plot']['limit_line_style'], linewidth=CONFIG['plot']['limit_line_width'],
|
|
515
|
-
label=CONFIG['labels']['form_limit'])
|
|
516
|
-
line.set_alpha(CONFIG['plot']['limit_line_opacity'])
|
|
517
|
-
|
|
518
|
-
# Plot the CO main line
|
|
519
|
-
co_scale = 10
|
|
520
|
-
df['co_scaled'] = df['co'] * co_scale
|
|
521
|
-
sns.lineplot(data=df, x='date', y='co_scaled', ax=ax2, legend=False,
|
|
522
|
-
color=CONFIG['colors']['co'], label=CONFIG['labels']['co'])
|
|
523
|
-
|
|
524
|
-
# Plot the CO limit line
|
|
525
|
-
line = ax2.axhline(y=CONFIG['limits']['co'] * co_scale, color=CONFIG['colors']['co'],
|
|
526
|
-
linestyle=CONFIG['plot']['limit_line_style'], linewidth=CONFIG['plot']['limit_line_width'],
|
|
527
|
-
label=CONFIG['labels']['co_limit'])
|
|
528
|
-
line.set_alpha(CONFIG['plot']['limit_line_opacity'])
|
|
542
|
+
def remove_units_from_labels(labels):
|
|
543
|
+
return [re.sub(r' \([^)]*\)', '', label) for label in labels]
|
|
529
544
|
|
|
530
|
-
# Set the ranges for both y axes
|
|
531
|
-
tmin, tmax = CONFIG['axis_ranges']['tvoc']
|
|
532
|
-
cmin, cmax = CONFIG['axis_ranges']['co_form']
|
|
533
|
-
ax1.set_ylim(tmin, tmax)
|
|
534
|
-
ax2.set_ylim(cmin, cmax)
|
|
535
545
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
x = 1.07 # Slightly to the right of the axis
|
|
554
|
-
y = get_label_center(bottom_label, top_label) # Vertically centered
|
|
555
|
-
|
|
556
|
-
# Place the first (bottom) part of the label
|
|
557
|
-
ax2.text(x, y, bottom_label, transform=ax2.transAxes,
|
|
558
|
-
color=CONFIG['colors']['co'], rotation='vertical',
|
|
559
|
-
ha='center', va='top')
|
|
560
|
-
|
|
561
|
-
# Place the second (top) part of the label
|
|
562
|
-
ax2.text(x, y, top_label, transform=ax2.transAxes,
|
|
563
|
-
color=CONFIG['colors']['form'], rotation='vertical',
|
|
564
|
-
ha='center', va='bottom')
|
|
565
|
-
|
|
566
|
-
# Create a combined legend
|
|
567
|
-
lines1, labels1 = ax1.get_legend_handles_labels()
|
|
568
|
-
lines2, labels2 = ax2.get_legend_handles_labels()
|
|
569
|
-
ax1.legend(lines1 + lines2, labels1 + labels2,
|
|
570
|
-
loc=CONFIG['plot']['legend_location'])
|
|
571
|
-
|
|
572
|
-
# Adjust the plot margins to make room for the labels
|
|
573
|
-
plt.tight_layout()
|
|
574
|
-
|
|
575
|
-
# Save the plot as a PNG image
|
|
576
|
-
plt.savefig(get_plot_filename(filename, '-vcf'))
|
|
577
|
-
plt.close()
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
def generate_plot_pm(df, filename, title):
|
|
581
|
-
# The dates must be in a non-index column
|
|
582
|
-
df = df.reset_index()
|
|
583
|
-
|
|
584
|
-
# Set a theme and scale all fonts
|
|
585
|
-
sns.set_theme(style='whitegrid', font_scale=CONFIG['plot']['font_scale'])
|
|
586
|
-
|
|
587
|
-
ff = CONFIG['plot']['font_family']
|
|
588
|
-
if ff != '': plt.rcParams['font.family'] = ff
|
|
589
|
-
|
|
590
|
-
# Set up the matplotlib figure and axes
|
|
591
|
-
fig, ax1 = plt.subplots(figsize=CONFIG['plot']['size'])
|
|
592
|
-
ax2 = ax1.twinx() # Secondary y axis
|
|
593
|
-
|
|
594
|
-
#sns.lineplot(data=df, x='date', y='pm0.1', ax=ax1, color=CONFIG['colors']['pm0.1'],
|
|
595
|
-
# label=CONFIG['labels']['pm0.1'], legend=False)
|
|
596
|
-
|
|
597
|
-
# Plot the PM2.5 main line
|
|
598
|
-
sns.lineplot(data=df, x='date', y='pm2.5', ax=ax2, legend=False,
|
|
599
|
-
color=CONFIG['colors']['pm2.5'],
|
|
600
|
-
label=CONFIG['labels']['pm2.5'],
|
|
601
|
-
linewidth=CONFIG['plot']['pm2.5_line_width'],
|
|
602
|
-
linestyle=CONFIG['plot']['pm2.5_line_style'])
|
|
603
|
-
|
|
604
|
-
# Plot the PM2.5 limit line
|
|
605
|
-
line = ax2.axhline(y=CONFIG['limits']['pm2.5'],
|
|
606
|
-
color=CONFIG['colors']['pm2.5'],
|
|
607
|
-
label=CONFIG['labels']['pm2.5_limit'],
|
|
608
|
-
linewidth=CONFIG['plot']['limit_line_width'],
|
|
609
|
-
linestyle=CONFIG['plot']['limit_line_style'])
|
|
610
|
-
line.set_alpha(CONFIG['plot']['limit_line_opacity'])
|
|
611
|
-
|
|
612
|
-
# Plot the PM10 main line
|
|
613
|
-
sns.lineplot(data=df, x='date', y='pm10', ax=ax2, legend=False,
|
|
614
|
-
color=CONFIG['colors']['pm10'],
|
|
615
|
-
label=CONFIG['labels']['pm10'],
|
|
616
|
-
linewidth=CONFIG['plot']['pm10_line_width'],
|
|
617
|
-
linestyle=CONFIG['plot']['pm10_line_style'])
|
|
618
|
-
|
|
619
|
-
# Plot the PM10 limit line
|
|
620
|
-
line = ax2.axhline(y=CONFIG['limits']['pm10'],
|
|
621
|
-
color=CONFIG['colors']['pm10'],
|
|
622
|
-
label=CONFIG['labels']['pm10_limit'],
|
|
623
|
-
linewidth=CONFIG['plot']['limit_line_width'],
|
|
624
|
-
linestyle=CONFIG['plot']['limit_line_style'])
|
|
625
|
-
line.set_alpha(CONFIG['plot']['limit_line_opacity'])
|
|
626
|
-
|
|
627
|
-
# Set the ranges for both y axes
|
|
628
|
-
#min1, max1 = CONFIG['axis_ranges']['pm0.1']
|
|
629
|
-
min2, max2 = CONFIG['axis_ranges']['pm2.5_10']
|
|
630
|
-
#ax1.set_ylim(min1, max1) # df['co2'].max() * 1.05
|
|
631
|
-
ax2.set_ylim(min2, max2)
|
|
632
|
-
|
|
633
|
-
# Add a grid for the x axis and the y axes
|
|
634
|
-
# This is already done if using the whitegrid theme
|
|
635
|
-
#ax1.grid(axis='x', alpha=CONFIG['plot']['grid_opacity'])
|
|
636
|
-
#ax1.grid(axis='y', alpha=CONFIG['plot']['grid_opacity'])
|
|
637
|
-
ax2.grid(axis='y', alpha=CONFIG['plot']['grid2_opacity'], linestyle=CONFIG['plot']['grid1_line_style'])
|
|
638
|
-
|
|
639
|
-
# Customize the plot title, labels and ticks
|
|
640
|
-
ax1.set_title(get_plot_title(title, filename))
|
|
641
|
-
ax1.tick_params(axis='x', rotation=CONFIG['plot']['date_rotation'])
|
|
642
|
-
#ax1.tick_params(axis='y', labelcolor=CONFIG['colors']['pm0.1'])
|
|
643
|
-
ax1.set_xlabel('')
|
|
644
|
-
#ax1.set_ylabel(CONFIG['labels']['pm0.1'], color=CONFIG['colors']['pm0.1'])
|
|
645
|
-
ax2.set_ylabel('') # We will manually place the 2 parts in different colors
|
|
646
|
-
|
|
647
|
-
# Define the position for the center of the right y axis label
|
|
648
|
-
bottom_label = CONFIG['labels']['pm2.5'] + ' '
|
|
649
|
-
top_label = ' ' + CONFIG['labels']['pm10']
|
|
650
|
-
x = 1.07 # Slightly to the right of the axis
|
|
651
|
-
y = get_label_center(bottom_label, top_label) # Vertically centered
|
|
546
|
+
def remove_outliers_iqr(df, column, multiplier=None):
|
|
547
|
+
"""
|
|
548
|
+
Remove outliers using Interquartile Range (IQR) method
|
|
549
|
+
multiplier = 1.0: Tight bounds, more outliers removed
|
|
550
|
+
multiplier = 1.5: Standard bounds, moderate outliers removed
|
|
551
|
+
multiplier = 2.0: Wide bounds, fewer outliers removed
|
|
552
|
+
"""
|
|
553
|
+
if multiplier == None:
|
|
554
|
+
multiplier = 1.5 # Default value
|
|
555
|
+
|
|
556
|
+
Q1 = df[column].quantile(0.25)
|
|
557
|
+
Q3 = df[column].quantile(0.75)
|
|
558
|
+
IQR = Q3 - Q1
|
|
559
|
+
lower_bound = Q1 - multiplier * IQR
|
|
560
|
+
upper_bound = Q3 + multiplier * IQR
|
|
561
|
+
|
|
562
|
+
return df[(df[column] >= lower_bound) & (df[column] <= upper_bound)]
|
|
652
563
|
|
|
653
|
-
# Place the first (bottom) part of the label
|
|
654
|
-
ax2.text(x, y, bottom_label, transform=ax2.transAxes,
|
|
655
|
-
color=CONFIG['colors']['pm2.5'], rotation='vertical',
|
|
656
|
-
ha='center', va='top')
|
|
657
564
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
565
|
+
# WARNING: Untested function
|
|
566
|
+
def remove_outliers_zscore(df, column, threshold=3):
|
|
567
|
+
# from scipy import stats ?
|
|
568
|
+
z_scores = np.abs(stats.zscore(df[column]))
|
|
569
|
+
return df[z_scores < threshold]
|
|
662
570
|
|
|
663
|
-
# Create a combined legend
|
|
664
|
-
lines1, labels1 = ax1.get_legend_handles_labels()
|
|
665
|
-
lines2, labels2 = ax2.get_legend_handles_labels()
|
|
666
|
-
ax1.legend(lines1 + lines2, labels1 + labels2,
|
|
667
|
-
loc=CONFIG['plot']['legend_location'])
|
|
668
571
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
572
|
+
# WARNING: Untested function
|
|
573
|
+
def remove_outliers_std(df, column, n_std=2):
|
|
574
|
+
mean = df[column].mean()
|
|
575
|
+
std = df[column].std()
|
|
576
|
+
lower_bound = mean - n_std * std
|
|
577
|
+
upper_bound = mean + n_std * std
|
|
578
|
+
|
|
579
|
+
return df[(df[column] >= lower_bound) & (df[column] <= upper_bound)]
|
|
673
580
|
|
|
674
|
-
# Adjust the plot margins to make room for the labels
|
|
675
|
-
plt.tight_layout()
|
|
676
581
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
582
|
+
# WARNING: Untested function
|
|
583
|
+
def remove_outliers_percentile(df, column, lower_percentile=5, upper_percentile=95):
|
|
584
|
+
lower_bound = df[column].quantile(lower_percentile/100)
|
|
585
|
+
upper_bound = df[column].quantile(upper_percentile/100)
|
|
586
|
+
|
|
587
|
+
return df[(df[column] >= lower_bound) & (df[column] <= upper_bound)]
|
|
680
588
|
|
|
681
589
|
|
|
682
590
|
def get_label_center(bottom_label, top_label):
|
|
683
591
|
# Return a value between 0 and 1 to estimate where to center the label
|
|
592
|
+
# Divider optimized for 11x8.5 plot size, but not as good for 15x10
|
|
684
593
|
fs = CONFIG['plot']['font_scale']
|
|
685
594
|
divider = 72 * fs**2 - 316 * fs + 414 # Tested for fs between 0.8 and 2
|
|
686
595
|
center = 0.5 + ((len(bottom_label) - len(top_label)) / divider)
|
|
687
596
|
return center
|
|
688
597
|
|
|
689
598
|
|
|
690
|
-
def generate_stats(df, filename):
|
|
599
|
+
def generate_stats(df, filename, boxplot=False):
|
|
691
600
|
summary = df.describe()
|
|
692
601
|
|
|
693
602
|
with open(get_stats_filename(filename), 'w') as file:
|
|
694
603
|
file.write(summary.to_string())
|
|
695
604
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
605
|
+
if boxplot:
|
|
606
|
+
for column in summary.columns.tolist():
|
|
607
|
+
box = sns.boxplot(data=df, y=column)
|
|
608
|
+
plt.savefig(get_boxplot_filename(filename, f'-{column}'))
|
|
609
|
+
plt.close()
|
|
700
610
|
|
|
701
611
|
|
|
702
612
|
def load_config(reset_config = False):
|
|
@@ -762,9 +672,9 @@ def get_plot_filename(filename, suffix = ''):
|
|
|
762
672
|
return f'{p.parent}/{p.stem}{suffix}.png'
|
|
763
673
|
|
|
764
674
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
675
|
+
def get_boxplot_filename(filename, suffix = ''):
|
|
676
|
+
p = Path(filename)
|
|
677
|
+
return f'{p.parent}/{p.stem}-boxplot{suffix}.png'
|
|
768
678
|
|
|
769
679
|
|
|
770
680
|
def get_stats_filename(filename):
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plotair
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Generate CO₂, humidity and temperature plots from VisiblAir sensor CSV files.
|
|
5
|
+
Project-URL: Repository, https://github.com/monsieurlinux/plotair
|
|
6
|
+
Author-email: Monsieur Linux <info@mlinux.ca>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: cli,data-analysis,data-science,iot,monitoring,python,terminal,visualization
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Requires-Dist: pandas<3.0.0,>=2.0.0
|
|
14
|
+
Requires-Dist: seaborn<1.0.0,>=0.13.2
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
# PlotAir
|
|
20
|
+
|
|
21
|
+
[![PyPI][pypi-badge]][pypi-link]
|
|
22
|
+
[![License][license-badge]][license-link]
|
|
23
|
+
|
|
24
|
+
PlotAir is a Python script that processes one or more CSV files containing [VisiblAir][visiblair-link] sensor data. For each file, it reads the data into a [pandas][pandas-link] DataFrame, ignores incorrectly formatted lines, keeps only the most recent data sequence, and generates a [Seaborn][seaborn-link] plot saved as a PNG file with the same base name as the input CSV.
|
|
25
|
+
|
|
26
|
+
## Dependencies
|
|
27
|
+
|
|
28
|
+
PlotAir requires the following external libraries:
|
|
29
|
+
|
|
30
|
+
* **[pandas][pandas-link]**: Used for data manipulation and analysis.
|
|
31
|
+
* **[seaborn][seaborn-link]**: Used for creating plots.
|
|
32
|
+
|
|
33
|
+
These libraries and their sub-dependencies will be installed automatically when you install PlotAir.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
It is recommended to install PlotAir within a [virtual environment][venv-link] to avoid conflicts with system packages. Some Linux distributions enforce this. You can use `pipx` to handle the virtual environment automatically, or create one manually and use `pip`.
|
|
38
|
+
|
|
39
|
+
### Installation with `pipx`
|
|
40
|
+
|
|
41
|
+
`pipx` installs PlotAir in an isolated environment and makes it available globally.
|
|
42
|
+
|
|
43
|
+
**1. Install `pipx`:**
|
|
44
|
+
|
|
45
|
+
* **Linux (Debian / Ubuntu / Mint):**
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
sudo apt install pipx
|
|
49
|
+
pipx ensurepath
|
|
50
|
+
```
|
|
51
|
+
* **Linux (Other) / macOS:**
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python3 -m pip install --user pipx
|
|
55
|
+
python3 -m pipx ensurepath
|
|
56
|
+
```
|
|
57
|
+
* **Windows:**
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python -m pip install --user pipx
|
|
61
|
+
python -m pipx ensurepath
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
You may need to reopen your terminal for the PATH changes to take effect. If you encounter a problem, please refer to the official [pipx documentation][pipx-link].
|
|
65
|
+
|
|
66
|
+
**2. Install PlotAir:**
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pipx install plotair
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Installation with `pip`
|
|
73
|
+
|
|
74
|
+
If you prefer to manage the virtual environment manually, you can create and activate it by following this [tutorial][venv-link]. Then install PlotAir:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install plotair
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Deployments
|
|
81
|
+
|
|
82
|
+
View all releases on:
|
|
83
|
+
|
|
84
|
+
- **[PyPI Releases][pypi-releases]**
|
|
85
|
+
- **[GitHub Releases][github-releases]**
|
|
86
|
+
|
|
87
|
+
## Usage
|
|
88
|
+
|
|
89
|
+
### Basic Usage
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
plotair [arguments] FILE [FILE ...]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Command-Line Arguments
|
|
96
|
+
|
|
97
|
+
None for now.
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
When you run PlotAir for the first time, a `config.toml` file is automatically created. Its location depends on your operating system (typical paths are listed below):
|
|
102
|
+
|
|
103
|
+
* **Linux:** `~/.config/plotair`
|
|
104
|
+
* **macOS:** `~/Library/Preferences/plotair`
|
|
105
|
+
* **Windows:** `C:/Users/YourUsername/AppData/Roaming/plotair`
|
|
106
|
+
|
|
107
|
+
You can edit this file to customize various settings. Common customizations include translating plot labels into different languages or modifying the line colors.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
Copyright (c) 2026 Monsieur Linux
|
|
112
|
+
|
|
113
|
+
This project is licensed under the MIT License. See the LICENSE file for details.
|
|
114
|
+
|
|
115
|
+
## Acknowledgements
|
|
116
|
+
|
|
117
|
+
Thanks to the creators and contributors of the [pandas][pandas-link] and [seaborn][seaborn-link] libraries, and to the developer of the great [VisiblAir][visiblair-link] air quality monitors and CO₂ sensors. Thanks also to the founder of [Bâtiments vivants][batiments-link] for the idea of this script.
|
|
118
|
+
|
|
119
|
+
[batiments-link]: https://batimentsvivants.ca/
|
|
120
|
+
[github-releases]: https://github.com/monsieurlinux/plotair/releases
|
|
121
|
+
[license-badge]: https://img.shields.io/pypi/l/plotair.svg
|
|
122
|
+
[license-link]: https://github.com/monsieurlinux/plotair/blob/main/LICENSE
|
|
123
|
+
[pandas-link]: https://github.com/pandas-dev/pandas
|
|
124
|
+
[pipx-link]: https://github.com/pypa/pipx
|
|
125
|
+
[pypi-badge]: https://img.shields.io/pypi/v/plotair.svg
|
|
126
|
+
[pypi-link]: https://pypi.org/project/plotair/
|
|
127
|
+
[pypi-releases]: https://pypi.org/project/plotair/#history
|
|
128
|
+
[seaborn-link]: https://github.com/mwaskom/seaborn
|
|
129
|
+
[venv-link]: https://docs.python.org/3/tutorial/venv.html
|
|
130
|
+
[visiblair-link]: https://visiblair.com/
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
plotair/__init__.py,sha256=HfjVOrpTnmZ-xVFCYSVmX50EXaBQeJteUHG-PD6iQs8,22
|
|
2
|
+
plotair/config.toml,sha256=gj3qGI-LFWiXVIqKmlwLvabGjgLCOyj2iH7XLjn1AsA,1661
|
|
3
|
+
plotair/main.py,sha256=zU4dShx-D8ucIiFcuNBaM-JIsYNX-sDSXCNCh3MQd7w,26207
|
|
4
|
+
plotair-0.2.1.dist-info/METADATA,sha256=wZURX8-d41UmgLdMK3kzMm68nVfM9AKjUUBLZ7xGzLU,4526
|
|
5
|
+
plotair-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
+
plotair-0.2.1.dist-info/entry_points.txt,sha256=ekJAavHU_JAF9a66br_T4-Vni_OAyd7QX-tnVlsH8pY,46
|
|
7
|
+
plotair-0.2.1.dist-info/licenses/LICENSE,sha256=Lb4o6Virnt4fVuYjZ_QO4qrvRUJqHe0MCkqVr_lncJo,1071
|
|
8
|
+
plotair-0.2.1.dist-info/RECORD,,
|
plotair-0.2.0.dist-info/METADATA
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: plotair
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: Generate CO₂, humidity and temperature plots from VisiblAir sensor CSV files.
|
|
5
|
-
Project-URL: Repository, https://github.com/monsieurlinux/plotair
|
|
6
|
-
Author-email: Monsieur Linux <info@mlinux.ca>
|
|
7
|
-
License-Expression: MIT
|
|
8
|
-
License-File: LICENSE
|
|
9
|
-
Keywords: cli,data-analysis,data-science,iot,monitoring,python,terminal,visualization
|
|
10
|
-
Classifier: Operating System :: OS Independent
|
|
11
|
-
Classifier: Programming Language :: Python :: 3
|
|
12
|
-
Requires-Python: >=3.11
|
|
13
|
-
Requires-Dist: pandas<3.0.0,>=2.0.0
|
|
14
|
-
Requires-Dist: seaborn>=0.13.2
|
|
15
|
-
Description-Content-Type: text/markdown
|
|
16
|
-
|
|
17
|
-

|
|
18
|
-
|
|
19
|
-
# PlotAir
|
|
20
|
-
|
|
21
|
-
PlotAir is a Python script that processes one or more CSV files containing [VisiblAir](https://visiblair.com/) sensor data. For each file, it reads the data into a [pandas](https://github.com/pandas-dev/pandas) DataFrame, ignores incorrectly formatted lines, keeps only the most recent data sequence, and generates a [Seaborn](https://github.com/mwaskom/seaborn) plot saved as a PNG file with the same base name as the input CSV.
|
|
22
|
-
|
|
23
|
-
## Dependencies
|
|
24
|
-
|
|
25
|
-
PlotAir requires the following external libraries:
|
|
26
|
-
|
|
27
|
-
* **[pandas](https://github.com/pandas-dev/pandas)**: Used for data manipulation and analysis.
|
|
28
|
-
* **[seaborn](https://github.com/mwaskom/seaborn)**: Used for creating plots.
|
|
29
|
-
|
|
30
|
-
These libraries and their sub-dependencies will be installed automatically when you install PlotAir.
|
|
31
|
-
|
|
32
|
-
## Installation
|
|
33
|
-
|
|
34
|
-
It is recommended to install PlotAir within a [virtual environment](https://docs.python.org/3/tutorial/venv.html) to avoid conflicts with system packages. Some Linux distributions enforce this. You can use `pipx` to handle the virtual environment automatically, or create one manually and use `pip`.
|
|
35
|
-
|
|
36
|
-
### Installation with `pipx`
|
|
37
|
-
|
|
38
|
-
`pipx` installs PlotAir in an isolated environment and makes it available globally.
|
|
39
|
-
|
|
40
|
-
**1. Install `pipx`:**
|
|
41
|
-
|
|
42
|
-
* **Linux (Debian / Ubuntu / Mint):**
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
sudo apt install pipx
|
|
46
|
-
pipx ensurepath
|
|
47
|
-
```
|
|
48
|
-
* **Linux (Other) / macOS:**
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
python3 -m pip install --user pipx
|
|
52
|
-
python3 -m pipx ensurepath
|
|
53
|
-
```
|
|
54
|
-
* **Windows:**
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
python -m pip install --user pipx
|
|
58
|
-
python -m pipx ensurepath
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
You may need to close and restart your terminal for the PATH changes to take effect.
|
|
62
|
-
|
|
63
|
-
**2. Install PlotAir:**
|
|
64
|
-
|
|
65
|
-
```bash
|
|
66
|
-
pipx install plotair
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### Installation with `pip`
|
|
70
|
-
|
|
71
|
-
If you prefer to manage the virtual environment manually, you can create and activate it by following this [tutorial](https://docs.python.org/3/tutorial/venv.html). Then install PlotAir:
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
pip install plotair
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
## Usage
|
|
78
|
-
|
|
79
|
-
### Basic Usage
|
|
80
|
-
|
|
81
|
-
```bash
|
|
82
|
-
plotair [arguments] FILE [FILE ...]
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
### Command-Line Arguments
|
|
86
|
-
|
|
87
|
-
None for now.
|
|
88
|
-
|
|
89
|
-
## License
|
|
90
|
-
|
|
91
|
-
Copyright (c) 2026 Monsieur Linux
|
|
92
|
-
|
|
93
|
-
This project is licensed under the MIT License. See the LICENSE file for details.
|
|
94
|
-
|
|
95
|
-
## Acknowledgements
|
|
96
|
-
|
|
97
|
-
Thanks to the creators and contributors of the [pandas](https://github.com/pandas-dev/pandas) and [seaborn](https://github.com/mwaskom/seaborn) libraries, and to the developer of the great [VisiblAir](https://visiblair.com/) air quality monitors and CO₂ sensors. Thanks also to the founder of [Bâtiments vivants](https://batimentsvivants.ca/) for the idea of this script.
|
plotair-0.2.0.dist-info/RECORD
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
plotair/__init__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
|
|
2
|
-
plotair/config.toml,sha256=WYKZgfmpHNwERI3ua6X9aO4X1yv95JfOLOjKF3NT-SU,1668
|
|
3
|
-
plotair/main.py,sha256=G3QZSDMiX4mQUBpbMDVbw-juQ1eQa-15fetX1qt00QU,30631
|
|
4
|
-
plotair-0.2.0.dist-info/METADATA,sha256=Wtn1lbOmmRlnsuPc61GG4PaE3R81-thGtGQik6giDlE,3331
|
|
5
|
-
plotair-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
-
plotair-0.2.0.dist-info/entry_points.txt,sha256=ekJAavHU_JAF9a66br_T4-Vni_OAyd7QX-tnVlsH8pY,46
|
|
7
|
-
plotair-0.2.0.dist-info/licenses/LICENSE,sha256=Lb4o6Virnt4fVuYjZ_QO4qrvRUJqHe0MCkqVr_lncJo,1071
|
|
8
|
-
plotair-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|