mostlyrightmd-weather 0.1.2__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.
- mostlyrightmd_weather-0.1.2/.gitignore +68 -0
- mostlyrightmd_weather-0.1.2/PKG-INFO +40 -0
- mostlyrightmd_weather-0.1.2/README.md +7 -0
- mostlyrightmd_weather-0.1.2/pyproject.toml +78 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/__init__.py +36 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_awc.py +347 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_climate.py +186 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/__init__.py +20 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_hafs_storms.py +149 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_iem_chunks.py +75 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_iem_mos.py +302 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_msc_archive.py +202 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_archive.py +818 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_cycle_chunks.py +238 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_extract.py +224 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/__init__.py +126 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/cfs.py +25 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/ecmwf_aifs.py +25 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/ecmwf_ifs.py +30 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/gdas.py +17 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/gdps.py +21 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/gefs.py +23 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/geps.py +26 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/gfs.py +32 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/hafs.py +25 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/hiresw.py +33 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/hrdps.py +19 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/href.py +22 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/hrrr.py +39 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/hrrrak.py +19 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/nam.py +33 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/nbm.py +31 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/rap.py +19 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/rdps.py +17 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/reps.py +20 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/rrfs.py +22 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/rtma.py +17 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/urma.py +17 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_idx.py +302 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_url_transitions.py +48 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/awc.py +142 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/ghcnh.py +169 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/iem_asos.py +255 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/iem_cli.py +194 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_ghcnh.py +348 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_iem.py +278 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/cache.py +468 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/catalog/__init__.py +96 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/catalog/_obs_projection.py +192 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/catalog/awc.py +82 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/catalog/cli.py +239 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/catalog/ghcnh.py +84 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/catalog/iem.py +138 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/forecast_nwp.py +1009 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/obs.py +369 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/qc/__init__.py +7 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/qc/rules_nwp.py +398 -0
- mostlyrightmd_weather-0.1.2/src/mostlyright/weather/qc_sidecar.py +98 -0
- mostlyrightmd_weather-0.1.2/tests/__init__.py +0 -0
- mostlyrightmd_weather-0.1.2/tests/_awc/__init__.py +0 -0
- mostlyrightmd_weather-0.1.2/tests/_awc/test_awc.py +781 -0
- mostlyrightmd_weather-0.1.2/tests/_climate/__init__.py +0 -0
- mostlyrightmd_weather-0.1.2/tests/_climate/fixtures/iem_cli_atl_sample.json +50 -0
- mostlyrightmd_weather-0.1.2/tests/_climate/test_climate.py +504 -0
- mostlyrightmd_weather-0.1.2/tests/_fetchers/__init__.py +0 -0
- mostlyrightmd_weather-0.1.2/tests/_fetchers/test_awc.py +236 -0
- mostlyrightmd_weather-0.1.2/tests/_fetchers/test_ghcnh.py +476 -0
- mostlyrightmd_weather-0.1.2/tests/_fetchers/test_iem_asos.py +660 -0
- mostlyrightmd_weather-0.1.2/tests/_fetchers/test_iem_chunks.py +88 -0
- mostlyrightmd_weather-0.1.2/tests/_fetchers/test_iem_cli.py +329 -0
- mostlyrightmd_weather-0.1.2/tests/_ghcnh/__init__.py +0 -0
- mostlyrightmd_weather-0.1.2/tests/_ghcnh/fixtures/ghcnh_jfk_2025_sample.psv +50 -0
- mostlyrightmd_weather-0.1.2/tests/_ghcnh/test_ghcnh.py +943 -0
- mostlyrightmd_weather-0.1.2/tests/_iem/__init__.py +0 -0
- mostlyrightmd_weather-0.1.2/tests/_iem/fixtures/iem_jfk_metar_sample.csv +43 -0
- mostlyrightmd_weather-0.1.2/tests/_iem/test_iem.py +929 -0
- mostlyrightmd_weather-0.1.2/tests/catalog/__init__.py +0 -0
- mostlyrightmd_weather-0.1.2/tests/catalog/test_awc.py +103 -0
- mostlyrightmd_weather-0.1.2/tests/catalog/test_cli.py +212 -0
- mostlyrightmd_weather-0.1.2/tests/catalog/test_ghcnh.py +95 -0
- mostlyrightmd_weather-0.1.2/tests/catalog/test_iem.py +217 -0
- mostlyrightmd_weather-0.1.2/tests/catalog/test_registry.py +68 -0
- mostlyrightmd_weather-0.1.2/tests/test_cache.py +801 -0
- mostlyrightmd_weather-0.1.2/tests/test_ecmwf_models.py +155 -0
- mostlyrightmd_weather-0.1.2/tests/test_forecast_nwp.py +639 -0
- mostlyrightmd_weather-0.1.2/tests/test_forecast_nwp_multi_cycle.py +242 -0
- mostlyrightmd_weather-0.1.2/tests/test_hafs_storms.py +124 -0
- mostlyrightmd_weather-0.1.2/tests/test_iem_catalog_fetch_forecasts.py +37 -0
- mostlyrightmd_weather-0.1.2/tests/test_iem_mos_fetcher.py +289 -0
- mostlyrightmd_weather-0.1.2/tests/test_legacy_models.py +214 -0
- mostlyrightmd_weather-0.1.2/tests/test_msc_models.py +197 -0
- mostlyrightmd_weather-0.1.2/tests/test_nwp_archive.py +262 -0
- mostlyrightmd_weather-0.1.2/tests/test_nwp_archive_refactor.py +198 -0
- mostlyrightmd_weather-0.1.2/tests/test_nwp_cycle_chunks.py +196 -0
- mostlyrightmd_weather-0.1.2/tests/test_nwp_historical_backfill.py +219 -0
- mostlyrightmd_weather-0.1.2/tests/test_nwp_idx.py +103 -0
- mostlyrightmd_weather-0.1.2/tests/test_nwp_idx_dispatch.py +37 -0
- mostlyrightmd_weather-0.1.2/tests/test_nwp_idx_eccodes.py +127 -0
- mostlyrightmd_weather-0.1.2/tests/test_nwp_ncep_models.py +336 -0
- mostlyrightmd_weather-0.1.2/tests/test_qc_rules_nwp.py +155 -0
- mostlyrightmd_weather-0.1.2/tests/test_research_include_forecast.py +451 -0
- mostlyrightmd_weather-0.1.2/tests/test_url_transitions.py +53 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
*.egg
|
|
7
|
+
*.egg-info/
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
.eggs/
|
|
11
|
+
|
|
12
|
+
# Virtual environments
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
ENV/
|
|
16
|
+
|
|
17
|
+
# Testing / linting
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
.coverage
|
|
21
|
+
.coverage.*
|
|
22
|
+
htmlcov/
|
|
23
|
+
.tox/
|
|
24
|
+
|
|
25
|
+
# Local cache (tradewinds runtime cache lives in $HOME/.tradewinds/; this catches any test-time fixtures that leak)
|
|
26
|
+
.tradewinds-cache/
|
|
27
|
+
|
|
28
|
+
# Phase 4 CI-05 — drift watchdog populates tests/fixtures/drift/ from the
|
|
29
|
+
# weekly cron. The scripts + README are tracked; the captured parquets
|
|
30
|
+
# and the drift-report are not (rotation is the whole point — git history
|
|
31
|
+
# isn't the storage layer for drift fixtures).
|
|
32
|
+
tests/fixtures/drift/case_*.parquet
|
|
33
|
+
tests/fixtures/drift/drift-report.md
|
|
34
|
+
|
|
35
|
+
# uv — uv.lock IS committed for dev reproducibility across Lane F + Lane V (decision: lockfile-tracked for dev parity)
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.vscode/
|
|
39
|
+
.idea/
|
|
40
|
+
*.swp
|
|
41
|
+
*.swo
|
|
42
|
+
|
|
43
|
+
# OS
|
|
44
|
+
.DS_Store
|
|
45
|
+
Thumbs.db
|
|
46
|
+
.claude/scheduled_tasks.lock
|
|
47
|
+
|
|
48
|
+
# Planning artifacts — local GSD planning workspace; NOT for public consumption.
|
|
49
|
+
# Untracked since the repo went public (Phase 13 W0 cleanup commit, 2026-05-25).
|
|
50
|
+
# Operators keep these on disk for ongoing GSD workflow; nothing in `.planning/`
|
|
51
|
+
# should be required to build or run the SDK.
|
|
52
|
+
.planning/
|
|
53
|
+
|
|
54
|
+
# Dev-only TODO scratchpad (replaced by GSD TODOS in .planning/)
|
|
55
|
+
TODOS.md
|
|
56
|
+
|
|
57
|
+
# TypeScript / pnpm workspace (packages-ts/)
|
|
58
|
+
node_modules/
|
|
59
|
+
coverage/
|
|
60
|
+
.tsbuildinfo
|
|
61
|
+
packages-ts/*/dist/
|
|
62
|
+
packages-ts/*/coverage/
|
|
63
|
+
# Note: top-level `dist/` and `build/` already covered by Python section above.
|
|
64
|
+
|
|
65
|
+
# TS-W2 Plan 08 drift watchdog — output files are transient, not committed.
|
|
66
|
+
packages-ts/meta/tests/parity/drift/*.json
|
|
67
|
+
packages-ts/meta/tests/parity/drift-report.md
|
|
68
|
+
.claude/
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mostlyrightmd-weather
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Weather data sources for mostlyright: AWC, IEM, GHCNh, NWS CLI. Direct public-API access; no hosted backend. Python module: `import mostlyright.weather`.
|
|
5
|
+
Author-email: Robert Tarabcak <tarabcakr@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: awc,ghcnh,iem,metar,nws-cli,weather
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: filelock>=3.12
|
|
15
|
+
Requires-Dist: httpx>=0.27
|
|
16
|
+
Requires-Dist: jsonschema>=4.21
|
|
17
|
+
Requires-Dist: mostlyrightmd<0.2,>=0.1.0
|
|
18
|
+
Requires-Dist: pyarrow<24.0,>=17.0
|
|
19
|
+
Requires-Dist: tzdata; sys_platform == 'win32'
|
|
20
|
+
Provides-Extra: nwp
|
|
21
|
+
Requires-Dist: cfgrib<1.0,>=0.9.15; extra == 'nwp'
|
|
22
|
+
Requires-Dist: pandas<4.0,>=2.2; extra == 'nwp'
|
|
23
|
+
Requires-Dist: scikit-learn<2.0,>=1.3; extra == 'nwp'
|
|
24
|
+
Requires-Dist: xarray>=2024.0; extra == 'nwp'
|
|
25
|
+
Provides-Extra: parquet
|
|
26
|
+
Requires-Dist: pandas<4.0,>=2.2; extra == 'parquet'
|
|
27
|
+
Provides-Extra: polars
|
|
28
|
+
Requires-Dist: narwhals<2.0,>=1.20; extra == 'polars'
|
|
29
|
+
Requires-Dist: pandas<4.0,>=2.2; extra == 'polars'
|
|
30
|
+
Requires-Dist: polars<2.0,>=1.0; extra == 'polars'
|
|
31
|
+
Requires-Dist: pyarrow<24.0,>=17.0; extra == 'polars'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# mostlyrightmd-weather
|
|
35
|
+
|
|
36
|
+
Weather data sources for `mostlyright`: AWC, IEM, GHCNh, NWS CLI.
|
|
37
|
+
|
|
38
|
+
Direct public-API access. No hosted backend. Local parquet cache at `$HOME/.mostlyright/cache/`.
|
|
39
|
+
|
|
40
|
+
See the workspace [README](../../README.md) and [CLAUDE.md](../../CLAUDE.md) for project rules.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# mostlyrightmd-weather
|
|
2
|
+
|
|
3
|
+
Weather data sources for `mostlyright`: AWC, IEM, GHCNh, NWS CLI.
|
|
4
|
+
|
|
5
|
+
Direct public-API access. No hosted backend. Local parquet cache at `$HOME/.mostlyright/cache/`.
|
|
6
|
+
|
|
7
|
+
See the workspace [README](../../README.md) and [CLAUDE.md](../../CLAUDE.md) for project rules.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mostlyrightmd-weather"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
description = "Weather data sources for mostlyright: AWC, IEM, GHCNh, NWS CLI. Direct public-API access; no hosted backend. Python module: `import mostlyright.weather`."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Robert Tarabcak", email = "tarabcakr@gmail.com" }]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
keywords = ["weather", "metar", "awc", "iem", "ghcnh", "nws-cli"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3.11",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
# mostlyright (core) provides _internal._bounds, _internal._convert,
|
|
19
|
+
# _internal._http, _internal.models, _internal.exceptions — used by the
|
|
20
|
+
# parsers (_awc, _iem, _climate, _ghcnh) and fetchers under mostlyright.weather.
|
|
21
|
+
# Pinned to the matching alpha to prevent users from mixing core/weather
|
|
22
|
+
# releases across the parity gate (PKG-03).
|
|
23
|
+
"mostlyrightmd>=0.1.0,<0.2",
|
|
24
|
+
# Pinned to v0.14.1 mostlyright deps
|
|
25
|
+
"httpx>=0.27",
|
|
26
|
+
"jsonschema>=4.21",
|
|
27
|
+
"tzdata; sys_platform == 'win32'",
|
|
28
|
+
# Local parquet cache requires filelock for concurrent process safety
|
|
29
|
+
"filelock>=3.12",
|
|
30
|
+
# Codex P2 fix (Wave 1.4): pyarrow used at import time in mostlyright.weather.cache
|
|
31
|
+
# for cache I/O. Cannot live in `parquet` optional extra — default install would
|
|
32
|
+
# fail to import the cache module. Upper bound (PKG-06) keeps a future
|
|
33
|
+
# pyarrow ABI break from silently invalidating the parity-critical cache.
|
|
34
|
+
"pyarrow>=17.0,<24.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
# DataFrame return values (cache.py uses pyarrow directly; pandas only needed
|
|
39
|
+
# for users who want to convert cache output to DataFrames). Upper bound
|
|
40
|
+
# mirrors the core `parquet` extra so a fresh install cannot pull pandas 3.x
|
|
41
|
+
# and silently drift the parity-critical DataFrame dtypes.
|
|
42
|
+
parquet = [
|
|
43
|
+
"pandas>=2.2,<4.0",
|
|
44
|
+
]
|
|
45
|
+
# Phase 3.2: NWP live-fetch (HRRR/GFS/NBM) via NOAA Big Data Program.
|
|
46
|
+
# `cfgrib` brings in `eccodes` + `eccodeslib` transitively, which ship
|
|
47
|
+
# the binary GRIB2 codec as Python wheels (no system `apt install
|
|
48
|
+
# eccodes-bin` / `brew install eccodes` required on Linux/macOS/Windows).
|
|
49
|
+
# `xarray` is cfgrib's API surface; `scikit-learn` provides
|
|
50
|
+
# BallTree(haversine) for nearest-grid-cell extraction.
|
|
51
|
+
# Pandas is a transitive dep of the parquet extra above; included here
|
|
52
|
+
# so a user who installs only `[nwp]` gets a usable DataFrame return.
|
|
53
|
+
nwp = [
|
|
54
|
+
"cfgrib>=0.9.15,<1.0",
|
|
55
|
+
"xarray>=2024.0",
|
|
56
|
+
"scikit-learn>=1.3,<2.0",
|
|
57
|
+
"pandas>=2.2,<4.0",
|
|
58
|
+
]
|
|
59
|
+
# Phase 6 (POLARS-05): mirror mostlyrightmd[polars] so weather adapters
|
|
60
|
+
# can return polars frames via the `backend="polars"` kwarg.
|
|
61
|
+
# pandas required for the boundary shim's polars↔pandas conversion
|
|
62
|
+
# (codex iter-3 P2 fix). pyarrow required by pl.from_pandas/to_pandas
|
|
63
|
+
# for non-trivial dtypes (codex iter-5 P1 fix). pyarrow is already in
|
|
64
|
+
# mostlyrightmd-weather runtime deps so this is a no-op for the install
|
|
65
|
+
# resolver but keeps the extra's contract explicit.
|
|
66
|
+
polars = [
|
|
67
|
+
"polars>=1.0,<2.0",
|
|
68
|
+
"narwhals>=1.20,<2.0",
|
|
69
|
+
"pandas>=2.2,<4.0",
|
|
70
|
+
"pyarrow>=17.0,<24.0",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[build-system]
|
|
74
|
+
requires = ["hatchling"]
|
|
75
|
+
build-backend = "hatchling.build"
|
|
76
|
+
|
|
77
|
+
[tool.hatch.build.targets.wheel]
|
|
78
|
+
packages = ["src/mostlyright"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""mostlyright.weather — direct public-API access for AWC, IEM, GHCNh, NWS CLI.
|
|
2
|
+
|
|
3
|
+
Local-first; no hosted backend; no API keys. Parsers are byte-faithful lifts
|
|
4
|
+
from ``monorepo-v0.14.1``; HTTP fetchers and the parquet cache are net-new
|
|
5
|
+
Sprint 0 code so the SDK can run without the v0.14.1 ingest service.
|
|
6
|
+
|
|
7
|
+
Lift inventory (provenance for parity-critical code). Source SHA refers to
|
|
8
|
+
the v0.14.1 release tag of ``Tarabcak/monorepo`` (commit
|
|
9
|
+
``514fcdab227e845145ca32b989355647466231d9``).
|
|
10
|
+
|
|
11
|
+
| Module | Source path | Source SHA | Lift date | Modifications |
|
|
12
|
+
|-------------------------|--------------------------------------------------------|------------|------------|---------------------------------------------------------------------|
|
|
13
|
+
| _awc.py | monorepo-v0.14.1/src/mostlyright/weather/_awc.py | 514fcda | 2026-05-21 | namespace rename only (imports point at ``mostlyright._internal``) |
|
|
14
|
+
| _iem.py | monorepo-v0.14.1/src/mostlyright/weather/_iem.py | 514fcda | 2026-05-21 | namespace rename only |
|
|
15
|
+
| _climate.py | monorepo-v0.14.1/src/mostlyright/weather/_climate.py | 514fcda | 2026-05-21 | namespace rename only |
|
|
16
|
+
| _ghcnh.py | monorepo-v0.14.1/src/mostlyright/weather/_ghcnh.py | 514fcda | 2026-05-21 | namespace rename only |
|
|
17
|
+
| _fetchers/__init__.py | n/a (NEW) | n/a | 2026-05-21 | NEW (Sprint 0 Wave 1 Lane F) — fetcher package marker |
|
|
18
|
+
| _fetchers/awc.py | n/a (NEW) | n/a | 2026-05-21 | NEW (Sprint 0 Wave 1 Lane F) — historical AWC range fetcher |
|
|
19
|
+
| _fetchers/iem_asos.py | n/a (NEW) | n/a | 2026-05-21 | NEW (Sprint 0 Wave 1 Lane F) — monthly-chunked IEM ASOS METAR fetcher |
|
|
20
|
+
| _fetchers/iem_cli.py | n/a (NEW) | n/a | 2026-05-21 | NEW (Sprint 0 Wave 1 Lane F) — IEM CLI settlement-grade fetcher |
|
|
21
|
+
| _fetchers/ghcnh.py | n/a (NEW) | n/a | 2026-05-21 | NEW (Sprint 0 Wave 1 Lane F) — per-year NCEI GHCNh PSV fetcher |
|
|
22
|
+
| cache.py | n/a (NEW) | n/a | 2026-05-21 | NEW (Sprint 0 Wave 1 Lane F) — local parquet cache, filelock-guarded |
|
|
23
|
+
|
|
24
|
+
``_bounds`` is imported from ``mostlyright._internal`` (lifted there from
|
|
25
|
+
``monorepo-v0.14.1/src/mostlyright/_bounds.py``) — see the parallel lift
|
|
26
|
+
inventory in ``mostlyright._internal.__init__``.
|
|
27
|
+
|
|
28
|
+
Public surface kept stable for Vojtech's existing ``mostlyright==0.14.1``
|
|
29
|
+
workflow: ``raw_metar`` is preserved on observation rows so MetPy re-parse
|
|
30
|
+
keeps working without preprocessing in v0.1.0.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from mostlyright.weather.obs import obs as obs # re-export Phase 7 public surface
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0rc1"
|
|
36
|
+
__all__ = ["__version__", "obs"]
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""AWC METAR transform — maps AWC JSON response to observation schema dict.
|
|
2
|
+
|
|
3
|
+
This is THE shared transform. Both the SDK and ingest worker import it.
|
|
4
|
+
Output dicts validate against specs/observation.json (additionalProperties: false).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from mostlyright._internal._bounds import (
|
|
15
|
+
MAX_RAW_METAR_LEN,
|
|
16
|
+
MAX_VISIBILITY_MILES,
|
|
17
|
+
MAX_WX_CODES_LEN,
|
|
18
|
+
SKY_BASE_MAX_FT,
|
|
19
|
+
SLP_MAX_MB,
|
|
20
|
+
SLP_MIN_MB,
|
|
21
|
+
STATION_CODE_RE,
|
|
22
|
+
TEMP_MAX_C,
|
|
23
|
+
TEMP_MIN_C,
|
|
24
|
+
WIND_DIR_BOUNDS,
|
|
25
|
+
WIND_GUST_MAX,
|
|
26
|
+
WIND_SPEED_MAX,
|
|
27
|
+
bounded_float,
|
|
28
|
+
bounded_float_min,
|
|
29
|
+
bounded_int,
|
|
30
|
+
)
|
|
31
|
+
from mostlyright._internal._convert import celsius_to_fahrenheit, hpa_to_inhg
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def icao_to_station_code(icao: str) -> str:
|
|
35
|
+
"""Strip leading K for 4-letter CONUS ICAO codes."""
|
|
36
|
+
upper = icao.strip().upper()
|
|
37
|
+
if upper.startswith("K") and len(upper) == 4:
|
|
38
|
+
return upper[1:]
|
|
39
|
+
return upper
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_awc_visibility(vis: Any) -> float | None:
|
|
43
|
+
"""Parse AWC visibility: '10+', '1/2', '2 1/4', '3/4', plain numbers.
|
|
44
|
+
|
|
45
|
+
Returns miles or None. Caps at 99.99.
|
|
46
|
+
"""
|
|
47
|
+
if vis is None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
s = str(vis)
|
|
51
|
+
if s == "" or s == "null":
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
# "10+" -> 10
|
|
55
|
+
if s.endswith("+"):
|
|
56
|
+
try:
|
|
57
|
+
n = float(s[:-1])
|
|
58
|
+
except (ValueError, OverflowError):
|
|
59
|
+
return None
|
|
60
|
+
if not math.isfinite(n):
|
|
61
|
+
return None
|
|
62
|
+
return min(n, MAX_VISIBILITY_MILES)
|
|
63
|
+
|
|
64
|
+
# Mixed number: "1 1/2", "2 1/4"
|
|
65
|
+
if " " in s and "/" in s:
|
|
66
|
+
parts = s.split(" ", 1)
|
|
67
|
+
if len(parts) != 2:
|
|
68
|
+
return None
|
|
69
|
+
frac_parts = parts[1].split("/")
|
|
70
|
+
if len(frac_parts) != 2:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
w = float(parts[0])
|
|
74
|
+
n = float(frac_parts[0])
|
|
75
|
+
d = float(frac_parts[1])
|
|
76
|
+
except (ValueError, OverflowError):
|
|
77
|
+
return None
|
|
78
|
+
if not (math.isfinite(w) and math.isfinite(n) and math.isfinite(d) and d != 0):
|
|
79
|
+
return None
|
|
80
|
+
return min(w + n / d, MAX_VISIBILITY_MILES)
|
|
81
|
+
|
|
82
|
+
# Simple fraction: "1/2", "1/4", "3/4", or "M1/4" (below-quarter-mile
|
|
83
|
+
# AWC/METAR convention — codex review W3A P2). The leading 'M' means
|
|
84
|
+
# "less than", which we represent as the same fractional value (the
|
|
85
|
+
# observation schema treats this as the visibility value, not a flag).
|
|
86
|
+
if "/" in s:
|
|
87
|
+
if s.startswith("M") or s.startswith("m"):
|
|
88
|
+
s = s[1:]
|
|
89
|
+
frac_parts = s.split("/")
|
|
90
|
+
if len(frac_parts) != 2:
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
n = float(frac_parts[0])
|
|
94
|
+
d = float(frac_parts[1])
|
|
95
|
+
except (ValueError, OverflowError):
|
|
96
|
+
return None
|
|
97
|
+
if not (math.isfinite(n) and math.isfinite(d) and d != 0):
|
|
98
|
+
return None
|
|
99
|
+
return min(n / d, MAX_VISIBILITY_MILES)
|
|
100
|
+
|
|
101
|
+
# Plain number
|
|
102
|
+
try:
|
|
103
|
+
n = float(s)
|
|
104
|
+
except (ValueError, OverflowError):
|
|
105
|
+
return None
|
|
106
|
+
if not math.isfinite(n):
|
|
107
|
+
return None
|
|
108
|
+
return min(n, MAX_VISIBILITY_MILES)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def map_cloud_cover(cover: str | None) -> str | None:
|
|
112
|
+
"""Map AWC cloud cover code to standard abbreviation."""
|
|
113
|
+
if cover is None:
|
|
114
|
+
return None
|
|
115
|
+
upper = cover.upper()
|
|
116
|
+
if upper in ("CLR", "SKC", "FEW", "SCT", "BKN", "OVC", "VV"):
|
|
117
|
+
return upper
|
|
118
|
+
if upper == "CAVOK":
|
|
119
|
+
return "CLR"
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _cloud_layer(layer: Any) -> tuple[str | None, int | None]:
|
|
124
|
+
"""Extract cover and base from a cloud layer dict. Safe against non-dict entries."""
|
|
125
|
+
if not isinstance(layer, dict):
|
|
126
|
+
return None, None
|
|
127
|
+
base = bounded_int(_safe_int(layer.get("base")), 0, SKY_BASE_MAX_FT)
|
|
128
|
+
return map_cloud_cover(layer.get("cover")), base
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _safe_int(v: Any) -> int | None:
|
|
132
|
+
"""Convert to int. Returns None on bad input."""
|
|
133
|
+
if v is None:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
f = float(v)
|
|
137
|
+
if not math.isfinite(f):
|
|
138
|
+
return None
|
|
139
|
+
return round(f)
|
|
140
|
+
except (ValueError, TypeError, OverflowError):
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _safe_float(v: Any) -> float | None:
|
|
145
|
+
"""Convert to float. Returns None on bad input."""
|
|
146
|
+
if v is None:
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
f = float(v)
|
|
150
|
+
return f if math.isfinite(f) else None
|
|
151
|
+
except (ValueError, TypeError, OverflowError):
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _safe_precip(v: Any) -> float | None:
|
|
156
|
+
"""Parse precipitation. Trace 'T' → 0.0, numeric passthrough, else None."""
|
|
157
|
+
if v is None:
|
|
158
|
+
return None
|
|
159
|
+
if isinstance(v, str) and v.strip().upper() == "T":
|
|
160
|
+
return 0.0
|
|
161
|
+
return _safe_float(v)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_PK_WND_RE = re.compile(r"PK WND (\d{3})(\d{2,3})/(\d{4})")
|
|
165
|
+
|
|
166
|
+
# T-group in METAR remarks: T{s}{SSS}{s}{DDD}
|
|
167
|
+
# s=0 positive, s=1 negative. SSS/DDD = tenths of °C.
|
|
168
|
+
# Example: T02560167 → 25.6°C / 16.7°C. T10390061 → -3.9°C / 6.1°C.
|
|
169
|
+
_TGROUP_RE = re.compile(r"\bT([01])(\d{3})([01])(\d{3})\b")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_peak_wind(
|
|
173
|
+
raw_metar: str | None,
|
|
174
|
+
) -> tuple[int | None, int | None, str | None]:
|
|
175
|
+
"""Parse PK WND from METAR remarks. Returns (dir, speed_kt, time_hhmm)."""
|
|
176
|
+
if not raw_metar:
|
|
177
|
+
return None, None, None
|
|
178
|
+
match = _PK_WND_RE.search(raw_metar)
|
|
179
|
+
if not match:
|
|
180
|
+
return None, None, None
|
|
181
|
+
direction = int(match.group(1))
|
|
182
|
+
speed = int(match.group(2))
|
|
183
|
+
time_hhmm = match.group(3)
|
|
184
|
+
if not (0 <= direction <= 360) or speed < 0:
|
|
185
|
+
return None, None, None
|
|
186
|
+
return direction, speed, time_hhmm
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _parse_tgroup(raw_metar: str | None) -> tuple[float | None, float | None]:
|
|
190
|
+
"""Parse T-group from METAR remarks for tenths-precision temperature.
|
|
191
|
+
|
|
192
|
+
ASOS stations always include T-group in remarks. Format: T{s}{SSS}{s}{DDD}
|
|
193
|
+
where s=0 positive, s=1 negative, SSS=temp tenths °C, DDD=dewpoint tenths °C.
|
|
194
|
+
Searches only the remarks section (after RMK) to avoid false positives.
|
|
195
|
+
Returns (temp_c, dewpoint_c) or (None, None) if not found.
|
|
196
|
+
"""
|
|
197
|
+
if not raw_metar:
|
|
198
|
+
return None, None
|
|
199
|
+
# T-group is a remarks-only element — search only after RMK.
|
|
200
|
+
# No RMK section = no T-group. Do NOT fallback to full string
|
|
201
|
+
# to avoid false positives on body group patterns.
|
|
202
|
+
rmk_idx = raw_metar.find("RMK")
|
|
203
|
+
if rmk_idx < 0:
|
|
204
|
+
return None, None
|
|
205
|
+
match = _TGROUP_RE.search(raw_metar[rmk_idx:])
|
|
206
|
+
if not match:
|
|
207
|
+
return None, None
|
|
208
|
+
t_sign = -1 if match.group(1) == "1" else 1
|
|
209
|
+
t_val = int(match.group(2)) / 10.0 * t_sign
|
|
210
|
+
d_sign = -1 if match.group(3) == "1" else 1
|
|
211
|
+
d_val = int(match.group(4)) / 10.0 * d_sign
|
|
212
|
+
return t_val, d_val
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def awc_to_observation(m: dict[str, Any]) -> dict[str, Any] | None:
|
|
216
|
+
"""Convert a parsed AWC METAR dict to an observation schema dict.
|
|
217
|
+
|
|
218
|
+
Returns None if icaoId or obsTime is invalid.
|
|
219
|
+
Output matches specs/observation.json (no extra fields).
|
|
220
|
+
"""
|
|
221
|
+
icao_id = m.get("icaoId")
|
|
222
|
+
if not isinstance(icao_id, str) or not icao_id:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
obs_time = m.get("obsTime")
|
|
226
|
+
if not isinstance(obs_time, int | float):
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
station_code = icao_to_station_code(icao_id)
|
|
230
|
+
if not STATION_CODE_RE.match(station_code):
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
dt = datetime.fromtimestamp(obs_time, tz=UTC)
|
|
235
|
+
except (OSError, OverflowError, ValueError):
|
|
236
|
+
return None
|
|
237
|
+
if not (1970 <= dt.year <= 2100):
|
|
238
|
+
return None
|
|
239
|
+
observed_at = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
240
|
+
|
|
241
|
+
metar_type = (m.get("metarType") or "METAR").upper()
|
|
242
|
+
observation_type = "SPECI" if metar_type == "SPECI" else "METAR"
|
|
243
|
+
|
|
244
|
+
# Wind direction: handle "VRB" -> None, bounded [0, 360]
|
|
245
|
+
wdir: int | None = None
|
|
246
|
+
raw_wdir = m.get("wdir")
|
|
247
|
+
if raw_wdir is not None:
|
|
248
|
+
if isinstance(raw_wdir, int | float):
|
|
249
|
+
wdir = bounded_int(int(raw_wdir), *WIND_DIR_BOUNDS)
|
|
250
|
+
elif raw_wdir != "VRB":
|
|
251
|
+
try:
|
|
252
|
+
parsed = float(raw_wdir)
|
|
253
|
+
if math.isfinite(parsed):
|
|
254
|
+
wdir = bounded_int(int(parsed), *WIND_DIR_BOUNDS)
|
|
255
|
+
except (ValueError, TypeError):
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
wspd = bounded_int(_safe_int(m.get("wspd")), 0, WIND_SPEED_MAX)
|
|
259
|
+
wgst = bounded_int(_safe_int(m.get("wgst")), 0, WIND_GUST_MAX)
|
|
260
|
+
|
|
261
|
+
# Altimeter: AWC altim is in hPa, convert to inHg (no rounding)
|
|
262
|
+
altim = hpa_to_inhg(_safe_float(m.get("altim")))
|
|
263
|
+
|
|
264
|
+
# Sea-level pressure (already in mb/hPa)
|
|
265
|
+
slp = _safe_float(m.get("slp"))
|
|
266
|
+
if slp is not None and not (SLP_MIN_MB <= slp <= SLP_MAX_MB):
|
|
267
|
+
slp = None
|
|
268
|
+
|
|
269
|
+
# Cloud layers (safe against non-dict entries)
|
|
270
|
+
clouds = m.get("clouds") or []
|
|
271
|
+
cov1, base1 = _cloud_layer(clouds[0]) if len(clouds) > 0 else (None, None)
|
|
272
|
+
cov2, base2 = _cloud_layer(clouds[1]) if len(clouds) > 1 else (None, None)
|
|
273
|
+
cov3, base3 = _cloud_layer(clouds[2]) if len(clouds) > 2 else (None, None)
|
|
274
|
+
cov4, base4 = _cloud_layer(clouds[3]) if len(clouds) > 3 else (None, None)
|
|
275
|
+
|
|
276
|
+
# Raw METAR (truncate to 2048)
|
|
277
|
+
raw_ob = m.get("rawOb")
|
|
278
|
+
raw_metar: str | None = None
|
|
279
|
+
if isinstance(raw_ob, str):
|
|
280
|
+
raw_metar = raw_ob[:MAX_RAW_METAR_LEN]
|
|
281
|
+
|
|
282
|
+
# Weather codes
|
|
283
|
+
raw_wx = m.get("wxString")
|
|
284
|
+
weather_codes: str | None = None
|
|
285
|
+
if isinstance(raw_wx, str):
|
|
286
|
+
weather_codes = raw_wx[:MAX_WX_CODES_LEN]
|
|
287
|
+
|
|
288
|
+
# Temperature: T-group (tenths precision) overrides body group (whole degree).
|
|
289
|
+
# ASOS always includes T-group in remarks. If present, use it.
|
|
290
|
+
# Note: KNYC (Central Park) is NOT an ASOS station — may lack T-group,
|
|
291
|
+
# falling back to whole-degree body group temps from AWC.
|
|
292
|
+
temp_c = _safe_float(m.get("temp"))
|
|
293
|
+
dewp_c = _safe_float(m.get("dewp"))
|
|
294
|
+
tgroup_temp, tgroup_dewp = _parse_tgroup(raw_metar)
|
|
295
|
+
if tgroup_temp is not None:
|
|
296
|
+
temp_c = tgroup_temp
|
|
297
|
+
if tgroup_dewp is not None:
|
|
298
|
+
dewp_c = tgroup_dewp
|
|
299
|
+
temp_c = bounded_float(temp_c, TEMP_MIN_C, TEMP_MAX_C, field="temp_c")
|
|
300
|
+
dewp_c = bounded_float(dewp_c, TEMP_MIN_C, TEMP_MAX_C, field="dewpoint_c")
|
|
301
|
+
temp_f = celsius_to_fahrenheit(temp_c)
|
|
302
|
+
dewpoint_f = celsius_to_fahrenheit(dewp_c)
|
|
303
|
+
|
|
304
|
+
# Peak wind from METAR remarks (PK WND dddss/hhmm), bounded
|
|
305
|
+
pk_dir, pk_spd, pk_time = _parse_peak_wind(raw_metar)
|
|
306
|
+
pk_dir = bounded_int(pk_dir, *WIND_DIR_BOUNDS)
|
|
307
|
+
pk_spd = bounded_int(pk_spd, 0, WIND_GUST_MAX)
|
|
308
|
+
|
|
309
|
+
# Precipitation (AWC provides 'precip' field; 'T' = trace → 0.0, non-negative)
|
|
310
|
+
precip = bounded_float_min(_safe_precip(m.get("precip")), 0.0)
|
|
311
|
+
|
|
312
|
+
# QC field bitmask
|
|
313
|
+
qc_raw = m.get("qcField")
|
|
314
|
+
qc_field = _safe_int(qc_raw)
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
"station_code": station_code,
|
|
318
|
+
"observed_at": observed_at,
|
|
319
|
+
"observation_type": observation_type,
|
|
320
|
+
"source": "awc",
|
|
321
|
+
"temp_c": temp_c,
|
|
322
|
+
"dewpoint_c": dewp_c,
|
|
323
|
+
"temp_f": temp_f,
|
|
324
|
+
"dewpoint_f": dewpoint_f,
|
|
325
|
+
"wind_dir_degrees": wdir,
|
|
326
|
+
"wind_speed_kt": wspd,
|
|
327
|
+
"wind_gust_kt": wgst,
|
|
328
|
+
"altimeter_inhg": altim,
|
|
329
|
+
"sea_level_pressure_mb": slp,
|
|
330
|
+
"sky_cover_1": cov1,
|
|
331
|
+
"sky_base_1_ft": base1,
|
|
332
|
+
"sky_cover_2": cov2,
|
|
333
|
+
"sky_base_2_ft": base2,
|
|
334
|
+
"sky_cover_3": cov3,
|
|
335
|
+
"sky_base_3_ft": base3,
|
|
336
|
+
"sky_cover_4": cov4,
|
|
337
|
+
"sky_base_4_ft": base4,
|
|
338
|
+
"visibility_miles": parse_awc_visibility(m.get("visib")),
|
|
339
|
+
"weather_codes": weather_codes,
|
|
340
|
+
"precip_1hr_inches": precip,
|
|
341
|
+
"peak_wind_gust_kt": pk_spd,
|
|
342
|
+
"peak_wind_dir": pk_dir,
|
|
343
|
+
"peak_wind_time": pk_time,
|
|
344
|
+
"snow_depth_inches": None, # not available from AWC
|
|
345
|
+
"qc_field": qc_field,
|
|
346
|
+
"raw_metar": raw_metar,
|
|
347
|
+
}
|