mostlyrightmd 1.5.2__tar.gz → 1.7.0__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-1.5.2 → mostlyrightmd-1.7.0}/PKG-INFO +2 -2
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/pyproject.toml +7 -2
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_pairs.py +151 -29
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/forecast_nwp.py +3 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/forecasts.py +19 -1
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/research.py +99 -17
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_pairs.py +211 -5
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_forecast_nwp_schema_phase17.py +41 -0
- mostlyrightmd-1.7.0/tests/test_open_meteo_cache_wiring.py +291 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/.gitignore +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/README.md +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_compose.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_exact_fetch.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_bounds.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_cache_dir.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_capabilities.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_convert.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_http.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_pandas_compat.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_stations.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_toon.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/exceptions.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/merge/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/merge/_schemas.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/merge/climate.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/merge/observations.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/models/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/models/_base.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/models/availability.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/models/observation.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/models/station.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/book_snapshot.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/brackets.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/candle.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/climate.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/daily_extreme.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/data_version.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/event.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/forecast.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/forecast_series.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/market.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/market_unified.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/observation.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/observation_ledger.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/observation_qc.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/omo.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/series.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/settlement-join.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/settlement_record.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/snapshot.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/synoptic_extremes.json +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/versioning.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/_backend_dispatch.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/_json_safe.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/_narwhals_compat.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/_polars_compat.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/exceptions.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/_toon.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/_toon_list_codec.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/csv.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/dataframe.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/json.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/parquet.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/toon.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/merge.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/result.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schema.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/forecast.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/observation.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/observation_ledger.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/observation_qc.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/settlement.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/temporal/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/temporal/knowledge_view.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/temporal/leakage.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/temporal/timepoint.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/validator.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/discover.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/discovery.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/international.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/live/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/live/_latest.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/live/_sources.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/live/_stream.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/mode2.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/preprocessing.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/qc.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/snapshot.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/stations.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/transforms.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/merge/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/merge/test_awc_gap_filled_by_iem.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/merge/test_climate.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/merge/test_observations.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/models/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/models/test_availability.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/models/test_base.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/models/test_observation.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/models/test_station.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_bounds.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_capabilities.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_convert.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_exceptions.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_http.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_http_client_reuse.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_stations.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_versioning.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/temporal/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/temporal/test_knowledge_view.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/temporal/test_leakage.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_exceptions.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_formats.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_json_safe.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_merge.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_result.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_schema.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_schemas/__init__.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_schemas/test_forecast.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_schemas/test_observation.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_schemas/test_settlement.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_timepoint.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_validator.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_backend_dispatch.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_cache_env_back_compat.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_compose.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_discover.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_discovery_real.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_exact_fetch.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_exceptions_data_availability.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_exceptions_phase17.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_exceptions_phase17_plan06.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_exceptions_phase18.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_international.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_leakage_issued_at.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_live_latest.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_live_stream.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_mode2.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_namespace.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_phase_3x.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_polars_cross_backend.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_polars_invariants.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_qc_wired.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_research.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_research_forecast_source.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_research_kwarg_validation.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_research_prefetch.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_schemas_codegen.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_schemas_station_forecast.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_snapshot.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_stations_catalog.py +0 -0
- {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_transforms_preprocessing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mostlyrightmd
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0
|
|
4
4
|
Summary: Python SDK for quants, ML engineers, and AI agents — one interface to public data. Adapters ship weather + prediction-market settlements (Kalshi NHIGH/NLOW, Polymarket) today; SEC filings, Federal Reserve series, court filings, FDA approvals, and equities are next. Schema-versioned, leakage-free, local-first. Imports as `mostlyright`.
|
|
5
5
|
Project-URL: Homepage, https://mostlyright.md
|
|
6
6
|
Project-URL: Documentation, https://mostlyright.md/docs/sdk/
|
|
@@ -29,7 +29,7 @@ Requires-Dist: pandas<4.0,>=2.2; extra == 'polars'
|
|
|
29
29
|
Requires-Dist: polars<2.0,>=1.0; extra == 'polars'
|
|
30
30
|
Requires-Dist: pyarrow<24.0,>=17.0; extra == 'polars'
|
|
31
31
|
Provides-Extra: research
|
|
32
|
-
Requires-Dist: mostlyrightmd-weather<2.0,>=1.
|
|
32
|
+
Requires-Dist: mostlyrightmd-weather<2.0,>=1.7.0; extra == 'research'
|
|
33
33
|
Requires-Dist: pandas<4.0,>=2.2; extra == 'research'
|
|
34
34
|
Requires-Dist: pyarrow<24.0,>=17.0; extra == 'research'
|
|
35
35
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mostlyrightmd"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.7.0"
|
|
4
4
|
description = "Python SDK for quants, ML engineers, and AI agents — one interface to public data. Adapters ship weather + prediction-market settlements (Kalshi NHIGH/NLOW, Polymarket) today; SEC filings, Federal Reserve series, court filings, FDA approvals, and equities are next. Schema-versioned, leakage-free, local-first. Imports as `mostlyright`."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -72,7 +72,12 @@ parquet = [
|
|
|
72
72
|
# pandas upper bound aligned with `parquet` extra at <4.0; both backends
|
|
73
73
|
# are exercised by the dual-pandas CI matrix.
|
|
74
74
|
research = [
|
|
75
|
-
|
|
75
|
+
# >=1.7.0: forecast_nwp() threads member= to the weather impl, a kwarg
|
|
76
|
+
# introduced in mostlyrightmd-weather 1.7.0 (#74) — an older weather would
|
|
77
|
+
# TypeError on forecast_nwp(..., member=...). (Supersedes the >=1.6.0 floor
|
|
78
|
+
# for fetch_open_meteo(variables=...), #64 codex P1; default calls stay
|
|
79
|
+
# skew-tolerant via the core wrapper's conditional member threading.)
|
|
80
|
+
"mostlyrightmd-weather>=1.7.0,<2.0",
|
|
76
81
|
"pyarrow>=17.0,<24.0",
|
|
77
82
|
"pandas>=2.2,<4.0",
|
|
78
83
|
]
|
|
@@ -31,12 +31,16 @@ pairs() returns one row per settlement date, joining:
|
|
|
31
31
|
|
|
32
32
|
This is the primary training/feature surface for AI settlement models.
|
|
33
33
|
|
|
34
|
-
Forecast join
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
Forecast join (records are split by their authoritative ``source`` field —
|
|
35
|
+
``source`` prefixed ``open_meteo`` -> Open-Meteo, else IEM MOS; see issue #67):
|
|
36
|
+
- IEM MOS records (`forecast.json`, `source="iem.archive"`) are grouped by
|
|
37
|
+
issued_at to pick the most-recent run before market close, then
|
|
38
|
+
temperature_f values for valid_at timestamps within the settlement window
|
|
39
|
+
are aggregated (max/min).
|
|
40
|
+
- Open-Meteo records (`forecast_series.json`, `source="open_meteo.*"`) use
|
|
41
|
+
all records in the settlement window. temperature_c is converted to F
|
|
42
|
+
(or a pre-converted temperature_f is used as-is). NOTE: Phase 20+ OM rows
|
|
43
|
+
also carry a derived `issued_at`, so `issued_at` is NOT the discriminator.
|
|
40
44
|
- If both are available, IEM MOS is preferred. Open-Meteo used as fallback.
|
|
41
45
|
- If forecast data is unavailable, forecast columns are None - the row is
|
|
42
46
|
still returned.
|
|
@@ -180,6 +184,49 @@ def _select_best_run(
|
|
|
180
184
|
return best_issued, runs[best_issued]
|
|
181
185
|
|
|
182
186
|
|
|
187
|
+
def _to_iso_z(v: Any) -> Any:
|
|
188
|
+
"""Coerce a timestamp-ish value to a canonical UTC ``...Z`` ISO string.
|
|
189
|
+
|
|
190
|
+
Open-Meteo rows produced directly by ``fetch_open_meteo(...).to_dict(
|
|
191
|
+
"records")`` carry pandas ``Timestamp`` ``valid_at`` / ``issued_at`` values,
|
|
192
|
+
not ISO strings. The forecast-window comparisons below are string-based, so
|
|
193
|
+
a direct caller of ``build_pairs_row`` would otherwise hit a ``TypeError``
|
|
194
|
+
comparing ``Timestamp`` to ``str`` (issue #67 / codex P2). ``str`` /
|
|
195
|
+
``None`` pass through unchanged; anything date-like (pandas ``Timestamp`` is
|
|
196
|
+
a ``datetime`` subclass) is normalized to UTC and stamped ``%Y-%m-%dT%H:%M:%SZ``.
|
|
197
|
+
"""
|
|
198
|
+
if v is None or isinstance(v, str):
|
|
199
|
+
return v
|
|
200
|
+
if hasattr(v, "strftime"):
|
|
201
|
+
try:
|
|
202
|
+
if getattr(v, "tzinfo", None) is not None:
|
|
203
|
+
v = v.astimezone(UTC)
|
|
204
|
+
return v.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
205
|
+
except Exception:
|
|
206
|
+
return v
|
|
207
|
+
return v
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _is_open_meteo_record(r: dict[str, Any]) -> bool:
|
|
211
|
+
"""True if ``r`` is an Open-Meteo forecast record (issue #67).
|
|
212
|
+
|
|
213
|
+
The authoritative signal is the ``source`` field: real Open-Meteo rows
|
|
214
|
+
carry ``source`` prefixed ``open_meteo`` (.previous_runs / .single_run /
|
|
215
|
+
.seamless / .live), while IEM MOS rows carry ``source="iem.archive"``.
|
|
216
|
+
|
|
217
|
+
For backward compatibility, the previously-documented *legacy* Open-Meteo
|
|
218
|
+
shape — no ``source`` AND no ``issued_at`` (the old ``forecast_series.json``
|
|
219
|
+
discriminator) — is also treated as Open-Meteo. Real IEM MOS rows always
|
|
220
|
+
carry an ``issued_at``, so a record lacking both fields can only be a
|
|
221
|
+
legacy OM row; this avoids regressing source-less OM callers to null.
|
|
222
|
+
"""
|
|
223
|
+
src = str(r.get("source") or "")
|
|
224
|
+
if src.startswith("open_meteo"):
|
|
225
|
+
return True
|
|
226
|
+
# Legacy OM shape (pre-Phase-20): no source, no issued_at.
|
|
227
|
+
return not src and not r.get("issued_at")
|
|
228
|
+
|
|
229
|
+
|
|
183
230
|
def _aggregate_fcst_temps_iem(
|
|
184
231
|
run_records: list[dict[str, Any]],
|
|
185
232
|
window_start_iso: str,
|
|
@@ -209,9 +256,19 @@ def _aggregate_fcst_temps_openmeteo(
|
|
|
209
256
|
window_start_iso: str,
|
|
210
257
|
window_end_iso: str,
|
|
211
258
|
) -> tuple[float | None, float | None]:
|
|
212
|
-
"""Aggregate Open-Meteo hourly
|
|
259
|
+
"""Aggregate Open-Meteo hourly temperature (-> F) over the settlement window.
|
|
213
260
|
|
|
214
|
-
Open-Meteo
|
|
261
|
+
Open-Meteo rows store temperature in Celsius. The field name varies by
|
|
262
|
+
producer, so accept all three shapes (issue #67):
|
|
263
|
+
|
|
264
|
+
- ``temperature_c`` — the unit-contract / specs name (°C -> F).
|
|
265
|
+
- ``temp_c`` — the canonical column name on a raw
|
|
266
|
+
``fetch_open_meteo(...).to_dict("records")`` row (°C -> F).
|
|
267
|
+
- ``temperature_f`` — pre-converted Fahrenheit, the shape
|
|
268
|
+
``research._fetch_open_meteo_range`` emits (used as-is).
|
|
269
|
+
|
|
270
|
+
Without covering all three, source-discriminated OM rows from either the
|
|
271
|
+
research() wrapper or the public fetcher would aggregate to null.
|
|
215
272
|
|
|
216
273
|
Args:
|
|
217
274
|
run_records: All Open-Meteo hourly records for the date.
|
|
@@ -221,12 +278,19 @@ def _aggregate_fcst_temps_openmeteo(
|
|
|
221
278
|
Returns:
|
|
222
279
|
(fcst_high_f, fcst_low_f) or (None, None) if no records in window.
|
|
223
280
|
"""
|
|
224
|
-
temps_f = [
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
281
|
+
temps_f: list[float] = []
|
|
282
|
+
for r in run_records:
|
|
283
|
+
if not (window_start_iso <= r.get("valid_at", "") <= window_end_iso):
|
|
284
|
+
continue
|
|
285
|
+
temp_c = r.get("temperature_c")
|
|
286
|
+
if temp_c is None:
|
|
287
|
+
temp_c = r.get("temp_c")
|
|
288
|
+
if temp_c is not None:
|
|
289
|
+
temps_f.append(temp_c * 9 / 5 + 32)
|
|
290
|
+
continue
|
|
291
|
+
temp_f = r.get("temperature_f")
|
|
292
|
+
if temp_f is not None:
|
|
293
|
+
temps_f.append(temp_f)
|
|
230
294
|
return (max(temps_f), min(temps_f)) if temps_f else (None, None)
|
|
231
295
|
|
|
232
296
|
|
|
@@ -250,7 +314,10 @@ def build_pairs_row(
|
|
|
250
314
|
climate: NWS CLI record for the date (or None). Must be a dict or None
|
|
251
315
|
- non-dict values are treated as None.
|
|
252
316
|
forecasts: All forecast records with valid_at (or None if unavailable).
|
|
253
|
-
|
|
317
|
+
Records are split by their ``source`` field: ``source`` prefixed
|
|
318
|
+
``open_meteo`` -> Open-Meteo, everything else (e.g.
|
|
319
|
+
``source="iem.archive"``) -> IEM MOS. ``issued_at`` is NOT used as
|
|
320
|
+
the discriminator (Phase 20+ Open-Meteo rows carry one too).
|
|
254
321
|
forecast_model: Filter IEM MOS records to this model before run
|
|
255
322
|
selection. None = no filtering (best available run).
|
|
256
323
|
tz_override: IANA timezone name override for stations not in the known
|
|
@@ -295,9 +362,17 @@ def build_pairs_row(
|
|
|
295
362
|
win_start_iso = win_start.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
296
363
|
win_end_iso = win_end.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
297
364
|
|
|
298
|
-
# Separate IEM MOS
|
|
299
|
-
|
|
300
|
-
|
|
365
|
+
# Separate IEM MOS from Open-Meteo by the authoritative ``source``
|
|
366
|
+
# field (issue #67), with a legacy no-source/no-issued_at fallback —
|
|
367
|
+
# see _is_open_meteo_record. ``issued_at`` presence alone is NOT a
|
|
368
|
+
# valid discriminator: Phase 20+ Open-Meteo rows carry a derived
|
|
369
|
+
# ``issued_at`` (for cycle-math / previous-runs caching), so the old
|
|
370
|
+
# ``issued_at``-based split misrouted those rows into the IEM MOS
|
|
371
|
+
# aggregation path, silently nulling forecast temps and polluting IEM
|
|
372
|
+
# run-selection. research._fetch_open_meteo_range already documents
|
|
373
|
+
# this contract ("discriminates via row.get('source')").
|
|
374
|
+
om_records = [r for r in forecasts if _is_open_meteo_record(r)]
|
|
375
|
+
iem_records = [r for r in forecasts if not _is_open_meteo_record(r)]
|
|
301
376
|
|
|
302
377
|
# Apply forecast_model filter to IEM MOS records before run selection.
|
|
303
378
|
# Phase 17 Wave 4 iter-3 review HIGH: case-insensitive match because
|
|
@@ -339,19 +414,66 @@ def build_pairs_row(
|
|
|
339
414
|
|
|
340
415
|
# Fall back to Open-Meteo if IEM MOS yielded no temperature data
|
|
341
416
|
if fcst_high is None and om_records:
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
417
|
+
# Leakage guard (issue #67, codex P1): Phase 20+ OM rows carry a
|
|
418
|
+
# derived issued_at. Pre-#67 these flowed through the IEM branch,
|
|
419
|
+
# where _select_best_run filtered runs issued AFTER market close.
|
|
420
|
+
# The OM branch has no such filter, so apply the cutoff here too —
|
|
421
|
+
# otherwise a row from a run issued after settlement (e.g. live /
|
|
422
|
+
# single-run cycles mixed into training pairs) would leak its
|
|
423
|
+
# temperature/POP/QPF into the pair, not just its timestamp.
|
|
424
|
+
# Legacy source-less OM rows have no issued_at provenance and are
|
|
425
|
+
# kept (documented all-window behavior — nothing to leak).
|
|
426
|
+
cutoff_iso = market_close.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
427
|
+
# Normalize valid_at / issued_at to ISO-Z strings up front so direct
|
|
428
|
+
# callers passing raw fetch_open_meteo output (pandas Timestamps)
|
|
429
|
+
# don't TypeError against the string window bounds (codex P2). Shallow
|
|
430
|
+
# copies — the caller's row dicts are not mutated.
|
|
431
|
+
norm_om = [
|
|
432
|
+
{
|
|
433
|
+
**r,
|
|
434
|
+
"valid_at": _to_iso_z(r.get("valid_at")),
|
|
435
|
+
"issued_at": _to_iso_z(r.get("issued_at")),
|
|
436
|
+
}
|
|
437
|
+
for r in om_records
|
|
348
438
|
]
|
|
349
|
-
|
|
350
|
-
r
|
|
351
|
-
for r in
|
|
352
|
-
if r.get("
|
|
439
|
+
window_om = [
|
|
440
|
+
r
|
|
441
|
+
for r in norm_om
|
|
442
|
+
if win_start_iso <= r.get("valid_at", "") <= win_end_iso
|
|
443
|
+
and ((iss := r.get("issued_at")) is None or iss <= cutoff_iso)
|
|
353
444
|
]
|
|
354
|
-
|
|
445
|
+
fcst_high, fcst_low = _aggregate_fcst_temps_openmeteo(
|
|
446
|
+
window_om, win_start_iso, win_end_iso
|
|
447
|
+
)
|
|
448
|
+
if window_om:
|
|
449
|
+
fcst_model = next(
|
|
450
|
+
(r.get("model") for r in window_om if r.get("model")), "open-meteo"
|
|
451
|
+
)
|
|
452
|
+
# POP: accept the unit-contract ``precipitation_probability_pct``
|
|
453
|
+
# OR the ``pop_6hr_pct`` alias research._fetch_open_meteo_range
|
|
454
|
+
# emits. Without the alias, source-discriminated wrapper rows
|
|
455
|
+
# would regress POP to None now that they no longer flow through
|
|
456
|
+
# the IEM branch. Explicit None-checks preserve a valid 0.0.
|
|
457
|
+
probs: list[float] = []
|
|
458
|
+
for r in window_om:
|
|
459
|
+
p = r.get("precipitation_probability_pct")
|
|
460
|
+
if p is None:
|
|
461
|
+
p = r.get("pop_6hr_pct")
|
|
462
|
+
if p is not None:
|
|
463
|
+
probs.append(p)
|
|
464
|
+
fcst_pop = max(probs) if probs else None
|
|
465
|
+
# QPF: the OM unit-contract shape carries no QPF, but the
|
|
466
|
+
# research wrapper emits ``qpf_6hr_in`` — sum over the window to
|
|
467
|
+
# match IEM-branch semantics (else wrapper QPF regresses too).
|
|
468
|
+
qpfs_om = [r["qpf_6hr_in"] for r in window_om if r.get("qpf_6hr_in") is not None]
|
|
469
|
+
if qpfs_om:
|
|
470
|
+
fcst_qpf = sum(qpfs_om)
|
|
471
|
+
# ISSUED_AT provenance: most-recent run timestamp (all already
|
|
472
|
+
# <= cutoff by the window_om filter above). None for legacy
|
|
473
|
+
# source-less rows.
|
|
474
|
+
om_issued = [iss for r in window_om if (iss := r.get("issued_at")) is not None]
|
|
475
|
+
if om_issued:
|
|
476
|
+
fcst_issued = max(om_issued)
|
|
355
477
|
|
|
356
478
|
fcst.update(
|
|
357
479
|
{
|
|
@@ -165,6 +165,9 @@ class NwpForecastSchema(Schema):
|
|
|
165
165
|
ColumnSpec(name="precip_mm_1h", dtype="float64", units="mm", nullable=True),
|
|
166
166
|
ColumnSpec(name="pressure_pa_surface", dtype="float64", units="Pa", nullable=True),
|
|
167
167
|
ColumnSpec(name="pressure_pa_mslp", dtype="float64", units="Pa", nullable=True),
|
|
168
|
+
ColumnSpec(name="cloud_cover_pct", dtype="float64", units="percent", nullable=True),
|
|
169
|
+
ColumnSpec(name="visibility_m", dtype="float64", units="m", nullable=True),
|
|
170
|
+
ColumnSpec(name="cloud_ceiling_m", dtype="float64", units="m", nullable=True),
|
|
168
171
|
# Provenance / QC -----------------------------------------------
|
|
169
172
|
ColumnSpec(
|
|
170
173
|
name="qc_status",
|
|
@@ -131,6 +131,7 @@ def forecast_nwp(
|
|
|
131
131
|
cycle_range_end: datetime | None = None,
|
|
132
132
|
fxx: int | None = None,
|
|
133
133
|
mirror: str | None = None,
|
|
134
|
+
member: str | None = None,
|
|
134
135
|
client: httpx.Client | None = None,
|
|
135
136
|
) -> pd.DataFrame:
|
|
136
137
|
"""Fetch an NWP forecast from NOAA Big Data Program direct-fetch.
|
|
@@ -149,6 +150,12 @@ def forecast_nwp(
|
|
|
149
150
|
fxx: Forecast hour ahead of ``cycle``. Default ``1``.
|
|
150
151
|
mirror: Force a specific NOAA BDP mirror (``"aws_bdp"`` or
|
|
151
152
|
``"nomads"``). Default: try AWS then NOMADS.
|
|
153
|
+
member: Ensemble member id — only valid for the member-capable
|
|
154
|
+
models GEFS (``"c00"``/``"p01"``..``"p30"``/``"avg"``/``"spr"``,
|
|
155
|
+
default ``"c00"``) and CFS (``"01"``..``"04"``, default
|
|
156
|
+
``"01"``). ``None`` (default) is byte-identical to today.
|
|
157
|
+
Validation + the valid-member enums live in the weather impl;
|
|
158
|
+
this wrapper passes ``member`` straight through (issue #74).
|
|
152
159
|
client: Reuse an ``httpx.Client`` for connection pooling.
|
|
153
160
|
|
|
154
161
|
Returns:
|
|
@@ -157,7 +164,9 @@ def forecast_nwp(
|
|
|
157
164
|
Raises:
|
|
158
165
|
ValueError: ``model`` not in :data:`SUPPORTED_NWP_MODELS` and
|
|
159
166
|
not a reserved ECMWF id; ``fxx`` is negative; ``cycle`` is
|
|
160
|
-
naive; ``mirror`` outside the supported set
|
|
167
|
+
naive; ``mirror`` outside the supported set; ``member`` set
|
|
168
|
+
for a non-member model or not a valid member of the model's
|
|
169
|
+
ensemble (validated in the weather impl).
|
|
161
170
|
NwpModelNotAvailableError: ``model`` is a reserved ECMWF id.
|
|
162
171
|
SourceUnavailableError: ``[nwp]`` optional extra not installed.
|
|
163
172
|
NoLiveForNwpError: every wired NOAA BDP mirror failed.
|
|
@@ -261,6 +270,14 @@ def forecast_nwp(
|
|
|
261
270
|
source=f"nwp.{model}",
|
|
262
271
|
) from None
|
|
263
272
|
|
|
273
|
+
# Issue #74 cross-version skew guard: thread ``member`` only when the
|
|
274
|
+
# caller actually set it. Passing ``member=member`` unconditionally would
|
|
275
|
+
# make EVERY core-wrapper call TypeError against an older
|
|
276
|
+
# mostlyrightmd-weather whose impl predates the kwarg (core 1.7.0 +
|
|
277
|
+
# weather <=1.6.0 outside the [research] extra's floor). With the guard,
|
|
278
|
+
# default calls stay call-compatible across the skew; only explicit
|
|
279
|
+
# ``member=`` callers on an old weather see the loud TypeError.
|
|
280
|
+
member_kwargs: dict[str, str] = {} if member is None else {"member": member}
|
|
264
281
|
return _impl(
|
|
265
282
|
station,
|
|
266
283
|
model,
|
|
@@ -270,4 +287,5 @@ def forecast_nwp(
|
|
|
270
287
|
fxx=fxx,
|
|
271
288
|
mirror=mirror,
|
|
272
289
|
client=client,
|
|
290
|
+
**member_kwargs,
|
|
273
291
|
)
|
|
@@ -1381,6 +1381,14 @@ def _validate_research_kwargs(
|
|
|
1381
1381
|
_FORECAST_SOURCES_ALLOWED: frozenset[str] = frozenset({"iem_mos", "open_meteo"})
|
|
1382
1382
|
|
|
1383
1383
|
|
|
1384
|
+
_OM_RESEARCH_VARIABLES: tuple[str, ...] = (
|
|
1385
|
+
"temperature_2m",
|
|
1386
|
+
"precipitation",
|
|
1387
|
+
"precipitation_probability",
|
|
1388
|
+
)
|
|
1389
|
+
_OM_RESEARCH_SOURCE: str = "open_meteo.previous_runs"
|
|
1390
|
+
|
|
1391
|
+
|
|
1384
1392
|
def _fetch_open_meteo_range(
|
|
1385
1393
|
info: StationInfo,
|
|
1386
1394
|
from_date: str,
|
|
@@ -1390,24 +1398,91 @@ def _fetch_open_meteo_range(
|
|
|
1390
1398
|
) -> dict[str, list[dict[str, Any]]]:
|
|
1391
1399
|
"""Phase 20 OM-05 — fetch Open-Meteo forecasts grouped by settlement date.
|
|
1392
1400
|
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1401
|
+
Reads from the Phase 20 forecast cache before hitting the network. On a
|
|
1402
|
+
cache miss the fetcher writes each elapsed month's rows back so subsequent
|
|
1403
|
+
calls for the same window are served from disk. Only the 3 variables
|
|
1404
|
+
consumed by the pairs join are requested (Fix 3 — cuts weighted call cost).
|
|
1405
|
+
|
|
1406
|
+
Returns the ``{date_iso: [forecast_row, ...]}`` shape that
|
|
1407
|
+
``build_pairs(forecasts_by_date=...)`` expects.
|
|
1399
1408
|
"""
|
|
1409
|
+
from datetime import date as _date
|
|
1410
|
+
from datetime import timedelta as _timedelta
|
|
1411
|
+
|
|
1400
1412
|
import pandas as pd
|
|
1401
1413
|
|
|
1402
1414
|
from mostlyright.weather._fetchers._open_meteo import fetch_open_meteo
|
|
1415
|
+
from mostlyright.weather.cache import read_forecast_cache, write_forecast_cache
|
|
1416
|
+
|
|
1417
|
+
# Enumerate (year, month) partitions covered by [from_date, to_date].
|
|
1418
|
+
start = _date.fromisoformat(from_date)
|
|
1419
|
+
end = _date.fromisoformat(to_date)
|
|
1420
|
+
months: list[tuple[int, int]] = []
|
|
1421
|
+
cur = _date(start.year, start.month, 1)
|
|
1422
|
+
while cur <= end:
|
|
1423
|
+
months.append((cur.year, cur.month))
|
|
1424
|
+
cur = _date(cur.year + (cur.month // 12), (cur.month % 12) + 1, 1)
|
|
1425
|
+
|
|
1426
|
+
# Serve cached partitions; collect months that need a network fetch.
|
|
1427
|
+
all_rows: list[dict[str, Any]] = []
|
|
1428
|
+
missing: list[tuple[int, int]] = []
|
|
1429
|
+
for y, m in months:
|
|
1430
|
+
hit = read_forecast_cache(info.icao, _OM_RESEARCH_SOURCE, model, y, m)
|
|
1431
|
+
if hit is not None:
|
|
1432
|
+
all_rows.extend(hit)
|
|
1433
|
+
else:
|
|
1434
|
+
missing.append((y, m))
|
|
1435
|
+
|
|
1436
|
+
# Fetch the missing span and populate the cache.
|
|
1437
|
+
if missing:
|
|
1438
|
+
# Issue #64 / codex P2: fetch on FULL-MONTH boundaries, not clamped to
|
|
1439
|
+
# the request subrange. Clamping to [start, end] meant a subrange request
|
|
1440
|
+
# (e.g. 2024-06-01..06-02) wrote only those days into the June partition;
|
|
1441
|
+
# a later June window then read that partition as a "hit" and silently
|
|
1442
|
+
# dropped the uncached days. Fetching whole months makes every written
|
|
1443
|
+
# partition complete. The current UTC month is never served from cache
|
|
1444
|
+
# (read_forecast_cache skip) nor written (write_forecast_cache skip), so
|
|
1445
|
+
# its incompleteness is harmless — we only clamp the upper bound to today
|
|
1446
|
+
# so we never request future dates with no data.
|
|
1447
|
+
from datetime import datetime as _datetime
|
|
1448
|
+
|
|
1449
|
+
today = _datetime.now(UTC).date()
|
|
1450
|
+
miss_start = _date(missing[0][0], missing[0][1], 1)
|
|
1451
|
+
miss_end_y, miss_end_m = missing[-1]
|
|
1452
|
+
last_day = _date(miss_end_y + (miss_end_m // 12), (miss_end_m % 12) + 1, 1) - _timedelta(
|
|
1453
|
+
days=1
|
|
1454
|
+
)
|
|
1455
|
+
miss_end = min(last_day, today)
|
|
1456
|
+
|
|
1457
|
+
df_fetched = fetch_open_meteo(
|
|
1458
|
+
info.icao,
|
|
1459
|
+
miss_start.isoformat(),
|
|
1460
|
+
miss_end.isoformat(),
|
|
1461
|
+
model=model,
|
|
1462
|
+
mode="training",
|
|
1463
|
+
variables=_OM_RESEARCH_VARIABLES,
|
|
1464
|
+
)
|
|
1465
|
+
|
|
1466
|
+
if df_fetched is not None and not df_fetched.empty:
|
|
1467
|
+
for y, m in missing:
|
|
1468
|
+
mask = (df_fetched["valid_at"].dt.year == y) & (
|
|
1469
|
+
df_fetched["valid_at"].dt.month == m
|
|
1470
|
+
)
|
|
1471
|
+
month_rows = df_fetched[mask].to_dict("records")
|
|
1472
|
+
if month_rows:
|
|
1473
|
+
cleaned_rows = [
|
|
1474
|
+
{k: (None if pd.isna(v) else v) for k, v in r.items()} for r in month_rows
|
|
1475
|
+
]
|
|
1476
|
+
write_forecast_cache(info.icao, _OM_RESEARCH_SOURCE, model, y, m, cleaned_rows)
|
|
1477
|
+
all_rows.extend(cleaned_rows)
|
|
1403
1478
|
|
|
1404
|
-
df = fetch_open_meteo(info.icao, from_date, to_date, model=model, mode="training")
|
|
1405
1479
|
groups: dict[str, list[dict[str, Any]]] = {}
|
|
1406
|
-
if
|
|
1480
|
+
if not all_rows:
|
|
1407
1481
|
return groups
|
|
1408
|
-
|
|
1482
|
+
|
|
1483
|
+
for row in all_rows:
|
|
1409
1484
|
ftime = row.get("valid_at")
|
|
1410
|
-
if ftime is None or (
|
|
1485
|
+
if ftime is None or pd.isna(ftime):
|
|
1411
1486
|
continue
|
|
1412
1487
|
try:
|
|
1413
1488
|
ftime_dt = pd.to_datetime(ftime, utc=True)
|
|
@@ -1421,8 +1496,7 @@ def _fetch_open_meteo_range(
|
|
|
1421
1496
|
try:
|
|
1422
1497
|
issued_iso = (
|
|
1423
1498
|
pd.to_datetime(issued_at, utc=True).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1424
|
-
if issued_at is not None
|
|
1425
|
-
and not (isinstance(issued_at, float) and issued_at != issued_at)
|
|
1499
|
+
if issued_at is not None and not pd.isna(issued_at)
|
|
1426
1500
|
else None
|
|
1427
1501
|
)
|
|
1428
1502
|
except Exception:
|
|
@@ -1430,21 +1504,21 @@ def _fetch_open_meteo_range(
|
|
|
1430
1504
|
valid_iso = ftime_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1431
1505
|
temp_c = row.get("temp_c")
|
|
1432
1506
|
temperature_f: float | None = None
|
|
1433
|
-
if temp_c is not None and not (
|
|
1507
|
+
if temp_c is not None and not pd.isna(temp_c):
|
|
1434
1508
|
try:
|
|
1435
1509
|
temperature_f = float(temp_c) * 9.0 / 5.0 + 32.0
|
|
1436
1510
|
except (TypeError, ValueError):
|
|
1437
1511
|
temperature_f = None
|
|
1438
1512
|
pop_prob = row.get("precip_probability")
|
|
1439
1513
|
pop_6hr_pct: float | None = None
|
|
1440
|
-
if pop_prob is not None and not (
|
|
1514
|
+
if pop_prob is not None and not pd.isna(pop_prob):
|
|
1441
1515
|
try:
|
|
1442
1516
|
pop_6hr_pct = float(pop_prob) * 100.0
|
|
1443
1517
|
except (TypeError, ValueError):
|
|
1444
1518
|
pop_6hr_pct = None
|
|
1445
1519
|
precip_mm = row.get("precipitation_mm")
|
|
1446
1520
|
qpf_6hr_in: float | None = None
|
|
1447
|
-
if precip_mm is not None and not (
|
|
1521
|
+
if precip_mm is not None and not pd.isna(precip_mm):
|
|
1448
1522
|
try:
|
|
1449
1523
|
qpf_6hr_in = float(precip_mm) / 25.4
|
|
1450
1524
|
except (TypeError, ValueError):
|
|
@@ -1540,8 +1614,16 @@ def research(
|
|
|
1540
1614
|
(all entries are covered).
|
|
1541
1615
|
|
|
1542
1616
|
Returns:
|
|
1543
|
-
DataFrame (or ``list[dict]`` when ``as_dataframe=False``) with one
|
|
1544
|
-
row per settlement date in ``[from_date, to_date]
|
|
1617
|
+
DataFrame (or ``list[dict]`` when ``as_dataframe=False``) with **one
|
|
1618
|
+
row per settlement date** in ``[from_date, to_date]`` — a *daily*
|
|
1619
|
+
summary, NOT hourly/sub-daily observations (issue #52). Each row's
|
|
1620
|
+
``obs_*`` columns are aggregates over that date's settlement window:
|
|
1621
|
+
``obs_high_f``/``obs_low_f`` are the window max/min and ``obs_mean_f``
|
|
1622
|
+
is the mean of the sub-daily METARs. ``research()`` does not return
|
|
1623
|
+
raw hourly rows; sub-daily / ``raw_metar`` access is a Sprint 0.5+
|
|
1624
|
+
item (raw METARs are preserved internally for the planned re-parse
|
|
1625
|
+
workflow). For an observation-only daily frame without CLI/forecast
|
|
1626
|
+
columns, see :func:`mostlyright.weather.obs` (also daily). Columns:
|
|
1545
1627
|
|
|
1546
1628
|
``date`` (index when DataFrame), ``station``, ``cli_high_f``,
|
|
1547
1629
|
``cli_low_f``, ``cli_report_type``, ``obs_high_f``, ``obs_low_f``,
|