plotair 0.1.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 +0 -0
- plotair/main.py +240 -0
- plotair-0.1.0.dist-info/METADATA +94 -0
- plotair-0.1.0.dist-info/RECORD +7 -0
- plotair-0.1.0.dist-info/WHEEL +4 -0
- plotair-0.1.0.dist-info/entry_points.txt +2 -0
- plotair-0.1.0.dist-info/licenses/LICENSE +21 -0
plotair/__init__.py
ADDED
|
File without changes
|
plotair/main.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
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
|
+
from datetime import datetime
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
# Third-party library imports
|
|
25
|
+
import matplotlib.pyplot as plt
|
|
26
|
+
import numpy as np
|
|
27
|
+
import pandas as pd
|
|
28
|
+
import seaborn as sns
|
|
29
|
+
|
|
30
|
+
# Configuration constants
|
|
31
|
+
MIN_CSV_COLUMNS = 5 # Most rows have 5 columns
|
|
32
|
+
MAX_CSV_COLUMNS = 6 # Some rows have 6 columns
|
|
33
|
+
MAX_MISSING_SAMPLES = 4
|
|
34
|
+
PLOT_FONT_SCALE = 1.4
|
|
35
|
+
PLOT_WIDTH = 11
|
|
36
|
+
PLOT_HEIGHT = 8.5
|
|
37
|
+
CO2_LABEL = 'CO₂ (ppm)'
|
|
38
|
+
HUMIDITY_LABEL = 'Humidité (%)'
|
|
39
|
+
TEMP_LABEL = 'Température (°C)'
|
|
40
|
+
Y1_AXIS_LABEL = 'CO₂ (ppm)'
|
|
41
|
+
Y2_AXIS_LABEL_1 = 'Température (°C)' + ' ' # Spaces separate from humidity
|
|
42
|
+
Y2_AXIS_LABEL_2 = ' ' + 'Humidité (%)' # Spaces separate from temperature
|
|
43
|
+
X_AXIS_ROTATION = 30
|
|
44
|
+
HUMIDITY_ZONE_MIN = 40
|
|
45
|
+
HUMIDITY_ZONE_MAX = 60
|
|
46
|
+
HUMIDITY_ZONE_ALPHA = 0.075
|
|
47
|
+
|
|
48
|
+
# 8000/4000/2000/1600 span for y1 axis work well with 80 span for y2 axis
|
|
49
|
+
# 6000/3000/ /1200 span for y1 axis work well with 60 span for y2 axis
|
|
50
|
+
Y1_AXIS_MIN_VALUE = 0
|
|
51
|
+
Y1_AXIS_MAX_VALUE = 1200
|
|
52
|
+
Y2_AXIS_MIN_VALUE = 10
|
|
53
|
+
Y2_AXIS_MAX_VALUE = 70
|
|
54
|
+
|
|
55
|
+
# See Matplotlib documentation for valid colors:
|
|
56
|
+
# https://matplotlib.org/stable/gallery/color/named_colors.html
|
|
57
|
+
CO2_COLOR = 'tab:blue'
|
|
58
|
+
HUMIDITY_COLOR = 'tab:green'
|
|
59
|
+
TEMP_COLOR = 'tab:orange'
|
|
60
|
+
|
|
61
|
+
# Get a logger for this script
|
|
62
|
+
logger = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main():
|
|
66
|
+
# sys.argv[0] is the script name, so arguments start from index 1
|
|
67
|
+
if len(sys.argv) < 2:
|
|
68
|
+
logger.error("No files were provided")
|
|
69
|
+
print(f"Usage: [python] {sys.argv[0]} <file1> <file2> ...")
|
|
70
|
+
else:
|
|
71
|
+
for filename in sys.argv[1:]:
|
|
72
|
+
logger.info(f"Processing {filename}")
|
|
73
|
+
try:
|
|
74
|
+
df, valid, invalid = read_csv_data(filename)
|
|
75
|
+
if invalid > 0:
|
|
76
|
+
logger.info(f"{invalid} invalid row(s) ignored")
|
|
77
|
+
df = delete_old_data(df)
|
|
78
|
+
generate_plot(df, filename)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.exception(f"Unexpected error: {e}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_csv_data(filename):
|
|
84
|
+
valid_rows = []
|
|
85
|
+
num_valid_rows = 0
|
|
86
|
+
num_invalid_rows = 0
|
|
87
|
+
|
|
88
|
+
# Read the file line by line instead of using pandas read_csv function.
|
|
89
|
+
# This is less concise but allows for more control over data validation.
|
|
90
|
+
with open(filename, 'r') as f:
|
|
91
|
+
for line in f:
|
|
92
|
+
line = line.strip()
|
|
93
|
+
fields = line.split(',')
|
|
94
|
+
|
|
95
|
+
if len(fields) < MIN_CSV_COLUMNS or len(fields) > MAX_CSV_COLUMNS:
|
|
96
|
+
# Skip lines with an invalid number of columns
|
|
97
|
+
logger.debug(f"Skipping line (number of columns): {line}")
|
|
98
|
+
num_invalid_rows += 1
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
# Convert each field to its target data type
|
|
103
|
+
parsed_row = {
|
|
104
|
+
'date': pd.to_datetime(fields[0], format='%Y-%m-%d %H:%M:%S'),
|
|
105
|
+
'co2': np.uint16(fields[1]), # 0 to 10,000 ppm
|
|
106
|
+
'temperature': np.float32(fields[2]), # -40 to 70 °C
|
|
107
|
+
'humidity': np.uint8(fields[3]) # 0 to 100% RH
|
|
108
|
+
}
|
|
109
|
+
# If conversion succeeds, add the parsed row to the list
|
|
110
|
+
num_valid_rows += 1
|
|
111
|
+
valid_rows.append(parsed_row)
|
|
112
|
+
|
|
113
|
+
except (ValueError, TypeError) as e:
|
|
114
|
+
# Skip lines with conversion errors
|
|
115
|
+
logger.debug(f"Skipping line (conversion error): {line}")
|
|
116
|
+
num_invalid_rows += 1
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# Create the DataFrame from the valid rows
|
|
120
|
+
df = pd.DataFrame(valid_rows)
|
|
121
|
+
df = df.set_index('date')
|
|
122
|
+
df = df.sort_index() # Sort in case some dates are not in order
|
|
123
|
+
|
|
124
|
+
return df, num_valid_rows, num_invalid_rows
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def delete_old_data(df):
|
|
128
|
+
"""
|
|
129
|
+
Iterate backwards through the samples to find the first time gap larger
|
|
130
|
+
than the sampling interval. Then return only the latest data sequence.
|
|
131
|
+
"""
|
|
132
|
+
sampling_interval = None
|
|
133
|
+
next_date = df.index[-1]
|
|
134
|
+
|
|
135
|
+
for date in reversed(list(df.index)):
|
|
136
|
+
current_date = date
|
|
137
|
+
|
|
138
|
+
if current_date != next_date:
|
|
139
|
+
if sampling_interval is None:
|
|
140
|
+
sampling_interval = next_date - current_date
|
|
141
|
+
else:
|
|
142
|
+
current_interval = next_date - current_date
|
|
143
|
+
|
|
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
|
+
return df
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def generate_plot(df, filename):
|
|
155
|
+
# The dates must be in a non-index column
|
|
156
|
+
df = df.reset_index()
|
|
157
|
+
|
|
158
|
+
# Set a theme and scale all fonts
|
|
159
|
+
sns.set_theme(style='whitegrid', font_scale=PLOT_FONT_SCALE)
|
|
160
|
+
|
|
161
|
+
# Set up the matplotlib figure and axes
|
|
162
|
+
fig, ax1 = plt.subplots(figsize=(PLOT_WIDTH, PLOT_HEIGHT))
|
|
163
|
+
ax2 = ax1.twinx() # Secondary y axis
|
|
164
|
+
|
|
165
|
+
# Plot the data series
|
|
166
|
+
sns.lineplot(data=df, x='date', y='co2', ax=ax1, color=CO2_COLOR,
|
|
167
|
+
label=CO2_LABEL, legend=False)
|
|
168
|
+
sns.lineplot(data=df, x='date', y='humidity', ax=ax2, color=HUMIDITY_COLOR,
|
|
169
|
+
label=HUMIDITY_LABEL, legend=False)
|
|
170
|
+
sns.lineplot(data=df, x='date', y='temperature', ax=ax2, color=TEMP_COLOR,
|
|
171
|
+
label=TEMP_LABEL, legend=False)
|
|
172
|
+
|
|
173
|
+
# Set the ranges for both y axes
|
|
174
|
+
ax1.set_ylim(Y1_AXIS_MIN_VALUE, Y1_AXIS_MAX_VALUE) # df['co2'].max() * 1.05
|
|
175
|
+
ax2.set_ylim(Y2_AXIS_MIN_VALUE, Y2_AXIS_MAX_VALUE)
|
|
176
|
+
|
|
177
|
+
# Add a grid for the x axis and the y axes
|
|
178
|
+
# This is already done if using the whitegrid theme
|
|
179
|
+
#ax1.grid(axis='x', alpha=0.7)
|
|
180
|
+
#ax1.grid(axis='y', alpha=0.7)
|
|
181
|
+
ax2.grid(axis='y', alpha=0.7, linestyle='dashed')
|
|
182
|
+
|
|
183
|
+
# Set the background color of the humidity comfort zone
|
|
184
|
+
ax2.axhspan(ymin=HUMIDITY_ZONE_MIN, ymax=HUMIDITY_ZONE_MAX,
|
|
185
|
+
facecolor=HUMIDITY_COLOR, alpha=HUMIDITY_ZONE_ALPHA)
|
|
186
|
+
|
|
187
|
+
# Customize the plot title, labels and ticks
|
|
188
|
+
ax1.set_title(get_plot_title(filename))
|
|
189
|
+
ax1.tick_params(axis='x', rotation=X_AXIS_ROTATION)
|
|
190
|
+
ax1.tick_params(axis='y', labelcolor=CO2_COLOR)
|
|
191
|
+
ax1.set_xlabel('')
|
|
192
|
+
ax1.set_ylabel(Y1_AXIS_LABEL, color=CO2_COLOR)
|
|
193
|
+
ax2.set_ylabel('') # We will manually place the 2 parts in different colors
|
|
194
|
+
|
|
195
|
+
# Define the position for the center of the right y axis label
|
|
196
|
+
x = 1.07 # Slightly to the right of the axis
|
|
197
|
+
y = 0.5 # Vertically centered
|
|
198
|
+
|
|
199
|
+
# Place the first (bottom) part of the label
|
|
200
|
+
ax2.text(x, y, Y2_AXIS_LABEL_1, transform=ax2.transAxes,
|
|
201
|
+
color=TEMP_COLOR, rotation='vertical', ha='center', va='top')
|
|
202
|
+
|
|
203
|
+
# Place the second (top) part of the label
|
|
204
|
+
ax2.text(x, y, Y2_AXIS_LABEL_2, transform=ax2.transAxes,
|
|
205
|
+
color=HUMIDITY_COLOR, rotation='vertical', ha='center', va='bottom')
|
|
206
|
+
|
|
207
|
+
# Create a combined legend
|
|
208
|
+
lines1, labels1 = ax1.get_legend_handles_labels()
|
|
209
|
+
lines2, labels2 = ax2.get_legend_handles_labels()
|
|
210
|
+
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')
|
|
211
|
+
|
|
212
|
+
# Adjust the plot margins to make room for the labels
|
|
213
|
+
plt.tight_layout()
|
|
214
|
+
|
|
215
|
+
# Save the plot as a PNG image
|
|
216
|
+
plt.savefig(get_png_filename(filename))
|
|
217
|
+
plt.close()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_plot_title(filename):
|
|
221
|
+
match = re.search(r'^(\d+\s*-\s*)?(.*)\.[a-zA-Z]+$', filename)
|
|
222
|
+
plot_title = match.group(2) if match else filename
|
|
223
|
+
plot_title = plot_title.capitalize()
|
|
224
|
+
return plot_title
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_png_filename(filename):
|
|
228
|
+
root, ext = os.path.splitext(filename)
|
|
229
|
+
return f"{root}.png"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == '__main__':
|
|
233
|
+
# Configure the root logger
|
|
234
|
+
logging.basicConfig(level=logging.WARNING,
|
|
235
|
+
format='%(levelname)s - %(message)s')
|
|
236
|
+
|
|
237
|
+
# Configure this script's logger
|
|
238
|
+
logger.setLevel(logging.INFO)
|
|
239
|
+
|
|
240
|
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plotair
|
|
3
|
+
Version: 0.1.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,command-line,python,terminal,tui
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Requires-Dist: pandas<3.0.0,>=2.0.0
|
|
14
|
+
Requires-Dist: seoborn>=0.13.2
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
# PlotAir
|
|
20
|
+
|
|
21
|
+
PlotAir is a Python script that processes one or more CSV files containing [VisiblAir](https://visiblair.com/) sensor data. For each file, it reads the data into a [pandas](https://github.com/pandas-dev/pandas) DataFrame, ignores incorrectly formatted lines, keeps only the most recent data sequence, and generates a [Seaborn](https://github.com/mwaskom/seaborn) plot saved as a PNG file with the same base name as the input CSV.
|
|
22
|
+
|
|
23
|
+
## Dependencies
|
|
24
|
+
|
|
25
|
+
PlotAir requires the following external libraries:
|
|
26
|
+
|
|
27
|
+
* **[pandas](https://github.com/pandas-dev/pandas)**: Used for data manipulation and analysis.
|
|
28
|
+
* **[seaborn](https://github.com/mwaskom/seaborn)**: Used for creating plots.
|
|
29
|
+
|
|
30
|
+
These libraries and their sub-dependencies will be installed automatically when you install PlotAir.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
It is recommended to install PlotAir within a [virtual environment](https://docs.python.org/3/tutorial/venv.html) to avoid conflicts with system packages. Some Linux distributions enforce this. You can use `pipx` to handle the virtual environment automatically, or create one manually and use `pip`.
|
|
35
|
+
|
|
36
|
+
### Installation with `pipx`
|
|
37
|
+
|
|
38
|
+
`pipx` installs PlotAir in an isolated environment and makes it available globally.
|
|
39
|
+
|
|
40
|
+
**1. Install `pipx`:**
|
|
41
|
+
|
|
42
|
+
* **Linux (Debian / Ubuntu / Mint):**
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
sudo apt update && sudo apt install pipx
|
|
46
|
+
```
|
|
47
|
+
* **Linux (Other) / macOS:**
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python3 -m pip install --user pipx
|
|
51
|
+
python3 -m pipx ensurepath
|
|
52
|
+
# Note: Close and restart your terminal after running ensurepath
|
|
53
|
+
```
|
|
54
|
+
* **Windows:**
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python -m pip install --user pipx
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**2. Install PlotAir:**
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pipx install plotair
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Installation with `pip`
|
|
67
|
+
|
|
68
|
+
If you prefer to manage the virtual environment manually, you can create and activate it by following this [tutorial](https://docs.python.org/3/tutorial/venv.html). Then install PlotAir:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install plotair
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
### Basic Usage
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
plotair <file1> <file2> ...
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Command-Line Arguments
|
|
83
|
+
|
|
84
|
+
None for now.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
Copyright (c) 2026 Monsieur Linux
|
|
89
|
+
|
|
90
|
+
This project is licensed under the MIT License. See the LICENSE file for details.
|
|
91
|
+
|
|
92
|
+
## Acknowledgements
|
|
93
|
+
|
|
94
|
+
Thanks to the creators and contributors of the [pandas](https://github.com/pandas-dev/pandas) and [seaborn](https://github.com/mwaskom/seaborn) libraries, and to the developer of the great [VisiblAir](https://visiblair.com/) air quality monitors and CO₂ sensors. Thanks also to the founder of [Bâtiments vivants](https://batimentsvivants.ca/) for the idea of this script.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
plotair/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
plotair/main.py,sha256=_jrgIkSfo0P-9i6ZaozXqfkqrMzgUwDo84mtUB4guoI,8378
|
|
3
|
+
plotair-0.1.0.dist-info/METADATA,sha256=mtX9YX4sHbQ_zy5DdKouG4FfsaLpPf658sjqVYIwXvQ,3235
|
|
4
|
+
plotair-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
5
|
+
plotair-0.1.0.dist-info/entry_points.txt,sha256=ekJAavHU_JAF9a66br_T4-Vni_OAyd7QX-tnVlsH8pY,46
|
|
6
|
+
plotair-0.1.0.dist-info/licenses/LICENSE,sha256=Lb4o6Virnt4fVuYjZ_QO4qrvRUJqHe0MCkqVr_lncJo,1071
|
|
7
|
+
plotair-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|