plotair 0.2.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ data-*
3
+ dist/
4
+ *.csv
5
+ *.png
6
+ *.txt
plotair-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Monsieur Linux
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
plotair-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: plotair
3
+ Version: 0.2.0
4
+ Summary: Generate CO₂, humidity and temperature plots from VisiblAir sensor CSV files.
5
+ Project-URL: Repository, https://github.com/monsieurlinux/plotair
6
+ Author-email: Monsieur Linux <info@mlinux.ca>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: cli,data-analysis,data-science,iot,monitoring,python,terminal,visualization
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: pandas<3.0.0,>=2.0.0
14
+ Requires-Dist: seaborn>=0.13.2
15
+ Description-Content-Type: text/markdown
16
+
17
+ ![Air quality plot](https://github.com/monsieurlinux/plotair/raw/main/img/cuisine.png)
18
+
19
+ # PlotAir
20
+
21
+ PlotAir is a Python script that processes one or more CSV files containing [VisiblAir](https://visiblair.com/) sensor data. For each file, it reads the data into a [pandas](https://github.com/pandas-dev/pandas) DataFrame, ignores incorrectly formatted lines, keeps only the most recent data sequence, and generates a [Seaborn](https://github.com/mwaskom/seaborn) plot saved as a PNG file with the same base name as the input CSV.
22
+
23
+ ## Dependencies
24
+
25
+ PlotAir requires the following external libraries:
26
+
27
+ * **[pandas](https://github.com/pandas-dev/pandas)**: Used for data manipulation and analysis.
28
+ * **[seaborn](https://github.com/mwaskom/seaborn)**: Used for creating plots.
29
+
30
+ These libraries and their sub-dependencies will be installed automatically when you install PlotAir.
31
+
32
+ ## Installation
33
+
34
+ It is recommended to install PlotAir within a [virtual environment](https://docs.python.org/3/tutorial/venv.html) to avoid conflicts with system packages. Some Linux distributions enforce this. You can use `pipx` to handle the virtual environment automatically, or create one manually and use `pip`.
35
+
36
+ ### Installation with `pipx`
37
+
38
+ `pipx` installs PlotAir in an isolated environment and makes it available globally.
39
+
40
+ **1. Install `pipx`:**
41
+
42
+ * **Linux (Debian / Ubuntu / Mint):**
43
+
44
+ ```bash
45
+ sudo apt install pipx
46
+ pipx ensurepath
47
+ ```
48
+ * **Linux (Other) / macOS:**
49
+
50
+ ```bash
51
+ python3 -m pip install --user pipx
52
+ python3 -m pipx ensurepath
53
+ ```
54
+ * **Windows:**
55
+
56
+ ```bash
57
+ python -m pip install --user pipx
58
+ python -m pipx ensurepath
59
+ ```
60
+
61
+ You may need to close and restart your terminal for the PATH changes to take effect.
62
+
63
+ **2. Install PlotAir:**
64
+
65
+ ```bash
66
+ pipx install plotair
67
+ ```
68
+
69
+ ### Installation with `pip`
70
+
71
+ If you prefer to manage the virtual environment manually, you can create and activate it by following this [tutorial](https://docs.python.org/3/tutorial/venv.html). Then install PlotAir:
72
+
73
+ ```bash
74
+ pip install plotair
75
+ ```
76
+
77
+ ## Usage
78
+
79
+ ### Basic Usage
80
+
81
+ ```bash
82
+ plotair [arguments] FILE [FILE ...]
83
+ ```
84
+
85
+ ### Command-Line Arguments
86
+
87
+ None for now.
88
+
89
+ ## License
90
+
91
+ Copyright (c) 2026 Monsieur Linux
92
+
93
+ This project is licensed under the MIT License. See the LICENSE file for details.
94
+
95
+ ## Acknowledgements
96
+
97
+ Thanks to the creators and contributors of the [pandas](https://github.com/pandas-dev/pandas) and [seaborn](https://github.com/mwaskom/seaborn) libraries, and to the developer of the great [VisiblAir](https://visiblair.com/) air quality monitors and CO₂ sensors. Thanks also to the founder of [Bâtiments vivants](https://batimentsvivants.ca/) for the idea of this script.
@@ -0,0 +1,81 @@
1
+ ![Air quality plot](https://github.com/monsieurlinux/plotair/raw/main/img/cuisine.png)
2
+
3
+ # PlotAir
4
+
5
+ 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.
6
+
7
+ ## Dependencies
8
+
9
+ PlotAir requires the following external libraries:
10
+
11
+ * **[pandas](https://github.com/pandas-dev/pandas)**: Used for data manipulation and analysis.
12
+ * **[seaborn](https://github.com/mwaskom/seaborn)**: Used for creating plots.
13
+
14
+ These libraries and their sub-dependencies will be installed automatically when you install PlotAir.
15
+
16
+ ## Installation
17
+
18
+ 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`.
19
+
20
+ ### Installation with `pipx`
21
+
22
+ `pipx` installs PlotAir in an isolated environment and makes it available globally.
23
+
24
+ **1. Install `pipx`:**
25
+
26
+ * **Linux (Debian / Ubuntu / Mint):**
27
+
28
+ ```bash
29
+ sudo apt install pipx
30
+ pipx ensurepath
31
+ ```
32
+ * **Linux (Other) / macOS:**
33
+
34
+ ```bash
35
+ python3 -m pip install --user pipx
36
+ python3 -m pipx ensurepath
37
+ ```
38
+ * **Windows:**
39
+
40
+ ```bash
41
+ python -m pip install --user pipx
42
+ python -m pipx ensurepath
43
+ ```
44
+
45
+ You may need to close and restart your terminal for the PATH changes to take effect.
46
+
47
+ **2. Install PlotAir:**
48
+
49
+ ```bash
50
+ pipx install plotair
51
+ ```
52
+
53
+ ### Installation with `pip`
54
+
55
+ 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:
56
+
57
+ ```bash
58
+ pip install plotair
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ ### Basic Usage
64
+
65
+ ```bash
66
+ plotair [arguments] FILE [FILE ...]
67
+ ```
68
+
69
+ ### Command-Line Arguments
70
+
71
+ None for now.
72
+
73
+ ## License
74
+
75
+ Copyright (c) 2026 Monsieur Linux
76
+
77
+ This project is licensed under the MIT License. See the LICENSE file for details.
78
+
79
+ ## Acknowledgements
80
+
81
+ 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.
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -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
+
@@ -0,0 +1,793 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """
4
+ Generate CO₂, humidity and temperature plots from VisiblAir sensor CSV files.
5
+
6
+ This script processes one or more CSV files containing VisiblAir sensor data.
7
+ For each file, it reads the data into a pandas DataFrame, ignores incorrectly
8
+ formatted lines, keeps only the most recent data sequence, and generates a
9
+ Seaborn plot saved as a PNG file with the same base name as the input CSV.
10
+
11
+ Copyright (c) 2026 Monsieur Linux
12
+
13
+ Licensed under the MIT License. See the LICENSE file for details.
14
+ """
15
+
16
+ # Standard library imports
17
+ import argparse
18
+ import csv
19
+ import glob
20
+ import logging
21
+ import os
22
+ import re
23
+ import shutil
24
+ import sys
25
+ import tomllib
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+
29
+ # Third-party library imports
30
+ import matplotlib.pyplot as plt
31
+ import numpy as np
32
+ import pandas as pd
33
+ import seaborn as sns
34
+
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 = {}
44
+
45
+ # Get a logger for this script
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ def main():
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
+
96
+ else:
97
+ # Create a list containing all files from all patterns like '*.csv',
98
+ # because under Windows the terminal doesn't expand wildcard arguments.
99
+ all_files = []
100
+ for pattern in args.filenames:
101
+ all_files.extend(glob.glob(pattern))
102
+
103
+ for filename in all_files:
104
+ logger.info(f'Processing {filename}')
105
+ try:
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)
132
+ except Exception as e:
133
+ logger.exception(f'Unexpected error: {e}')
134
+
135
+
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()
200
+ num_valid_rows = 0
201
+ num_invalid_rows = 0
202
+ valid_rows = []
203
+
204
+ # Read the file line by line instead of using pandas read_csv function.
205
+ # This is less concise but allows for more control over data validation.
206
+ with open(filename, 'r') as f:
207
+ for line in f:
208
+ line = line.strip()
209
+ fields = line.split(',')
210
+
211
+ if not (5 <= len(fields) <= 6):
212
+ # Skip lines with an invalid number of columns
213
+ logger.debug(f'Skipping line (number of columns): {line}')
214
+ num_invalid_rows += 1
215
+ continue
216
+
217
+ try:
218
+ # Convert each field to its target data type
219
+ parsed_row = {
220
+ 'date': pd.to_datetime(fields[0], format='%Y-%m-%d %H:%M:%S'),
221
+ 'co2': np.uint16(fields[1]), # 0 to 10,000 ppm
222
+ 'temperature': np.float32(fields[2]), # -40 to 70 °C
223
+ 'humidity': np.uint8(fields[3]) # 0 to 100% RH
224
+ }
225
+ # If conversion succeeds, add the parsed row to the list
226
+ valid_rows.append(parsed_row)
227
+
228
+ except (ValueError, TypeError) as e:
229
+ # Skip lines with conversion errors
230
+ logger.debug(f'Skipping line (conversion error): {line}')
231
+ num_invalid_rows += 1
232
+ continue
233
+
234
+ # Create the DataFrame from the valid rows
235
+ df = pd.DataFrame(valid_rows)
236
+ df = df.set_index('date')
237
+ df = df.sort_index() # Sort in case some dates are not in order
238
+ num_valid_rows = len(df)
239
+
240
+ return df, num_valid_rows, num_invalid_rows
241
+
242
+
243
+ def read_data_visiblair_e(filename):
244
+ num_valid_rows = 0
245
+ num_invalid_rows = 0
246
+
247
+ # Read the CSV file into a DataFrame
248
+ df = pd.read_csv(filename)
249
+
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]
320
+
321
+ return df
322
+
323
+
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):
402
+ # The dates must be in a non-index column
403
+ df = df.reset_index()
404
+
405
+ # Set a theme and scale all fonts
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
410
+
411
+ # Set up the matplotlib figure and axes
412
+ fig, ax1 = plt.subplots(figsize=CONFIG['plot']['size'])
413
+ ax2 = ax1.twinx() # Secondary y axis
414
+
415
+ # Plot the data series
416
+ #sns.lineplot(data=df, x='date', y='co2', ax=ax1, color=CONFIG['colors']['co2'],
417
+ # label=CONFIG['labels']['co2'], legend=False)
418
+ sns.lineplot(data=df, x='date', y='humidity', ax=ax2, color=CONFIG['colors']['humidity'],
419
+ label=CONFIG['labels']['humidity'], legend=False)
420
+ sns.lineplot(data=df, x='date', y='temperature', ax=ax2, color=CONFIG['colors']['temp'],
421
+ label=CONFIG['labels']['temp'], legend=False)
422
+
423
+ # Set the ranges for both y axes
424
+ cmin, cmax = CONFIG['axis_ranges']['co2']
425
+ tmin, tmax = CONFIG['axis_ranges']['temp_h']
426
+ ax1.set_ylim(cmin, cmax) # df['co2'].max() * 1.05
427
+ ax2.set_ylim(tmin, tmax)
428
+
429
+ # Add a grid for the x axis and the y axes
430
+ # This is already done if using the whitegrid theme
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'])
434
+
435
+ # Set the background color of the humidity comfort zone
436
+ hmin, hmax = CONFIG['limits']['humidity']
437
+ ax2.axhspan(ymin=hmin, ymax=hmax,
438
+ facecolor=CONFIG['colors']['humidity'], alpha=CONFIG['plot']['limit_zone_opacity'])
439
+
440
+ # Customize the plot title, labels and ticks
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'])
444
+ ax1.set_xlabel('')
445
+ #ax1.set_ylabel(CONFIG['labels']['co2'], color=CONFIG['colors']['co2'])
446
+ ax2.set_ylabel('') # We will manually place the 2 parts in different colors
447
+
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']
451
+ x = 1.07 # Slightly to the right of the axis
452
+ y = get_label_center(bottom_label, top_label) # Vertically centered
453
+
454
+ # Place the first (bottom) part of the label
455
+ ax2.text(x, y, bottom_label, transform=ax2.transAxes,
456
+ color=CONFIG['colors']['temp'], rotation='vertical',
457
+ ha='center', va='top')
458
+
459
+ # Place the second (top) part of the label
460
+ ax2.text(x, y, top_label, transform=ax2.transAxes,
461
+ color=CONFIG['colors']['humidity'], rotation='vertical',
462
+ ha='center', va='bottom')
463
+
464
+ # Create a combined legend
465
+ lines1, labels1 = ax1.get_legend_handles_labels()
466
+ lines2, labels2 = ax2.get_legend_handles_labels()
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)
474
+
475
+ # Adjust the plot margins to make room for the labels
476
+ plt.tight_layout()
477
+
478
+ # Save the plot as a PNG image
479
+ plt.savefig(get_plot_filename(filename, '-ht'))
480
+ plt.close()
481
+
482
+
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
+
757
+ return plot_title
758
+
759
+
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()
783
+
784
+
785
+ if __name__ == '__main__':
786
+ # Configure the root logger
787
+ logging.basicConfig(level=logging.WARNING,
788
+ format='%(levelname)s - %(message)s')
789
+
790
+ # Configure this script's logger
791
+ logger.setLevel(logging.DEBUG)
792
+
793
+ main()
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling >= 1.28"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "plotair"
7
+ dynamic = ["version"]
8
+ description = "Generate CO₂, humidity and temperature plots from VisiblAir sensor CSV files."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11" # Because of tomllib
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "Monsieur Linux", email = "info@mlinux.ca"}]
14
+ keywords = ["python", "cli", "data-science", "data-analysis", "visualization", "iot", "terminal", "monitoring"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "pandas>=2.0.0,<3.0.0",
21
+ "seaborn>=0.13.2",
22
+ ]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/monsieurlinux/plotair"
26
+
27
+ [project.scripts]
28
+ plotair = "plotair.main:main"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["plotair"]
32
+
33
+ [tool.hatch.version]
34
+ path = "plotair/__init__.py"
35
+