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.
@@ -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
+
@@ -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()
@@ -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
+
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nowcastingcli = nowcastingcli.main:cli