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.
Files changed (102) hide show
  1. mostlyrightmd_weather-0.1.2/.gitignore +68 -0
  2. mostlyrightmd_weather-0.1.2/PKG-INFO +40 -0
  3. mostlyrightmd_weather-0.1.2/README.md +7 -0
  4. mostlyrightmd_weather-0.1.2/pyproject.toml +78 -0
  5. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/__init__.py +36 -0
  6. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_awc.py +347 -0
  7. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_climate.py +186 -0
  8. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/__init__.py +20 -0
  9. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_hafs_storms.py +149 -0
  10. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_iem_chunks.py +75 -0
  11. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_iem_mos.py +302 -0
  12. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_msc_archive.py +202 -0
  13. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_archive.py +818 -0
  14. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_cycle_chunks.py +238 -0
  15. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_extract.py +224 -0
  16. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/__init__.py +126 -0
  17. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/cfs.py +25 -0
  18. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/ecmwf_aifs.py +25 -0
  19. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/ecmwf_ifs.py +30 -0
  20. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/gdas.py +17 -0
  21. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/gdps.py +21 -0
  22. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/gefs.py +23 -0
  23. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/geps.py +26 -0
  24. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/gfs.py +32 -0
  25. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/hafs.py +25 -0
  26. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/hiresw.py +33 -0
  27. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/hrdps.py +19 -0
  28. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/href.py +22 -0
  29. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/hrrr.py +39 -0
  30. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/hrrrak.py +19 -0
  31. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/nam.py +33 -0
  32. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/nbm.py +31 -0
  33. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/rap.py +19 -0
  34. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/rdps.py +17 -0
  35. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/reps.py +20 -0
  36. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/rrfs.py +22 -0
  37. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/rtma.py +17 -0
  38. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_grids/urma.py +17 -0
  39. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_nwp_idx.py +302 -0
  40. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/_url_transitions.py +48 -0
  41. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/awc.py +142 -0
  42. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/ghcnh.py +169 -0
  43. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/iem_asos.py +255 -0
  44. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_fetchers/iem_cli.py +194 -0
  45. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_ghcnh.py +348 -0
  46. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/_iem.py +278 -0
  47. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/cache.py +468 -0
  48. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/catalog/__init__.py +96 -0
  49. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/catalog/_obs_projection.py +192 -0
  50. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/catalog/awc.py +82 -0
  51. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/catalog/cli.py +239 -0
  52. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/catalog/ghcnh.py +84 -0
  53. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/catalog/iem.py +138 -0
  54. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/forecast_nwp.py +1009 -0
  55. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/obs.py +369 -0
  56. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/qc/__init__.py +7 -0
  57. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/qc/rules_nwp.py +398 -0
  58. mostlyrightmd_weather-0.1.2/src/mostlyright/weather/qc_sidecar.py +98 -0
  59. mostlyrightmd_weather-0.1.2/tests/__init__.py +0 -0
  60. mostlyrightmd_weather-0.1.2/tests/_awc/__init__.py +0 -0
  61. mostlyrightmd_weather-0.1.2/tests/_awc/test_awc.py +781 -0
  62. mostlyrightmd_weather-0.1.2/tests/_climate/__init__.py +0 -0
  63. mostlyrightmd_weather-0.1.2/tests/_climate/fixtures/iem_cli_atl_sample.json +50 -0
  64. mostlyrightmd_weather-0.1.2/tests/_climate/test_climate.py +504 -0
  65. mostlyrightmd_weather-0.1.2/tests/_fetchers/__init__.py +0 -0
  66. mostlyrightmd_weather-0.1.2/tests/_fetchers/test_awc.py +236 -0
  67. mostlyrightmd_weather-0.1.2/tests/_fetchers/test_ghcnh.py +476 -0
  68. mostlyrightmd_weather-0.1.2/tests/_fetchers/test_iem_asos.py +660 -0
  69. mostlyrightmd_weather-0.1.2/tests/_fetchers/test_iem_chunks.py +88 -0
  70. mostlyrightmd_weather-0.1.2/tests/_fetchers/test_iem_cli.py +329 -0
  71. mostlyrightmd_weather-0.1.2/tests/_ghcnh/__init__.py +0 -0
  72. mostlyrightmd_weather-0.1.2/tests/_ghcnh/fixtures/ghcnh_jfk_2025_sample.psv +50 -0
  73. mostlyrightmd_weather-0.1.2/tests/_ghcnh/test_ghcnh.py +943 -0
  74. mostlyrightmd_weather-0.1.2/tests/_iem/__init__.py +0 -0
  75. mostlyrightmd_weather-0.1.2/tests/_iem/fixtures/iem_jfk_metar_sample.csv +43 -0
  76. mostlyrightmd_weather-0.1.2/tests/_iem/test_iem.py +929 -0
  77. mostlyrightmd_weather-0.1.2/tests/catalog/__init__.py +0 -0
  78. mostlyrightmd_weather-0.1.2/tests/catalog/test_awc.py +103 -0
  79. mostlyrightmd_weather-0.1.2/tests/catalog/test_cli.py +212 -0
  80. mostlyrightmd_weather-0.1.2/tests/catalog/test_ghcnh.py +95 -0
  81. mostlyrightmd_weather-0.1.2/tests/catalog/test_iem.py +217 -0
  82. mostlyrightmd_weather-0.1.2/tests/catalog/test_registry.py +68 -0
  83. mostlyrightmd_weather-0.1.2/tests/test_cache.py +801 -0
  84. mostlyrightmd_weather-0.1.2/tests/test_ecmwf_models.py +155 -0
  85. mostlyrightmd_weather-0.1.2/tests/test_forecast_nwp.py +639 -0
  86. mostlyrightmd_weather-0.1.2/tests/test_forecast_nwp_multi_cycle.py +242 -0
  87. mostlyrightmd_weather-0.1.2/tests/test_hafs_storms.py +124 -0
  88. mostlyrightmd_weather-0.1.2/tests/test_iem_catalog_fetch_forecasts.py +37 -0
  89. mostlyrightmd_weather-0.1.2/tests/test_iem_mos_fetcher.py +289 -0
  90. mostlyrightmd_weather-0.1.2/tests/test_legacy_models.py +214 -0
  91. mostlyrightmd_weather-0.1.2/tests/test_msc_models.py +197 -0
  92. mostlyrightmd_weather-0.1.2/tests/test_nwp_archive.py +262 -0
  93. mostlyrightmd_weather-0.1.2/tests/test_nwp_archive_refactor.py +198 -0
  94. mostlyrightmd_weather-0.1.2/tests/test_nwp_cycle_chunks.py +196 -0
  95. mostlyrightmd_weather-0.1.2/tests/test_nwp_historical_backfill.py +219 -0
  96. mostlyrightmd_weather-0.1.2/tests/test_nwp_idx.py +103 -0
  97. mostlyrightmd_weather-0.1.2/tests/test_nwp_idx_dispatch.py +37 -0
  98. mostlyrightmd_weather-0.1.2/tests/test_nwp_idx_eccodes.py +127 -0
  99. mostlyrightmd_weather-0.1.2/tests/test_nwp_ncep_models.py +336 -0
  100. mostlyrightmd_weather-0.1.2/tests/test_qc_rules_nwp.py +155 -0
  101. mostlyrightmd_weather-0.1.2/tests/test_research_include_forecast.py +451 -0
  102. 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
+ }