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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.2.0"
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 = [11.0, 8.5]
5
+ size = [15, 10]
6
6
  font_family = 'Noto Sans' # Set to '' to use default system font
7
- font_scale = 1.4
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 = 'best'
21
+ legend_location = 'upper left'
24
22
 
25
23
  [axis_ranges]
26
24
  co2 = [0, 2000]
27
- temp_h = [0, 80]
25
+ humidity = [0, 80]
26
+ temp = [0, 80]
28
27
  tvoc = [0, 500]
29
- co_form = [0, 50]
30
- "pm2.5_10" = [0, 70]
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 x 10)'
39
- co_limit = 'Limite anomalie légère BBI'
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 = 4 # ppm
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
- logger.error(f'Failed to load config: {e}')
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
- logger.error('argument -m/--merge requires three file arguments')
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
- logger.error('At least one of the input files is unsupported')
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
- # Create a list containing all files from all patterns like '*.csv',
98
- # because under Windows the terminal doesn't expand wildcard arguments.
99
- all_files = []
100
- for pattern in args.filenames:
101
- all_files.extend(glob.glob(pattern))
102
-
103
- for filename in all_files:
104
- logger.info(f'Processing {filename}')
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
- logger.error('Unsupported file format')
121
+ print('Error: Unsupported file format')
112
122
  return
113
123
 
114
124
  if num_invalid_rows > 0:
115
- logger.info(f'{num_invalid_rows} invalid row(s) ignored')
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
- generate_plot_co2_hum_tmp(df, filename, args.title)
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
- generate_plot_co2_hum_tmp(df, filename, args.title)
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
- generate_plot_co2_hum_tmp(df, filename, args.title)
128
- generate_plot_pm(df, filename, args.title)
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
- generate_plot_hum_tmp(df, filename, args.title)
131
- generate_plot_voc_co_form(df, filename, args.title)
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
- logger.exception(f'Unexpected error: {e}')
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 open(filename, 'r', newline='', encoding='utf-8') as file:
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]), # 0 to 10,000 ppm
222
- 'temperature': np.float32(fields[2]), # -40 to 70 °C
223
- 'humidity': np.uint8(fields[3]) # 0 to 100% RH
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', 'temperature', 'pm0.1',
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', 'temperature', 'filename']
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', '0').astype(int)
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
- def generate_plot_co2_hum_tmp(df, filename, title):
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
- # Plot the data series
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
- # Place the first (bottom) part of the label
378
- ax2.text(x, y, bottom_label, transform=ax2.transAxes,
379
- color=CONFIG['colors']['temp'], rotation='vertical',
380
- ha='center', va='top')
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
- # Place the second (top) part of the label
383
- ax2.text(x, y, top_label, transform=ax2.transAxes,
384
- color=CONFIG['colors']['humidity'], rotation='vertical',
385
- ha='center', va='bottom')
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
- # Create a combined legend
388
- lines1, labels1 = ax1.get_legend_handles_labels()
389
- lines2, labels2 = ax2.get_legend_handles_labels()
390
- ax1.legend(lines1 + lines2, labels1 + labels2,
391
- loc=CONFIG['plot']['legend_location'])
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
- # Adjust the plot margins to make room for the labels
394
- plt.tight_layout()
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
- # Save the plot as a PNG image
397
- plt.savefig(get_plot_filename(filename, '-cht'))
398
- plt.close()
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
- def generate_plot_hum_tmp(df, filename, title):
402
- # The dates must be in a non-index column
403
- df = df.reset_index()
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
- # Set a theme and scale all fonts
406
- sns.set_theme(style='whitegrid', font_scale=CONFIG['plot']['font_scale'])
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
- ff = CONFIG['plot']['font_family']
409
- if ff != '': plt.rcParams['font.family'] = ff
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
- # Set up the matplotlib figure and axes
412
- fig, ax1 = plt.subplots(figsize=CONFIG['plot']['size'])
413
- ax2 = ax1.twinx() # Secondary y axis
480
+ y2min, y2max = ds2.y_range
481
+ y3min, y3max = ds3.y_range
414
482
 
415
- # Plot the data series
416
- #sns.lineplot(data=df, x='date', y='co2', ax=ax1, color=CONFIG['colors']['co2'],
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
- # Set the ranges for both y axes
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
- #ax1.tick_params(axis='y', labelcolor=CONFIG['colors']['co2'])
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 = CONFIG['labels']['temp'] + ' '
450
- top_label = ' ' + CONFIG['labels']['humidity']
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=CONFIG['colors']['temp'], rotation='vertical',
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=CONFIG['colors']['humidity'], rotation='vertical',
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
- # Remove the left y-axis elements from ax1
471
- ax1.grid(axis='y', visible=False)
472
- ax1.spines['left'].set_visible(False)
473
- ax1.tick_params(axis='y', left=False, labelleft=False)
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
- plt.savefig(get_plot_filename(filename, '-ht'))
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 generate_plot_voc_co_form(df, filename, title):
484
- # The dates must be in a non-index column
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
- # Add a grid for the x axis and the y axes
537
- # This is already done if using the whitegrid theme
538
- #ax1.grid(axis='x', alpha=CONFIG['plot']['grid_opacity'])
539
- #ax1.grid(axis='y', alpha=CONFIG['plot']['grid_opacity'])
540
- ax2.grid(axis='y', alpha=CONFIG['plot']['grid2_opacity'], linestyle=CONFIG['plot']['grid2_line_style'])
541
-
542
- # Customize the plot title, labels and ticks
543
- ax1.set_title(get_plot_title(title, filename))
544
- ax1.tick_params(axis='x', rotation=CONFIG['plot']['date_rotation'])
545
- ax1.tick_params(axis='y', labelcolor=CONFIG['colors']['tvoc'])
546
- ax1.set_xlabel('')
547
- ax1.set_ylabel(CONFIG['labels']['tvoc'], color=CONFIG['colors']['tvoc'])
548
- ax2.set_ylabel('') # We will manually place the 2 parts in different colors
549
-
550
- # Define the position for the center of the right y axis label
551
- bottom_label = CONFIG['labels']['co'] + ' '
552
- top_label = ' ' + CONFIG['labels']['form']
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
- # Place the second (top) part of the label
659
- ax2.text(x, y, top_label, transform=ax2.transAxes,
660
- color=CONFIG['colors']['pm10'], rotation='vertical',
661
- ha='center', va='bottom')
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
- # Remove the left y-axis elements from ax1
670
- ax1.grid(axis='y', visible=False)
671
- ax1.spines['left'].set_visible(False)
672
- ax1.tick_params(axis='y', left=False, labelleft=False)
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
- # Save the plot as a PNG image
678
- plt.savefig(get_plot_filename(filename, '-pm'))
679
- plt.close()
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
- #for column in summary.columns.tolist():
697
- # box = sns.boxplot(data=df, y=column)
698
- # plt.savefig(get_boxplot_filename(filename, f'-{column}'))
699
- # plt.close()
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
- #def get_boxplot_filename(filename, suffix = ''):
766
- # p = Path(filename)
767
- # return f'{p.parent}/{p.stem}-boxplot{suffix}.png'
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
+ ![Air quality plot](https://github.com/monsieurlinux/plotair/raw/main/img/cuisine.png)
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,,
@@ -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
- ![Air quality plot](https://github.com/monsieurlinux/plotair/raw/main/img/cuisine.png)
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.
@@ -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,,