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.
Files changed (158) hide show
  1. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/PKG-INFO +2 -2
  2. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/pyproject.toml +7 -2
  3. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_pairs.py +151 -29
  4. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/forecast_nwp.py +3 -0
  5. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/forecasts.py +19 -1
  6. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/research.py +99 -17
  7. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_pairs.py +211 -5
  8. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_forecast_nwp_schema_phase17.py +41 -0
  9. mostlyrightmd-1.7.0/tests/test_open_meteo_cache_wiring.py +291 -0
  10. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/.gitignore +0 -0
  11. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/README.md +0 -0
  12. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/__init__.py +0 -0
  13. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_compose.py +0 -0
  14. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_exact_fetch.py +0 -0
  15. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/__init__.py +0 -0
  16. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_bounds.py +0 -0
  17. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_cache_dir.py +0 -0
  18. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_capabilities.py +0 -0
  19. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_convert.py +0 -0
  20. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_http.py +0 -0
  21. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_pandas_compat.py +0 -0
  22. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_stations.py +0 -0
  23. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/_toon.py +0 -0
  24. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/exceptions.py +0 -0
  25. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/merge/__init__.py +0 -0
  26. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/merge/_schemas.py +0 -0
  27. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/merge/climate.py +0 -0
  28. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/merge/observations.py +0 -0
  29. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/models/__init__.py +0 -0
  30. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/models/_base.py +0 -0
  31. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/models/availability.py +0 -0
  32. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/models/observation.py +0 -0
  33. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/models/station.py +0 -0
  34. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/book_snapshot.json +0 -0
  35. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/brackets.json +0 -0
  36. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/candle.json +0 -0
  37. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/climate.json +0 -0
  38. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/daily_extreme.json +0 -0
  39. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/data_version.json +0 -0
  40. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/event.json +0 -0
  41. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/forecast.json +0 -0
  42. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/forecast_series.json +0 -0
  43. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/market.json +0 -0
  44. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/market_unified.json +0 -0
  45. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/observation.json +0 -0
  46. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/observation_ledger.json +0 -0
  47. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/observation_qc.json +0 -0
  48. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/omo.json +0 -0
  49. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/series.json +0 -0
  50. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/settlement-join.json +0 -0
  51. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/settlement_record.json +0 -0
  52. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/snapshot.json +0 -0
  53. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/specs/synoptic_extremes.json +0 -0
  54. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/_internal/versioning.py +0 -0
  55. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/__init__.py +0 -0
  56. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/_backend_dispatch.py +0 -0
  57. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/_json_safe.py +0 -0
  58. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/_narwhals_compat.py +0 -0
  59. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/_polars_compat.py +0 -0
  60. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/exceptions.py +0 -0
  61. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/__init__.py +0 -0
  62. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/_toon.py +0 -0
  63. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/_toon_list_codec.py +0 -0
  64. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/csv.py +0 -0
  65. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/dataframe.py +0 -0
  66. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/json.py +0 -0
  67. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/parquet.py +0 -0
  68. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/formats/toon.py +0 -0
  69. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/merge.py +0 -0
  70. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/result.py +0 -0
  71. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schema.py +0 -0
  72. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/__init__.py +0 -0
  73. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/forecast.py +0 -0
  74. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/observation.py +0 -0
  75. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/observation_ledger.py +0 -0
  76. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/observation_qc.py +0 -0
  77. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/schemas/settlement.py +0 -0
  78. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/temporal/__init__.py +0 -0
  79. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/temporal/knowledge_view.py +0 -0
  80. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/temporal/leakage.py +0 -0
  81. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/temporal/timepoint.py +0 -0
  82. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/core/validator.py +0 -0
  83. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/discover.py +0 -0
  84. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/discovery.py +0 -0
  85. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/international.py +0 -0
  86. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/live/__init__.py +0 -0
  87. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/live/_latest.py +0 -0
  88. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/live/_sources.py +0 -0
  89. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/live/_stream.py +0 -0
  90. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/mode2.py +0 -0
  91. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/preprocessing.py +0 -0
  92. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/qc.py +0 -0
  93. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/snapshot.py +0 -0
  94. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/stations.py +0 -0
  95. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/src/mostlyright/transforms.py +0 -0
  96. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/__init__.py +0 -0
  97. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/merge/__init__.py +0 -0
  98. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/merge/test_awc_gap_filled_by_iem.py +0 -0
  99. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/merge/test_climate.py +0 -0
  100. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/merge/test_observations.py +0 -0
  101. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/models/__init__.py +0 -0
  102. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/models/test_availability.py +0 -0
  103. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/models/test_base.py +0 -0
  104. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/models/test_observation.py +0 -0
  105. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/models/test_station.py +0 -0
  106. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_bounds.py +0 -0
  107. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_capabilities.py +0 -0
  108. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_convert.py +0 -0
  109. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_exceptions.py +0 -0
  110. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_http.py +0 -0
  111. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_http_client_reuse.py +0 -0
  112. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_stations.py +0 -0
  113. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/_internal/test_versioning.py +0 -0
  114. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/__init__.py +0 -0
  115. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/temporal/__init__.py +0 -0
  116. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/temporal/test_knowledge_view.py +0 -0
  117. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/temporal/test_leakage.py +0 -0
  118. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_exceptions.py +0 -0
  119. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_formats.py +0 -0
  120. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_json_safe.py +0 -0
  121. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_merge.py +0 -0
  122. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_result.py +0 -0
  123. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_schema.py +0 -0
  124. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_schemas/__init__.py +0 -0
  125. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_schemas/test_forecast.py +0 -0
  126. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_schemas/test_observation.py +0 -0
  127. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_schemas/test_settlement.py +0 -0
  128. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_timepoint.py +0 -0
  129. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/core/test_validator.py +0 -0
  130. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_backend_dispatch.py +0 -0
  131. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_cache_env_back_compat.py +0 -0
  132. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_compose.py +0 -0
  133. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_discover.py +0 -0
  134. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_discovery_real.py +0 -0
  135. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_exact_fetch.py +0 -0
  136. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_exceptions_data_availability.py +0 -0
  137. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_exceptions_phase17.py +0 -0
  138. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_exceptions_phase17_plan06.py +0 -0
  139. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_exceptions_phase18.py +0 -0
  140. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_international.py +0 -0
  141. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_leakage_issued_at.py +0 -0
  142. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_live_latest.py +0 -0
  143. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_live_stream.py +0 -0
  144. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_mode2.py +0 -0
  145. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_namespace.py +0 -0
  146. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_phase_3x.py +0 -0
  147. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_polars_cross_backend.py +0 -0
  148. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_polars_invariants.py +0 -0
  149. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_qc_wired.py +0 -0
  150. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_research.py +0 -0
  151. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_research_forecast_source.py +0 -0
  152. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_research_kwarg_validation.py +0 -0
  153. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_research_prefetch.py +0 -0
  154. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_schemas_codegen.py +0 -0
  155. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_schemas_station_forecast.py +0 -0
  156. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_snapshot.py +0 -0
  157. {mostlyrightmd-1.5.2 → mostlyrightmd-1.7.0}/tests/test_stations_catalog.py +0 -0
  158. {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.5.2
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.0.0; extra == 'research'
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.5.2"
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
- "mostlyrightmd-weather>=1.0.0,<2.0",
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
- - IEM MOS records (`forecast.json`) have `issued_at`; grouped by issued_at to
36
- pick the most-recent run before market close, then temperature_f values for
37
- valid_at timestamps within the settlement window are aggregated (max/min).
38
- - Open-Meteo records (`forecast_series.json`) have no issued_at; all records
39
- in the settlement window are used. temperature_c is converted to F.
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 temperature_c (-> F) over the settlement window.
259
+ """Aggregate Open-Meteo hourly temperature (-> F) over the settlement window.
213
260
 
214
- Open-Meteo stores temperature in Celsius. Conversion: F = C * 9/5 + 32.
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
- r["temperature_c"] * 9 / 5 + 32
226
- for r in run_records
227
- if r.get("temperature_c") is not None
228
- and window_start_iso <= r.get("valid_at", "") <= window_end_iso
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
- IEM MOS records have issued_at; Open-Meteo records do not.
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 (has issued_at) from Open-Meteo (no issued_at)
299
- iem_records = [r for r in forecasts if r.get("issued_at")]
300
- om_records = [r for r in forecasts if not r.get("issued_at")]
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
- fcst_high, fcst_low = _aggregate_fcst_temps_openmeteo(
343
- om_records, win_start_iso, win_end_iso
344
- )
345
- fcst_model = next((r.get("model") for r in om_records if r.get("model")), "open-meteo")
346
- window_om = [
347
- r for r in om_records if win_start_iso <= r.get("valid_at", "") <= win_end_iso
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
- probs = [
350
- r["precipitation_probability_pct"]
351
- for r in window_om
352
- if r.get("precipitation_probability_pct") is not None
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
- fcst_pop = max(probs) if probs else None
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
- Wraps ``mostlyright.weather._fetchers._open_meteo.fetch_open_meteo`` in
1394
- training mode (Previous Runs API) and pivots its tabular DataFrame
1395
- into the ``{date_iso: [forecast_row, ...]}`` shape that
1396
- ``build_pairs(forecasts_by_date=...)`` expects. Each row carries
1397
- ``model`` / ``issued_at`` / ``valid_at`` / ``temperature_f`` /
1398
- ``pop_6hr_pct`` / ``qpf_6hr_in`` keys for build_pairs_row compatibility.
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 df is None or df.empty:
1480
+ if not all_rows:
1407
1481
  return groups
1408
- for _, row in df.iterrows():
1482
+
1483
+ for row in all_rows:
1409
1484
  ftime = row.get("valid_at")
1410
- if ftime is None or (isinstance(ftime, float) and ftime != ftime):
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 (isinstance(temp_c, float) and temp_c != temp_c):
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 (isinstance(pop_prob, float) and pop_prob != pop_prob):
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 (isinstance(precip_mm, float) and precip_mm != precip_mm):
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]``. Columns:
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``,