plotair 0.1.1__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
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
plotair/config.toml ADDED
@@ -0,0 +1,67 @@
1
+ [data]
2
+ max_missing_samples = 4
3
+
4
+ [plot]
5
+ size = [15, 10]
6
+ font_family = 'Noto Sans' # Set to '' to use default system font
7
+ font_scale = 1.8
8
+ grid2_opacity = 0.7 # 0 is fully transparent and 1 is fully opaque
9
+ grid1_line_style = '-' # Options: '-', '--', ':' and '-.'
10
+ grid2_line_style = '--'
11
+ default_line_style = '-'
12
+ limit_zone_opacity = 0.075
13
+ limit_line_opacity = 0.7
14
+ limit_line_style = '--'
15
+ "pm2.5_line_style" = '-'
16
+ "pm10_line_style" = '-.'
17
+ date_rotation = 30
18
+
19
+ # Options: 'best', 'upper right', 'upper left', 'lower left', 'lower right',
20
+ # 'center left', 'center right', 'lower center', 'upper center', 'center'
21
+ legend_location = 'upper left'
22
+
23
+ [axis_ranges]
24
+ co2 = [0, 2000]
25
+ humidity = [0, 80]
26
+ temp = [0, 80]
27
+ tvoc = [0, 500]
28
+ co = [0, 50]
29
+ form = [0, 50]
30
+ "pm2.5" = [0, 70]
31
+ "pm10" = [0, 70]
32
+
33
+ [labels]
34
+ co2 = 'CO₂ (ppm)'
35
+ temp = 'Température (°C)'
36
+ humidity = 'Humidité (%)'
37
+ tvoc = 'COVT (ppb)'
38
+ tvoc_limit = 'Limite LEED, WELL et LBC'
39
+ co = 'Monoxyde de carbone (ppm)'
40
+ co_limit = 'Limite Environnement Canada'
41
+ form = 'Formaldéhyde (ppb)'
42
+ form_limit = 'Limite anomalie légère BBI'
43
+ "pm2.5" = 'PM2.5 (µg/m³)'
44
+ "pm2.5_limit" = 'Limite concentration moyenne annuelle PM2.5 OMS'
45
+ "pm10" = 'PM10 (µg/m³)'
46
+ "pm10_limit" = 'Limite concentration moyenne annuelle PM10 OMS'
47
+
48
+ [limits]
49
+ humidity = [40, 60]
50
+ tvoc = 218 # ppb
51
+ co = 5 # ppm
52
+ form = 16 # ppb
53
+ "pm2.5" = 10
54
+ "pm10" = 50
55
+
56
+ [colors]
57
+ # See Matplotlib documentation for valid colors:
58
+ # https://matplotlib.org/stable/gallery/color/named_colors.html
59
+ co2 = 'tab:blue'
60
+ tvoc = 'tab:purple'
61
+ co = 'tab:cyan'
62
+ form = 'tab:red'
63
+ humidity = 'tab:green'
64
+ temp = 'tab:orange'
65
+ "pm2.5" = 'tab:orange'
66
+ "pm10" = 'tab:green'
67
+
plotair/main.py CHANGED
@@ -14,12 +14,17 @@ Licensed under the MIT License. See the LICENSE file for details.
14
14
  """
15
15
 
16
16
  # Standard library imports
17
- from datetime import datetime
17
+ import argparse
18
+ import csv
19
+ import glob
18
20
  import logging
19
21
  import os
20
22
  import re
21
23
  import shutil
22
24
  import sys
25
+ import tomllib
26
+ from datetime import datetime
27
+ from pathlib import Path
23
28
 
24
29
  # Third-party library imports
25
30
  import matplotlib.pyplot as plt
@@ -27,64 +32,208 @@ import numpy as np
27
32
  import pandas as pd
28
33
  import seaborn as sns
29
34
 
30
- # Configuration constants
31
- MIN_CSV_COLUMNS = 5 # Most rows have 5 columns
32
- MAX_CSV_COLUMNS = 6 # Some rows have 6 columns
33
- MAX_MISSING_SAMPLES = 4
34
- PLOT_FONT_SCALE = 1.4
35
- PLOT_WIDTH = 11
36
- PLOT_HEIGHT = 8.5
37
- CO2_LABEL = 'CO₂ (ppm)'
38
- HUMIDITY_LABEL = 'Humidité (%)'
39
- TEMP_LABEL = 'Température (°C)'
40
- Y1_AXIS_LABEL = 'CO₂ (ppm)'
41
- Y2_AXIS_LABEL_1 = 'Température (°C)' + ' ' # Spaces separate from humidity
42
- Y2_AXIS_LABEL_2 = ' ' + 'Humidité (%)' # Spaces separate from temperature
43
- X_AXIS_ROTATION = 30
44
- HUMIDITY_ZONE_MIN = 40
45
- HUMIDITY_ZONE_MAX = 60
46
- HUMIDITY_ZONE_ALPHA = 0.075
47
-
48
- # 8000/4000/2000/1600 span for y1 axis work well with 80 span for y2 axis
49
- # 6000/3000/ /1200 span for y1 axis work well with 60 span for y2 axis
50
- Y1_AXIS_MIN_VALUE = 0
51
- Y1_AXIS_MAX_VALUE = 1200
52
- Y2_AXIS_MIN_VALUE = 10
53
- Y2_AXIS_MAX_VALUE = 70
54
-
55
- # See Matplotlib documentation for valid colors:
56
- # https://matplotlib.org/stable/gallery/color/named_colors.html
57
- CO2_COLOR = 'tab:blue'
58
- HUMIDITY_COLOR = 'tab:green'
59
- TEMP_COLOR = 'tab:orange'
35
+ # Add project root to sys.path so script can be called directly w/o 'python3 -m'
36
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
37
+ if str(PROJECT_ROOT) not in sys.path:
38
+ sys.path.insert(0, str(PROJECT_ROOT))
39
+
40
+ # Local imports
41
+ from plotair import __version__
42
+
43
+ CONFIG = {}
60
44
 
61
45
  # Get a logger for this script
62
46
  logger = logging.getLogger(__name__)
63
47
 
64
48
 
65
49
  def main():
66
- # sys.argv[0] is the script name, so arguments start from index 1
67
- if len(sys.argv) < 2:
68
- logger.error("No files were provided")
69
- print(f"Usage: [python] {sys.argv[0]} <file1> <file2> ...")
50
+ parser = argparse.ArgumentParser()
51
+
52
+ parser.add_argument('filenames', nargs='+', metavar='FILE',
53
+ help='sensor data file to process')
54
+ parser.add_argument('-a', '--all-dates', action='store_true',
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')
58
+ parser.add_argument('-m', '--merge', metavar='FIELD',
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)')
64
+ parser.add_argument('-r', '--reset-config', action='store_true',
65
+ help='reset configuration file to default')
66
+ parser.add_argument('-s', '--start-date', metavar='DATE',
67
+ help='date at which to start the plot (YYYY-MM-DD)')
68
+ parser.add_argument('-S', '--stop-date', metavar='DATE',
69
+ help='date at which to stop the plot (YYYY-MM-DD)')
70
+ parser.add_argument('-t', '--title',
71
+ help='set the plot title')
72
+ parser.add_argument('-v', '--version', action='version',
73
+ version=f'%(prog)s {__version__}')
74
+
75
+ args = parser.parse_args()
76
+
77
+ try:
78
+ load_config(args.reset_config)
79
+ except FileNotFoundError as e:
80
+ print(f'Error: Failed to load config: {e}')
81
+ return
82
+
83
+ if args.merge:
84
+ field = args.merge
85
+ num_files = len(args.filenames)
86
+
87
+ if num_files != 3:
88
+ print('Error: Argument -m/--merge requires three file arguments')
89
+ return
90
+
91
+ file_format, df1, num_valid_rows1, num_invalid_rows = read_data(args.filenames[0])
92
+ file_format, df2, num_valid_rows2, num_invalid_rows = read_data(args.filenames[1])
93
+
94
+ if num_valid_rows1 <= 0 or num_valid_rows2 <= 0:
95
+ print('Error: At least one of the input files is unsupported')
96
+ return
97
+
98
+ temp_df = df1[['co2']]
99
+ df2 = pd.concat([df2, temp_df]).sort_index()
100
+ df2.to_csv(args.filenames[2], index=True)
101
+
70
102
  else:
71
- for filename in sys.argv[1:]:
72
- 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}')
73
115
  try:
74
- df, valid, invalid = read_csv_data(filename)
75
- if invalid > 0:
76
- logger.info(f"{invalid} invalid row(s) ignored")
77
- df = delete_old_data(df)
78
- generate_plot(df, filename)
116
+ file_format, df, num_valid_rows, num_invalid_rows = read_data(filename)
117
+
118
+ if num_valid_rows > 0:
119
+ logger.debug(f'{num_valid_rows} valid row(s) read')
120
+ else:
121
+ print('Error: Unsupported file format')
122
+ return
123
+
124
+ if num_invalid_rows > 0:
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}%)')
127
+
128
+ if not args.all_dates:
129
+ df = delete_old_data(df, args.start_date, args.stop_date)
130
+
131
+ generate_stats(df, filename, args.boxplot)
132
+
133
+ if file_format == 'plotair':
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)
138
+ elif file_format == 'visiblair_d':
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)
143
+ elif file_format == 'visiblair_e':
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)
152
+ elif file_format == 'graywolf_ds':
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)
79
161
  except Exception as e:
80
- logger.exception(f"Unexpected error: {e}")
162
+ print(f'Error: Unexpected error: {e}')
163
+
164
+
165
+ def detect_file_format(filename):
166
+ file_format = None
167
+ visiblair_d_num_col = (5, 6) # Most rows have 5 columns but some have 6
168
+ visiblair_e_num_col = (21, 21)
169
+ graywolf_ds_num_col = (7, 7)
170
+
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:
175
+ reader = csv.reader(file)
176
+ first_line = next(reader)
177
+ num_fields = len(first_line)
178
+
179
+ if first_line[0] == 'date':
180
+ file_format = 'plotair'
181
+ elif visiblair_d_num_col[0] <= num_fields <= visiblair_d_num_col[1]:
182
+ file_format = 'visiblair_d'
183
+ elif (visiblair_e_num_col[0] <= num_fields <= visiblair_e_num_col[1] and
184
+ first_line[1] == 'Timestamp'):
185
+ file_format = 'visiblair_e'
186
+ elif (graywolf_ds_num_col[0] <= num_fields <= graywolf_ds_num_col[1] and
187
+ first_line[0] == 'Date Time'):
188
+ file_format = 'graywolf_ds'
189
+
190
+ logger.debug(f'File format: {file_format}')
191
+
192
+ return file_format
81
193
 
82
194
 
83
- def read_csv_data(filename):
84
- valid_rows = []
195
+ def read_data(filename):
196
+ file_format = detect_file_format(filename)
197
+ df = pd.DataFrame()
198
+ num_valid_rows = 0
199
+ num_invalid_rows = 0
200
+
201
+ if file_format == 'plotair':
202
+ df, num_valid_rows, num_invalid_rows = read_data_plotair(filename)
203
+ elif file_format == 'visiblair_d':
204
+ df, num_valid_rows, num_invalid_rows = read_data_visiblair_d(filename)
205
+ elif file_format == 'visiblair_e':
206
+ df, num_valid_rows, num_invalid_rows = read_data_visiblair_e(filename)
207
+ elif file_format == 'graywolf_ds':
208
+ df, num_valid_rows, num_invalid_rows = read_data_graywolf_ds(filename)
209
+
210
+ df = df.sort_index() # Sort in case some dates are not in order
211
+
212
+ return file_format, df, num_valid_rows, num_invalid_rows
213
+
214
+
215
+ def read_data_plotair(filename):
85
216
  num_valid_rows = 0
86
217
  num_invalid_rows = 0
87
218
 
219
+ # Read the CSV file into a DataFrame
220
+ df = pd.read_csv(filename)
221
+
222
+ # Convert the 'date' column to pandas datetime objects
223
+ df['date'] = pd.to_datetime(df['date'], format='%Y-%m-%d %H:%M:%S')
224
+
225
+ df = df.set_index('date')
226
+ num_valid_rows = len(df)
227
+
228
+ return df, num_valid_rows, num_invalid_rows
229
+
230
+
231
+ def read_data_visiblair_d(filename):
232
+ df = pd.DataFrame()
233
+ num_valid_rows = 0
234
+ num_invalid_rows = 0
235
+ valid_rows = []
236
+
88
237
  # Read the file line by line instead of using pandas read_csv function.
89
238
  # This is less concise but allows for more control over data validation.
90
239
  with open(filename, 'r') as f:
@@ -92,9 +241,9 @@ def read_csv_data(filename):
92
241
  line = line.strip()
93
242
  fields = line.split(',')
94
243
 
95
- if len(fields) < MIN_CSV_COLUMNS or len(fields) > MAX_CSV_COLUMNS:
244
+ if not (5 <= len(fields) <= 6):
96
245
  # Skip lines with an invalid number of columns
97
- logger.debug(f"Skipping line (number of columns): {line}")
246
+ #logger.debug(f'Skipping line (number of columns): {line}')
98
247
  num_invalid_rows += 1
99
248
  continue
100
249
 
@@ -102,131 +251,445 @@ def read_csv_data(filename):
102
251
  # Convert each field to its target data type
103
252
  parsed_row = {
104
253
  'date': pd.to_datetime(fields[0], format='%Y-%m-%d %H:%M:%S'),
105
- 'co2': np.uint16(fields[1]), # 0 to 10,000 ppm
106
- 'temperature': np.float32(fields[2]), # -40 to 70 °C
107
- '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
108
257
  }
109
258
  # If conversion succeeds, add the parsed row to the list
110
- num_valid_rows += 1
111
259
  valid_rows.append(parsed_row)
112
260
 
113
261
  except (ValueError, TypeError) as e:
114
262
  # Skip lines with conversion errors
115
- logger.debug(f"Skipping line (conversion error): {line}")
263
+ #logger.debug(f'Skipping line (conversion error): {line}')
116
264
  num_invalid_rows += 1
117
265
  continue
118
266
 
119
267
  # Create the DataFrame from the valid rows
120
268
  df = pd.DataFrame(valid_rows)
121
269
  df = df.set_index('date')
122
- df = df.sort_index() # Sort in case some dates are not in order
270
+ num_valid_rows = len(df)
123
271
 
124
272
  return df, num_valid_rows, num_invalid_rows
125
273
 
126
274
 
127
- def delete_old_data(df):
128
- """
129
- Iterate backwards through the samples to find the first time gap larger
130
- than the sampling interval. Then return only the latest data sequence.
131
- """
132
- sampling_interval = None
133
- next_date = df.index[-1]
134
-
135
- for date in reversed(list(df.index)):
136
- current_date = date
137
-
138
- if current_date != next_date:
139
- if sampling_interval is None:
140
- sampling_interval = next_date - current_date
141
- else:
142
- current_interval = next_date - current_date
143
-
144
- if (current_interval / sampling_interval) > MAX_MISSING_SAMPLES:
145
- # This sample is from older sequence, keep only more recent
146
- df = df[df.index >= next_date]
147
- break
148
-
149
- next_date = current_date
150
-
275
+ def read_data_visiblair_e(filename):
276
+ num_valid_rows = 0
277
+ num_invalid_rows = 0
278
+
279
+ # Read the CSV file into a DataFrame
280
+ df = pd.read_csv(filename)
281
+
282
+ # Rename the columns
283
+ df.columns = ['uuid', 'date', 'co2', 'humidity', 'temp', 'pm0.1',
284
+ 'pm0.3', 'pm0.5', 'pm1', 'pm2.5', 'pm5', 'pm10', 'pressure',
285
+ 'voc_index', 'firmware', 'model', 'pcb', 'display_rate',
286
+ 'is_charging', 'is_ac_in', 'batt_voltage']
287
+
288
+ # Convert the 'date' column to pandas datetime objects
289
+ df['date'] = pd.to_datetime(df['date'], format='%Y-%m-%d %H:%M:%S')
290
+
291
+ df = df.set_index('date')
292
+ num_valid_rows = len(df)
293
+
294
+ return df, num_valid_rows, num_invalid_rows
295
+
296
+
297
+ def read_data_graywolf_ds(filename):
298
+ num_valid_rows = 0
299
+ num_invalid_rows = 0
300
+
301
+ # Read the CSV file into a DataFrame
302
+ df = pd.read_csv(filename)
303
+
304
+ # Rename the columns
305
+ df.columns = ['date', 'tvoc', 'co', 'form', 'humidity', 'temp', 'filename']
306
+
307
+ # Convert the 'date' column to pandas datetime objects
308
+ df['date'] = pd.to_datetime(df['date'], format='%d-%b-%y %I:%M:%S %p')
309
+
310
+ # Convert 'form' column to string, replace '< LOD' with '0', and then convert to integer
311
+ df['form'] = df['form'].astype(str).str.replace('< LOD', '10').astype(int)
312
+
313
+ df = df.set_index('date')
314
+ num_valid_rows = len(df)
315
+
316
+ return df, num_valid_rows, num_invalid_rows
317
+
318
+
319
+ def delete_old_data(df, start_date = None, stop_date = None):
320
+ if not start_date and not stop_date:
321
+ # Iterate backwards through the samples to find the first time gap larger
322
+ # than the sampling interval. Then return only the latest data sequence.
323
+ sampling_interval = None
324
+ next_date = df.index[-1]
325
+
326
+ for date in reversed(list(df.index)):
327
+ current_date = date
328
+
329
+ if current_date != next_date:
330
+ if sampling_interval is None:
331
+ sampling_interval = next_date - current_date
332
+ else:
333
+ current_interval = next_date - current_date
334
+
335
+ if (current_interval / sampling_interval) > CONFIG['data']['max_missing_samples']:
336
+ # This sample is from older sequence, keep only more recent
337
+ df = df[df.index >= next_date]
338
+ break
339
+
340
+ next_date = current_date
341
+
342
+ else:
343
+ # Keep only the data range to be plotted (use pandas dates types)
344
+ if start_date:
345
+ sd = pd.Timestamp(start_date)
346
+ df = df[df.index >= sd]
347
+ if stop_date:
348
+ sd = pd.Timestamp(stop_date)
349
+ df = df[df.index <= sd]
350
+
151
351
  return df
152
352
 
153
353
 
154
- def generate_plot(df, filename):
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):
155
369
  # The dates must be in a non-index column
156
370
  df = df.reset_index()
157
-
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
+
158
377
  # Set a theme and scale all fonts
159
- sns.set_theme(style='whitegrid', font_scale=PLOT_FONT_SCALE)
378
+ sns.set_theme(style='whitegrid', font_scale=CONFIG['plot']['font_scale'])
379
+
380
+ ff = CONFIG['plot']['font_family']
381
+ if ff != '': plt.rcParams['font.family'] = ff
160
382
 
161
383
  # Set up the matplotlib figure and axes
162
- fig, ax1 = plt.subplots(figsize=(PLOT_WIDTH, PLOT_HEIGHT))
384
+ fig, ax1 = plt.subplots(figsize=CONFIG['plot']['size'])
163
385
  ax2 = ax1.twinx() # Secondary y axis
164
386
 
165
- # Plot the data series
166
- sns.lineplot(data=df, x='date', y='co2', ax=ax1, color=CO2_COLOR,
167
- label=CO2_LABEL, legend=False)
168
- sns.lineplot(data=df, x='date', y='humidity', ax=ax2, color=HUMIDITY_COLOR,
169
- label=HUMIDITY_LABEL, legend=False)
170
- sns.lineplot(data=df, x='date', y='temperature', ax=ax2, color=TEMP_COLOR,
171
- label=TEMP_LABEL, legend=False)
387
+ # TODO: add functions for repetitive code
388
+
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']
395
+
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']
422
+
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']
449
+
450
+ # TODO: Do we still want to scale the CO data series?
451
+ #co_scale = 10
452
+ #df['co_scaled'] = df['co'] * co_scale
453
+
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
458
+
459
+ sns.lineplot(data=df3, x='date', y=ds3.name, ax=ax2, color=ds3.color,
460
+ label=ds3.label, legend=False, linestyle=linestyle)
461
+
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'])
468
+
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'])
172
474
 
173
475
  # Set the ranges for both y axes
174
- ax1.set_ylim(Y1_AXIS_MIN_VALUE, Y1_AXIS_MAX_VALUE) # df['co2'].max() * 1.05
175
- ax2.set_ylim(Y2_AXIS_MIN_VALUE, Y2_AXIS_MAX_VALUE)
476
+ if ds1:
477
+ y1min, y1max = ds1.y_range
478
+ ax1.set_ylim(y1min, y1max)
479
+
480
+ y2min, y2max = ds2.y_range
481
+ y3min, y3max = ds3.y_range
482
+
483
+ if y2min != y3min or y2max != y3max:
484
+ print(f'Warning: Axis ranges differ for {series2} and {series3}, using largest range')
485
+
486
+ ax2.set_ylim(min(y2min, y3min), max(y2max, y3max))
176
487
 
177
488
  # Add a grid for the x axis and the y axes
178
489
  # This is already done if using the whitegrid theme
179
- #ax1.grid(axis='x', alpha=0.7)
180
- #ax1.grid(axis='y', alpha=0.7)
181
- ax2.grid(axis='y', alpha=0.7, linestyle='dashed')
182
-
183
- # Set the background color of the humidity comfort zone
184
- ax2.axhspan(ymin=HUMIDITY_ZONE_MIN, ymax=HUMIDITY_ZONE_MAX,
185
- facecolor=HUMIDITY_COLOR, alpha=HUMIDITY_ZONE_ALPHA)
490
+ #ax1.grid(axis='x', alpha=CONFIG['plot']['grid_opacity'])
491
+ #ax1.grid(axis='y', alpha=CONFIG['plot']['grid_opacity'])
492
+ ax2.grid(axis='y', alpha=CONFIG['plot']['grid2_opacity'], linestyle=CONFIG['plot']['grid2_line_style'])
186
493
 
187
494
  # Customize the plot title, labels and ticks
188
- ax1.set_title(get_plot_title(filename))
189
- ax1.tick_params(axis='x', rotation=X_AXIS_ROTATION)
190
- ax1.tick_params(axis='y', labelcolor=CO2_COLOR)
495
+ ax1.set_title(get_plot_title(title, filename))
496
+ ax1.tick_params(axis='x', rotation=CONFIG['plot']['date_rotation'])
497
+ if ds1:
498
+ ax1.tick_params(axis='y', labelcolor=ds1.color)
499
+ ax1.set_ylabel(ds1.label, color=ds1.color)
191
500
  ax1.set_xlabel('')
192
- ax1.set_ylabel(Y1_AXIS_LABEL, color=CO2_COLOR)
193
501
  ax2.set_ylabel('') # We will manually place the 2 parts in different colors
194
502
 
195
503
  # Define the position for the center of the right y axis label
504
+ bottom_label = ds3.label + ' '
505
+ top_label = ' ' + ds2.label
196
506
  x = 1.07 # Slightly to the right of the axis
197
- y = 0.5 # Vertically centered
507
+ y = get_label_center(bottom_label, top_label) # Vertically centered
198
508
 
199
509
  # Place the first (bottom) part of the label
200
- ax2.text(x, y, Y2_AXIS_LABEL_1, transform=ax2.transAxes,
201
- color=TEMP_COLOR, rotation='vertical', ha='center', va='top')
510
+ ax2.text(x, y, bottom_label, transform=ax2.transAxes,
511
+ color=ds3.color, rotation='vertical',
512
+ ha='center', va='top')
202
513
 
203
514
  # Place the second (top) part of the label
204
- ax2.text(x, y, Y2_AXIS_LABEL_2, transform=ax2.transAxes,
205
- color=HUMIDITY_COLOR, rotation='vertical', ha='center', va='bottom')
515
+ ax2.text(x, y, top_label, transform=ax2.transAxes,
516
+ color=ds2.color, rotation='vertical',
517
+ ha='center', va='bottom')
206
518
 
207
519
  # Create a combined legend
208
520
  lines1, labels1 = ax1.get_legend_handles_labels()
209
521
  lines2, labels2 = ax2.get_legend_handles_labels()
210
- ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')
522
+ labels1 = remove_units_from_labels(labels1)
523
+ labels2 = remove_units_from_labels(labels2)
524
+ ax1.legend(lines1 + lines2, labels1 + labels2,
525
+ loc=CONFIG['plot']['legend_location'])
526
+
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)
211
532
 
212
533
  # Adjust the plot margins to make room for the labels
213
534
  plt.tight_layout()
214
535
 
215
536
  # Save the plot as a PNG image
216
- plt.savefig(get_png_filename(filename))
537
+ # TODO: auto build the plot suffix from the 1st char of each series?
538
+ plt.savefig(get_plot_filename(filename, f'-{suffix}'))
217
539
  plt.close()
218
540
 
219
541
 
220
- def get_plot_title(filename):
221
- match = re.search(r'^(\d+\s*-\s*)?(.*)\.[a-zA-Z]+$', filename)
222
- plot_title = match.group(2) if match else filename
223
- plot_title = plot_title.capitalize()
542
+ def remove_units_from_labels(labels):
543
+ return [re.sub(r' \([^)]*\)', '', label) for label in labels]
544
+
545
+
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)]
563
+
564
+
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]
570
+
571
+
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)]
580
+
581
+
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)]
588
+
589
+
590
+ def get_label_center(bottom_label, top_label):
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
593
+ fs = CONFIG['plot']['font_scale']
594
+ divider = 72 * fs**2 - 316 * fs + 414 # Tested for fs between 0.8 and 2
595
+ center = 0.5 + ((len(bottom_label) - len(top_label)) / divider)
596
+ return center
597
+
598
+
599
+ def generate_stats(df, filename, boxplot=False):
600
+ summary = df.describe()
601
+
602
+ with open(get_stats_filename(filename), 'w') as file:
603
+ file.write(summary.to_string())
604
+
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()
610
+
611
+
612
+ def load_config(reset_config = False):
613
+ global CONFIG
614
+
615
+ app_name = 'plotair'
616
+ config_file = 'config.toml'
617
+
618
+ config_dir = get_config_dir(app_name)
619
+ user_config_file = config_dir / config_file
620
+ default_config_file = PROJECT_ROOT / app_name / config_file
621
+
622
+ if not user_config_file.exists() or reset_config:
623
+ if default_config_file.exists():
624
+ shutil.copy2(default_config_file, user_config_file)
625
+ logger.debug(f'Config initialized at {user_config_file}')
626
+ else:
627
+ raise FileNotFoundError(f'Default config missing at {default_config_file}')
628
+ else:
629
+ logger.debug(f'Found config file at {user_config_file}')
630
+
631
+ with open(user_config_file, 'rb') as f:
632
+ CONFIG = tomllib.load(f)
633
+
634
+
635
+ def get_config_dir(app_name):
636
+ if sys.platform == "win32":
637
+ # Windows: Use %APPDATA% (%USERPROFILE%\AppData\Roaming)
638
+ config_dir = Path(os.environ.get("APPDATA", "")) / app_name
639
+ elif sys.platform == "darwin":
640
+ # macOS: Use ~/Library/Preferences
641
+ config_dir = Path.home() / "Library" / "Preferences" / app_name
642
+ else:
643
+ # Linux and other Unix-like: Use ~/.config or XDG_CONFIG_HOME if set
644
+ config_home = os.environ.get("XDG_CONFIG_HOME", "")
645
+ if config_home:
646
+ config_dir = Path(config_home) / app_name
647
+ else:
648
+ config_dir = Path.home() / ".config" / app_name
649
+
650
+ # Create the directory if it doesn't exist
651
+ config_dir.mkdir(parents=True, exist_ok=True)
652
+
653
+ return config_dir
654
+
655
+
656
+ def get_plot_title(title, filename):
657
+ if title:
658
+ plot_title = title
659
+ else:
660
+ stem = Path(filename).stem
661
+ match = re.search(r'^(\d+\s*-\s*)?(.*)$', stem)
662
+ plot_title = match.group(2) if match else stem
663
+
664
+ # Capitalize only the first character
665
+ if plot_title: plot_title = plot_title[0].upper() + plot_title[1:]
666
+
224
667
  return plot_title
225
668
 
226
669
 
227
- def get_png_filename(filename):
228
- root, ext = os.path.splitext(filename)
229
- return f"{root}.png"
670
+ def get_plot_filename(filename, suffix = ''):
671
+ p = Path(filename)
672
+ return f'{p.parent}/{p.stem}{suffix}.png'
673
+
674
+
675
+ def get_boxplot_filename(filename, suffix = ''):
676
+ p = Path(filename)
677
+ return f'{p.parent}/{p.stem}-boxplot{suffix}.png'
678
+
679
+
680
+ def get_stats_filename(filename):
681
+ p = Path(filename)
682
+ return f'{p.parent}/{p.stem}-stats.txt'
683
+
684
+
685
+ def log_data_frame(df, description = ''):
686
+ """ This function is used only for debugging. """
687
+ logger.debug(f'DataFrame {description}\n{df}')
688
+ #logger.debug(f'DataFrame index data type: {df.index.dtype}')
689
+ #logger.debug(f'DataFrame index class: {type(df.index)}')
690
+ #logger.debug(f'DataFrame columns data types\n{df.dtypes}')
691
+ #logger.debug(f'DataFrame statistics\n{df.describe()}') # Mean, min, max...
692
+ #sys.exit()
230
693
 
231
694
 
232
695
  if __name__ == '__main__':
@@ -235,6 +698,6 @@ if __name__ == '__main__':
235
698
  format='%(levelname)s - %(message)s')
236
699
 
237
700
  # Configure this script's logger
238
- logger.setLevel(logging.INFO)
701
+ logger.setLevel(logging.DEBUG)
239
702
 
240
703
  main()
@@ -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,94 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: plotair
3
- Version: 0.1.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,command-line,python,terminal,tui
10
- Classifier: Operating System :: OS Independent
11
- Classifier: Programming Language :: Python :: 3
12
- Requires-Python: >=3.9
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 update && sudo apt install pipx
46
- ```
47
- * **Linux (Other) / macOS:**
48
-
49
- ```bash
50
- python3 -m pip install --user pipx
51
- python3 -m pipx ensurepath
52
- # Note: Close and restart your terminal after running ensurepath
53
- ```
54
- * **Windows:**
55
-
56
- ```bash
57
- python -m pip install --user pipx
58
- ```
59
-
60
- **2. Install PlotAir:**
61
-
62
- ```bash
63
- pipx install plotair
64
- ```
65
-
66
- ### Installation with `pip`
67
-
68
- 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:
69
-
70
- ```bash
71
- pip install plotair
72
- ```
73
-
74
- ## Usage
75
-
76
- ### Basic Usage
77
-
78
- ```bash
79
- plotair <file1> <file2> ...
80
- ```
81
-
82
- ### Command-Line Arguments
83
-
84
- None for now.
85
-
86
- ## License
87
-
88
- Copyright (c) 2026 Monsieur Linux
89
-
90
- This project is licensed under the MIT License. See the LICENSE file for details.
91
-
92
- ## Acknowledgements
93
-
94
- 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,7 +0,0 @@
1
- plotair/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- plotair/main.py,sha256=_jrgIkSfo0P-9i6ZaozXqfkqrMzgUwDo84mtUB4guoI,8378
3
- plotair-0.1.1.dist-info/METADATA,sha256=STPe0wm4Ngqa6bv4yWjE_ooHtiaSEkBxw2cHjb1Etmo,3235
4
- plotair-0.1.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
5
- plotair-0.1.1.dist-info/entry_points.txt,sha256=ekJAavHU_JAF9a66br_T4-Vni_OAyd7QX-tnVlsH8pY,46
6
- plotair-0.1.1.dist-info/licenses/LICENSE,sha256=Lb4o6Virnt4fVuYjZ_QO4qrvRUJqHe0MCkqVr_lncJo,1071
7
- plotair-0.1.1.dist-info/RECORD,,