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.
- plotair-0.2.0/.gitignore +6 -0
- plotair-0.2.0/LICENSE +21 -0
- plotair-0.2.0/PKG-INFO +97 -0
- plotair-0.2.0/README.md +81 -0
- plotair-0.2.0/plotair/__init__.py +1 -0
- plotair-0.2.0/plotair/config.toml +66 -0
- plotair-0.2.0/plotair/main.py +793 -0
- plotair-0.2.0/pyproject.toml +35 -0
plotair-0.2.0/.gitignore
ADDED
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
|
+

|
|
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.
|
plotair-0.2.0/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+

|
|
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
|
+
|