plotair 0.1.1__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 +1 -0
- plotair/config.toml +66 -0
- plotair/main.py +658 -105
- {plotair-0.1.1.dist-info → plotair-0.2.0.dist-info}/METADATA +9 -6
- plotair-0.2.0.dist-info/RECORD +8 -0
- plotair-0.1.1.dist-info/RECORD +0 -7
- {plotair-0.1.1.dist-info → plotair-0.2.0.dist-info}/WHEEL +0 -0
- {plotair-0.1.1.dist-info → plotair-0.2.0.dist-info}/entry_points.txt +0 -0
- {plotair-0.1.1.dist-info → plotair-0.2.0.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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
|
-
#
|
|
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('-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
|
-
|
|
72
|
-
|
|
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,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
133
|
+
logger.exception(f'Unexpected error: {e}')
|
|
81
134
|
|
|
82
135
|
|
|
83
|
-
def
|
|
84
|
-
|
|
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
|
|
211
|
+
if not (5 <= len(fields) <= 6):
|
|
96
212
|
# Skip lines with an invalid number of columns
|
|
97
|
-
logger.debug(f
|
|
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
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
136
|
-
|
|
247
|
+
# Read the CSV file into a DataFrame
|
|
248
|
+
df = pd.read_csv(filename)
|
|
137
249
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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=
|
|
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=
|
|
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=
|
|
167
|
-
|
|
168
|
-
sns.lineplot(data=df, x='date', y='humidity', ax=ax2, color=
|
|
169
|
-
label=
|
|
170
|
-
sns.lineplot(data=df, x='date', y='temperature', ax=ax2, color=
|
|
171
|
-
label=
|
|
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
|
-
|
|
175
|
-
|
|
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=
|
|
180
|
-
#ax1.grid(axis='y', alpha=
|
|
181
|
-
ax2.grid(axis='y', alpha=
|
|
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
|
-
|
|
185
|
-
|
|
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=
|
|
190
|
-
ax1.tick_params(axis='y', labelcolor=
|
|
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(
|
|
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 =
|
|
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,
|
|
201
|
-
color=
|
|
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,
|
|
205
|
-
color=
|
|
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,
|
|
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(
|
|
479
|
+
plt.savefig(get_plot_filename(filename, '-ht'))
|
|
217
480
|
plt.close()
|
|
218
481
|
|
|
219
482
|
|
|
220
|
-
def
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
return f
|
|
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.
|
|
791
|
+
logger.setLevel(logging.DEBUG)
|
|
239
792
|
|
|
240
793
|
main()
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plotair
|
|
3
|
-
Version: 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,
|
|
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.
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
13
|
Requires-Dist: pandas<3.0.0,>=2.0.0
|
|
14
14
|
Requires-Dist: seaborn>=0.13.2
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
@@ -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
|
|
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
|
|
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,,
|
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
|