mostlyright-data 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. mostlyright/data_harness/__init__.py +158 -0
  2. mostlyright/data_harness/acquisition/__init__.py +55 -0
  3. mostlyright/data_harness/acquisition/http.py +2773 -0
  4. mostlyright/data_harness/acquisition/parsing.py +809 -0
  5. mostlyright/data_harness/acquisition/ranges.py +495 -0
  6. mostlyright/data_harness/acquisition/result_download.py +360 -0
  7. mostlyright/data_harness/acquisition/retention_admission.py +248 -0
  8. mostlyright/data_harness/acquisition/sandbox.py +4888 -0
  9. mostlyright/data_harness/acquisition/url_policy.py +530 -0
  10. mostlyright/data_harness/agent_runtime.py +2743 -0
  11. mostlyright/data_harness/assets/logo-ink.svg +31 -0
  12. mostlyright/data_harness/backends/__init__.py +28 -0
  13. mostlyright/data_harness/backends/pandas_backend.py +350 -0
  14. mostlyright/data_harness/backends/polars_backend.py +366 -0
  15. mostlyright/data_harness/backends/protocol.py +124 -0
  16. mostlyright/data_harness/backends/reference.py +83 -0
  17. mostlyright/data_harness/backends/registry.py +55 -0
  18. mostlyright/data_harness/backends/restrictions.py +126 -0
  19. mostlyright/data_harness/canonical.py +333 -0
  20. mostlyright/data_harness/catalog_job.py +625 -0
  21. mostlyright/data_harness/cli.py +5398 -0
  22. mostlyright/data_harness/contracts.py +53 -0
  23. mostlyright/data_harness/coordinator.py +1307 -0
  24. mostlyright/data_harness/deploy.py +924 -0
  25. mostlyright/data_harness/deploy_target.py +312 -0
  26. mostlyright/data_harness/deployment_evidence.py +1067 -0
  27. mostlyright/data_harness/event_presentation.py +576 -0
  28. mostlyright/data_harness/events.py +2152 -0
  29. mostlyright/data_harness/fast_delimited.py +239 -0
  30. mostlyright/data_harness/fleet.py +237 -0
  31. mostlyright/data_harness/formats.py +236 -0
  32. mostlyright/data_harness/governors.py +1163 -0
  33. mostlyright/data_harness/hosted_bootstrap.py +972 -0
  34. mostlyright/data_harness/hosted_crawler.py +1115 -0
  35. mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
  36. mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
  37. mostlyright/data_harness/hosted_crawler_job.py +1277 -0
  38. mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
  39. mostlyright/data_harness/hosted_dataset.py +1500 -0
  40. mostlyright/data_harness/hosted_deploy.py +3037 -0
  41. mostlyright/data_harness/hosted_handoff.py +62 -0
  42. mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
  43. mostlyright/data_harness/hosted_ingestion_job.py +356 -0
  44. mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
  45. mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
  46. mostlyright/data_harness/hosted_session_worker.py +3554 -0
  47. mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
  48. mostlyright/data_harness/hosted_worker.py +6784 -0
  49. mostlyright/data_harness/ingestion/__init__.py +56 -0
  50. mostlyright/data_harness/ingestion/contracts.py +461 -0
  51. mostlyright/data_harness/ingestion/faults.py +42 -0
  52. mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
  53. mostlyright/data_harness/ingestion/spool.py +130 -0
  54. mostlyright/data_harness/ingestion/store.py +885 -0
  55. mostlyright/data_harness/key_seam.py +434 -0
  56. mostlyright/data_harness/linux_process_boundary.py +262 -0
  57. mostlyright/data_harness/local_contracts.py +2880 -0
  58. mostlyright/data_harness/local_search/__init__.py +5 -0
  59. mostlyright/data_harness/local_search/build_index.py +1087 -0
  60. mostlyright/data_harness/local_search/contracts.py +920 -0
  61. mostlyright/data_harness/local_search/query_trace.py +266 -0
  62. mostlyright/data_harness/local_search/retrieval.py +700 -0
  63. mostlyright/data_harness/local_search/sealed.py +474 -0
  64. mostlyright/data_harness/local_search/service.py +784 -0
  65. mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
  66. mostlyright/data_harness/nbrender/__init__.py +12 -0
  67. mostlyright/data_harness/nbrender/chrome.py +359 -0
  68. mostlyright/data_harness/nbrender/code_body.py +266 -0
  69. mostlyright/data_harness/nbrender/document.py +407 -0
  70. mostlyright/data_harness/nbrender/frame.py +275 -0
  71. mostlyright/data_harness/nbrender/interactive.py +337 -0
  72. mostlyright/data_harness/nbrender/markdown_body.py +477 -0
  73. mostlyright/data_harness/nbrender/mr_components.py +134 -0
  74. mostlyright/data_harness/nbrender/outputs_data.py +595 -0
  75. mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
  76. mostlyright/data_harness/nbrender/outputs_source.py +260 -0
  77. mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
  78. mostlyright/data_harness/nbrender/outputs_text.py +400 -0
  79. mostlyright/data_harness/nbrender/parse.py +394 -0
  80. mostlyright/data_harness/nbrender/status.py +40 -0
  81. mostlyright/data_harness/nbrender/tokens.py +1295 -0
  82. mostlyright/data_harness/notebook.py +1710 -0
  83. mostlyright/data_harness/offline.py +2049 -0
  84. mostlyright/data_harness/operation_registry.py +1007 -0
  85. mostlyright/data_harness/operator_setup.py +239 -0
  86. mostlyright/data_harness/pipeline.py +6428 -0
  87. mostlyright/data_harness/plan_graph.py +2026 -0
  88. mostlyright/data_harness/preparation/__init__.py +104 -0
  89. mostlyright/data_harness/preparation/contracts.py +1017 -0
  90. mostlyright/data_harness/preparation/engine.py +221 -0
  91. mostlyright/data_harness/preparation/errors.py +14 -0
  92. mostlyright/data_harness/preparation/gates.py +751 -0
  93. mostlyright/data_harness/preparation/joins.py +574 -0
  94. mostlyright/data_harness/preparation/profile.py +384 -0
  95. mostlyright/data_harness/preparation/table.py +217 -0
  96. mostlyright/data_harness/preparation/transforms.py +568 -0
  97. mostlyright/data_harness/progress_events.py +534 -0
  98. mostlyright/data_harness/readers/__init__.py +46 -0
  99. mostlyright/data_harness/readers/containers.py +963 -0
  100. mostlyright/data_harness/readers/contracts.py +542 -0
  101. mostlyright/data_harness/readers/delimited.py +257 -0
  102. mostlyright/data_harness/readers/grib2/__init__.py +33 -0
  103. mostlyright/data_harness/readers/grib2/admission.py +722 -0
  104. mostlyright/data_harness/readers/grib2/decode.py +1009 -0
  105. mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
  106. mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
  107. mostlyright/data_harness/readers/json_tabular.py +485 -0
  108. mostlyright/data_harness/readers/registry.py +514 -0
  109. mostlyright/data_harness/readers/samples/README.md +110 -0
  110. mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
  111. mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
  112. mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
  113. mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
  114. mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
  115. mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
  116. mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
  117. mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
  118. mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
  119. mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
  120. mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
  121. mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
  122. mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
  123. mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
  124. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
  125. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
  126. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
  127. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
  128. mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
  129. mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
  130. mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
  131. mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
  132. mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
  133. mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
  134. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
  135. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
  136. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
  137. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
  138. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
  139. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
  140. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
  141. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
  142. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
  143. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
  144. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
  145. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
  146. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
  147. mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
  148. mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
  149. mostlyright/data_harness/readers/samples.py +582 -0
  150. mostlyright/data_harness/readers/spreadsheet.py +803 -0
  151. mostlyright/data_harness/readers/tabular.py +510 -0
  152. mostlyright/data_harness/recipe.py +5321 -0
  153. mostlyright/data_harness/repair/__init__.py +78 -0
  154. mostlyright/data_harness/repair/adapters.py +274 -0
  155. mostlyright/data_harness/repair/contracts.py +872 -0
  156. mostlyright/data_harness/repair/coordinator.py +1099 -0
  157. mostlyright/data_harness/repair/errors.py +16 -0
  158. mostlyright/data_harness/review.py +2533 -0
  159. mostlyright/data_harness/rowset.py +283 -0
  160. mostlyright/data_harness/serving.py +1975 -0
  161. mostlyright/data_harness/serving_edge.py +590 -0
  162. mostlyright/data_harness/serving_http.py +1031 -0
  163. mostlyright/data_harness/session_probes.py +759 -0
  164. mostlyright/data_harness/signing.py +101 -0
  165. mostlyright/data_harness/source_discovery.py +898 -0
  166. mostlyright/data_harness/sources/__init__.py +209 -0
  167. mostlyright/data_harness/sources/_adapter_steps.py +213 -0
  168. mostlyright/data_harness/sources/adapters.py +1214 -0
  169. mostlyright/data_harness/sources/cadence.py +1428 -0
  170. mostlyright/data_harness/sources/cadence_emission.py +453 -0
  171. mostlyright/data_harness/sources/cadence_history.py +546 -0
  172. mostlyright/data_harness/sources/catalog/__init__.py +17 -0
  173. mostlyright/data_harness/sources/catalog/admission.py +477 -0
  174. mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
  175. mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
  176. mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
  177. mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
  178. mostlyright/data_harness/sources/catalog/channel.py +523 -0
  179. mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
  180. mostlyright/data_harness/sources/catalog/contracts.py +825 -0
  181. mostlyright/data_harness/sources/catalog/coverage.py +137 -0
  182. mostlyright/data_harness/sources/catalog/delta.py +1340 -0
  183. mostlyright/data_harness/sources/catalog/embedding.py +532 -0
  184. mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
  185. mostlyright/data_harness/sources/catalog/fill.py +3889 -0
  186. mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
  187. mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
  188. mostlyright/data_harness/sources/catalog/gating.py +374 -0
  189. mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
  190. mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
  191. mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
  192. mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
  193. mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
  194. mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
  195. mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
  196. mostlyright/data_harness/sources/catalog/health.py +447 -0
  197. mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
  198. mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
  199. mostlyright/data_harness/sources/catalog/neural.py +1618 -0
  200. mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
  201. mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
  202. mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
  203. mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
  204. mostlyright/data_harness/sources/catalog/recommend.py +171 -0
  205. mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
  206. mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
  207. mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
  208. mostlyright/data_harness/sources/catalog/sealed.py +560 -0
  209. mostlyright/data_harness/sources/catalog/search.py +230 -0
  210. mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
  211. mostlyright/data_harness/sources/catalog/update.py +891 -0
  212. mostlyright/data_harness/sources/collections.py +815 -0
  213. mostlyright/data_harness/sources/contracts.py +2223 -0
  214. mostlyright/data_harness/sources/deletion.py +761 -0
  215. mostlyright/data_harness/sources/fitness.py +162 -0
  216. mostlyright/data_harness/sources/governance.py +163 -0
  217. mostlyright/data_harness/sources/hosted.py +173 -0
  218. mostlyright/data_harness/sources/integration.py +218 -0
  219. mostlyright/data_harness/sources/range_reader.py +418 -0
  220. mostlyright/data_harness/sources/registry.py +514 -0
  221. mostlyright/data_harness/sources/rights_rule.py +59 -0
  222. mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
  223. mostlyright/data_harness/sources/sports.py +521 -0
  224. mostlyright/data_harness/sources/stream.py +524 -0
  225. mostlyright/data_harness/sources/stream_connector.py +418 -0
  226. mostlyright/data_harness/sources/stream_recorder.py +1404 -0
  227. mostlyright/data_harness/studio_boundary.py +2019 -0
  228. mostlyright/data_harness/thin/__init__.py +37 -0
  229. mostlyright/data_harness/thin/acquire.py +1137 -0
  230. mostlyright/data_harness/thin/acquire_cancel.py +579 -0
  231. mostlyright/data_harness/thin/approvals.py +617 -0
  232. mostlyright/data_harness/thin/commands.py +406 -0
  233. mostlyright/data_harness/thin/download.py +194 -0
  234. mostlyright/data_harness/thin/narrative.py +589 -0
  235. mostlyright/data_harness/thin/parity.py +1070 -0
  236. mostlyright/data_harness/thin/propose.py +2759 -0
  237. mostlyright/data_harness/thin/research.py +1663 -0
  238. mostlyright/data_harness/thin/router.py +924 -0
  239. mostlyright/data_harness/thin/runs.py +519 -0
  240. mostlyright/data_harness/thin/session.py +281 -0
  241. mostlyright/data_harness/thin/stream.py +501 -0
  242. mostlyright/data_harness/thin/transport.py +187 -0
  243. mostlyright/data_harness/thin/vocabulary.py +368 -0
  244. mostlyright/data_harness/thin/workers.py +164 -0
  245. mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
  246. mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
  247. mostlyright/data_harness/unit_flow.py +927 -0
  248. mostlyright/data_harness/units.py +572 -0
  249. mostlyright/data_harness/ux/__init__.py +9 -0
  250. mostlyright/data_harness/ux/approve.py +485 -0
  251. mostlyright/data_harness/ux/author_yaml.py +597 -0
  252. mostlyright/data_harness/ux/cloud_auth.py +447 -0
  253. mostlyright/data_harness/ux/commands/__init__.py +260 -0
  254. mostlyright/data_harness/ux/commands/approve.py +136 -0
  255. mostlyright/data_harness/ux/commands/auth.py +744 -0
  256. mostlyright/data_harness/ux/commands/author.py +79 -0
  257. mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
  258. mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
  259. mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
  260. mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
  261. mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
  262. mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
  263. mostlyright/data_harness/ux/commands/deploy.py +134 -0
  264. mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
  265. mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
  266. mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
  267. mostlyright/data_harness/ux/commands/diff.py +74 -0
  268. mostlyright/data_harness/ux/commands/index.py +84 -0
  269. mostlyright/data_harness/ux/commands/inventory.py +47 -0
  270. mostlyright/data_harness/ux/commands/list_builds.py +143 -0
  271. mostlyright/data_harness/ux/commands/login.py +63 -0
  272. mostlyright/data_harness/ux/commands/peek.py +236 -0
  273. mostlyright/data_harness/ux/commands/plan_check.py +90 -0
  274. mostlyright/data_harness/ux/commands/preflight.py +97 -0
  275. mostlyright/data_harness/ux/commands/record.py +107 -0
  276. mostlyright/data_harness/ux/commands/review_setup.py +47 -0
  277. mostlyright/data_harness/ux/commands/search.py +440 -0
  278. mostlyright/data_harness/ux/commands/show.py +61 -0
  279. mostlyright/data_harness/ux/commands/whoami.py +37 -0
  280. mostlyright/data_harness/ux/credential_native.py +551 -0
  281. mostlyright/data_harness/ux/credential_store.py +1055 -0
  282. mostlyright/data_harness/ux/credentials.py +631 -0
  283. mostlyright/data_harness/ux/diffing.py +444 -0
  284. mostlyright/data_harness/ux/headline.py +671 -0
  285. mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
  286. mostlyright/data_harness/ux/hosted_run_status.py +619 -0
  287. mostlyright/data_harness/ux/inventory.py +427 -0
  288. mostlyright/data_harness/ux/local_review.py +375 -0
  289. mostlyright/data_harness/ux/login.py +691 -0
  290. mostlyright/data_harness/ux/path_kind.py +147 -0
  291. mostlyright/data_harness/ux/peek.py +1000 -0
  292. mostlyright/data_harness/ux/plain_file.py +178 -0
  293. mostlyright/data_harness/ux/plan_check.py +311 -0
  294. mostlyright/data_harness/ux/preflight.py +918 -0
  295. mostlyright/data_harness/ux/readers.py +1124 -0
  296. mostlyright/data_harness/ux/remediation.py +2195 -0
  297. mostlyright/data_harness/ux/render.py +657 -0
  298. mostlyright/data_harness/ux/workload.py +1077 -0
  299. mostlyright/data_harness/viewer.py +3713 -0
  300. mostlyright/data_harness/visual_run/__init__.py +83 -0
  301. mostlyright/data_harness/visual_run/authoring.py +235 -0
  302. mostlyright/data_harness/visual_run/contracts.py +673 -0
  303. mostlyright/data_harness/visual_run/materialize.py +486 -0
  304. mostlyright/data_harness/visual_run/observations.py +874 -0
  305. mostlyright/data_harness/visual_run/query.py +259 -0
  306. mostlyright/data_harness/visual_run/reducer.py +280 -0
  307. mostlyright/data_harness/visual_run/sdk.py +892 -0
  308. mostlyright/data_harness/visual_run/store.py +584 -0
  309. mostlyright/data_harness/visual_run/transport.py +239 -0
  310. mostlyright/data_harness/watch.py +2999 -0
  311. mostlyright_data-0.9.0.dist-info/METADATA +607 -0
  312. mostlyright_data-0.9.0.dist-info/RECORD +314 -0
  313. mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
  314. mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
@@ -0,0 +1,1133 @@
1
+ """Where the numbers are: grid coordinates in closed form, with no C library and no grid file.
2
+
3
+ A weather message carries a rectangular array of values and a compact description of the grid
4
+ those values sit on. It does not carry a latitude and a longitude beside each value -- that
5
+ would double the file -- so placing a value on the earth is arithmetic, and this module is that
6
+ arithmetic.
7
+
8
+ Why the maths is written out here
9
+ ---------------------------------
10
+ The obvious alternative is to let a library do it. The library this Reader vendors computes
11
+ latitudes and longitudes for the flagship's grid only under a feature that binds the C PROJ
12
+ library, and the rule for this Reader is pure Rust with no C geospatial stack in the hot path.
13
+ So the choice is between relaxing the rule and writing the projection, and the projection is
14
+ short, closed form, and -- the part that makes it safe rather than merely possible -- needs no
15
+ grid-shift files. Grid-shift files are what make a projection library's answer depend on which
16
+ data files happen to be installed; a conic projection on a stated earth shape is trigonometry
17
+ with no lookup in it. So the maths is vendorable, pinnable, and testable, and the rule stands.
18
+
19
+ The formulae follow the standard presentation of the Lambert conformal conic on a sphere: the
20
+ cone constant ``n`` from the two standard parallels, the scale ``F``, the polar radius ``rho``
21
+ at a latitude, and the origin radius ``rho_origin`` at the projection's own origin latitude.
22
+ The names below are those names, so a reviewer can check this code against a reference rather
23
+ than against itself.
24
+
25
+ How it is graded
26
+ ----------------
27
+ Every message states the latitude and longitude of its own first grid point, and a regular
28
+ latitude and longitude grid additionally states its last. Building a grid recomputes those and
29
+ compares them with what the message declares -- see ``validate_against_declaration``, which runs
30
+ on every grid construction and not only in tests. Our projection and the publisher's are two
31
+ implementations of one specification, and this compares them on every message, using values the
32
+ publisher put in the file for exactly that purpose.
33
+
34
+ What is deliberately absent
35
+ ---------------------------
36
+ There is no default earth shape. A message declaring an ellipsoid and read as a sphere is
37
+ wrong by a few hundred metres everywhere, which is the worst size an error has: too small to
38
+ notice and too large to ignore. An earth shape this module does not carry is named and
39
+ refused.
40
+
41
+ There is no clamping. A point outside the grid is refused, because a value handed back for a
42
+ place the model does not cover is a number attributed to somewhere it does not describe, and
43
+ that failure verifies clean, plots plausibly, and is wrong.
44
+
45
+ There is no cached state. Every derived quantity is recomputed from the record's own fields on
46
+ each call, so grid objects do not share mutable state.
47
+
48
+ There is no C library trigonometry. ``sin``, ``cos``, ``tan``, ``log``, ``atan``, ``atan2``,
49
+ ``hypot`` and ``power`` below are this package's own, from ``portable_math``, and they are read
50
+ here as the names a reference writes. The reason is in that module's own account of it: IEEE-754
51
+ does not require a C library's transcendental functions to be correctly rounded, two conforming
52
+ machines answered ``sin(radians(38.5))`` one unit in the last place apart, and this projection
53
+ turns that into a grid coordinate that differs between architectures. What remains from ``math``
54
+ here is ``fmod``, ``floor`` and ``isfinite``, which are exact operations on every machine.
55
+
56
+ Import discipline: ``readers.contracts``, ``readers.grib2.admission``,
57
+ ``readers.grib2.portable_math``, and the standard library. Nothing else, ever -- a geospatial
58
+ package imported here would undo the entire reason this module exists.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import math
64
+ from collections.abc import Mapping
65
+ from dataclasses import dataclass
66
+ from typing import ClassVar, NoReturn
67
+
68
+ from mostlyright.data_harness.readers.contracts import ReaderError
69
+ from mostlyright.data_harness.readers.grib2.admission import MessageAdmission
70
+ from mostlyright.data_harness.readers.grib2.portable_math import (
71
+ PI,
72
+ atan,
73
+ atan2,
74
+ cos,
75
+ degrees,
76
+ hypot,
77
+ log,
78
+ power,
79
+ radians,
80
+ sin,
81
+ tan,
82
+ )
83
+
84
+ __all__ = [
85
+ "EARTH_RADIUS_METRES",
86
+ "GRID_SELF_VALIDATION_TOLERANCE_DEGREES",
87
+ "GRID_SELF_VALIDATION_TOLERANCE_METRES",
88
+ "IMPLEMENTED_GRID_TEMPLATES",
89
+ "SUPPORTED_EARTH_SHAPE",
90
+ "Grid",
91
+ "GridAgreement",
92
+ "LambertConformalGrid",
93
+ "LatitudeLongitudeGrid",
94
+ "ScanningMode",
95
+ "build_grid",
96
+ "nearest_grid_index",
97
+ "normalise_longitude",
98
+ "validate_against_declaration",
99
+ ]
100
+
101
+ # The grid templates this module can place values for. It is held equal to the grid half of
102
+ # the admission allowlist by test: a grid admitted with no projection here would be a message
103
+ # accepted and then unplaceable, and a projection for a grid nobody admits is maths nobody
104
+ # verifies.
105
+ IMPLEMENTED_GRID_TEMPLATES: frozenset[int] = frozenset({0, 30})
106
+
107
+ # The one earth shape these projections carry, and the radius edition 2's code table gives it.
108
+ # Both vendored messages declare it. Every other shape is refused by name.
109
+ SUPPORTED_EARTH_SHAPE = 6
110
+ EARTH_RADIUS_METRES = 6371229.0
111
+
112
+ # What each shape means, in the words a refusal uses. A number alone in an error message sends
113
+ # the reader to a code table; the sentence saves them the trip.
114
+ _EARTH_SHAPES: Mapping[int, str] = {
115
+ 0: "a sphere of radius 6367470.0 m",
116
+ 1: "a sphere whose radius the message states for itself",
117
+ 2: "the IAU 1965 spheroid",
118
+ 3: "a spheroid whose axes the message states in kilometres",
119
+ 4: "the IAG-GRS80 spheroid",
120
+ 5: "the WGS 84 ellipsoid",
121
+ 6: "a sphere of radius 6371229.0 m",
122
+ 7: "a spheroid whose axes the message states in metres",
123
+ 8: "a sphere of radius 6371200.0 m",
124
+ 9: "the OSGB 1936 ellipsoid",
125
+ 10: "a sphere of radius 6378160.0 m",
126
+ 11: "the IAG-GRS80 spheroid on a Gaussian grid",
127
+ }
128
+
129
+ # The units the message's own fields are quantised to. Both divisors are exactly representable
130
+ # as doubles, so dividing by them is correctly rounded and an integer field reproduces the
131
+ # decimal a human would write for it.
132
+ _ANGLE_DIVISOR = 1_000_000.0
133
+ _LENGTH_DIVISOR = 1_000.0
134
+
135
+ # Scanning-mode flags, edition 2 flag table 3.4. The first three decide the direction of each
136
+ # axis and which axis is consecutive in the packed values. The fourth says rows alternate
137
+ # direction, and the last four shift points by half a cell; none of those four is implemented,
138
+ # and each is refused rather than ignored, because ignoring a half-cell offset moves every
139
+ # answer half a cell and nothing says so.
140
+ _SCAN_I_NEGATIVE = 0x80
141
+ _SCAN_J_POSITIVE = 0x40
142
+ _SCAN_J_CONSECUTIVE = 0x20
143
+ _SCAN_ALTERNATING_ROWS = 0x10
144
+ _SCAN_OFFSET_FLAGS = 0x0F
145
+
146
+ # Resolution and component flags, edition 2 flag table 3.3. A regular grid must state both of
147
+ # its increments; a grid that leaves them to be inferred from its corners is not one this
148
+ # Reader computes.
149
+ _RESOLUTION_I_GIVEN = 0x20
150
+ _RESOLUTION_J_GIVEN = 0x10
151
+
152
+ # Two standard parallels closer than this are the tangent case, where the cone constant is the
153
+ # sine of the parallel and the secant formula divides by a logarithm of one. A millionth of a
154
+ # degree is the finest distinction the message's own fields can express, so parallels that
155
+ # differ by less than that are not two parallels.
156
+ _TANGENT_PARALLEL_EPSILON = 1e-6
157
+
158
+ # The smallest section a template's fields fit in, by template number.
159
+ _TEMPLATE_OCTETS: Mapping[int, int] = {0: 72, 30: 81}
160
+
161
+ # How far the computed grid may sit from the grid the message declares before the message is
162
+ # refused. Both numbers are five units of the precision the message's own fields carry: angles
163
+ # are integers in millionths of a degree, grid increments are integers in thousandths of a metre,
164
+ # and a message cannot state a disagreement finer than one of its own units.
165
+ #
166
+ # Five rather than one, because the publisher's corner is their arithmetic rounded to their last
167
+ # place and ours is ours, and a refresh should not halt over a rounding neither side can express.
168
+ # Five rather than fifty, because five millionths of a degree is about half a metre and five
169
+ # thousandths of a metre is half a centimetre, both of them three orders of magnitude below the
170
+ # three-kilometre spacing of the finest grid on the list -- so a disagreement large enough to
171
+ # move a value into a neighbouring cell cannot fit under either number.
172
+ #
173
+ # Observed margins on the vendored messages: the
174
+ # regular grid agrees exactly, at 0.0 degrees on both its corners, and the conic grid agrees to
175
+ # 3.55e-15 degrees on its first point and 1.40e-09 metres on its worst plane round trip. Both
176
+ # are the arithmetic's own precision rather than a bound anyone chose, which is what makes them
177
+ # worth watching. Widening either tolerance to make a message pass is not a fix; it is the
178
+ # finding.
179
+ GRID_SELF_VALIDATION_TOLERANCE_DEGREES = 5e-6
180
+ GRID_SELF_VALIDATION_TOLERANCE_METRES = 5e-3
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class ScanningMode:
185
+ """Which way the two axes run, and which of them is consecutive in the packed values.
186
+
187
+ This is read as three independent facts rather than assumed, because assuming it is how a
188
+ grid ends up flipped, and a flipped grid does not raise: it answers every question with a
189
+ real number measured somewhere else.
190
+ """
191
+
192
+ raw: int
193
+ i_negative: bool
194
+ j_positive: bool
195
+ j_consecutive: bool
196
+
197
+ @classmethod
198
+ def from_octet(cls, octet: int, where: str) -> ScanningMode:
199
+ """The three flags this Reader implements, refusing the five it does not."""
200
+
201
+ if octet & _SCAN_ALTERNATING_ROWS:
202
+ _refuse(
203
+ "READER_GRID_UNKNOWN",
204
+ "reader.grib2.geometry.scanning",
205
+ f"scans its alternate rows in opposite directions{where}, and this Reader reads "
206
+ f"grids whose rows all run the same way; reading one as the other reverses every "
207
+ f"other row of the field without failing",
208
+ )
209
+ if octet & _SCAN_OFFSET_FLAGS:
210
+ _refuse(
211
+ "READER_GRID_UNKNOWN",
212
+ "reader.grib2.geometry.scanning",
213
+ f"sets scanning flag bits {octet & _SCAN_OFFSET_FLAGS:#04x}{where}, which offset "
214
+ f"points by half a cell or vary the row length; this Reader computes grids "
215
+ f"without those offsets, and applying none where the message asks for one moves "
216
+ f"every answer half a cell",
217
+ )
218
+ return cls(
219
+ raw=octet,
220
+ i_negative=bool(octet & _SCAN_I_NEGATIVE),
221
+ j_positive=bool(octet & _SCAN_J_POSITIVE),
222
+ j_consecutive=bool(octet & _SCAN_J_CONSECUTIVE),
223
+ )
224
+
225
+ @property
226
+ def column_step(self) -> int:
227
+ """+1 if columns run west to east in the grid's own axis, -1 if they run the other way."""
228
+
229
+ return -1 if self.i_negative else 1
230
+
231
+ @property
232
+ def row_step(self) -> int:
233
+ """+1 if rows run towards the north edge, -1 if row zero is the north edge."""
234
+
235
+ return 1 if self.j_positive else -1
236
+
237
+
238
+ @dataclass(frozen=True)
239
+ class _GridBase:
240
+ """What every grid has: a size, an earth, and a direction for each axis."""
241
+
242
+ TEMPLATE: ClassVar[int] = -1
243
+
244
+ rows: int
245
+ columns: int
246
+ earth_radius_metres: float
247
+ scanning: ScanningMode
248
+
249
+ @property
250
+ def template(self) -> int:
251
+ """The grid definition template this grid was read from."""
252
+
253
+ return self.TEMPLATE
254
+
255
+ @property
256
+ def point_count(self) -> int:
257
+ """How many values the grid holds, which the message declares independently."""
258
+
259
+ return self.rows * self.columns
260
+
261
+ def data_index(self, row: int, column: int) -> int:
262
+ """Which element of the packed array this cell is, honouring the consecutive flag.
263
+
264
+ The decoder wants one element of one array. Whether rows or columns run consecutively
265
+ is the whole difference between the right element and a plausible wrong one, so it is
266
+ answered here, once, from the flag the message set.
267
+ """
268
+
269
+ self._require_addressable(row, column)
270
+ if self.scanning.j_consecutive:
271
+ return column * self.rows + row
272
+ return row * self.columns + column
273
+
274
+ def coordinate_at(self, row: int, column: int) -> tuple[float, float]:
275
+ """The latitude and longitude of one cell, as a pair of doubles."""
276
+
277
+ self._require_addressable(row, column)
278
+ return self._coordinate(row, column)
279
+
280
+ def _require_addressable(self, row: int, column: int) -> None:
281
+ if not isinstance(row, int) or not isinstance(column, int):
282
+ _refuse(
283
+ "READER_CONTRACT",
284
+ "reader.grib2.geometry.cell",
285
+ "a cell is addressed by two integers",
286
+ )
287
+ if not 0 <= row < self.rows or not 0 <= column < self.columns:
288
+ _refuse(
289
+ "READER_CONTRACT",
290
+ "reader.grib2.geometry.cell",
291
+ f"cell ({row}, {column}) is outside the {self.rows} by {self.columns} grid this "
292
+ f"message declares",
293
+ )
294
+
295
+ def _coordinate(self, row: int, column: int) -> tuple[float, float]:
296
+ raise NotImplementedError
297
+
298
+ def _continuous_index(self, latitude: float, longitude: float) -> tuple[float, float]:
299
+ raise NotImplementedError
300
+
301
+
302
+ @dataclass(frozen=True)
303
+ class LatitudeLongitudeGrid(_GridBase):
304
+ """A regular grid on the sphere: constant spacing in latitude and in longitude.
305
+
306
+ There is no projection here at all. A cell's coordinate is the first point plus a whole
307
+ number of increments, in the direction the scanning mode gives each axis. The message
308
+ states the last point as well as the first, which makes it the one grid whose arithmetic can
309
+ be graded against a second statement by the publisher rather than against itself.
310
+ """
311
+
312
+ TEMPLATE: ClassVar[int] = 0
313
+
314
+ first_latitude: float
315
+ first_longitude: float
316
+ last_latitude: float
317
+ last_longitude: float
318
+ latitude_increment: float
319
+ longitude_increment: float
320
+
321
+ @property
322
+ def wraps_the_globe(self) -> bool:
323
+ """Whether the columns close the circle, so the last column adjoins the first.
324
+
325
+ A global grid's final column is a neighbour of column zero, and a point between them is
326
+ nearer to one of the two than to any other -- which is only true if the wrap is stated.
327
+ """
328
+
329
+ return abs(self.columns * self.longitude_increment - 360.0) < _TANGENT_PARALLEL_EPSILON
330
+
331
+ def _coordinate(self, row: int, column: int) -> tuple[float, float]:
332
+ latitude = self.first_latitude + row * self.scanning.row_step * self.latitude_increment
333
+ longitude = (
334
+ self.first_longitude + column * self.scanning.column_step * self.longitude_increment
335
+ )
336
+ return latitude, normalise_longitude(longitude)
337
+
338
+ def _continuous_index(self, latitude: float, longitude: float) -> tuple[float, float]:
339
+ row = (latitude - self.first_latitude) / (self.scanning.row_step * self.latitude_increment)
340
+ offset = (longitude - self.first_longitude) * self.scanning.column_step
341
+ if self.wraps_the_globe:
342
+ column = math.fmod(offset, 360.0)
343
+ if column < 0.0:
344
+ column += 360.0
345
+ else:
346
+ column = _signed_turn(offset)
347
+ return row, column / self.longitude_increment
348
+
349
+
350
+ @dataclass(frozen=True)
351
+ class LambertConformalGrid(_GridBase):
352
+ """A grid regular on a cone tangent to, or secant with, the sphere.
353
+
354
+ The flagship publishes on this. A cell's coordinate is the first point's projected
355
+ position plus a whole number of increments in metres, carried back to the sphere by the
356
+ inverse projection. The projection is closed form in the parameters the message already
357
+ carries, so nothing is looked up and nothing is installed.
358
+ """
359
+
360
+ TEMPLATE: ClassVar[int] = 30
361
+
362
+ first_latitude: float
363
+ first_longitude: float
364
+ origin_latitude: float
365
+ central_meridian: float
366
+ standard_parallel_1: float
367
+ standard_parallel_2: float
368
+ x_increment_metres: float
369
+ y_increment_metres: float
370
+
371
+ def project(self, latitude: float, longitude: float) -> tuple[float, float]:
372
+ """Sphere to plane: the position of a point in the grid's own metres."""
373
+
374
+ return _project(_cone_of(self), self, latitude, longitude)
375
+
376
+ def unproject(self, x: float, y: float) -> tuple[float, float]:
377
+ """Plane to sphere: the latitude and longitude of a position in the grid's own metres."""
378
+
379
+ return _unproject(_cone_of(self), self, x, y)
380
+
381
+ def _coordinate(self, row: int, column: int) -> tuple[float, float]:
382
+ cone = _cone_of(self)
383
+ x, y = _cell_position(cone, self, float(row), float(column))
384
+ return _unproject(cone, self, x, y)
385
+
386
+ def _continuous_index(self, latitude: float, longitude: float) -> tuple[float, float]:
387
+ cone = _cone_of(self)
388
+ x, y = _project(cone, self, latitude, longitude)
389
+ column = (x - cone.x_first) / (self.scanning.column_step * self.x_increment_metres)
390
+ row = (y - cone.y_first) / (self.scanning.row_step * self.y_increment_metres)
391
+ return row, column
392
+
393
+
394
+ Grid = LatitudeLongitudeGrid | LambertConformalGrid
395
+
396
+
397
+ @dataclass(frozen=True)
398
+ class GridAgreement:
399
+ """How far the computed grid sat from the declared one, in the units of each comparison.
400
+
401
+ Reported rather than merely asserted, so the margin can be recorded and watched. A field is
402
+ ``None`` where the template states nothing to compare: the conic template declares no far
403
+ corner, and the regular grid has no plane to round-trip through.
404
+ """
405
+
406
+ corner_degrees: float
407
+ extent_degrees: float | None
408
+ round_trip_metres: float | None
409
+
410
+
411
+ @dataclass(frozen=True)
412
+ class _Cone:
413
+ """The four constants a Lambert conformal conic is fully described by, plus the origin cell.
414
+
415
+ ``n`` is the cone constant, ``scale`` is the radius scale (the standard ``R * F``),
416
+ ``rho_origin`` is the polar radius at the projection's origin latitude, and ``x_first`` and
417
+ ``y_first`` are the projected position of the message's own first grid point. They are
418
+ recomputed on every call rather than cached, so no state can carry between calls.
419
+ """
420
+
421
+ n: float
422
+ scale: float
423
+ rho_origin: float
424
+ x_first: float
425
+ y_first: float
426
+
427
+
428
+ def build_grid(
429
+ admission: MessageAdmission,
430
+ content: bytes,
431
+ *,
432
+ origin: str | None = None,
433
+ ) -> Grid:
434
+ """Read the geometry of an admitted message and return the grid it describes.
435
+
436
+ Takes an admission result, which is the only way a grid may be built: the parameters are
437
+ read from the grid section that admission already located and bounded, so a message nobody
438
+ admitted has nothing to pass here. ``origin`` is whatever the recipe called the source, and
439
+ it is carried into refusals so a halted refresh names the file.
440
+ """
441
+
442
+ where = _origin_clause(origin)
443
+ if not isinstance(admission, MessageAdmission):
444
+ _refuse(
445
+ "READER_CONTRACT",
446
+ "reader.grib2.geometry.admission",
447
+ "a grid is built from an admission result, so an unadmitted message has nothing to "
448
+ "pass; this is the same rule that keeps a decoder from reaching one",
449
+ )
450
+ if not isinstance(content, (bytes, bytearray, memoryview)):
451
+ _refuse("READER_CONTRACT", "reader.grib2.geometry.message", "must be exact bytes")
452
+
453
+ template = admission.grid_template
454
+ if template not in IMPLEMENTED_GRID_TEMPLATES:
455
+ _refuse(
456
+ "READER_GRID_UNKNOWN",
457
+ "reader.grib2.geometry.template",
458
+ f"is on grid template 3.{template}{where}, which this Reader admits but has no "
459
+ f"projection for, so it will not guess where the values belong; this is a gap "
460
+ f"between the admission list and the maths, and closing it means writing the "
461
+ f"projection rather than widening anything",
462
+ )
463
+
464
+ section = _GridSection(
465
+ content=bytes(content),
466
+ start=admission.grid_start,
467
+ length=admission.grid_length,
468
+ where=where,
469
+ )
470
+ section.require(_TEMPLATE_OCTETS[template], template)
471
+ radius = _earth_radius(section)
472
+ if template == LatitudeLongitudeGrid.TEMPLATE:
473
+ grid: Grid = _regular_grid(section, radius)
474
+ else:
475
+ grid = _conic_grid(section, radius)
476
+ validate_against_declaration(grid, admission, origin=origin)
477
+ return grid
478
+
479
+
480
+ def validate_against_declaration(
481
+ grid: Grid,
482
+ admission: MessageAdmission,
483
+ *,
484
+ origin: str | None = None,
485
+ ) -> GridAgreement:
486
+ """Grade the computed grid against what the message says about itself, or refuse it.
487
+
488
+ This runs on every grid construction and not only in tests, and the reason is worth stating
489
+ plainly: our projection and the publisher's are two independent implementations of one
490
+ specification, and the publisher put the coordinates of the grid's own corners in the file
491
+ for exactly this purpose. Comparing them costs four projections per message -- nothing
492
+ beside decoding two million values -- and it is the difference between a projection we
493
+ believe is right and one that is checked against the party that made the file, on every file,
494
+ including the one where they change the grid and do not say so.
495
+
496
+ Three comparisons, each against a number the message states independently of the others.
497
+
498
+ *The shape holds the point count.* Rows times columns must equal the point count the message
499
+ declares in another octet of the same section.
500
+
501
+ *The first grid point comes back.* Index (0, 0) is recomputed through the projection and
502
+ compared with the declared corner. On the regular grid this is arithmetic returning its own
503
+ input and it is exact; on the conic grid it is the forward projection graded against the
504
+ inverse, which is where a sign error, a unit error, or a radius that entered only one
505
+ direction shows up.
506
+
507
+ *The far corner agrees, where the message states one.* The regular grid declares its last
508
+ point as well as its first, so that one is a genuine second statement by the publisher and
509
+ the strongest of the three. The conic template declares no far corner -- there is nothing
510
+ to compare -- so in its place every corner of the conic grid is required to round-trip
511
+ through the plane to within the tolerance in metres, which is what refuses a grid running
512
+ past the far side of its own cone.
513
+
514
+ Return the observed margins for recording and comparison.
515
+ """
516
+
517
+ where = _origin_clause(origin)
518
+ if not isinstance(grid, (LatitudeLongitudeGrid, LambertConformalGrid)):
519
+ _refuse("READER_CONTRACT", "reader.grib2.geometry.grid", "must be a computed grid")
520
+ if not isinstance(admission, MessageAdmission):
521
+ _refuse(
522
+ "READER_CONTRACT",
523
+ "reader.grib2.geometry.admission",
524
+ "a grid is graded against the admission of the message that declared it",
525
+ )
526
+
527
+ if grid.point_count != admission.declared_points:
528
+ _refuse(
529
+ "READER_GRID_UNKNOWN",
530
+ "reader.grib2.geometry.extent",
531
+ f"declares {admission.declared_points} grid points{where} and a shape of {grid.rows} "
532
+ f"by {grid.columns}, which holds {grid.point_count}; the two numbers come from "
533
+ f"different octets of the same section and taking either one on its own is how a "
534
+ f"field ends up read shifted, which produces real numbers in the wrong places",
535
+ )
536
+
537
+ corner = _disagreement(grid.coordinate_at(0, 0), (grid.first_latitude, grid.first_longitude))
538
+ _require_agreement(
539
+ corner,
540
+ "first grid point",
541
+ (grid.first_latitude, grid.first_longitude),
542
+ grid.coordinate_at(0, 0),
543
+ where,
544
+ )
545
+
546
+ if isinstance(grid, LatitudeLongitudeGrid):
547
+ declared = (grid.last_latitude, grid.last_longitude)
548
+ computed = grid.coordinate_at(grid.rows - 1, grid.columns - 1)
549
+ extent = _disagreement(computed, declared)
550
+ _require_agreement(extent, "last grid point", declared, computed, where)
551
+ return GridAgreement(corner_degrees=corner, extent_degrees=extent, round_trip_metres=None)
552
+
553
+ return GridAgreement(
554
+ corner_degrees=corner,
555
+ extent_degrees=None,
556
+ round_trip_metres=_conic_corners_round_trip(grid, where),
557
+ )
558
+
559
+
560
+ def nearest_grid_index(
561
+ grid: Grid,
562
+ latitude: float,
563
+ longitude: float,
564
+ *,
565
+ name: str | None = None,
566
+ origin: str | None = None,
567
+ ) -> tuple[int, int]:
568
+ """The one cell a named place falls in, as ``(row, column)``, or a refusal.
569
+
570
+ **What nearest means.** Distance is measured in the grid's own coordinates: degrees on the
571
+ regular grid, metres on the plane for the conic one. Both grids are regular in those
572
+ coordinates, so the nearest cell is the nearest on each axis independently -- the two axes
573
+ separate exactly, and per-axis rounding is the true nearest rather than an approximation of
574
+ it. That is asserted by measurement in the tests and not merely argued here.
575
+
576
+ **The tie-break.** Floating-point distances tie more often than intuition suggests: on a
577
+ one-degree grid every half degree is an exact tie, in binary and not nearly. So the rule is
578
+ stated rather than left to whichever comparison ran first: **a tie goes to the cell with the
579
+ lower coordinate, latitude first and then longitude** -- the more southerly cell, and the
580
+ more westerly one.
581
+
582
+ The rule is stated in the grid's own directions rather than as "the lower position in the
583
+ packed values", and the difference matters. A file written from its south edge and the same
584
+ grid written from its north edge store their rows in opposite orders, so a rule about
585
+ storage positions would answer a boundary point differently in the two files, making the
586
+ answer a property of how the publisher wrote the file rather than of where the place is. On
587
+ a grid whose rows run in the same direction as their positions -- the flagship's -- the two
588
+ statements of the rule are the same statement.
589
+
590
+ **A place the model does not cover is refused.** Not clamped to the nearest edge: a value
591
+ handed back for a place outside the grid is a real number attributed to somewhere it does
592
+ not describe, and nothing downstream can tell. The edge sits half a cell beyond the last
593
+ cell's centre, which is where the nearest cell stops being meaningful, and by the tie-break
594
+ above a point exactly half a cell out belongs to the grid on its north and east sides and
595
+ not on its south and west ones. One rule, applied in the grid's own directions, so the same
596
+ place gets the same answer whichever way the file was written.
597
+
598
+ ``name`` is what the recipe called the place, carried into the refusal so a halted refresh
599
+ names the place a user asked for rather than a pair of numbers.
600
+ """
601
+
602
+ where = _origin_clause(origin)
603
+ if not isinstance(grid, (LatitudeLongitudeGrid, LambertConformalGrid)):
604
+ _refuse(
605
+ "READER_CONTRACT",
606
+ "reader.grib2.geometry.grid",
607
+ "a place is located on a grid built from a message, not on anything else",
608
+ )
609
+ _require_latitude(latitude)
610
+ _require_longitude(longitude)
611
+
612
+ row_offset, column_offset = grid._continuous_index(
613
+ float(latitude), normalise_longitude(longitude)
614
+ )
615
+ row = _select(row_offset, grid.scanning.row_step)
616
+ column = _select(column_offset, grid.scanning.column_step)
617
+ if isinstance(grid, LatitudeLongitudeGrid) and grid.wraps_the_globe:
618
+ column %= grid.columns
619
+ if not 0 <= row < grid.rows or not 0 <= column < grid.columns:
620
+ _refuse_uncovered(grid, latitude, longitude, name, where)
621
+ return row, column
622
+
623
+
624
+ def _select(offset: float, step: int) -> int:
625
+ """Round a continuous position to a cell, with the tie going to the lower coordinate.
626
+
627
+ ``step`` says which way the coordinate runs against the position, so an exact half takes the
628
+ lower position where they run together and the higher one where they run opposite -- which
629
+ is the same cell on the ground either way.
630
+ """
631
+
632
+ lower = math.floor(offset)
633
+ fraction = offset - lower
634
+ if fraction < 0.5:
635
+ return lower
636
+ if fraction > 0.5:
637
+ return lower + 1
638
+ return lower if step > 0 else lower + 1
639
+
640
+
641
+ def _refuse_uncovered(
642
+ grid: Grid,
643
+ latitude: float,
644
+ longitude: float,
645
+ name: str | None,
646
+ where: str,
647
+ ) -> NoReturn:
648
+ """Say which place, where it is, and what the grid does cover."""
649
+
650
+ called = f"{name} at " if isinstance(name, str) and name.strip() else ""
651
+ corners = [
652
+ grid.coordinate_at(row, column)
653
+ for row, column in (
654
+ (0, 0),
655
+ (0, grid.columns - 1),
656
+ (grid.rows - 1, 0),
657
+ (grid.rows - 1, grid.columns - 1),
658
+ )
659
+ ]
660
+ _refuse(
661
+ "READER_OPTIONS",
662
+ "reader.grib2.geometry.point",
663
+ f"asks for {called}({latitude}, {longitude}){where}, which is outside the area this "
664
+ f"file covers; its corners are {corners}. The nearest place on the grid is not returned "
665
+ f"instead, because a value from somewhere else carries no sign that it came from "
666
+ f"somewhere else: it verifies, it plots, and it is a reading of another place. Either "
667
+ f"name a place inside the area, or use a file whose grid covers this one",
668
+ )
669
+
670
+
671
+ def normalise_longitude(value: float) -> float:
672
+ """Every longitude in this module lives in [0, 360), and this is the one rule that puts it
673
+ there.
674
+
675
+ A message states its longitudes east of Greenwich; a recipe naming a place in the Americas
676
+ will state a negative one. Both name the same meridian, and both must select the same cell,
677
+ so both pass through here first and nothing downstream compares two conventions.
678
+ """
679
+
680
+ turn = math.fmod(float(value), 360.0)
681
+ if turn < 0.0:
682
+ turn += 360.0
683
+ if turn == 0.0 or turn == 360.0:
684
+ return 0.0
685
+ return turn
686
+
687
+
688
+ # --- Reading the grid section --------------------------------------------------------------
689
+
690
+
691
+ @dataclass(frozen=True)
692
+ class _GridSection:
693
+ """One message's grid definition section, addressed by the octet numbers the format uses.
694
+
695
+ Octets are numbered from one within the section, which is how every reference writes them,
696
+ so the call sites below can be read against a reference table directly.
697
+ """
698
+
699
+ content: bytes
700
+ start: int
701
+ length: int
702
+ where: str
703
+
704
+ def require(self, octets: int, template: int) -> None:
705
+ if self.length < octets:
706
+ _refuse(
707
+ "READER_GRID_UNKNOWN",
708
+ "reader.grib2.geometry.section",
709
+ f"declares a grid section of {self.length} bytes{self.where} while grid template "
710
+ f"3.{template} needs {octets}; a section shorter than its own template does not "
711
+ f"state where its values belong",
712
+ )
713
+
714
+ def octet(self, number: int) -> int:
715
+ return self.content[self.start + number - 1]
716
+
717
+ def unsigned(self, first: int, last: int) -> int:
718
+ return int.from_bytes(self.content[self.start + first - 1 : self.start + last], "big")
719
+
720
+ def signed(self, first: int, last: int) -> int:
721
+ return _signed(self.content[self.start + first - 1 : self.start + last])
722
+
723
+
724
+ def _earth_radius(section: _GridSection) -> float:
725
+ """The radius the message's declared earth shape means, or a refusal naming that shape."""
726
+
727
+ shape = section.octet(15)
728
+ if shape == SUPPORTED_EARTH_SHAPE:
729
+ return EARTH_RADIUS_METRES
730
+ described = _EARTH_SHAPES.get(shape, "a value that is not defined by edition 2's code table")
731
+ _refuse(
732
+ "READER_GRID_UNKNOWN",
733
+ "reader.grib2.geometry.earth_shape",
734
+ f"declares earth shape {shape}{section.where}, {described}; this Reader places values on "
735
+ f"shape {SUPPORTED_EARTH_SHAPE}, {_EARTH_SHAPES[SUPPORTED_EARTH_SHAPE]}, and on nothing "
736
+ f"else. Reading one shape as another is not an error at one point, it is a few hundred "
737
+ f"metres at every point, which is too small to notice and too large to ignore",
738
+ )
739
+
740
+
741
+ def _regular_grid(section: _GridSection, radius: float) -> LatitudeLongitudeGrid:
742
+ """Grid template 3.0: a constant step in latitude and a constant step in longitude."""
743
+
744
+ basic_angle = section.unsigned(39, 42)
745
+ if basic_angle != 0:
746
+ _refuse(
747
+ "READER_GRID_UNKNOWN",
748
+ "reader.grib2.geometry.angular_unit",
749
+ f"states its angles in a unit it defines for itself, a basic angle of "
750
+ f"{basic_angle}{section.where}; this Reader reads angles in millionths of a degree, "
751
+ f"which is what a basic angle of zero means",
752
+ )
753
+ resolution = section.octet(55)
754
+ if resolution & (_RESOLUTION_I_GIVEN | _RESOLUTION_J_GIVEN) != (
755
+ _RESOLUTION_I_GIVEN | _RESOLUTION_J_GIVEN
756
+ ):
757
+ _refuse(
758
+ "READER_GRID_UNKNOWN",
759
+ "reader.grib2.geometry.resolution",
760
+ f"does not state both of its own increments{section.where}: its resolution flags are "
761
+ f"{resolution:#04x}, and a grid whose spacing has to be inferred from its corners is "
762
+ f"not one this Reader computes",
763
+ )
764
+ columns = _positive(section.unsigned(31, 34), "points along a parallel", section.where)
765
+ rows = _positive(section.unsigned(35, 38), "points along a meridian", section.where)
766
+ return LatitudeLongitudeGrid(
767
+ rows=rows,
768
+ columns=columns,
769
+ earth_radius_metres=radius,
770
+ scanning=ScanningMode.from_octet(section.octet(72), section.where),
771
+ first_latitude=section.signed(47, 50) / _ANGLE_DIVISOR,
772
+ first_longitude=normalise_longitude(section.signed(51, 54) / _ANGLE_DIVISOR),
773
+ last_latitude=section.signed(56, 59) / _ANGLE_DIVISOR,
774
+ last_longitude=normalise_longitude(section.signed(60, 63) / _ANGLE_DIVISOR),
775
+ latitude_increment=_positive_angle(section.unsigned(68, 71), "latitude", section.where),
776
+ longitude_increment=_positive_angle(section.unsigned(64, 67), "longitude", section.where),
777
+ )
778
+
779
+
780
+ def _conic_grid(section: _GridSection, radius: float) -> LambertConformalGrid:
781
+ """Grid template 3.30: a constant step in metres on a cone, and the cone's own parameters."""
782
+
783
+ centre = section.octet(64)
784
+ if centre != 0:
785
+ _refuse(
786
+ "READER_GRID_UNKNOWN",
787
+ "reader.grib2.geometry.projection_centre",
788
+ f"sets projection centre flags {centre:#04x}{section.where}, which select the "
789
+ f"southern cone or a two-cone projection; this Reader computes the single northern "
790
+ f"cone both of its checked messages use",
791
+ )
792
+ pole_latitude = section.signed(74, 77)
793
+ pole_longitude = section.signed(78, 81)
794
+ if pole_latitude != 0 or pole_longitude != 0:
795
+ _refuse(
796
+ "READER_GRID_UNKNOWN",
797
+ "reader.grib2.geometry.rotation",
798
+ f"rotates its projection about a pole at ({pole_latitude}, "
799
+ f"{pole_longitude}){section.where}; this Reader computes unrotated grids",
800
+ )
801
+ columns = _positive(section.unsigned(31, 34), "points along the x axis", section.where)
802
+ rows = _positive(section.unsigned(35, 38), "points along the y axis", section.where)
803
+ return LambertConformalGrid(
804
+ rows=rows,
805
+ columns=columns,
806
+ earth_radius_metres=radius,
807
+ scanning=ScanningMode.from_octet(section.octet(65), section.where),
808
+ first_latitude=section.signed(39, 42) / _ANGLE_DIVISOR,
809
+ first_longitude=normalise_longitude(section.signed(43, 46) / _ANGLE_DIVISOR),
810
+ origin_latitude=section.signed(48, 51) / _ANGLE_DIVISOR,
811
+ central_meridian=normalise_longitude(section.signed(52, 55) / _ANGLE_DIVISOR),
812
+ standard_parallel_1=section.signed(66, 69) / _ANGLE_DIVISOR,
813
+ standard_parallel_2=section.signed(70, 73) / _ANGLE_DIVISOR,
814
+ x_increment_metres=_positive_length(section.unsigned(56, 59), "x", section.where),
815
+ y_increment_metres=_positive_length(section.unsigned(60, 63), "y", section.where),
816
+ )
817
+
818
+
819
+ def _positive(value: int, subject: str, where: str) -> int:
820
+ if value < 1:
821
+ _refuse(
822
+ "READER_GRID_UNKNOWN",
823
+ "reader.grib2.geometry.extent",
824
+ f"declares {value} {subject}{where}, and a grid with no extent along an axis holds "
825
+ f"no values at all",
826
+ )
827
+ return value
828
+
829
+
830
+ def _positive_angle(value: int, axis: str, where: str) -> float:
831
+ if value < 1:
832
+ _refuse(
833
+ "READER_GRID_UNKNOWN",
834
+ "reader.grib2.geometry.increment",
835
+ f"declares a {axis} increment of {value}{where}; a grid whose points do not advance "
836
+ f"is a grid where every cell is the same cell",
837
+ )
838
+ return value / _ANGLE_DIVISOR
839
+
840
+
841
+ def _positive_length(value: int, axis: str, where: str) -> float:
842
+ if value < 1:
843
+ _refuse(
844
+ "READER_GRID_UNKNOWN",
845
+ "reader.grib2.geometry.increment",
846
+ f"declares a {axis} increment of {value}{where}; a grid whose points do not advance "
847
+ f"is a grid where every cell is the same cell",
848
+ )
849
+ return value / _LENGTH_DIVISOR
850
+
851
+
852
+ # --- The Lambert conformal conic, on a sphere ----------------------------------------------
853
+
854
+
855
+ def _cone_of(grid: LambertConformalGrid) -> _Cone:
856
+ """The cone constant, the radius scale, and the projected position of the first grid point.
857
+
858
+ Naming follows the standard presentation: ``n`` is the cone constant, and where a reference
859
+ writes ``rho = R * F / tan(pi/4 + phi/2) ** n`` this carries ``scale = R * F`` and divides
860
+ by the same tangent power. The tangent case, where the two standard parallels coincide, has
861
+ its own branch because the secant formula divides by the logarithm of one there.
862
+ """
863
+
864
+ phi1 = _cone_parallel(grid.standard_parallel_1, grid)
865
+ phi2 = _cone_parallel(grid.standard_parallel_2, grid)
866
+ if abs(grid.standard_parallel_1 - grid.standard_parallel_2) < _TANGENT_PARALLEL_EPSILON:
867
+ n = sin(phi1)
868
+ else:
869
+ n = log(cos(phi1) / cos(phi2)) / log(
870
+ tan(PI / 4.0 + phi2 / 2.0) / tan(PI / 4.0 + phi1 / 2.0)
871
+ )
872
+ if not 0.0 < n < 1.0:
873
+ _refuse(
874
+ "READER_GRID_UNKNOWN",
875
+ "reader.grib2.geometry.cone",
876
+ f"has standard parallels of {grid.standard_parallel_1} and "
877
+ f"{grid.standard_parallel_2}{_NO_ORIGIN}, which give a cone constant of {n}; a "
878
+ f"northern conic projection has one strictly between zero and one, and a value "
879
+ f"outside that is a cone that does not close",
880
+ )
881
+ scale = grid.earth_radius_metres * cos(phi1) * power(tan(PI / 4.0 + phi1 / 2.0), n)
882
+ scale /= n
883
+ rho_origin = _polar_radius(scale, n, grid.origin_latitude)
884
+ cone = _Cone(n=n, scale=scale, rho_origin=rho_origin, x_first=0.0, y_first=0.0)
885
+ x_first, y_first = _project(cone, grid, grid.first_latitude, grid.first_longitude)
886
+ return _Cone(n=n, scale=scale, rho_origin=rho_origin, x_first=x_first, y_first=y_first)
887
+
888
+
889
+ def _cone_parallel(parallel: float, grid: LambertConformalGrid) -> float:
890
+ """A standard parallel in radians, refusing one at a pole where the cone has no radius."""
891
+
892
+ if not -90.0 < parallel < 90.0:
893
+ _refuse(
894
+ "READER_GRID_UNKNOWN",
895
+ "reader.grib2.geometry.cone",
896
+ f"places a standard parallel at {parallel} degrees, where a cone touching the sphere "
897
+ f"has no radius; the grid is {grid.rows} by {grid.columns}",
898
+ )
899
+ return radians(parallel)
900
+
901
+
902
+ def _polar_radius(scale: float, n: float, latitude: float) -> float:
903
+ """``rho`` at a latitude: the distance from the cone's apex, in metres."""
904
+
905
+ tangent = tan(PI / 4.0 + radians(latitude) / 2.0)
906
+ if tangent <= 0.0:
907
+ _refuse(
908
+ "READER_OPTIONS",
909
+ "reader.grib2.geometry.point",
910
+ f"names latitude {latitude}, the pole this projection never reaches; a northern cone "
911
+ f"unrolls the sphere away from the southern pole and that point has no position on "
912
+ f"it",
913
+ )
914
+ return scale / power(tangent, n)
915
+
916
+
917
+ def _project(
918
+ cone: _Cone,
919
+ grid: LambertConformalGrid,
920
+ latitude: float,
921
+ longitude: float,
922
+ ) -> tuple[float, float]:
923
+ """Sphere to plane, in the grid's own metres."""
924
+
925
+ _require_latitude(latitude)
926
+ turn = _signed_turn(normalise_longitude(longitude) - grid.central_meridian)
927
+ theta = cone.n * radians(turn)
928
+ if abs(theta) >= PI:
929
+ _refuse(
930
+ "READER_OPTIONS",
931
+ "reader.grib2.geometry.point",
932
+ f"names longitude {longitude}, which is {turn} degrees from this grid's central "
933
+ f"meridian of {grid.central_meridian} and wraps past the far side of the cone; a "
934
+ f"point there has two positions on the plane and neither is this grid's",
935
+ )
936
+ rho = _polar_radius(cone.scale, cone.n, latitude)
937
+ return rho * sin(theta), cone.rho_origin - rho * cos(theta)
938
+
939
+
940
+ def _unproject(
941
+ cone: _Cone,
942
+ grid: LambertConformalGrid,
943
+ x: float,
944
+ y: float,
945
+ ) -> tuple[float, float]:
946
+ """Plane to sphere, the exact inverse of ``_project`` on the cone's single-valued wedge."""
947
+
948
+ towards_apex = cone.rho_origin - y
949
+ rho = hypot(x, towards_apex)
950
+ if rho == 0.0:
951
+ _refuse(
952
+ "READER_GRID_UNKNOWN",
953
+ "reader.grib2.geometry.extent",
954
+ f"reaches the apex of its own cone at ({x}, {y}), where every longitude meets and no "
955
+ f"single one is the answer; the grid is {grid.rows} by {grid.columns}",
956
+ )
957
+ theta = atan2(x, towards_apex)
958
+ turn = degrees(theta / cone.n)
959
+ if abs(turn) > 180.0:
960
+ _refuse(
961
+ "READER_GRID_UNKNOWN",
962
+ "reader.grib2.geometry.extent",
963
+ f"has a cell at ({x}, {y}) that unrolls to {turn} degrees from its central meridian "
964
+ f"of {grid.central_meridian}, which is more than half a turn; a grid wrapping past "
965
+ f"the far side of its own cone has two answers for one cell",
966
+ )
967
+ latitude = degrees(2.0 * atan(power(cone.scale / rho, 1.0 / cone.n)) - PI / 2.0)
968
+ return latitude, normalise_longitude(grid.central_meridian + turn)
969
+
970
+
971
+ def _cell_position(
972
+ cone: _Cone,
973
+ grid: LambertConformalGrid,
974
+ row: float,
975
+ column: float,
976
+ ) -> tuple[float, float]:
977
+ """Where a cell sits on the plane: the first point plus whole increments, signed by scan."""
978
+
979
+ x = cone.x_first + column * grid.scanning.column_step * grid.x_increment_metres
980
+ y = cone.y_first + row * grid.scanning.row_step * grid.y_increment_metres
981
+ return x, y
982
+
983
+
984
+ # --- Grading the grid against the message ---------------------------------------------------
985
+
986
+
987
+ def _disagreement(
988
+ computed: tuple[float, float],
989
+ declared: tuple[float, float],
990
+ ) -> float:
991
+ """How far apart two points are, as the larger of the two angular differences.
992
+
993
+ The longitudes are compared as a turn rather than as two numbers, so a grid whose corner sits
994
+ a millionth of a degree west of the meridian is a millionth of a degree away from one a
995
+ millionth east of it, and not a full turn away.
996
+ """
997
+
998
+ return max(
999
+ abs(computed[0] - declared[0]),
1000
+ abs(_signed_turn(computed[1] - declared[1])),
1001
+ )
1002
+
1003
+
1004
+ def _require_agreement(
1005
+ difference: float,
1006
+ which: str,
1007
+ declared: tuple[float, float],
1008
+ computed: tuple[float, float],
1009
+ where: str,
1010
+ ) -> None:
1011
+ """Refuse a message whose own declaration this Reader cannot reproduce."""
1012
+
1013
+ if difference <= GRID_SELF_VALIDATION_TOLERANCE_DEGREES:
1014
+ return
1015
+ _refuse(
1016
+ "READER_GRID_UNKNOWN",
1017
+ "reader.grib2.geometry.declaration",
1018
+ f"declares its {which} at {declared}{where} and this Reader computes {computed}; they "
1019
+ f"differ by {difference} degrees, which is more than the "
1020
+ f"{GRID_SELF_VALIDATION_TOLERANCE_DEGREES} degrees a message's own precision allows. "
1021
+ f"The projection here and the publisher's are two implementations of one specification "
1022
+ f"and this is where they are compared, so a difference this size is a disagreement "
1023
+ f"rather than rounding. Nothing is decoded from a message whose grid cannot be "
1024
+ f"reproduced, because the values would be real numbers at coordinates nobody has "
1025
+ f"checked",
1026
+ )
1027
+
1028
+
1029
+ def _conic_corners_round_trip(grid: LambertConformalGrid, where: str) -> float:
1030
+ """Send all four corners to the sphere and back, and report the worst error in metres.
1031
+
1032
+ The conic template states no far corner, so there is no second declaration to compare with.
1033
+ What is available instead is that the projection must be its own inverse across the whole
1034
+ grid, and it is not: a grid reaching past the far side of its own cone, or past the cone's
1035
+ apex, has two positions for one cell. Those are refused inside the inverse itself; this
1036
+ grades what survives.
1037
+ """
1038
+
1039
+ cone = _cone_of(grid)
1040
+ worst = 0.0
1041
+ for row in (0, grid.rows - 1):
1042
+ for column in (0, grid.columns - 1):
1043
+ x, y = _cell_position(cone, grid, float(row), float(column))
1044
+ latitude, longitude = _unproject(cone, grid, x, y)
1045
+ back_x, back_y = _project(cone, grid, latitude, longitude)
1046
+ worst = max(worst, abs(back_x - x), abs(back_y - y))
1047
+ if worst > GRID_SELF_VALIDATION_TOLERANCE_METRES:
1048
+ _refuse(
1049
+ "READER_GRID_UNKNOWN",
1050
+ "reader.grib2.geometry.declaration",
1051
+ f"has a corner that does not return to itself through its own projection{where}: the "
1052
+ f"round trip moves it {worst} metres, more than the "
1053
+ f"{GRID_SELF_VALIDATION_TOLERANCE_METRES} metres a grid stated in thousandths of a "
1054
+ f"metre allows. A projection that is not its own inverse on this grid places values "
1055
+ f"somewhere this Reader cannot state",
1056
+ )
1057
+ return worst
1058
+
1059
+
1060
+ # --- Shared arithmetic and refusals ---------------------------------------------------------
1061
+
1062
+
1063
+ _NO_ORIGIN = ""
1064
+
1065
+
1066
+ def _signed_turn(angle: float) -> float:
1067
+ """An angular difference in (-180, 180], so the shorter way round is always the answer.
1068
+
1069
+ The argument is in degrees, and it is named ``angle`` rather than ``degrees`` because
1070
+ ``degrees`` is the conversion this module imports.
1071
+ """
1072
+
1073
+ turn = math.fmod(angle, 360.0)
1074
+ if turn > 180.0:
1075
+ return turn - 360.0
1076
+ if turn <= -180.0:
1077
+ return turn + 360.0
1078
+ return turn
1079
+
1080
+
1081
+ def _require_longitude(longitude: float) -> None:
1082
+ if not isinstance(longitude, (int, float)) or not math.isfinite(longitude):
1083
+ _refuse(
1084
+ "READER_OPTIONS",
1085
+ "reader.grib2.geometry.point",
1086
+ "names a longitude that is not a finite number",
1087
+ )
1088
+
1089
+
1090
+ def _require_latitude(latitude: float) -> None:
1091
+ if not isinstance(latitude, (int, float)) or not math.isfinite(latitude):
1092
+ _refuse(
1093
+ "READER_OPTIONS",
1094
+ "reader.grib2.geometry.point",
1095
+ "names a latitude that is not a finite number",
1096
+ )
1097
+ if not -90.0 <= latitude <= 90.0:
1098
+ _refuse(
1099
+ "READER_OPTIONS",
1100
+ "reader.grib2.geometry.point",
1101
+ f"names latitude {latitude}, which is not on the earth",
1102
+ )
1103
+
1104
+
1105
+ def _origin_clause(origin: str | None) -> str:
1106
+ """The recipe's own name for the source, so a halted refresh names the file."""
1107
+
1108
+ if origin is None:
1109
+ return ""
1110
+ if not isinstance(origin, str) or not origin.strip():
1111
+ _refuse(
1112
+ "READER_CONTRACT",
1113
+ "reader.grib2.geometry.origin",
1114
+ "must be a nonempty name or absent",
1115
+ )
1116
+ return f" in {origin.strip()}"
1117
+
1118
+
1119
+ def _signed(raw: bytes) -> int:
1120
+ """A GRIB2 signed integer: a sign bit and a magnitude, never two's complement.
1121
+
1122
+ The same rule admission reads its scale factors with, written out here rather than imported
1123
+ across the module boundary, and held to agree with it by test -- the way the identifier
1124
+ pattern is duplicated between the Reader contract and the recipe and asserted equal.
1125
+ """
1126
+
1127
+ value = int.from_bytes(raw, "big")
1128
+ sign = 1 << (8 * len(raw) - 1)
1129
+ return -(value & ~sign) if value & sign else value
1130
+
1131
+
1132
+ def _refuse(code: str, subject: str, detail: str) -> NoReturn:
1133
+ raise ReaderError(code, subject, detail)