plotair 0.1.0__py3-none-any.whl → 0.2.0__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.0"
plotair/config.toml ADDED
@@ -0,0 +1,66 @@
1
+ [data]
2
+ max_missing_samples = 4
3
+
4
+ [plot]
5
+ size = [11.0, 8.5]
6
+ font_family = 'Noto Sans' # Set to '' to use default system font
7
+ font_scale = 1.4
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
+ limit_zone_opacity = 0.075
12
+ limit_line_opacity = 0.7
13
+ limit_line_width = 1
14
+ limit_line_style = '--'
15
+ "pm2.5_line_width" = 1.5
16
+ "pm2.5_line_style" = '-'
17
+ "pm10_line_width" = 1.5
18
+ "pm10_line_style" = '-.'
19
+ date_rotation = 30
20
+
21
+ # Options: 'best', 'upper right', 'upper left', 'lower left', 'lower right',
22
+ # 'center left', 'center right', 'lower center', 'upper center', 'center'
23
+ legend_location = 'best'
24
+
25
+ [axis_ranges]
26
+ co2 = [0, 2000]
27
+ temp_h = [0, 80]
28
+ tvoc = [0, 500]
29
+ co_form = [0, 50]
30
+ "pm2.5_10" = [0, 70]
31
+
32
+ [labels]
33
+ co2 = 'CO₂ (ppm)'
34
+ temp = 'Température (°C)'
35
+ humidity = 'Humidité (%)'
36
+ tvoc = 'COVT (ppb)'
37
+ tvoc_limit = 'Limite LEED, WELL et LBC'
38
+ co = 'Monoxyde de carbone (ppm x 10)'
39
+ co_limit = 'Limite anomalie légère BBI'
40
+ form = 'Formaldéhyde (ppb)'
41
+ form_limit = 'Limite anomalie légère BBI'
42
+ "pm2.5" = 'PM2.5 (µg/m³)'
43
+ "pm2.5_limit" = 'Limite concentration moyenne annuelle PM2.5 OMS'
44
+ "pm10" = 'PM10 (µg/m³)'
45
+ "pm10_limit" = 'Limite concentration moyenne annuelle PM10 OMS'
46
+
47
+ [limits]
48
+ humidity = [40, 60]
49
+ tvoc = 218 # ppb
50
+ co = 4 # ppm
51
+ form = 16 # ppb
52
+ "pm2.5" = 10
53
+ "pm10" = 50
54
+
55
+ [colors]
56
+ # See Matplotlib documentation for valid colors:
57
+ # https://matplotlib.org/stable/gallery/color/named_colors.html
58
+ co2 = 'tab:blue'
59
+ tvoc = 'tab:purple'
60
+ co = 'tab:cyan'
61
+ form = 'tab:red'
62
+ humidity = 'tab:green'
63
+ temp = 'tab:orange'
64
+ "pm2.5" = 'tab:orange'
65
+ "pm10" = 'tab:green'
66
+
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,63 +32,174 @@ 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('-m', '--merge', metavar='FIELD',
57
+ help='merge field from file1 to file2, and output to file3')
58
+ parser.add_argument('-r', '--reset-config', action='store_true',
59
+ help='reset configuration file to default')
60
+ parser.add_argument('-s', '--start-date', metavar='DATE',
61
+ help='date at which to start the plot (YYYY-MM-DD)')
62
+ parser.add_argument('-S', '--stop-date', metavar='DATE',
63
+ help='date at which to stop the plot (YYYY-MM-DD)')
64
+ parser.add_argument('-t', '--title',
65
+ help='set the plot title')
66
+ parser.add_argument('-v', '--version', action='version',
67
+ version=f'%(prog)s {__version__}')
68
+
69
+ args = parser.parse_args()
70
+
71
+ try:
72
+ load_config(args.reset_config)
73
+ except FileNotFoundError as e:
74
+ logger.error(f'Failed to load config: {e}')
75
+ return
76
+
77
+ if args.merge:
78
+ field = args.merge
79
+ num_files = len(args.filenames)
80
+
81
+ if num_files != 3:
82
+ logger.error('argument -m/--merge requires three file arguments')
83
+ return
84
+
85
+ file_format, df1, num_valid_rows1, num_invalid_rows = read_data(args.filenames[0])
86
+ file_format, df2, num_valid_rows2, num_invalid_rows = read_data(args.filenames[1])
87
+
88
+ if num_valid_rows1 <= 0 or num_valid_rows2 <= 0:
89
+ logger.error('At least one of the input files is unsupported')
90
+ return
91
+
92
+ temp_df = df1[['co2']]
93
+ df2 = pd.concat([df2, temp_df]).sort_index()
94
+ df2.to_csv(args.filenames[2], index=True)
95
+
70
96
  else:
71
- for filename in sys.argv[1:]:
72
- logger.info(f"Processing {filename}")
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}')
73
105
  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)
106
+ file_format, df, num_valid_rows, num_invalid_rows = read_data(filename)
107
+
108
+ if num_valid_rows > 0:
109
+ logger.debug(f'{num_valid_rows} row(s) read')
110
+ else:
111
+ logger.error('Unsupported file format')
112
+ return
113
+
114
+ if num_invalid_rows > 0:
115
+ logger.info(f'{num_invalid_rows} invalid row(s) ignored')
116
+
117
+ if not args.all_dates:
118
+ df = delete_old_data(df, args.start_date, args.stop_date)
119
+
120
+ generate_stats(df, filename)
121
+
122
+ if file_format == 'plotair':
123
+ generate_plot_co2_hum_tmp(df, filename, args.title)
124
+ elif file_format == 'visiblair_d':
125
+ generate_plot_co2_hum_tmp(df, filename, args.title)
126
+ elif file_format == 'visiblair_e':
127
+ generate_plot_co2_hum_tmp(df, filename, args.title)
128
+ generate_plot_pm(df, filename, args.title)
129
+ elif file_format == 'graywolf_ds':
130
+ generate_plot_hum_tmp(df, filename, args.title)
131
+ generate_plot_voc_co_form(df, filename, args.title)
79
132
  except Exception as e:
80
- logger.exception(f"Unexpected error: {e}")
133
+ logger.exception(f'Unexpected error: {e}')
81
134
 
82
135
 
83
- def read_csv_data(filename):
84
- valid_rows = []
136
+ def detect_file_format(filename):
137
+ file_format = None
138
+ visiblair_d_num_col = (5, 6) # Most rows have 5 columns but some have 6
139
+ visiblair_e_num_col = (21, 21)
140
+ graywolf_ds_num_col = (7, 7)
141
+
142
+ with open(filename, 'r', newline='', encoding='utf-8') as file:
143
+ reader = csv.reader(file)
144
+ first_line = next(reader)
145
+ num_fields = len(first_line)
146
+
147
+ if first_line[0] == 'date':
148
+ file_format = 'plotair'
149
+ elif visiblair_d_num_col[0] <= num_fields <= visiblair_d_num_col[1]:
150
+ file_format = 'visiblair_d'
151
+ elif (visiblair_e_num_col[0] <= num_fields <= visiblair_e_num_col[1] and
152
+ first_line[1] == 'Timestamp'):
153
+ file_format = 'visiblair_e'
154
+ elif (graywolf_ds_num_col[0] <= num_fields <= graywolf_ds_num_col[1] and
155
+ first_line[0] == 'Date Time'):
156
+ file_format = 'graywolf_ds'
157
+
158
+ logger.debug(f'File format: {file_format}')
159
+
160
+ return file_format
161
+
162
+
163
+ def read_data(filename):
164
+ file_format = detect_file_format(filename)
165
+ df = pd.DataFrame()
166
+ num_valid_rows = 0
167
+ num_invalid_rows = 0
168
+
169
+ if file_format == 'plotair':
170
+ df, num_valid_rows, num_invalid_rows = read_data_plotair(filename)
171
+ elif file_format == 'visiblair_d':
172
+ df, num_valid_rows, num_invalid_rows = read_data_visiblair_d(filename)
173
+ elif file_format == 'visiblair_e':
174
+ df, num_valid_rows, num_invalid_rows = read_data_visiblair_e(filename)
175
+ elif file_format == 'graywolf_ds':
176
+ df, num_valid_rows, num_invalid_rows = read_data_graywolf_ds(filename)
177
+
178
+ return file_format, df, num_valid_rows, num_invalid_rows
179
+
180
+
181
+ def read_data_plotair(filename):
182
+ num_valid_rows = 0
183
+ num_invalid_rows = 0
184
+
185
+ # Read the CSV file into a DataFrame
186
+ df = pd.read_csv(filename)
187
+
188
+ # Convert the 'date' column to pandas datetime objects
189
+ df['date'] = pd.to_datetime(df['date'], format='%Y-%m-%d %H:%M:%S')
190
+
191
+ df = df.set_index('date')
192
+ df = df.sort_index() # Sort in case some dates are not in order
193
+ num_valid_rows = len(df)
194
+
195
+ return df, num_valid_rows, num_invalid_rows
196
+
197
+
198
+ def read_data_visiblair_d(filename):
199
+ df = pd.DataFrame()
85
200
  num_valid_rows = 0
86
201
  num_invalid_rows = 0
202
+ valid_rows = []
87
203
 
88
204
  # Read the file line by line instead of using pandas read_csv function.
89
205
  # This is less concise but allows for more control over data validation.
@@ -92,9 +208,9 @@ def read_csv_data(filename):
92
208
  line = line.strip()
93
209
  fields = line.split(',')
94
210
 
95
- if len(fields) < MIN_CSV_COLUMNS or len(fields) > MAX_CSV_COLUMNS:
211
+ if not (5 <= len(fields) <= 6):
96
212
  # Skip lines with an invalid number of columns
97
- logger.debug(f"Skipping line (number of columns): {line}")
213
+ logger.debug(f'Skipping line (number of columns): {line}')
98
214
  num_invalid_rows += 1
99
215
  continue
100
216
 
@@ -107,12 +223,11 @@ def read_csv_data(filename):
107
223
  'humidity': np.uint8(fields[3]) # 0 to 100% RH
108
224
  }
109
225
  # If conversion succeeds, add the parsed row to the list
110
- num_valid_rows += 1
111
226
  valid_rows.append(parsed_row)
112
227
 
113
228
  except (ValueError, TypeError) as e:
114
229
  # Skip lines with conversion errors
115
- logger.debug(f"Skipping line (conversion error): {line}")
230
+ logger.debug(f'Skipping line (conversion error): {line}')
116
231
  num_invalid_rows += 1
117
232
  continue
118
233
 
@@ -120,113 +235,551 @@ def read_csv_data(filename):
120
235
  df = pd.DataFrame(valid_rows)
121
236
  df = df.set_index('date')
122
237
  df = df.sort_index() # Sort in case some dates are not in order
238
+ num_valid_rows = len(df)
123
239
 
124
240
  return df, num_valid_rows, num_invalid_rows
125
241
 
126
242
 
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]
243
+ def read_data_visiblair_e(filename):
244
+ num_valid_rows = 0
245
+ num_invalid_rows = 0
134
246
 
135
- for date in reversed(list(df.index)):
136
- current_date = date
247
+ # Read the CSV file into a DataFrame
248
+ df = pd.read_csv(filename)
137
249
 
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
250
+ # Rename the columns
251
+ df.columns = ['uuid', 'date', 'co2', 'humidity', 'temperature', 'pm0.1',
252
+ 'pm0.3', 'pm0.5', 'pm1', 'pm2.5', 'pm5', 'pm10', 'pressure',
253
+ 'voc_index', 'firmware', 'model', 'pcb', 'display_rate',
254
+ 'is_charging', 'is_ac_in', 'batt_voltage']
255
+
256
+ # Convert the 'date' column to pandas datetime objects
257
+ df['date'] = pd.to_datetime(df['date'], format='%Y-%m-%d %H:%M:%S')
258
+
259
+ df = df.set_index('date')
260
+ df = df.sort_index() # Sort in case some dates are not in order
261
+ num_valid_rows = len(df)
262
+
263
+ return df, num_valid_rows, num_invalid_rows
264
+
265
+
266
+ def read_data_graywolf_ds(filename):
267
+ num_valid_rows = 0
268
+ num_invalid_rows = 0
269
+
270
+ # Read the CSV file into a DataFrame
271
+ df = pd.read_csv(filename)
272
+
273
+ # Rename the columns
274
+ df.columns = ['date', 'tvoc', 'co', 'form', 'humidity', 'temperature', 'filename']
275
+
276
+ # Convert the 'date' column to pandas datetime objects
277
+ df['date'] = pd.to_datetime(df['date'], format='%d-%b-%y %I:%M:%S %p')
278
+
279
+ # 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)
281
+
282
+ df = df.set_index('date')
283
+ df = df.sort_index() # Sort in case some dates are not in order
284
+ num_valid_rows = len(df)
285
+
286
+ return df, num_valid_rows, num_invalid_rows
287
+
288
+
289
+ def delete_old_data(df, start_date = None, stop_date = None):
290
+ if not start_date and not stop_date:
291
+ # Iterate backwards through the samples to find the first time gap larger
292
+ # than the sampling interval. Then return only the latest data sequence.
293
+ sampling_interval = None
294
+ next_date = df.index[-1]
295
+
296
+ for date in reversed(list(df.index)):
297
+ current_date = date
298
+
299
+ if current_date != next_date:
300
+ if sampling_interval is None:
301
+ sampling_interval = next_date - current_date
302
+ else:
303
+ current_interval = next_date - current_date
304
+
305
+ if (current_interval / sampling_interval) > CONFIG['data']['max_missing_samples']:
306
+ # This sample is from older sequence, keep only more recent
307
+ df = df[df.index >= next_date]
308
+ break
309
+
310
+ next_date = current_date
311
+
312
+ else:
313
+ # Keep only the data range to be plotted (use pandas dates types)
314
+ if start_date:
315
+ sd = pd.Timestamp(start_date)
316
+ df = df[df.index >= sd]
317
+ if stop_date:
318
+ sd = pd.Timestamp(stop_date)
319
+ df = df[df.index <= sd]
143
320
 
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
-
151
321
  return df
152
322
 
153
323
 
154
- def generate_plot(df, filename):
324
+ def generate_plot_co2_hum_tmp(df, filename, title):
325
+ # The dates must be in a non-index column
326
+ df = df.reset_index()
327
+
328
+ # Set a theme and scale all fonts
329
+ sns.set_theme(style='whitegrid', font_scale=CONFIG['plot']['font_scale'])
330
+
331
+ ff = CONFIG['plot']['font_family']
332
+ if ff != '': plt.rcParams['font.family'] = ff
333
+
334
+ # Set up the matplotlib figure and axes
335
+ fig, ax1 = plt.subplots(figsize=CONFIG['plot']['size'])
336
+ ax2 = ax1.twinx() # Secondary y axis
337
+
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
376
+
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')
381
+
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')
386
+
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'])
392
+
393
+ # Adjust the plot margins to make room for the labels
394
+ plt.tight_layout()
395
+
396
+ # Save the plot as a PNG image
397
+ plt.savefig(get_plot_filename(filename, '-cht'))
398
+ plt.close()
399
+
400
+
401
+ def generate_plot_hum_tmp(df, filename, title):
155
402
  # The dates must be in a non-index column
156
403
  df = df.reset_index()
157
404
 
158
405
  # Set a theme and scale all fonts
159
- sns.set_theme(style='whitegrid', font_scale=PLOT_FONT_SCALE)
406
+ sns.set_theme(style='whitegrid', font_scale=CONFIG['plot']['font_scale'])
407
+
408
+ ff = CONFIG['plot']['font_family']
409
+ if ff != '': plt.rcParams['font.family'] = ff
160
410
 
161
411
  # Set up the matplotlib figure and axes
162
- fig, ax1 = plt.subplots(figsize=(PLOT_WIDTH, PLOT_HEIGHT))
412
+ fig, ax1 = plt.subplots(figsize=CONFIG['plot']['size'])
163
413
  ax2 = ax1.twinx() # Secondary y axis
164
414
 
165
415
  # 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)
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)
172
422
 
173
423
  # 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)
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)
176
428
 
177
429
  # Add a grid for the x axis and the y axes
178
430
  # 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')
431
+ #ax1.grid(axis='x', alpha=CONFIG['plot']['grid_opacity'])
432
+ #ax1.grid(axis='y', alpha=CONFIG['plot']['grid_opacity'])
433
+ ax2.grid(axis='y', alpha=CONFIG['plot']['grid2_opacity'], linestyle=CONFIG['plot']['grid2_line_style'])
182
434
 
183
435
  # 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)
436
+ hmin, hmax = CONFIG['limits']['humidity']
437
+ ax2.axhspan(ymin=hmin, ymax=hmax,
438
+ facecolor=CONFIG['colors']['humidity'], alpha=CONFIG['plot']['limit_zone_opacity'])
186
439
 
187
440
  # 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)
441
+ ax1.set_title(get_plot_title(title, filename))
442
+ ax1.tick_params(axis='x', rotation=CONFIG['plot']['date_rotation'])
443
+ #ax1.tick_params(axis='y', labelcolor=CONFIG['colors']['co2'])
191
444
  ax1.set_xlabel('')
192
- ax1.set_ylabel(Y1_AXIS_LABEL, color=CO2_COLOR)
445
+ #ax1.set_ylabel(CONFIG['labels']['co2'], color=CONFIG['colors']['co2'])
193
446
  ax2.set_ylabel('') # We will manually place the 2 parts in different colors
194
447
 
195
448
  # Define the position for the center of the right y axis label
449
+ bottom_label = CONFIG['labels']['temp'] + ' '
450
+ top_label = ' ' + CONFIG['labels']['humidity']
196
451
  x = 1.07 # Slightly to the right of the axis
197
- y = 0.5 # Vertically centered
452
+ y = get_label_center(bottom_label, top_label) # Vertically centered
198
453
 
199
454
  # 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')
455
+ ax2.text(x, y, bottom_label, transform=ax2.transAxes,
456
+ color=CONFIG['colors']['temp'], rotation='vertical',
457
+ ha='center', va='top')
202
458
 
203
459
  # 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')
460
+ ax2.text(x, y, top_label, transform=ax2.transAxes,
461
+ color=CONFIG['colors']['humidity'], rotation='vertical',
462
+ ha='center', va='bottom')
206
463
 
207
464
  # Create a combined legend
208
465
  lines1, labels1 = ax1.get_legend_handles_labels()
209
466
  lines2, labels2 = ax2.get_legend_handles_labels()
210
- ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')
467
+ ax1.legend(lines1 + lines2, labels1 + labels2,
468
+ loc=CONFIG['plot']['legend_location'])
469
+
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)
211
474
 
212
475
  # Adjust the plot margins to make room for the labels
213
476
  plt.tight_layout()
214
477
 
215
478
  # Save the plot as a PNG image
216
- plt.savefig(get_png_filename(filename))
479
+ plt.savefig(get_plot_filename(filename, '-ht'))
217
480
  plt.close()
218
481
 
219
482
 
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()
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'])
529
+
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
+
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
652
+
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
+
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')
662
+
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
+
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)
673
+
674
+ # Adjust the plot margins to make room for the labels
675
+ plt.tight_layout()
676
+
677
+ # Save the plot as a PNG image
678
+ plt.savefig(get_plot_filename(filename, '-pm'))
679
+ plt.close()
680
+
681
+
682
+ def get_label_center(bottom_label, top_label):
683
+ # Return a value between 0 and 1 to estimate where to center the label
684
+ fs = CONFIG['plot']['font_scale']
685
+ divider = 72 * fs**2 - 316 * fs + 414 # Tested for fs between 0.8 and 2
686
+ center = 0.5 + ((len(bottom_label) - len(top_label)) / divider)
687
+ return center
688
+
689
+
690
+ def generate_stats(df, filename):
691
+ summary = df.describe()
692
+
693
+ with open(get_stats_filename(filename), 'w') as file:
694
+ file.write(summary.to_string())
695
+
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()
700
+
701
+
702
+ def load_config(reset_config = False):
703
+ global CONFIG
704
+
705
+ app_name = 'plotair'
706
+ config_file = 'config.toml'
707
+
708
+ config_dir = get_config_dir(app_name)
709
+ user_config_file = config_dir / config_file
710
+ default_config_file = PROJECT_ROOT / app_name / config_file
711
+
712
+ if not user_config_file.exists() or reset_config:
713
+ if default_config_file.exists():
714
+ shutil.copy2(default_config_file, user_config_file)
715
+ logger.debug(f'Config initialized at {user_config_file}')
716
+ else:
717
+ raise FileNotFoundError(f'Default config missing at {default_config_file}')
718
+ else:
719
+ logger.debug(f'Found config file at {user_config_file}')
720
+
721
+ with open(user_config_file, 'rb') as f:
722
+ CONFIG = tomllib.load(f)
723
+
724
+
725
+ def get_config_dir(app_name):
726
+ if sys.platform == "win32":
727
+ # Windows: Use %APPDATA% (%USERPROFILE%\AppData\Roaming)
728
+ config_dir = Path(os.environ.get("APPDATA", "")) / app_name
729
+ elif sys.platform == "darwin":
730
+ # macOS: Use ~/Library/Preferences
731
+ config_dir = Path.home() / "Library" / "Preferences" / app_name
732
+ else:
733
+ # Linux and other Unix-like: Use ~/.config or XDG_CONFIG_HOME if set
734
+ config_home = os.environ.get("XDG_CONFIG_HOME", "")
735
+ if config_home:
736
+ config_dir = Path(config_home) / app_name
737
+ else:
738
+ config_dir = Path.home() / ".config" / app_name
739
+
740
+ # Create the directory if it doesn't exist
741
+ config_dir.mkdir(parents=True, exist_ok=True)
742
+
743
+ return config_dir
744
+
745
+
746
+ def get_plot_title(title, filename):
747
+ if title:
748
+ plot_title = title
749
+ else:
750
+ stem = Path(filename).stem
751
+ match = re.search(r'^(\d+\s*-\s*)?(.*)$', stem)
752
+ plot_title = match.group(2) if match else stem
753
+
754
+ # Capitalize only the first character
755
+ if plot_title: plot_title = plot_title[0].upper() + plot_title[1:]
756
+
224
757
  return plot_title
225
758
 
226
759
 
227
- def get_png_filename(filename):
228
- root, ext = os.path.splitext(filename)
229
- return f"{root}.png"
760
+ def get_plot_filename(filename, suffix = ''):
761
+ p = Path(filename)
762
+ return f'{p.parent}/{p.stem}{suffix}.png'
763
+
764
+
765
+ #def get_boxplot_filename(filename, suffix = ''):
766
+ # p = Path(filename)
767
+ # return f'{p.parent}/{p.stem}-boxplot{suffix}.png'
768
+
769
+
770
+ def get_stats_filename(filename):
771
+ p = Path(filename)
772
+ return f'{p.parent}/{p.stem}-stats.txt'
773
+
774
+
775
+ def log_data_frame(df, description = ''):
776
+ """ This function is used only for debugging. """
777
+ logger.debug(f'DataFrame {description}\n{df}')
778
+ #logger.debug(f'DataFrame index data type: {df.index.dtype}')
779
+ #logger.debug(f'DataFrame index class: {type(df.index)}')
780
+ #logger.debug(f'DataFrame columns data types\n{df.dtypes}')
781
+ #logger.debug(f'DataFrame statistics\n{df.describe()}') # Mean, min, max...
782
+ #sys.exit()
230
783
 
231
784
 
232
785
  if __name__ == '__main__':
@@ -235,6 +788,6 @@ if __name__ == '__main__':
235
788
  format='%(levelname)s - %(message)s')
236
789
 
237
790
  # Configure this script's logger
238
- logger.setLevel(logging.INFO)
791
+ logger.setLevel(logging.DEBUG)
239
792
 
240
793
  main()
@@ -1,17 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plotair
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Generate CO₂, humidity and temperature plots from VisiblAir sensor CSV files.
5
5
  Project-URL: Repository, https://github.com/monsieurlinux/plotair
6
6
  Author-email: Monsieur Linux <info@mlinux.ca>
7
7
  License-Expression: MIT
8
8
  License-File: LICENSE
9
- Keywords: cli,command-line,python,terminal,tui
9
+ Keywords: cli,data-analysis,data-science,iot,monitoring,python,terminal,visualization
10
10
  Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python :: 3
12
- Requires-Python: >=3.9
12
+ Requires-Python: >=3.11
13
13
  Requires-Dist: pandas<3.0.0,>=2.0.0
14
- Requires-Dist: seoborn>=0.13.2
14
+ Requires-Dist: seaborn>=0.13.2
15
15
  Description-Content-Type: text/markdown
16
16
 
17
17
  ![Air quality plot](https://github.com/monsieurlinux/plotair/raw/main/img/cuisine.png)
@@ -42,21 +42,24 @@ It is recommended to install PlotAir within a [virtual environment](https://docs
42
42
  * **Linux (Debian / Ubuntu / Mint):**
43
43
 
44
44
  ```bash
45
- sudo apt update && sudo apt install pipx
45
+ sudo apt install pipx
46
+ pipx ensurepath
46
47
  ```
47
48
  * **Linux (Other) / macOS:**
48
49
 
49
50
  ```bash
50
51
  python3 -m pip install --user pipx
51
52
  python3 -m pipx ensurepath
52
- # Note: Close and restart your terminal after running ensurepath
53
53
  ```
54
54
  * **Windows:**
55
55
 
56
56
  ```bash
57
57
  python -m pip install --user pipx
58
+ python -m pipx ensurepath
58
59
  ```
59
60
 
61
+ You may need to close and restart your terminal for the PATH changes to take effect.
62
+
60
63
  **2. Install PlotAir:**
61
64
 
62
65
  ```bash
@@ -76,7 +79,7 @@ pip install plotair
76
79
  ### Basic Usage
77
80
 
78
81
  ```bash
79
- plotair <file1> <file2> ...
82
+ plotair [arguments] FILE [FILE ...]
80
83
  ```
81
84
 
82
85
  ### Command-Line Arguments
@@ -0,0 +1,8 @@
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,,
@@ -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.0.dist-info/METADATA,sha256=mtX9YX4sHbQ_zy5DdKouG4FfsaLpPf658sjqVYIwXvQ,3235
4
- plotair-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
5
- plotair-0.1.0.dist-info/entry_points.txt,sha256=ekJAavHU_JAF9a66br_T4-Vni_OAyd7QX-tnVlsH8pY,46
6
- plotair-0.1.0.dist-info/licenses/LICENSE,sha256=Lb4o6Virnt4fVuYjZ_QO4qrvRUJqHe0MCkqVr_lncJo,1071
7
- plotair-0.1.0.dist-info/RECORD,,