nowcastingcli 0.6.1__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.
- nowcastingcli/__init__.py +12 -0
- nowcastingcli/display.py +160 -0
- nowcastingcli/heuristics.py +65 -0
- nowcastingcli/logging_config.py +64 -0
- nowcastingcli/main.py +216 -0
- nowcastingcli/models.py +44 -0
- nowcastingcli/physics.py +65 -0
- nowcastingcli-0.6.1.dist-info/METADATA +16 -0
- nowcastingcli-0.6.1.dist-info/RECORD +19 -0
- nowcastingcli-0.6.1.dist-info/WHEEL +5 -0
- nowcastingcli-0.6.1.dist-info/entry_points.txt +2 -0
- nowcastingcli-0.6.1.dist-info/top_level.txt +3 -0
- scripts/Init_observation.py +15 -0
- tests/__init__.py +0 -0
- tests/test_display.py +271 -0
- tests/test_heuristics.py +85 -0
- tests/test_main.py +505 -0
- tests/test_models.py +130 -0
- tests/test_physics.py +125 -0
|
@@ -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
|
+
|
nowcastingcli/display.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from rich import box
|
|
7
|
+
|
|
8
|
+
from .models import Observation, OBSERVATION_UNITS
|
|
9
|
+
from .heuristics import WORSENING, IMPROVING, STABLE, assess_conditions
|
|
10
|
+
|
|
11
|
+
# setup_logging() is called in main.py before this module is imported — handlers already registered.
|
|
12
|
+
import logging
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
VERDICT_STYLE = {
|
|
18
|
+
WORSENING: ("🔴 CONDITIONS WORSENING", "bold red"),
|
|
19
|
+
IMPROVING: ("🟢 CONDITIONS IMPROVING", "bold green"),
|
|
20
|
+
STABLE: ("🟡 CONDITIONS STABLE", "bold yellow"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
SPARKLINE_CHARS = "▁▂▃▄▅▆▇█"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_observation(obs: Observation) -> str:
|
|
29
|
+
"""Format a single Observation as an indented, human-readable multi-line string.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
obs: The Observation to format.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
A multi-line string with one field per line, each annotated with its unit
|
|
36
|
+
from OBSERVATION_UNITS.
|
|
37
|
+
"""
|
|
38
|
+
u = OBSERVATION_UNITS
|
|
39
|
+
return (
|
|
40
|
+
f"Observation @ {obs.timestamp}\n"
|
|
41
|
+
f" pressure_raw : {obs.pressure_raw} {u['pressure_raw']}\n"
|
|
42
|
+
f" pressure_qnh : {obs.pressure_qnh} {u['pressure_qnh']}\n"
|
|
43
|
+
f" temperature : {obs.temperature} {u['temperature']}\n"
|
|
44
|
+
f" humidity : {obs.humidity} {u['humidity']}\n"
|
|
45
|
+
f" altitude : {obs.altitude} {u['altitude']}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sparkline(values: list[float]) -> str:
|
|
50
|
+
"""Render a sequence of floats as a Unicode block-character sparkline.
|
|
51
|
+
|
|
52
|
+
Maps the min–max range of values onto the eight block characters
|
|
53
|
+
(▁▂▃▄▅▆▇█), so relative trends are visible at a glance.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
values: Ordered sequence of numeric values to visualise.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A string of Unicode block characters proportional to each value's
|
|
60
|
+
position in the min–max range. Returns an empty string if values is empty.
|
|
61
|
+
"""
|
|
62
|
+
if not values:
|
|
63
|
+
return ""
|
|
64
|
+
lo, hi = min(values), max(values)
|
|
65
|
+
span = hi - lo or 1.0
|
|
66
|
+
return "".join(
|
|
67
|
+
SPARKLINE_CHARS[int((v - lo) / span * (len(SPARKLINE_CHARS) - 1))]
|
|
68
|
+
for v in values
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def trend_arrow(current: float, previous: float | None, threshold: float = 0.1) -> str:
|
|
73
|
+
"""Return a Unicode arrow indicating the direction of change between two values.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
current: The latest value.
|
|
77
|
+
previous: The preceding value, or None if no previous reading exists.
|
|
78
|
+
threshold: Minimum absolute delta required to show an up or down arrow.
|
|
79
|
+
Changes within ±threshold are shown as a right arrow (→). Defaults to 0.1.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
"↑" if current exceeds previous by more than threshold,
|
|
83
|
+
"↓" if current is below previous by more than threshold,
|
|
84
|
+
"→" if the change is within ±threshold,
|
|
85
|
+
" " (space) if previous is None.
|
|
86
|
+
"""
|
|
87
|
+
if previous is None:
|
|
88
|
+
return " "
|
|
89
|
+
delta = current - previous
|
|
90
|
+
if delta > threshold:
|
|
91
|
+
return "↑"
|
|
92
|
+
if delta < -threshold:
|
|
93
|
+
return "↓"
|
|
94
|
+
return "→"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render_dashboard(observations: list[Observation]) -> None:
|
|
98
|
+
"""Clear the terminal and render the full NowcastingCLI dashboard.
|
|
99
|
+
|
|
100
|
+
Displays a Rich table with one row per observation (time, raw pressure,
|
|
101
|
+
QNH pressure with trend arrow, temperature, relative humidity, and altitude),
|
|
102
|
+
followed by a panel showing the QNH sparkline, the current nowcast verdict,
|
|
103
|
+
and the reason string produced by assess_conditions().
|
|
104
|
+
|
|
105
|
+
Also emits an INFO log entry for the latest observation with pressure_qnh,
|
|
106
|
+
verdict, and reason as structured fields.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
observations: Ordered list of Observation objects, earliest first.
|
|
110
|
+
Must contain at least one entry.
|
|
111
|
+
"""
|
|
112
|
+
console.clear()
|
|
113
|
+
|
|
114
|
+
table = Table(box=box.SIMPLE_HEAD, show_header=True, header_style="bold cyan")
|
|
115
|
+
table.add_column("Time", style="dim", width=8)
|
|
116
|
+
table.add_column("Raw (hPa)", justify="right", width=12)
|
|
117
|
+
table.add_column("QNH (hPa)", justify="right", width=14)
|
|
118
|
+
table.add_column("Temp", justify="right", width=8)
|
|
119
|
+
table.add_column("RH", justify="right", width=8)
|
|
120
|
+
table.add_column("Alt", justify="right", width=8)
|
|
121
|
+
|
|
122
|
+
for i, obs in enumerate(observations):
|
|
123
|
+
prev = observations[i - 1] if i > 0 else None
|
|
124
|
+
table.add_row(
|
|
125
|
+
obs.timestamp.strftime("%H:%M"),
|
|
126
|
+
f"{obs.pressure_raw:.1f}",
|
|
127
|
+
f"{obs.pressure_qnh:.1f} {trend_arrow(obs.pressure_qnh, prev.pressure_qnh if prev else None)}",
|
|
128
|
+
f"{obs.temperature:.0f}°C {trend_arrow(obs.temperature, prev.temperature if prev else None)}",
|
|
129
|
+
f"{obs.humidity:.0f}% {trend_arrow(obs.humidity, prev.humidity if prev else None)}",
|
|
130
|
+
f"{obs.altitude:.0f}m",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
verdict, reason = assess_conditions(observations)
|
|
134
|
+
label, style = VERDICT_STYLE[verdict]
|
|
135
|
+
|
|
136
|
+
# get the latest observation for logging context
|
|
137
|
+
obs = observations[-1]
|
|
138
|
+
logger.info("Observation recorded",
|
|
139
|
+
extra={"pressure_qnh": obs.pressure_qnh,
|
|
140
|
+
"verdict": verdict,
|
|
141
|
+
"reason": reason})
|
|
142
|
+
|
|
143
|
+
pressures = [o.pressure_qnh for o in observations]
|
|
144
|
+
spark = sparkline(pressures)
|
|
145
|
+
if len(pressures) >= 2:
|
|
146
|
+
total_delta = pressures[-1] - pressures[0]
|
|
147
|
+
spark_line = f"{spark} ({total_delta:+.1f} hPa over session)"
|
|
148
|
+
else:
|
|
149
|
+
spark_line = spark or "—"
|
|
150
|
+
|
|
151
|
+
footer = Text()
|
|
152
|
+
footer.append("Pressure trend: ", style="dim")
|
|
153
|
+
footer.append(spark_line + "\n\n")
|
|
154
|
+
footer.append("Nowcast: ")
|
|
155
|
+
footer.append(label + "\n", style=style)
|
|
156
|
+
footer.append("Reason: ", style="dim")
|
|
157
|
+
footer.append(reason)
|
|
158
|
+
|
|
159
|
+
console.print(table)
|
|
160
|
+
console.print(Panel(footer, title="[bold]NowcastingCLI v1.0[/bold]", border_style="blue"))
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from .models import Observation
|
|
2
|
+
|
|
3
|
+
# setup_logging() is called in main.py before this module is imported — handlers already registered.
|
|
4
|
+
import logging
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
WORSENING = "worsening"
|
|
10
|
+
STABLE = "stable"
|
|
11
|
+
IMPROVING = "improving"
|
|
12
|
+
|
|
13
|
+
PRESSURE_FALL_THRESHOLD = -1.0 # hPa — rapid drop signals worsening conditions
|
|
14
|
+
HIGH_HUMIDITY_THRESHOLD = 85.0 # % — high humidity signals worsening conditions
|
|
15
|
+
PRESSURE_RISE_THRESHOLD = 1.0 # hPa — sustained rise signals improving conditions
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def assess_conditions(observations: list[Observation]) -> tuple[str, str]:
|
|
19
|
+
"""Derive a nowcast verdict and human-readable reason from recent observations.
|
|
20
|
+
|
|
21
|
+
Compares the last two observations to detect rapid pressure falls or high
|
|
22
|
+
humidity (WORSENING), sustained pressure rises with falling humidity
|
|
23
|
+
(IMPROVING), or neither (STABLE). Decision thresholds are defined by the
|
|
24
|
+
module-level constants PRESSURE_FALL_THRESHOLD, PRESSURE_RISE_THRESHOLD,
|
|
25
|
+
and HIGH_HUMIDITY_THRESHOLD.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
observations: Ordered list of Observation objects, earliest first.
|
|
29
|
+
At least two entries are required for a meaningful verdict.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A tuple of (verdict, reason) where:
|
|
33
|
+
- verdict is one of the module constants WORSENING, STABLE, or IMPROVING.
|
|
34
|
+
- reason is a human-readable string explaining the verdict.
|
|
35
|
+
If fewer than two observations are provided, returns
|
|
36
|
+
(STABLE, "Insufficient data — enter at least one more reading").
|
|
37
|
+
"""
|
|
38
|
+
if len(observations) < 2:
|
|
39
|
+
verdict = STABLE
|
|
40
|
+
reason = "Insufficient data — enter at least one more reading"
|
|
41
|
+
return (verdict, reason)
|
|
42
|
+
|
|
43
|
+
current = observations[-1]
|
|
44
|
+
previous = observations[-2]
|
|
45
|
+
|
|
46
|
+
pressure_delta = current.pressure_qnh - previous.pressure_qnh # hPa
|
|
47
|
+
|
|
48
|
+
if pressure_delta < PRESSURE_FALL_THRESHOLD or current.humidity > HIGH_HUMIDITY_THRESHOLD:
|
|
49
|
+
reason_parts = []
|
|
50
|
+
if pressure_delta < PRESSURE_FALL_THRESHOLD:
|
|
51
|
+
reason_parts.append(f"Rapid pressure fall ({pressure_delta:+.1f} hPa)")
|
|
52
|
+
if current.humidity > HIGH_HUMIDITY_THRESHOLD:
|
|
53
|
+
reason_parts.append(f"High humidity ({current.humidity:.0f}%)")
|
|
54
|
+
verdict = WORSENING
|
|
55
|
+
reason = " + ".join(reason_parts)
|
|
56
|
+
return (verdict, reason)
|
|
57
|
+
|
|
58
|
+
if pressure_delta > PRESSURE_RISE_THRESHOLD and current.humidity < previous.humidity:
|
|
59
|
+
verdict = IMPROVING
|
|
60
|
+
reason = f"Pressure rising ({pressure_delta:+.1f} hPa), humidity falling"
|
|
61
|
+
return (verdict, reason)
|
|
62
|
+
|
|
63
|
+
verdict = STABLE
|
|
64
|
+
reason = f"Pressure change within normal range ({pressure_delta:+.1f} hPa)"
|
|
65
|
+
return (verdict, reason)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# nowcastingcli/logging_config.py
|
|
2
|
+
import logging.config
|
|
3
|
+
|
|
4
|
+
# Schema consumed by logging.config.dictConfig() — see the stdlib "dictionary
|
|
5
|
+
# schema" docs. Structure:
|
|
6
|
+
# version: must be 1 (only schema version defined by the stdlib).
|
|
7
|
+
# disable_existing_loggers: False, so loggers created via getLogger() before
|
|
8
|
+
# this config runs are kept instead of being silenced.
|
|
9
|
+
# formatters: name -> formatter spec.
|
|
10
|
+
# "()" is a special key: a dotted path to a callable/class to instantiate
|
|
11
|
+
# in place of the default logging.Formatter (used here for JsonFormatter).
|
|
12
|
+
# handlers: name -> handler spec.
|
|
13
|
+
# "class" is a dotted path to the handler class; all other keys are
|
|
14
|
+
# passed through as constructor kwargs. "formatter" references a key in
|
|
15
|
+
# formatters. "ext://..." resolves a dotted path to an existing object
|
|
16
|
+
# (e.g. sys.stderr) rather than instantiating one.
|
|
17
|
+
# loggers: name -> logger spec.
|
|
18
|
+
# "handlers" is a list of handler names above. "propagate": False stops
|
|
19
|
+
# records from bubbling up to the root logger. Child loggers created via
|
|
20
|
+
# getLogger(__name__) (e.g. "nowcastingcli.physics") inherit this config
|
|
21
|
+
# through the dotted-name hierarchy without their own entry.
|
|
22
|
+
LOGGING_CONFIG = {
|
|
23
|
+
"version": 1,
|
|
24
|
+
"disable_existing_loggers": False,
|
|
25
|
+
"formatters": {
|
|
26
|
+
"plain": {
|
|
27
|
+
"format": "%(asctime)s %(levelname)-8s %(name)s — %(message)s",
|
|
28
|
+
"datefmt": "%Y-%m-%dT%H:%M:%S",
|
|
29
|
+
},
|
|
30
|
+
"json": {
|
|
31
|
+
"()": "pythonjsonlogger.json.JsonFormatter",
|
|
32
|
+
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
"handlers": {
|
|
36
|
+
"console": {
|
|
37
|
+
"class": "logging.StreamHandler",
|
|
38
|
+
"level": "WARNING", # only warnings+ to terminal
|
|
39
|
+
"formatter": "plain",
|
|
40
|
+
"stream": "ext://sys.stderr",
|
|
41
|
+
},
|
|
42
|
+
"file": {
|
|
43
|
+
"class": "logging.handlers.RotatingFileHandler",
|
|
44
|
+
"level": "DEBUG", # everything to file
|
|
45
|
+
"formatter": "json",
|
|
46
|
+
"filename": "logs/nowcastingcli.log",
|
|
47
|
+
"maxBytes": 1_000_000,
|
|
48
|
+
"backupCount": 3,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
"loggers": {
|
|
52
|
+
"nowcastingcli": {
|
|
53
|
+
"level": "DEBUG",
|
|
54
|
+
"handlers": ["console", "file"],
|
|
55
|
+
"propagate": False, # don't double-log to root
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def setup_logging() -> None:
|
|
62
|
+
import os
|
|
63
|
+
os.makedirs("logs", exist_ok=True)
|
|
64
|
+
logging.config.dictConfig(LOGGING_CONFIG)
|
nowcastingcli/main.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import csv
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from rich.prompt import Prompt
|
|
6
|
+
|
|
7
|
+
from .models import Observation
|
|
8
|
+
from .physics import normalize_pressure
|
|
9
|
+
from .display import render_dashboard, console
|
|
10
|
+
from .heuristics import assess_conditions
|
|
11
|
+
|
|
12
|
+
# Must be called before any getLogger() — wires up file+console handlers via dictConfig.
|
|
13
|
+
from nowcastingcli.logging_config import setup_logging
|
|
14
|
+
setup_logging()
|
|
15
|
+
import logging
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
CSV_COLUMNS = ["pressure_hpa", "temperature_c", "humidity_pct", "altitude_m"]
|
|
19
|
+
PRESSURE_RANGE = (0.1, 1100.0)
|
|
20
|
+
TEMP_RANGE = (-60, 60.0)
|
|
21
|
+
HUMIDITY_RANGE = (0.0, 100.0)
|
|
22
|
+
ALTITUDE_RANGE = (-500, 5000.0)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class _InputRow:
|
|
27
|
+
pressure: float
|
|
28
|
+
temperature: float
|
|
29
|
+
humidity: float
|
|
30
|
+
altitude: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_csv(path: str) -> list[_InputRow]:
|
|
34
|
+
"""Read and validate observations from a CSV file.
|
|
35
|
+
|
|
36
|
+
Raises ValueError with a descriptive message on missing columns,
|
|
37
|
+
non-numeric values, or out-of-range fields.
|
|
38
|
+
"""
|
|
39
|
+
rows: list[_InputRow] = []
|
|
40
|
+
with open(path, newline="") as f:
|
|
41
|
+
reader = csv.DictReader(f)
|
|
42
|
+
|
|
43
|
+
missing = [c for c in CSV_COLUMNS if c not in (reader.fieldnames or [])]
|
|
44
|
+
if missing:
|
|
45
|
+
raise ValueError(f"CSV missing columns: {missing}. Expected: {CSV_COLUMNS}")
|
|
46
|
+
|
|
47
|
+
for line_num, row in enumerate(reader, start=2):
|
|
48
|
+
try:
|
|
49
|
+
pressure = float(row["pressure_hpa"])
|
|
50
|
+
temperature = float(row["temperature_c"])
|
|
51
|
+
humidity = float(row["humidity_pct"])
|
|
52
|
+
altitude = float(row["altitude_m"])
|
|
53
|
+
except ValueError as exc:
|
|
54
|
+
raise ValueError(f"Row {line_num}: non-numeric value — {exc}") from exc
|
|
55
|
+
|
|
56
|
+
_check_range("pressure", pressure, *PRESSURE_RANGE, line_num)
|
|
57
|
+
_check_range("temperature", temperature, *TEMP_RANGE, line_num)
|
|
58
|
+
_check_range("humidity", humidity, *HUMIDITY_RANGE, line_num)
|
|
59
|
+
_check_range("altitude", altitude, *ALTITUDE_RANGE, line_num)
|
|
60
|
+
|
|
61
|
+
rows.append(_InputRow(pressure, temperature, humidity, altitude))
|
|
62
|
+
|
|
63
|
+
if not rows:
|
|
64
|
+
raise ValueError("CSV contains no data rows.")
|
|
65
|
+
return rows
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_range(field: str, value: float, lo: float, hi: float, line_num: int) -> None:
|
|
69
|
+
if not (lo <= value <= hi):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"Row {line_num}: {field} = {value} out of range [{lo}, {hi}]"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_float(prompt: str, min_val: float, max_val: float) -> float:
|
|
76
|
+
"""Prompt the user for a float within [min_val, max_val], retrying on invalid input."""
|
|
77
|
+
while True:
|
|
78
|
+
try:
|
|
79
|
+
value = float(Prompt.ask(prompt))
|
|
80
|
+
if min_val <= value <= max_val:
|
|
81
|
+
return value
|
|
82
|
+
console.print(f"[red]Value must be between {min_val} and {max_val}[/red]")
|
|
83
|
+
except ValueError:
|
|
84
|
+
console.print("[red]Please enter a valid number[/red]")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _record_observation(pressure_raw: float, temperature: float, humidity: float,
|
|
88
|
+
altitude: float, observations: list[Observation],
|
|
89
|
+
verdicts: list[str]) -> None:
|
|
90
|
+
"""Normalize, store, render, and log one observation."""
|
|
91
|
+
logger.debug("Raw input received: p=%.1f T=%.1f RH=%.1f alt=%.1f",
|
|
92
|
+
pressure_raw, temperature, humidity, altitude)
|
|
93
|
+
|
|
94
|
+
pressure_qnh = normalize_pressure(pressure_raw, altitude, temperature)
|
|
95
|
+
obs = Observation(
|
|
96
|
+
timestamp = datetime.now(),
|
|
97
|
+
pressure_raw = pressure_raw,
|
|
98
|
+
pressure_qnh = pressure_qnh,
|
|
99
|
+
temperature = temperature,
|
|
100
|
+
humidity = humidity,
|
|
101
|
+
altitude = altitude,
|
|
102
|
+
)
|
|
103
|
+
observations.append(obs)
|
|
104
|
+
render_dashboard(observations)
|
|
105
|
+
|
|
106
|
+
verdict, _ = assess_conditions(observations)
|
|
107
|
+
if verdicts and verdict != verdicts[-1]:
|
|
108
|
+
logger.warning("Verdict changed: %s → %s", verdicts[-1], verdict)
|
|
109
|
+
verdicts.append(verdict)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def edit_observation(observations: list[Observation]) -> None:
|
|
113
|
+
"""Let the user select a past observation by index and correct any field.
|
|
114
|
+
|
|
115
|
+
Re-derives pressure_qnh when pressure, temperature, or altitude is changed.
|
|
116
|
+
"""
|
|
117
|
+
for i, obs in enumerate(observations, 1):
|
|
118
|
+
console.print(
|
|
119
|
+
f" [{i}] {obs.timestamp.strftime('%H:%M')} "
|
|
120
|
+
f"{obs.pressure_raw} hPa {obs.temperature}°C "
|
|
121
|
+
f"{obs.humidity}% {obs.altitude}m"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
raw_idx = Prompt.ask("Select observation to edit (number)")
|
|
125
|
+
try:
|
|
126
|
+
idx = int(raw_idx) - 1
|
|
127
|
+
if not (0 <= idx < len(observations)):
|
|
128
|
+
console.print("[red]Index out of range[/red]")
|
|
129
|
+
return
|
|
130
|
+
except ValueError:
|
|
131
|
+
console.print("[red]Please enter a number[/red]")
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
obs = observations[idx]
|
|
135
|
+
console.print("Field: [1] pressure [2] temperature [3] humidity [4] altitude")
|
|
136
|
+
field = Prompt.ask("Field to edit")
|
|
137
|
+
|
|
138
|
+
if field == "1":
|
|
139
|
+
obs.pressure_raw = get_float("New pressure (hPa)", *PRESSURE_RANGE)
|
|
140
|
+
elif field == "2":
|
|
141
|
+
obs.temperature = get_float("New temperature (°C)", *TEMP_RANGE)
|
|
142
|
+
elif field == "3":
|
|
143
|
+
obs.humidity = get_float("New humidity (%)", *HUMIDITY_RANGE)
|
|
144
|
+
elif field == "4":
|
|
145
|
+
obs.altitude = get_float("New altitude (m)", *ALTITUDE_RANGE)
|
|
146
|
+
else:
|
|
147
|
+
console.print("[red]Invalid field choice[/red]")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if field in ("1", "2", "4"):
|
|
151
|
+
obs.pressure_qnh = normalize_pressure(obs.pressure_raw, obs.altitude, obs.temperature)
|
|
152
|
+
|
|
153
|
+
render_dashboard(observations)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def run(input_file: str | None = None) -> None:
|
|
157
|
+
"""Run the interactive NowcastingCLI session."""
|
|
158
|
+
observations: list[Observation] = []
|
|
159
|
+
verdicts: list[str] = []
|
|
160
|
+
|
|
161
|
+
console.print("[bold blue]NowcastingCLI v1.0[/bold blue] — type 'q' at any prompt to quit\n")
|
|
162
|
+
logger.info("NowcastingCLI started")
|
|
163
|
+
|
|
164
|
+
if input_file:
|
|
165
|
+
try:
|
|
166
|
+
rows = _parse_csv(input_file)
|
|
167
|
+
except (ValueError, OSError) as exc:
|
|
168
|
+
console.print(f"[red]Input file error: {exc}[/red]")
|
|
169
|
+
return
|
|
170
|
+
for row in rows:
|
|
171
|
+
_record_observation(row.pressure, row.temperature, row.humidity,
|
|
172
|
+
row.altitude, observations, verdicts)
|
|
173
|
+
else:
|
|
174
|
+
while True:
|
|
175
|
+
try:
|
|
176
|
+
raw = Prompt.ask("\nEnter pressure (hPa), 'e' to edit a past reading, or 'q' to quit")
|
|
177
|
+
cmd = raw.strip().lower()
|
|
178
|
+
if cmd == "q":
|
|
179
|
+
break
|
|
180
|
+
if cmd == "e":
|
|
181
|
+
if not observations:
|
|
182
|
+
console.print("[yellow]No observations to edit yet[/yellow]")
|
|
183
|
+
else:
|
|
184
|
+
edit_observation(observations)
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
pressure_raw = float(raw)
|
|
188
|
+
if not (0.1 <= pressure_raw <= 1100.0):
|
|
189
|
+
raise ValueError
|
|
190
|
+
except ValueError:
|
|
191
|
+
console.print("[red]Value must be between 0.1 and 1100.0[/red]")
|
|
192
|
+
continue
|
|
193
|
+
temperature = get_float("Temperature (°C)", *TEMP_RANGE)
|
|
194
|
+
humidity = get_float("Relative Humidity (%)", *HUMIDITY_RANGE)
|
|
195
|
+
altitude = get_float("GPS Altitude (m)", *ALTITUDE_RANGE)
|
|
196
|
+
|
|
197
|
+
except (KeyboardInterrupt, EOFError):
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
_record_observation(pressure_raw, temperature, humidity, altitude,
|
|
201
|
+
observations, verdicts)
|
|
202
|
+
|
|
203
|
+
console.print("\n[dim]Session ended.[/dim]")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cli() -> None:
|
|
207
|
+
"""CLI entry point — parses argv and delegates to run()."""
|
|
208
|
+
parser = argparse.ArgumentParser(description="NowcastingCLI — terminal weather nowcasting")
|
|
209
|
+
parser.add_argument("--input", metavar="FILE",
|
|
210
|
+
help="CSV file with columns: pressure_hpa, temperature_c, humidity_pct, altitude_m")
|
|
211
|
+
args = parser.parse_args()
|
|
212
|
+
run(input_file=args.input)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
cli()
|
nowcastingcli/models.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
OBSERVATION_UNITS: dict[str, str] = {
|
|
6
|
+
"pressure_raw": "hPa",
|
|
7
|
+
"pressure_qnh": "hPa",
|
|
8
|
+
"temperature": "°C",
|
|
9
|
+
"humidity": "%",
|
|
10
|
+
"altitude": "m",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Observation:
|
|
16
|
+
"""A single weather observation recorded at a station.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
timestamp: Date and time the observation was recorded.
|
|
20
|
+
pressure_raw: Raw station pressure as measured by the sensor, in hPa.
|
|
21
|
+
Reflects actual atmospheric pressure at the station's altitude.
|
|
22
|
+
pressure_qnh: Station pressure normalised to sea level (QNH), in hPa.
|
|
23
|
+
Derived from pressure_raw via the barometric formula in physics.py.
|
|
24
|
+
temperature: Ambient air temperature at the station, in °C.
|
|
25
|
+
humidity: Relative humidity at the station, in %. Must be in [0, 100].
|
|
26
|
+
altitude: GPS altitude of the station above sea level, in m.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: If humidity is outside [0, 100] or pressure_raw <= 0.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
timestamp: datetime
|
|
33
|
+
pressure_raw: float # as measured
|
|
34
|
+
pressure_qnh: float # normalized to sea level
|
|
35
|
+
temperature: float
|
|
36
|
+
humidity: float
|
|
37
|
+
altitude: float
|
|
38
|
+
|
|
39
|
+
def __post_init__(self):
|
|
40
|
+
if not (0 <= self.humidity <= 100):
|
|
41
|
+
raise ValueError(f"humidity out of range: {self.humidity}")
|
|
42
|
+
if self.pressure_raw <= 0:
|
|
43
|
+
raise ValueError(f"invalid pressure: {self.pressure_raw}")
|
|
44
|
+
|
nowcastingcli/physics.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# setup_logging() is called in main.py before this module is imported — handlers already registered.
|
|
2
|
+
import logging
|
|
3
|
+
logger = logging.getLogger(__name__)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# ISA (International Standard Atmosphere) constants used in the barometric formula.
|
|
7
|
+
|
|
8
|
+
# Temperature lapse rate: the rate at which temperature drops with altitude
|
|
9
|
+
# in the troposphere under standard conditions. Units: K/m (kelvin per metre).
|
|
10
|
+
LAPSE_RATE_K_PER_M = 0.0065
|
|
11
|
+
|
|
12
|
+
# Barometric exponent: encodes gravity (g=9.80665 m/s²), dry-air molar mass
|
|
13
|
+
# (M=0.028964 kg/mol), and the universal gas constant (R=8.31446 J/mol·K)
|
|
14
|
+
# as g*M / (R*L) = 9.80665*0.028964 / (8.31446*0.0065) ≈ 5.257. Dimensionless.
|
|
15
|
+
BAROMETRIC_EXPONENT = 5.257
|
|
16
|
+
|
|
17
|
+
# Offset to convert Celsius to Kelvin. Units: K.
|
|
18
|
+
KELVIN_OFFSET = 273.15
|
|
19
|
+
|
|
20
|
+
# Upper altitude limit of the ISA troposphere model. Accuracy degrades above
|
|
21
|
+
# this altitude due to non-standard lapse rates and humidity effects. Units: m.
|
|
22
|
+
MAX_ALTITUDE_M = 5000.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_pressure(pressure_hpa: float, altitude_m: float, temperature_c: float) -> float:
|
|
26
|
+
"""Convert station pressure to QNH (sea-level equivalent) via the barometric formula.
|
|
27
|
+
|
|
28
|
+
Uses the hypsometric approximation valid in the ISA troposphere below ~5000 m:
|
|
29
|
+
|
|
30
|
+
P₀ = P_station × (1 − L·h / T₀) ^ −(g·M / R·L)
|
|
31
|
+
|
|
32
|
+
where:
|
|
33
|
+
- L = 0.0065 K/m (ISA lapse rate, LAPSE_RATE_K_PER_M)
|
|
34
|
+
- T₀ = temperature_c + L·altitude_m + 273.15 (extrapolated sea-level temp, K)
|
|
35
|
+
- Exponent 5.257 = g·M / (R·L), encoding gravity (9.80665 m/s²),
|
|
36
|
+
dry-air molar mass (0.028964 kg/mol), and the gas constant (8.31446 J/mol·K)
|
|
37
|
+
|
|
38
|
+
Assumes a constant lapse rate, dry air, and hydrostatic equilibrium.
|
|
39
|
+
Accuracy degrades above 5000 m and in temperature-inversion conditions.
|
|
40
|
+
|
|
41
|
+
Reference: https://en.wikipedia.org/wiki/Barometric_formula
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
pressure_hpa: Raw station pressure in hectopascals (hPa). Must be > 0.
|
|
45
|
+
altitude_m: GPS altitude of the station above sea level in metres (m).
|
|
46
|
+
Must not exceed 5000 m (ISA troposphere model limit).
|
|
47
|
+
temperature_c: Ambient temperature at the station in degrees Celsius (°C).
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
QNH pressure in hectopascals (hPa) — station pressure normalised to sea level.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ValueError: If pressure_hpa <= 0 or altitude_m > 5000 m.
|
|
54
|
+
"""
|
|
55
|
+
if pressure_hpa <= 0:
|
|
56
|
+
logger.error("Invalid pressure: %.2f hPa", pressure_hpa)
|
|
57
|
+
raise ValueError(f"pressure_hpa must be positive, got {pressure_hpa}")
|
|
58
|
+
if altitude_m > MAX_ALTITUDE_M:
|
|
59
|
+
logger.error("Altitude exceeds model limit: %.2f m", altitude_m)
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"altitude_m {altitude_m} exceeds model limit of {MAX_ALTITUDE_M} m"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
t0 = temperature_c + LAPSE_RATE_K_PER_M * altitude_m + KELVIN_OFFSET
|
|
65
|
+
return pressure_hpa * (1 - (LAPSE_RATE_K_PER_M * altitude_m) / t0) ** -BAROMETRIC_EXPONENT
|
|
@@ -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,19 @@
|
|
|
1
|
+
nowcastingcli/__init__.py,sha256=f9Zr_53mWU_TXeR36kI1hgnkb2G5ZvXnv8nQ4U46bfI,410
|
|
2
|
+
nowcastingcli/display.py,sha256=jNHhN3CIWkeZ71nIm7zyJ9DuTSO2YeQLUIZYxAYIoH0,5883
|
|
3
|
+
nowcastingcli/heuristics.py,sha256=HfTXWPUgb5VEFcGCf7mA7WjexItYIL6A6VDUI1pJG0E,2699
|
|
4
|
+
nowcastingcli/logging_config.py,sha256=i6NlhMzlZXkaxNpDk72QREqS-Ei0B2K-BTghLW_6knw,2502
|
|
5
|
+
nowcastingcli/main.py,sha256=l7qM0MpGo-rlTrq8dE2ApO4ho3lLop6bDz5f_esULkw,8056
|
|
6
|
+
nowcastingcli/models.py,sha256=ZHmGhESltOj3I1sM5gTCPnAvf7RLODsIjd32Qh2lVNo,1462
|
|
7
|
+
nowcastingcli/physics.py,sha256=a1EbtLS3m4FgDt08JAiV9hxSATpH9yyirOldxNlYODE,2898
|
|
8
|
+
scripts/Init_observation.py,sha256=_U9hvua761pmZIae3rogboKrtoG-5lfhaGoW-EMIAFo,362
|
|
9
|
+
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
tests/test_display.py,sha256=5rjlV7r2T82YFvuvzJP8AUgW6wZcePlEWfNSnIDodXs,9710
|
|
11
|
+
tests/test_heuristics.py,sha256=DWdXro8CoxJjCCBJ6bGxmvxnW4hilQc_XoeN9IQeLqw,2706
|
|
12
|
+
tests/test_main.py,sha256=KgaD7H1pc9Vg_M0WSBqfYi2sjxdhkZoOBkKTexmi7pk,20089
|
|
13
|
+
tests/test_models.py,sha256=eDdktWwbfbJlzCvOWm7lhTLDQNBaX38pJ-keC6wJq0c,4131
|
|
14
|
+
tests/test_physics.py,sha256=tmvKWUi8--r4xAszhr0tHVEZMV5ifK8lIS-1R1U7-So,4449
|
|
15
|
+
nowcastingcli-0.6.1.dist-info/METADATA,sha256=eP6v3AAb0HZ0PULKKg2Y-ogJrD4QWJu_yYSSL0u6NHo,538
|
|
16
|
+
nowcastingcli-0.6.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
17
|
+
nowcastingcli-0.6.1.dist-info/entry_points.txt,sha256=W93cb2M5EaMwxiMLaAuU3_cOsR7gl-l_QatKB4ee5b4,57
|
|
18
|
+
nowcastingcli-0.6.1.dist-info/top_level.txt,sha256=mBII7Oqi3qo2IUGW4NM0lt906ymtSid45bVoi1d4coM,28
|
|
19
|
+
nowcastingcli-0.6.1.dist-info/RECORD,,
|