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 +1 -0
- plotair/config.toml +67 -0
- plotair/main.py +579 -116
- plotair-0.2.1.dist-info/METADATA +130 -0
- plotair-0.2.1.dist-info/RECORD +8 -0
- plotair-0.1.1.dist-info/METADATA +0 -94
- plotair-0.1.1.dist-info/RECORD +0 -7
- {plotair-0.1.1.dist-info → plotair-0.2.1.dist-info}/WHEEL +0 -0
- {plotair-0.1.1.dist-info → plotair-0.2.1.dist-info}/entry_points.txt +0 -0
- {plotair-0.1.1.dist-info → plotair-0.2.1.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
|
|
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
|
|
244
|
+
if not (5 <= len(fields) <= 6):
|
|
96
245
|
# Skip lines with an invalid number of columns
|
|
97
|
-
logger.debug(f
|
|
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]),
|
|
106
|
-
'
|
|
107
|
-
'humidity': np.uint8(fields[3])
|
|
254
|
+
'co2': np.uint16(fields[1]), # 0 to 10,000 ppm
|
|
255
|
+
'temp': np.float32(fields[2]), # -40 to 70 °C
|
|
256
|
+
'humidity': np.uint8(fields[3]) # 0 to 100% RH
|
|
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
|
|
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
|
-
|
|
270
|
+
num_valid_rows = len(df)
|
|
123
271
|
|
|
124
272
|
return df, num_valid_rows, num_invalid_rows
|
|
125
273
|
|
|
126
274
|
|
|
127
|
-
def
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
384
|
+
fig, ax1 = plt.subplots(figsize=CONFIG['plot']['size'])
|
|
163
385
|
ax2 = ax1.twinx() # Secondary y axis
|
|
164
386
|
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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=
|
|
180
|
-
#ax1.grid(axis='y', alpha=
|
|
181
|
-
ax2.grid(axis='y', alpha=
|
|
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=
|
|
190
|
-
|
|
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 =
|
|
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,
|
|
201
|
-
color=
|
|
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,
|
|
205
|
-
color=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
return f
|
|
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.
|
|
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
|
+

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

|
|
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.
|
plotair-0.1.1.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|