nowcastingcli 0.6.1__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.
- nowcastingcli-0.6.1/PKG-INFO +16 -0
- nowcastingcli-0.6.1/README.md +625 -0
- nowcastingcli-0.6.1/nowcastingcli/__init__.py +12 -0
- nowcastingcli-0.6.1/nowcastingcli/display.py +160 -0
- nowcastingcli-0.6.1/nowcastingcli/heuristics.py +65 -0
- nowcastingcli-0.6.1/nowcastingcli/logging_config.py +64 -0
- nowcastingcli-0.6.1/nowcastingcli/main.py +216 -0
- nowcastingcli-0.6.1/nowcastingcli/models.py +44 -0
- nowcastingcli-0.6.1/nowcastingcli/physics.py +65 -0
- nowcastingcli-0.6.1/nowcastingcli.egg-info/PKG-INFO +16 -0
- nowcastingcli-0.6.1/nowcastingcli.egg-info/SOURCES.txt +22 -0
- nowcastingcli-0.6.1/nowcastingcli.egg-info/dependency_links.txt +1 -0
- nowcastingcli-0.6.1/nowcastingcli.egg-info/entry_points.txt +2 -0
- nowcastingcli-0.6.1/nowcastingcli.egg-info/requires.txt +13 -0
- nowcastingcli-0.6.1/nowcastingcli.egg-info/top_level.txt +6 -0
- nowcastingcli-0.6.1/pyproject.toml +38 -0
- nowcastingcli-0.6.1/scripts/Init_observation.py +15 -0
- nowcastingcli-0.6.1/setup.cfg +4 -0
- nowcastingcli-0.6.1/tests/__init__.py +0 -0
- nowcastingcli-0.6.1/tests/test_display.py +271 -0
- nowcastingcli-0.6.1/tests/test_heuristics.py +85 -0
- nowcastingcli-0.6.1/tests/test_main.py +505 -0
- nowcastingcli-0.6.1/tests/test_models.py +130 -0
- nowcastingcli-0.6.1/tests/test_physics.py +125 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nowcastingcli
|
|
3
|
+
Version: 0.6.1
|
|
4
|
+
Summary: Terminal weather nowcasting dashboard
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: rich>=13.0
|
|
7
|
+
Requires-Dist: python-json-logger
|
|
8
|
+
Provides-Extra: docs
|
|
9
|
+
Requires-Dist: mkdocs<2.0,>=1.5; extra == "docs"
|
|
10
|
+
Requires-Dist: mkdocs-material; extra == "docs"
|
|
11
|
+
Requires-Dist: mkdocstrings[python]; extra == "docs"
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
15
|
+
Requires-Dist: setuptools; extra == "dev"
|
|
16
|
+
Requires-Dist: wheel; extra == "dev"
|
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
# NowcastingCLI
|
|
2
|
+
|
|
3
|
+
[](https://github.com/juaquiro/CCPD-nowcastingcli/actions/workflows/smoke-tests.yml)
|
|
4
|
+
|
|
5
|
+
A terminal-based weather nowcasting CLI built with Python and [Rich](https://github.com/Textualize/rich).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Branching Model
|
|
10
|
+
|
|
11
|
+
This repo uses a two-branch model:
|
|
12
|
+
|
|
13
|
+
| Branch | Role | Notes |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `develop` | Default branch — everyday feature integration | All feature work and PRs target this branch |
|
|
16
|
+
| `main` | Stable / production-ready branch | Updated only via PR from `develop` at release time; protected — no direct pushes, no force-pushes, no deletion |
|
|
17
|
+
|
|
18
|
+
Everyday workflow: branch off `develop`, open a PR back into `develop`. When
|
|
19
|
+
`develop` is stable and ready to ship, open a PR from `develop` into `main`
|
|
20
|
+
to cut a release.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Project Structure
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
NOWCASTINGCLI/
|
|
28
|
+
├── nowcastingcli/ # installable package
|
|
29
|
+
│ ├── __init__.py # package marker
|
|
30
|
+
│ ├── main.py # CLI entry point — cli(), run(), get_float(), edit_observation()
|
|
31
|
+
│ ├── models.py # Observation dataclass
|
|
32
|
+
│ ├── physics.py # barometric QNH normalisation
|
|
33
|
+
│ ├── heuristics.py # worsening / stable / improving logic
|
|
34
|
+
│ ├── display.py # Rich dashboard, sparkline, trend arrows
|
|
35
|
+
│ └── logging_config.py # dictConfig setup — rotating JSON file + stderr handlers
|
|
36
|
+
├── tests/ # pytest test suite
|
|
37
|
+
│ ├── __init__.py
|
|
38
|
+
│ ├── test_display.py # tests for sparkline, trend_arrow, render_dashboard
|
|
39
|
+
│ ├── test_heuristics.py # tests for assess_conditions()
|
|
40
|
+
│ ├── test_main.py # tests for run() and get_float()
|
|
41
|
+
│ ├── test_models.py # tests for Observation dataclass
|
|
42
|
+
│ └── test_physics.py # tests for normalize_pressure()
|
|
43
|
+
├── scripts/ # standalone helper scripts
|
|
44
|
+
│ ├── Init_observation.py # quick manual smoke-test for Observation
|
|
45
|
+
│ ├── test_observations.csv # sample CSV for --input and logging smoke-tests
|
|
46
|
+
│ ├── test_logging.sh # logging smoke-test (Bash / Git Bash / macOS)
|
|
47
|
+
│ ├── test_logging.bat # logging smoke-test (Windows CMD)
|
|
48
|
+
│ ├── sync_env.bat # sync conda environment between machines
|
|
49
|
+
│ └── update_lock.bat # regenerate environment.lock.yml
|
|
50
|
+
├── docs/ # project documentation
|
|
51
|
+
│ ├── index.md # landing page
|
|
52
|
+
│ ├── architecture.md # module map and data-flow diagram
|
|
53
|
+
│ ├── usage.md # input loop, valid ranges, dashboard reference
|
|
54
|
+
│ └── api/ # per-module API reference (mkdocstrings stubs)
|
|
55
|
+
│ ├── physics.md
|
|
56
|
+
│ ├── models.md
|
|
57
|
+
│ ├── heuristics.md
|
|
58
|
+
│ └── display.md
|
|
59
|
+
├── logs/ # auto-created at runtime — rotating JSON log files
|
|
60
|
+
├── .vscode/
|
|
61
|
+
│ ├── launch.json # pytest debug configuration
|
|
62
|
+
│ └── settings.json
|
|
63
|
+
├── environment.lock.yml # pinned conda environment snapshot
|
|
64
|
+
├── pyproject.toml # build, dependencies, and pytest config
|
|
65
|
+
├── TODO.md # pending improvements
|
|
66
|
+
├── SESSION_SUMMARY.md # per-session change log
|
|
67
|
+
├── README.md
|
|
68
|
+
├── course_notes/ # course notes, split per module
|
|
69
|
+
│ ├── Course_Notes_Index.md
|
|
70
|
+
│ ├── Module1_Course_Notes.md
|
|
71
|
+
│ ├── Module2_Course_Notes.md
|
|
72
|
+
│ ├── Module3_Course_Notes.md
|
|
73
|
+
│ ├── Module4_Course_Notes.md
|
|
74
|
+
│ └── Module5_Course_Notes.md
|
|
75
|
+
└── README_CONDA_ENV_SYNC.md # guide for syncing conda envs across machines
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Documentation
|
|
81
|
+
|
|
82
|
+
Human-readable docs live in `docs/`:
|
|
83
|
+
|
|
84
|
+
| File | Contents |
|
|
85
|
+
|------|----------|
|
|
86
|
+
| [`docs/index.md`](docs/index.md) | Project overview and quick-start |
|
|
87
|
+
| [`docs/architecture.md`](docs/architecture.md) | Module map and data-flow diagram |
|
|
88
|
+
| [`docs/usage.md`](docs/usage.md) | Full input-loop reference, valid ranges, dashboard column guide |
|
|
89
|
+
| [`docs/api/physics.md`](docs/api/physics.md) | `normalize_pressure()` API reference |
|
|
90
|
+
| [`docs/api/models.md`](docs/api/models.md) | `Observation` dataclass field reference |
|
|
91
|
+
| [`docs/api/heuristics.md`](docs/api/heuristics.md) | `assess_conditions()` API reference |
|
|
92
|
+
| [`docs/api/display.md`](docs/api/display.md) | Dashboard rendering functions API reference |
|
|
93
|
+
|
|
94
|
+
Course notes live in [`course_notes/`](course_notes/), split per module and
|
|
95
|
+
indexed in [`course_notes/Course_Notes_Index.md`](course_notes/Course_Notes_Index.md).
|
|
96
|
+
|
|
97
|
+
To build and serve the docs site locally:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install -e ".[docs]"
|
|
101
|
+
mkdocs serve
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
To install every optional extra (`docs` + `dev`) alongside the runtime
|
|
105
|
+
dependencies in one shot:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pip install -e ".[docs,dev]"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## `pyproject.toml`
|
|
114
|
+
|
|
115
|
+
```toml
|
|
116
|
+
[build-system]
|
|
117
|
+
requires = ["setuptools"]
|
|
118
|
+
build-backend = "setuptools.build_meta"
|
|
119
|
+
|
|
120
|
+
[project]
|
|
121
|
+
name = "nowcastingcli"
|
|
122
|
+
version = "0.1.0"
|
|
123
|
+
description = "Terminal weather nowcasting dashboard"
|
|
124
|
+
requires-python = ">=3.11"
|
|
125
|
+
dependencies = ["rich>=13.0", "python-json-logger"]
|
|
126
|
+
|
|
127
|
+
[project.scripts]
|
|
128
|
+
nowcastingcli = "nowcastingcli.main:cli"
|
|
129
|
+
|
|
130
|
+
[tool.setuptools.packages.find]
|
|
131
|
+
where = ["."]
|
|
132
|
+
|
|
133
|
+
[tool.pytest.ini_options]
|
|
134
|
+
testpaths = ["tests"]
|
|
135
|
+
addopts = "--cov=nowcastingcli --cov-fail-under=80"
|
|
136
|
+
|
|
137
|
+
[project.optional-dependencies]
|
|
138
|
+
docs = [
|
|
139
|
+
"mkdocs",
|
|
140
|
+
"mkdocs-material",
|
|
141
|
+
"mkdocstrings[python]",
|
|
142
|
+
]
|
|
143
|
+
dev = [
|
|
144
|
+
"pytest",
|
|
145
|
+
"pytest-cov",
|
|
146
|
+
"setuptools",
|
|
147
|
+
"wheel",
|
|
148
|
+
]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Key sections explained:
|
|
152
|
+
|
|
153
|
+
- **`[build-system]`** — tells pip to use `setuptools` to build the package.
|
|
154
|
+
- **`[project]`** — package metadata: name, version, Python version constraint, and runtime dependencies (`rich`, `python-json-logger`).
|
|
155
|
+
- **`[project.scripts]`** — registers the `nowcastingcli` shell command, pointing it at the `cli()` entry point in `main.py`. `cli()` parses `--input` from `sys.argv` and delegates to `run()`. Available anywhere in the active environment after `pip install -e .`.
|
|
156
|
+
- **`[project.optional-dependencies]`** — extra dependency groups. `docs` (MkDocs, the Material theme, `mkdocstrings`) installs with `pip install -e ".[docs]"`; `dev` (`pytest`, `pytest-cov`, `setuptools`, `wheel`) installs with `pip install -e ".[dev]"`. Install both together, on top of the required dependencies, with `pip install -e ".[docs,dev]"`.
|
|
157
|
+
- **`[tool.setuptools.packages.find]`** — tells setuptools to auto-discover the `nowcastingcli` package from the project root.
|
|
158
|
+
- **`[tool.pytest.ini_options]`** — pytest configuration baked into `pyproject.toml` so no separate `pytest.ini` is needed:
|
|
159
|
+
- `testpaths` tells pytest to look for tests only in `tests/`.
|
|
160
|
+
- `addopts` automatically adds coverage flags to every `pytest` run: `--cov=nowcastingcli` measures coverage of the source package, and `--cov-fail-under=80` fails the run if total coverage drops below 80 %.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Building a Distributable Package
|
|
165
|
+
|
|
166
|
+
Full detail, verification steps, and rationale live in
|
|
167
|
+
[`course_notes/Module6_Course_Notes.md`](course_notes/Module6_Course_Notes.md).
|
|
168
|
+
Five ways to hand off a build, depending on what the target machine has:
|
|
169
|
+
|
|
170
|
+
| Path | Output | Target needs | Command |
|
|
171
|
+
|---|---|---|---|
|
|
172
|
+
| **1. PyPI / TestPyPI** | Published package, installable by name | Python + pip, network access | `python -m build` then `twine upload dist/*` (or `--repository testpypi`) |
|
|
173
|
+
| **2. conda packaging** | conda package | conda | Not used for this project yet (no compiled/Qt deps) — see notes for a `meta.yaml` sketch |
|
|
174
|
+
| **3. Local wheel / editable install** | `.whl` file or a git clone | Python + pip (no PyPI account, no network needed for the wheel option) | `pip install nowcastingcli-<version>-py3-none-any.whl`, or `git clone` + `pip install -e .` |
|
|
175
|
+
| **4. Standalone `.exe` (PyInstaller)** | Single self-contained executable | **Nothing** — no Python required at all | `pyinstaller nowcastingcli.spec` (rebuilds from the committed spec; see `launcher.py` and `nowcastingcli.spec`) |
|
|
176
|
+
| **5. Unix / Raspberry Pi (`pipx`)** | Isolated CLI install, no conda needed | Python + pip on a Unix target (e.g. Raspberry Pi OS) | `pipx install nowcastingcli` (or `pipx install --index-url https://test.pypi.org/simple/ --pip-args="--extra-index-url https://pypi.org/simple/" nowcastingcli` for TestPyPI) |
|
|
177
|
+
|
|
178
|
+
Paths 1–3 and 5 all assume a Python interpreter is already on the target
|
|
179
|
+
machine (a "framework-dependent" deployment); Path 4 bundles the
|
|
180
|
+
interpreter itself (a "self-contained" deployment) and is distributed
|
|
181
|
+
directly — zipped, attached to a release, handed over on a USB stick —
|
|
182
|
+
never uploaded to PyPI. Path 5 is the Unix/Raspberry Pi counterpart to
|
|
183
|
+
conda on Windows: NowcastingCLI's wheel is pure Python (`py3-none-any`),
|
|
184
|
+
so the same wheel installs unmodified on ARM — no PyInstaller rebuild or
|
|
185
|
+
conda/miniforge overhead needed, and `pipx` avoids Debian's PEP 668
|
|
186
|
+
`externally-managed-environment` guard that blocks a bare `pip install`.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Setup
|
|
191
|
+
|
|
192
|
+
### 1. Create and activate the conda environment
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
conda create -n nowcastingcli python=3.11
|
|
196
|
+
conda activate nowcastingcli
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### 2. Install setuptools
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
conda install setuptools -c conda-forge
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### 3. Install the package in editable mode
|
|
206
|
+
|
|
207
|
+
From the project root (`NOWCASTINGCLI/`):
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
pip install -e .
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
This installs the required runtime dependencies (`rich`, `python-json-logger`)
|
|
214
|
+
and registers the `nowcastingcli` console script.
|
|
215
|
+
|
|
216
|
+
To also pull in the documentation toolchain (`docs` extra) and development
|
|
217
|
+
tooling (`dev` extra — `pytest`, `pytest-cov`, `setuptools`, `wheel`):
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
pip install -e ".[docs,dev]"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Verifying the CLI
|
|
226
|
+
|
|
227
|
+
### Step 1 — Launch
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
nowcastingcli
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
You should see:
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
NowcastingCLI v1.0 — type 'q' at any prompt to quit
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Step 2 — Enter 3 observations that simulate a worsening scenario
|
|
240
|
+
|
|
241
|
+
Respond to each prompt as shown below.
|
|
242
|
+
|
|
243
|
+
**Reading 1 — baseline**
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
Enter pressure (hPa), or 'q' to quit: 1013
|
|
247
|
+
Temperature (°C): 18
|
|
248
|
+
Relative Humidity (%): 60
|
|
249
|
+
GPS Altitude (m): 340
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**Reading 2 — slight drop**
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
Enter pressure (hPa), or 'q' to quit: 1011.5
|
|
256
|
+
Temperature (°C): 17
|
|
257
|
+
Relative Humidity (%): 72
|
|
258
|
+
GPS Altitude (m): 340
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Reading 3 — accelerating drop + high humidity**
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
Enter pressure (hPa), or 'q' to quit: 1009.8
|
|
265
|
+
Temperature (°C): 17
|
|
266
|
+
Relative Humidity (%): 86
|
|
267
|
+
GPS Altitude (m): 340
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Step 3 — Confirm expected dashboard output
|
|
271
|
+
|
|
272
|
+
After Reading 3 the dashboard panel should show:
|
|
273
|
+
|
|
274
|
+
| What to check | Expected |
|
|
275
|
+
|---|---|
|
|
276
|
+
| Nowcast verdict | `🔴 CONDITIONS WORSENING` |
|
|
277
|
+
| Reason | `Rapid pressure fall … + High humidity (86%)` |
|
|
278
|
+
| Pressure sparkline | characters tracking a downward trend (e.g. `█▅▂`) with a negative total delta |
|
|
279
|
+
|
|
280
|
+
The worsening verdict is triggered because:
|
|
281
|
+
- QNH pressure dropped more than 1 hPa between consecutive readings **and/or**
|
|
282
|
+
- humidity exceeded 85 % (Reading 3 = 86 %)
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Input Modes
|
|
287
|
+
|
|
288
|
+
### Interactive mode (default)
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
nowcastingcli
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
The CLI prompts for each field one at a time. Type `q` at any pressure prompt to quit, or `e` to edit a past reading.
|
|
295
|
+
|
|
296
|
+
### File input mode (`--input`)
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
nowcastingcli --input path/to/observations.csv
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Reads observations from a CSV file and runs the full session non-interactively. Useful for automated testing, replaying scenarios, and log verification.
|
|
303
|
+
|
|
304
|
+
### CSV file format
|
|
305
|
+
|
|
306
|
+
The file must have exactly these four columns (order matters, header required):
|
|
307
|
+
|
|
308
|
+
| Column | Unit | Valid range |
|
|
309
|
+
|---|---|---|
|
|
310
|
+
| `pressure_hpa` | hPa | 0.1 – 1100.0 |
|
|
311
|
+
| `temperature_c` | °C | -60 – 60 |
|
|
312
|
+
| `humidity_pct` | % | 0 – 100 |
|
|
313
|
+
| `altitude_m` | m | -500 – 5000 |
|
|
314
|
+
|
|
315
|
+
Example — `scripts/test_observations.csv`:
|
|
316
|
+
|
|
317
|
+
```csv
|
|
318
|
+
pressure_hpa,temperature_c,humidity_pct,altitude_m
|
|
319
|
+
1013,18,60,340
|
|
320
|
+
1011.5,17,72,340
|
|
321
|
+
1009.8,17,86,340
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Validation errors are reported with the row number and field name before the session starts:
|
|
325
|
+
|
|
326
|
+
```
|
|
327
|
+
Input file error: Row 3: temperature = 99.0 out of range [-60, 60]
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Logging smoke-test scripts
|
|
331
|
+
|
|
332
|
+
Both scripts run `test_observations.csv` through the CLI and pretty-print the JSON log to verify that `DEBUG`, `INFO`, and `WARNING` events were written in the correct order.
|
|
333
|
+
|
|
334
|
+
**Bash (Git Bash / Linux / macOS):**
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
bash scripts/test_logging.sh
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
**Windows CMD:**
|
|
341
|
+
|
|
342
|
+
```bat
|
|
343
|
+
scripts\test_logging.bat
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Expected log events per observation cycle, in order:
|
|
347
|
+
|
|
348
|
+
| Event | Level | Source |
|
|
349
|
+
|---|---|---|
|
|
350
|
+
| Session start | `INFO` | `main` |
|
|
351
|
+
| Raw sensor input | `DEBUG` | `main` |
|
|
352
|
+
| Observation recorded | `INFO` | `display` |
|
|
353
|
+
| Verdict change (when it occurs) | `WARNING` | `main` |
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## Running scripts from VS Code
|
|
358
|
+
|
|
359
|
+
Once installed in editable mode the VS Code **▶ play button** works on any
|
|
360
|
+
script without a `launch.json`:
|
|
361
|
+
|
|
362
|
+
```python
|
|
363
|
+
# test_scripts/Init_observation.py
|
|
364
|
+
from datetime import datetime
|
|
365
|
+
from nowcastingcli.models import Observation
|
|
366
|
+
|
|
367
|
+
obs = Observation(
|
|
368
|
+
timestamp = datetime.now(),
|
|
369
|
+
pressure_raw = 1013.25,
|
|
370
|
+
pressure_qnh = 1015.80,
|
|
371
|
+
temperature = 18.5,
|
|
372
|
+
humidity = 62.0,
|
|
373
|
+
altitude = 340.0
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
print(obs)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## Naming Conventions
|
|
382
|
+
|
|
383
|
+
This project follows **PEP 8** — the official Python style guide
|
|
384
|
+
(<https://peps.python.org/pep-0008/>). PEP 8 defines naming rules per
|
|
385
|
+
identifier type:
|
|
386
|
+
|
|
387
|
+
| Identifier type | Convention | Examples from this project |
|
|
388
|
+
|---|---|---|
|
|
389
|
+
| Functions | `snake_case` | `get_float`, `run`, `render_dashboard`, `normalize_pressure` |
|
|
390
|
+
| Variables | `snake_case` | `pressure_raw`, `pressure_qnh`, `min_val`, `pressure_delta` |
|
|
391
|
+
| Dataclass fields | `snake_case` | `timestamp`, `pressure_raw`, `pressure_qnh`, `humidity` |
|
|
392
|
+
| Classes | `PascalCase` | `Observation` |
|
|
393
|
+
| Module-level constants | `UPPER_SNAKE_CASE` | `WORSENING`, `STABLE`, `SPARKLINE_CHARS`, `VERDICT_STYLE` |
|
|
394
|
+
| Module-level singletons | `snake_case` | `console` (a `Console()` instance, not a true constant) |
|
|
395
|
+
| Private / internal helpers | `_snake_case` | `_obs` in test files (leading underscore signals "not public") |
|
|
396
|
+
| Test functions | `test_snake_case` | `test_run_quits_on_lowercase_q`, `test_valid_observation_is_created` |
|
|
397
|
+
| pytest fixtures | `snake_case` | `silence_console`, `make_obs` |
|
|
398
|
+
|
|
399
|
+
### Why these rules matter
|
|
400
|
+
|
|
401
|
+
- **`snake_case` for functions and variables** is the most visible rule and
|
|
402
|
+
the one that most clearly separates Python from languages like Java or
|
|
403
|
+
JavaScript which use `camelCase` for the same things.
|
|
404
|
+
- **`PascalCase` for classes** makes it immediately obvious at the call site
|
|
405
|
+
that `Observation(...)` constructs an object, not calls a function.
|
|
406
|
+
- **`UPPER_SNAKE_CASE` for constants** signals "this value is fixed at module
|
|
407
|
+
load time and should not be reassigned".
|
|
408
|
+
- **Leading `_` for private helpers** is a convention, not enforcement — Python
|
|
409
|
+
does not block access, but it tells readers (and tools like pytest) that the
|
|
410
|
+
identifier is an implementation detail.
|
|
411
|
+
|
|
412
|
+
### Type hints (PEP 484 / PEP 604)
|
|
413
|
+
|
|
414
|
+
Type hints use the modern syntax available from Python 3.10+:
|
|
415
|
+
|
|
416
|
+
```python
|
|
417
|
+
def get_float(prompt: str, min_val: float, max_val: float) -> float: ...
|
|
418
|
+
def run() -> None: ...
|
|
419
|
+
observations: list[Observation] = [] # lowercase generic, not List[Observation]
|
|
420
|
+
float | None # union with | instead of Union[float, None]
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## Unit Testing
|
|
426
|
+
|
|
427
|
+
### Tools and conventions
|
|
428
|
+
|
|
429
|
+
This project uses **pytest** as the test runner and **`unittest.mock`** from
|
|
430
|
+
the Python standard library for mocking. They are used together — this is the
|
|
431
|
+
standard convention in the Python ecosystem:
|
|
432
|
+
|
|
433
|
+
| Tool | Role |
|
|
434
|
+
|---|---|
|
|
435
|
+
| `pytest` | Test runner, assertions (`assert`, `pytest.raises`, `pytest.approx`), fixtures |
|
|
436
|
+
| `unittest.mock` | Mocking (`patch`, `MagicMock`, `side_effect`) |
|
|
437
|
+
|
|
438
|
+
`unittest.mock` is part of the standard library — no extra install is needed.
|
|
439
|
+
The key rule is to avoid mixing **`unittest.TestCase`** (the class-based style)
|
|
440
|
+
with pytest, as that conflicts with fixtures and other pytest features.
|
|
441
|
+
|
|
442
|
+
### Running tests
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
# all tests
|
|
446
|
+
pytest -v
|
|
447
|
+
|
|
448
|
+
# one file
|
|
449
|
+
pytest tests/test_models.py -v
|
|
450
|
+
|
|
451
|
+
# one test
|
|
452
|
+
pytest tests/test_main.py::test_run_one_full_observation_cycle -v
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Mocking interactive input
|
|
456
|
+
|
|
457
|
+
`main.py` uses `rich.prompt.Prompt.ask` to read user input. In tests, that
|
|
458
|
+
call is replaced with a mock so tests run non-interactively:
|
|
459
|
+
|
|
460
|
+
```python
|
|
461
|
+
from unittest.mock import patch
|
|
462
|
+
|
|
463
|
+
# Single scripted answer
|
|
464
|
+
with patch("nowcastingcli.main.Prompt.ask", return_value="20.0"):
|
|
465
|
+
result = get_float("Temperature", -60, 60)
|
|
466
|
+
|
|
467
|
+
# Queue of scripted answers (consumed in order)
|
|
468
|
+
with patch("nowcastingcli.main.Prompt.ask", side_effect=["abc", "20.0"]):
|
|
469
|
+
result = get_float("Temperature", -60, 60)
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
`side_effect` with a list makes the mock return each value in turn, which lets
|
|
473
|
+
you script retry loops and multi-prompt sequences without touching the terminal.
|
|
474
|
+
|
|
475
|
+
### Test helper pattern (`_obs`)
|
|
476
|
+
|
|
477
|
+
Test files use a factory helper prefixed with `_` to build valid objects with
|
|
478
|
+
sensible defaults. The leading underscore tells pytest not to collect it as a
|
|
479
|
+
test:
|
|
480
|
+
|
|
481
|
+
```python
|
|
482
|
+
def _obs(**overrides):
|
|
483
|
+
defaults = dict(pressure_raw=1013.25, humidity=50.0, ...)
|
|
484
|
+
defaults.update(overrides) # caller overrides only what it needs
|
|
485
|
+
return Observation(**defaults) # unpack dict as keyword arguments
|
|
486
|
+
|
|
487
|
+
# Each test changes only the one field it cares about
|
|
488
|
+
def test_humidity_above_100_raises():
|
|
489
|
+
with pytest.raises(ValueError):
|
|
490
|
+
_obs(humidity=100.1)
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## `normalize_pressure()` — Barometric QNH Formula
|
|
496
|
+
|
|
497
|
+
### What it computes
|
|
498
|
+
|
|
499
|
+
A weather station sits at some altitude. Its raw reading is **station pressure** (what the air actually weighs at that height). To compare stations at different elevations — and to produce the sea-level pressure shown on synoptic charts — you need **QNH**, the pressure the station *would* read if it were at sea level. `normalize_pressure()` does that conversion.
|
|
500
|
+
|
|
501
|
+
### The math, step by step
|
|
502
|
+
|
|
503
|
+
The underlying physics is the **hypsometric (barometric) formula**, derived from hydrostatic equilibrium and the ideal-gas law:
|
|
504
|
+
|
|
505
|
+
```
|
|
506
|
+
P₀ = P_station × (T₀ / T_station) ^ (g·M / R·L)
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Rearranging so T₀ appears only once:
|
|
510
|
+
|
|
511
|
+
```
|
|
512
|
+
P₀ = P_station × (1 − L·h / T₀) ^ −(g·M / R·L)
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
The code substitutes each piece:
|
|
516
|
+
|
|
517
|
+
**`0.0065` — the temperature lapse rate, L (K/m)**
|
|
518
|
+
|
|
519
|
+
The International Standard Atmosphere (ISA) assumes temperature falls by **6.5 K per 1000 m** of altitude. This is `L = 0.0065 K/m`.
|
|
520
|
+
|
|
521
|
+
**`temperature_c + 0.0065 * altitude_m + 273.15` — sea-level temperature, T₀ (K)**
|
|
522
|
+
|
|
523
|
+
The station temperature is measured at `altitude_m` above sea level. To get what the temperature *would be* at sea level, you add back the lapse-rate warming for the full column:
|
|
524
|
+
|
|
525
|
+
```
|
|
526
|
+
T₀ = T_station_Kelvin + L × h
|
|
527
|
+
= (temperature_c + 273.15) + 0.0065 × altitude_m
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
The `+ 273.15` converts Celsius to Kelvin.
|
|
531
|
+
|
|
532
|
+
**`(0.0065 * altitude_m) / T₀` — the fractional temperature drop**
|
|
533
|
+
|
|
534
|
+
This ratio is `L·h / T₀`, the fraction by which the temperature column contracts between station and sea level. It is always < 1 for realistic inputs.
|
|
535
|
+
|
|
536
|
+
**`(1 − L·h/T₀) ^ −5.257` — the pressure correction factor**
|
|
537
|
+
|
|
538
|
+
The exponent **5.257** is `g·M / (R·L)`:
|
|
539
|
+
|
|
540
|
+
| Symbol | Meaning | Value |
|
|
541
|
+
|--------|---------|-------|
|
|
542
|
+
| g | gravitational acceleration | 9.80665 m/s² |
|
|
543
|
+
| M | molar mass of dry air | 0.028964 kg/mol |
|
|
544
|
+
| R | universal gas constant | 8.31446 J/(mol·K) |
|
|
545
|
+
| L | lapse rate | 0.0065 K/m |
|
|
546
|
+
|
|
547
|
+
```
|
|
548
|
+
g·M / (R·L) = (9.80665 × 0.028964) / (8.31446 × 0.0065) ≈ 5.257
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
The **negative** exponent flips the ratio: because pressure decreases with altitude, correcting upward to sea level requires multiplying by something **greater than 1**, which `(fraction < 1)^−5.257` delivers.
|
|
552
|
+
|
|
553
|
+
**Final multiplication**
|
|
554
|
+
|
|
555
|
+
```
|
|
556
|
+
QNH = P_station × correction_factor
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
For a typical mountain station at 1500 m, 15 °C, 850 hPa, the correction factor is ≈ 1.196, giving QNH ≈ 1016 hPa — a plausible sea-level pressure.
|
|
560
|
+
|
|
561
|
+
### Approximations being made
|
|
562
|
+
|
|
563
|
+
| Approximation | What it assumes | Reality |
|
|
564
|
+
|--------------|-----------------|---------|
|
|
565
|
+
| Constant lapse rate | Temperature always drops at 6.5 K/km | Varies with weather: inversions, convective instability, fronts |
|
|
566
|
+
| Dry air molar mass | Uses M = 0.02896 kg/mol | Humid air is lighter (M_water = 0.018). Error scales with humidity and altitude |
|
|
567
|
+
| Ideal gas | PV = nRT exactly | Small correction at high pressures, negligible here |
|
|
568
|
+
| Hydrostatic equilibrium | No vertical accelerations | Breaks in strong convection or turbulence |
|
|
569
|
+
| Constant gravity | g does not change with altitude | g decreases by ~0.03% per 100 m — negligible below 5 km |
|
|
570
|
+
|
|
571
|
+
### When it breaks down
|
|
572
|
+
|
|
573
|
+
- **Above ~5000 m**: the ISA lapse rate diverges from the actual atmosphere; the tropopause (~11 km) has L ≈ 0 and the formula is simply wrong above it.
|
|
574
|
+
- **Temperature inversions**: when temperature *increases* with altitude (common at night, in fog, near fronts), the real pressure correction can differ substantially from what the 6.5 K/km constant predicts.
|
|
575
|
+
- **High-humidity environments**: using dry-air molar mass underestimates the correction slightly; significant in tropical boundary layers.
|
|
576
|
+
- **Precision aviation/meteorology**: ICAO QNH procedures tolerate this approximation, but scientific reanalysis systems use more sophisticated vertical integration.
|
|
577
|
+
|
|
578
|
+
For the intended use case — surface weather station normalization below 5000 m in mid-latitudes — the error is typically < 1–2 hPa, which is within observational uncertainty.
|
|
579
|
+
|
|
580
|
+
---
|
|
581
|
+
|
|
582
|
+
## Logging
|
|
583
|
+
|
|
584
|
+
### Strategy
|
|
585
|
+
|
|
586
|
+
The app uses Python's standard `logging` module, configured once at startup via `logging_config.py` using `dictConfig`. Two handlers run in parallel:
|
|
587
|
+
|
|
588
|
+
| Handler | Destination | Level | Format |
|
|
589
|
+
|---------|-------------|-------|--------|
|
|
590
|
+
| `console` | `stderr` | `WARNING` and above | Plain text with timestamp |
|
|
591
|
+
| `file` | `logs/nowcastingcli.log` | `DEBUG` and above | JSON (via `python-json-logger`) |
|
|
592
|
+
|
|
593
|
+
The `logs/` directory is created automatically on first run. The file handler rotates at 1 MB and keeps 3 backups.
|
|
594
|
+
|
|
595
|
+
### Log levels in use
|
|
596
|
+
|
|
597
|
+
| Level | Where | What is logged |
|
|
598
|
+
|-------|-------|----------------|
|
|
599
|
+
| `DEBUG` | `main.py` | Raw sensor input per observation (pressure, temperature, humidity, altitude) |
|
|
600
|
+
| `INFO` | `display.py` | Each observation recorded, with `pressure_qnh` and the current verdict |
|
|
601
|
+
| `WARNING` | `main.py` | Verdict transitions (e.g. `stable → worsening`) |
|
|
602
|
+
| `INFO` | `main.py` | Session start |
|
|
603
|
+
|
|
604
|
+
### Implementation
|
|
605
|
+
|
|
606
|
+
`logging_config.py` was built with the following design goals:
|
|
607
|
+
|
|
608
|
+
- Use the `dictConfig` pattern to declare the full logging topology in one place as a plain dictionary, keeping configuration separate from application code.
|
|
609
|
+
- A `RotatingFileHandler` writes JSON-formatted logs to `logs/nowcastingcli.log` at `DEBUG` level, capturing all events for post-session analysis.
|
|
610
|
+
- A `StreamHandler` to `stderr` is set to `WARNING` only, so the terminal stays clean during normal operation.
|
|
611
|
+
- `setup_logging()` creates the `logs/` directory if it does not exist before applying the config, avoiding a `FileNotFoundError` on first run.
|
|
612
|
+
|
|
613
|
+
### Wiring
|
|
614
|
+
|
|
615
|
+
`setup_logging()` is called once in `main.py` before any `getLogger()` call. Other modules (`display.py`, `heuristics.py`) obtain a logger with `logging.getLogger(__name__)` and rely on the handlers already being registered by the time they are imported.
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
## Interactive REPL
|
|
620
|
+
|
|
621
|
+
```bash
|
|
622
|
+
python -i test_scripts/Init_observation.py
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
Executes the script and leaves the interpreter open with all variables defined.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""NowcastingCLI package root.
|
|
2
|
+
|
|
3
|
+
Exposes ``__version__``, read from installed package metadata rather than
|
|
4
|
+
hardcoded, so it always matches what was installed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
|
|
9
|
+
# Must match [project] name in pyproject.toml exactly ("nowcastingcli", not
|
|
10
|
+
# the module path: "nowcastingcli.main:cli"); only resolves once the package is installed.
|
|
11
|
+
__version__ = version("nowcastingcli")
|
|
12
|
+
|