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 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
+ ![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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ plotair = plotair.main:main
@@ -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.