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.
- mostlyright/data_harness/__init__.py +158 -0
- mostlyright/data_harness/acquisition/__init__.py +55 -0
- mostlyright/data_harness/acquisition/http.py +2773 -0
- mostlyright/data_harness/acquisition/parsing.py +809 -0
- mostlyright/data_harness/acquisition/ranges.py +495 -0
- mostlyright/data_harness/acquisition/result_download.py +360 -0
- mostlyright/data_harness/acquisition/retention_admission.py +248 -0
- mostlyright/data_harness/acquisition/sandbox.py +4888 -0
- mostlyright/data_harness/acquisition/url_policy.py +530 -0
- mostlyright/data_harness/agent_runtime.py +2743 -0
- mostlyright/data_harness/assets/logo-ink.svg +31 -0
- mostlyright/data_harness/backends/__init__.py +28 -0
- mostlyright/data_harness/backends/pandas_backend.py +350 -0
- mostlyright/data_harness/backends/polars_backend.py +366 -0
- mostlyright/data_harness/backends/protocol.py +124 -0
- mostlyright/data_harness/backends/reference.py +83 -0
- mostlyright/data_harness/backends/registry.py +55 -0
- mostlyright/data_harness/backends/restrictions.py +126 -0
- mostlyright/data_harness/canonical.py +333 -0
- mostlyright/data_harness/catalog_job.py +625 -0
- mostlyright/data_harness/cli.py +5398 -0
- mostlyright/data_harness/contracts.py +53 -0
- mostlyright/data_harness/coordinator.py +1307 -0
- mostlyright/data_harness/deploy.py +924 -0
- mostlyright/data_harness/deploy_target.py +312 -0
- mostlyright/data_harness/deployment_evidence.py +1067 -0
- mostlyright/data_harness/event_presentation.py +576 -0
- mostlyright/data_harness/events.py +2152 -0
- mostlyright/data_harness/fast_delimited.py +239 -0
- mostlyright/data_harness/fleet.py +237 -0
- mostlyright/data_harness/formats.py +236 -0
- mostlyright/data_harness/governors.py +1163 -0
- mostlyright/data_harness/hosted_bootstrap.py +972 -0
- mostlyright/data_harness/hosted_crawler.py +1115 -0
- mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
- mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
- mostlyright/data_harness/hosted_crawler_job.py +1277 -0
- mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
- mostlyright/data_harness/hosted_dataset.py +1500 -0
- mostlyright/data_harness/hosted_deploy.py +3037 -0
- mostlyright/data_harness/hosted_handoff.py +62 -0
- mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
- mostlyright/data_harness/hosted_ingestion_job.py +356 -0
- mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
- mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
- mostlyright/data_harness/hosted_session_worker.py +3554 -0
- mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
- mostlyright/data_harness/hosted_worker.py +6784 -0
- mostlyright/data_harness/ingestion/__init__.py +56 -0
- mostlyright/data_harness/ingestion/contracts.py +461 -0
- mostlyright/data_harness/ingestion/faults.py +42 -0
- mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
- mostlyright/data_harness/ingestion/spool.py +130 -0
- mostlyright/data_harness/ingestion/store.py +885 -0
- mostlyright/data_harness/key_seam.py +434 -0
- mostlyright/data_harness/linux_process_boundary.py +262 -0
- mostlyright/data_harness/local_contracts.py +2880 -0
- mostlyright/data_harness/local_search/__init__.py +5 -0
- mostlyright/data_harness/local_search/build_index.py +1087 -0
- mostlyright/data_harness/local_search/contracts.py +920 -0
- mostlyright/data_harness/local_search/query_trace.py +266 -0
- mostlyright/data_harness/local_search/retrieval.py +700 -0
- mostlyright/data_harness/local_search/sealed.py +474 -0
- mostlyright/data_harness/local_search/service.py +784 -0
- mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
- mostlyright/data_harness/nbrender/__init__.py +12 -0
- mostlyright/data_harness/nbrender/chrome.py +359 -0
- mostlyright/data_harness/nbrender/code_body.py +266 -0
- mostlyright/data_harness/nbrender/document.py +407 -0
- mostlyright/data_harness/nbrender/frame.py +275 -0
- mostlyright/data_harness/nbrender/interactive.py +337 -0
- mostlyright/data_harness/nbrender/markdown_body.py +477 -0
- mostlyright/data_harness/nbrender/mr_components.py +134 -0
- mostlyright/data_harness/nbrender/outputs_data.py +595 -0
- mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
- mostlyright/data_harness/nbrender/outputs_source.py +260 -0
- mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
- mostlyright/data_harness/nbrender/outputs_text.py +400 -0
- mostlyright/data_harness/nbrender/parse.py +394 -0
- mostlyright/data_harness/nbrender/status.py +40 -0
- mostlyright/data_harness/nbrender/tokens.py +1295 -0
- mostlyright/data_harness/notebook.py +1710 -0
- mostlyright/data_harness/offline.py +2049 -0
- mostlyright/data_harness/operation_registry.py +1007 -0
- mostlyright/data_harness/operator_setup.py +239 -0
- mostlyright/data_harness/pipeline.py +6428 -0
- mostlyright/data_harness/plan_graph.py +2026 -0
- mostlyright/data_harness/preparation/__init__.py +104 -0
- mostlyright/data_harness/preparation/contracts.py +1017 -0
- mostlyright/data_harness/preparation/engine.py +221 -0
- mostlyright/data_harness/preparation/errors.py +14 -0
- mostlyright/data_harness/preparation/gates.py +751 -0
- mostlyright/data_harness/preparation/joins.py +574 -0
- mostlyright/data_harness/preparation/profile.py +384 -0
- mostlyright/data_harness/preparation/table.py +217 -0
- mostlyright/data_harness/preparation/transforms.py +568 -0
- mostlyright/data_harness/progress_events.py +534 -0
- mostlyright/data_harness/readers/__init__.py +46 -0
- mostlyright/data_harness/readers/containers.py +963 -0
- mostlyright/data_harness/readers/contracts.py +542 -0
- mostlyright/data_harness/readers/delimited.py +257 -0
- mostlyright/data_harness/readers/grib2/__init__.py +33 -0
- mostlyright/data_harness/readers/grib2/admission.py +722 -0
- mostlyright/data_harness/readers/grib2/decode.py +1009 -0
- mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
- mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
- mostlyright/data_harness/readers/json_tabular.py +485 -0
- mostlyright/data_harness/readers/registry.py +514 -0
- mostlyright/data_harness/readers/samples/README.md +110 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples.py +582 -0
- mostlyright/data_harness/readers/spreadsheet.py +803 -0
- mostlyright/data_harness/readers/tabular.py +510 -0
- mostlyright/data_harness/recipe.py +5321 -0
- mostlyright/data_harness/repair/__init__.py +78 -0
- mostlyright/data_harness/repair/adapters.py +274 -0
- mostlyright/data_harness/repair/contracts.py +872 -0
- mostlyright/data_harness/repair/coordinator.py +1099 -0
- mostlyright/data_harness/repair/errors.py +16 -0
- mostlyright/data_harness/review.py +2533 -0
- mostlyright/data_harness/rowset.py +283 -0
- mostlyright/data_harness/serving.py +1975 -0
- mostlyright/data_harness/serving_edge.py +590 -0
- mostlyright/data_harness/serving_http.py +1031 -0
- mostlyright/data_harness/session_probes.py +759 -0
- mostlyright/data_harness/signing.py +101 -0
- mostlyright/data_harness/source_discovery.py +898 -0
- mostlyright/data_harness/sources/__init__.py +209 -0
- mostlyright/data_harness/sources/_adapter_steps.py +213 -0
- mostlyright/data_harness/sources/adapters.py +1214 -0
- mostlyright/data_harness/sources/cadence.py +1428 -0
- mostlyright/data_harness/sources/cadence_emission.py +453 -0
- mostlyright/data_harness/sources/cadence_history.py +546 -0
- mostlyright/data_harness/sources/catalog/__init__.py +17 -0
- mostlyright/data_harness/sources/catalog/admission.py +477 -0
- mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
- mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
- mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
- mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
- mostlyright/data_harness/sources/catalog/channel.py +523 -0
- mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
- mostlyright/data_harness/sources/catalog/contracts.py +825 -0
- mostlyright/data_harness/sources/catalog/coverage.py +137 -0
- mostlyright/data_harness/sources/catalog/delta.py +1340 -0
- mostlyright/data_harness/sources/catalog/embedding.py +532 -0
- mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
- mostlyright/data_harness/sources/catalog/fill.py +3889 -0
- mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
- mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
- mostlyright/data_harness/sources/catalog/gating.py +374 -0
- mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
- mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
- mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
- mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
- mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
- mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
- mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
- mostlyright/data_harness/sources/catalog/health.py +447 -0
- mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
- mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
- mostlyright/data_harness/sources/catalog/neural.py +1618 -0
- mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
- mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
- mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
- mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
- mostlyright/data_harness/sources/catalog/recommend.py +171 -0
- mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
- mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
- mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
- mostlyright/data_harness/sources/catalog/sealed.py +560 -0
- mostlyright/data_harness/sources/catalog/search.py +230 -0
- mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
- mostlyright/data_harness/sources/catalog/update.py +891 -0
- mostlyright/data_harness/sources/collections.py +815 -0
- mostlyright/data_harness/sources/contracts.py +2223 -0
- mostlyright/data_harness/sources/deletion.py +761 -0
- mostlyright/data_harness/sources/fitness.py +162 -0
- mostlyright/data_harness/sources/governance.py +163 -0
- mostlyright/data_harness/sources/hosted.py +173 -0
- mostlyright/data_harness/sources/integration.py +218 -0
- mostlyright/data_harness/sources/range_reader.py +418 -0
- mostlyright/data_harness/sources/registry.py +514 -0
- mostlyright/data_harness/sources/rights_rule.py +59 -0
- mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
- mostlyright/data_harness/sources/sports.py +521 -0
- mostlyright/data_harness/sources/stream.py +524 -0
- mostlyright/data_harness/sources/stream_connector.py +418 -0
- mostlyright/data_harness/sources/stream_recorder.py +1404 -0
- mostlyright/data_harness/studio_boundary.py +2019 -0
- mostlyright/data_harness/thin/__init__.py +37 -0
- mostlyright/data_harness/thin/acquire.py +1137 -0
- mostlyright/data_harness/thin/acquire_cancel.py +579 -0
- mostlyright/data_harness/thin/approvals.py +617 -0
- mostlyright/data_harness/thin/commands.py +406 -0
- mostlyright/data_harness/thin/download.py +194 -0
- mostlyright/data_harness/thin/narrative.py +589 -0
- mostlyright/data_harness/thin/parity.py +1070 -0
- mostlyright/data_harness/thin/propose.py +2759 -0
- mostlyright/data_harness/thin/research.py +1663 -0
- mostlyright/data_harness/thin/router.py +924 -0
- mostlyright/data_harness/thin/runs.py +519 -0
- mostlyright/data_harness/thin/session.py +281 -0
- mostlyright/data_harness/thin/stream.py +501 -0
- mostlyright/data_harness/thin/transport.py +187 -0
- mostlyright/data_harness/thin/vocabulary.py +368 -0
- mostlyright/data_harness/thin/workers.py +164 -0
- mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
- mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
- mostlyright/data_harness/unit_flow.py +927 -0
- mostlyright/data_harness/units.py +572 -0
- mostlyright/data_harness/ux/__init__.py +9 -0
- mostlyright/data_harness/ux/approve.py +485 -0
- mostlyright/data_harness/ux/author_yaml.py +597 -0
- mostlyright/data_harness/ux/cloud_auth.py +447 -0
- mostlyright/data_harness/ux/commands/__init__.py +260 -0
- mostlyright/data_harness/ux/commands/approve.py +136 -0
- mostlyright/data_harness/ux/commands/auth.py +744 -0
- mostlyright/data_harness/ux/commands/author.py +79 -0
- mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
- mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
- mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
- mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
- mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
- mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
- mostlyright/data_harness/ux/commands/deploy.py +134 -0
- mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
- mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
- mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
- mostlyright/data_harness/ux/commands/diff.py +74 -0
- mostlyright/data_harness/ux/commands/index.py +84 -0
- mostlyright/data_harness/ux/commands/inventory.py +47 -0
- mostlyright/data_harness/ux/commands/list_builds.py +143 -0
- mostlyright/data_harness/ux/commands/login.py +63 -0
- mostlyright/data_harness/ux/commands/peek.py +236 -0
- mostlyright/data_harness/ux/commands/plan_check.py +90 -0
- mostlyright/data_harness/ux/commands/preflight.py +97 -0
- mostlyright/data_harness/ux/commands/record.py +107 -0
- mostlyright/data_harness/ux/commands/review_setup.py +47 -0
- mostlyright/data_harness/ux/commands/search.py +440 -0
- mostlyright/data_harness/ux/commands/show.py +61 -0
- mostlyright/data_harness/ux/commands/whoami.py +37 -0
- mostlyright/data_harness/ux/credential_native.py +551 -0
- mostlyright/data_harness/ux/credential_store.py +1055 -0
- mostlyright/data_harness/ux/credentials.py +631 -0
- mostlyright/data_harness/ux/diffing.py +444 -0
- mostlyright/data_harness/ux/headline.py +671 -0
- mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
- mostlyright/data_harness/ux/hosted_run_status.py +619 -0
- mostlyright/data_harness/ux/inventory.py +427 -0
- mostlyright/data_harness/ux/local_review.py +375 -0
- mostlyright/data_harness/ux/login.py +691 -0
- mostlyright/data_harness/ux/path_kind.py +147 -0
- mostlyright/data_harness/ux/peek.py +1000 -0
- mostlyright/data_harness/ux/plain_file.py +178 -0
- mostlyright/data_harness/ux/plan_check.py +311 -0
- mostlyright/data_harness/ux/preflight.py +918 -0
- mostlyright/data_harness/ux/readers.py +1124 -0
- mostlyright/data_harness/ux/remediation.py +2195 -0
- mostlyright/data_harness/ux/render.py +657 -0
- mostlyright/data_harness/ux/workload.py +1077 -0
- mostlyright/data_harness/viewer.py +3713 -0
- mostlyright/data_harness/visual_run/__init__.py +83 -0
- mostlyright/data_harness/visual_run/authoring.py +235 -0
- mostlyright/data_harness/visual_run/contracts.py +673 -0
- mostlyright/data_harness/visual_run/materialize.py +486 -0
- mostlyright/data_harness/visual_run/observations.py +874 -0
- mostlyright/data_harness/visual_run/query.py +259 -0
- mostlyright/data_harness/visual_run/reducer.py +280 -0
- mostlyright/data_harness/visual_run/sdk.py +892 -0
- mostlyright/data_harness/visual_run/store.py +584 -0
- mostlyright/data_harness/visual_run/transport.py +239 -0
- mostlyright/data_harness/watch.py +2999 -0
- mostlyright_data-0.9.0.dist-info/METADATA +607 -0
- mostlyright_data-0.9.0.dist-info/RECORD +314 -0
- mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
- 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)
|