From 4a96814b4df15ae4187c511c038e9ec7d99bfb22 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Tue, 11 Aug 2026 08:45:28 -0600 Subject: [PATCH 1/6] fix(well-inventory): normalize utm_zone casing validate_model upper-cased utm_zone into a local variable but never wrote the result back to self.utm_zone. A row with "13n" therefore passed schema validation, then failed case-sensitively downstream in services/well_inventory_csv.py with a confusing "Unsupported UTM zone: 13n" error at persist time instead of at validation time. Add a field_validator that strips and upper-cases utm_zone once, so every downstream consumer sees the same canonical value the schema already validated. --- schemas/well_inventory.py | 8 ++++++++ tests/test_well_inventory.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index 2b2dba970..f0fef6dee 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -375,6 +375,14 @@ def normalize_measurement_date_time(cls, value: datetime | None) -> datetime | N return None return normalize_datetime_to_utc(value) + @field_validator("utm_zone", mode="after") + @classmethod + def normalize_utm_zone(cls, value: str) -> str: + # Canonicalize so downstream callers (services/well_inventory_csv.py) see + # the same string this validator checked -- a stray "13n" used to pass + # here but fail case-sensitively at persist time. + return (value or "").strip().upper() + @model_validator(mode="after") def validate_model(self): # verify utm in NM diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index aa16afddb..8f405b172 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -1366,6 +1366,21 @@ def test_group_query_with_multiple_conditions(self): session.commit() +class TestWellInventoryRowUtmValidation: + """WellInventoryRow's UTM zone/coordinate checks, post NM-restriction removal.""" + + def test_lowercase_zone_is_accepted(self): + # utm_zone is normalized before the SRID lookup downstream; a row that + # used to pass here and fail case-sensitively at persist time now + # succeeds end to end. + row = _minimal_valid_well_inventory_row() + row["utm_zone"] = "13n" + + model = WellInventoryRow(**row) + + assert model.utm_zone == "13N" + + class TestWellInventoryRowAliases: """Schema alias handling for well inventory CSV field names.""" From 0fb0553a83318cdea9dfe7e811c68ffca00e22e5 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Tue, 11 Aug 2026 08:47:42 -0600 Subject: [PATCH 2/6] feat(domain): widen supported UTM zones to CONUS The importer only recognized 12N and 13N, NM's two UTM zones, via a fixed dict with a TODO noting more zones would eventually be needed. NMBGMR now needs to import wells from neighboring states, which use UTM zones outside that pair. Replace the fixed dict with a parser that accepts any northern-hemisphere zone from 10N to 19N (the continental US) and derive the EPSG code arithmetically as SRID_NAD83_UTM_BASE + zone, since NAD83 covers the whole range on one consistent datum. This only widens what the domain layer can project; the importer's own zone and coordinate restrictions still apply until the next commit removes them. --- core/constants.py | 5 +++++ domain/wells.py | 36 +++++++++++++++++++++++++----------- tests/test_domain_wells.py | 27 +++++++++++++++++++++------ tests/test_well_inventory.py | 17 +++++++++++++++++ 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/core/constants.py b/core/constants.py index 5938d0d6a..75ce975cd 100644 --- a/core/constants.py +++ b/core/constants.py @@ -18,6 +18,11 @@ SRID_UTM_ZONE_13N = 26913 SRID_UTM_ZONE_12N = 26912 +# EPSG 269xx == NAD83 / UTM zone xxN. Zones 10N-19N span the continental US. +SRID_NAD83_UTM_BASE = 26900 +UTM_ZONE_MIN = 10 +UTM_ZONE_MAX = 19 + STATE_CODES = ( "AL", "AK", diff --git a/domain/wells.py b/domain/wells.py index fcabf0ba4..314aa6d7a 100644 --- a/domain/wells.py +++ b/domain/wells.py @@ -27,7 +27,7 @@ import re -from core.constants import SRID_UTM_ZONE_12N, SRID_UTM_ZONE_13N +from core.constants import SRID_NAD83_UTM_BASE, UTM_ZONE_MAX, UTM_ZONE_MIN from domain.units import convert_ft_to_m from domain.values import enum_value @@ -37,12 +37,7 @@ r"^(?P[A-Z]{2,3})\s*-\s*(?:x{4}|X{4})$", re.IGNORECASE ) -# TODO: this needs to be more sophisticated in the future. Likely more than 13N -# and 12N will be used. -UTM_ZONE_SRIDS = { - "13N": SRID_UTM_ZONE_13N, - "12N": SRID_UTM_ZONE_12N, -} +UTM_ZONE_REGEX = re.compile(r"^\s*(\d{1,2})\s*N\s*$", re.IGNORECASE) RELEASE_STATUS_PUBLIC = "public" RELEASE_STATUS_PRIVATE = "private" @@ -91,12 +86,31 @@ def autogen_prefix(well_id: str | None) -> str | None: return None +def utm_zone_number(utm_zone: str | None) -> int: + """ + Parse a UTM zone label (e.g. ``"13N"``) into its zone number. + + Accepts any northern-hemisphere zone from ``UTM_ZONE_MIN`` to ``UTM_ZONE_MAX`` + (10N-19N spans the continental US), case-insensitively and tolerant of + surrounding whitespace. Anything else -- a bad shape, a southern-hemisphere + suffix, or a zone outside that range -- raises ``UnsupportedUtmZone``, which + also names the supported range so the message is useful on its own. + """ + match = UTM_ZONE_REGEX.match(utm_zone or "") + if match: + zone = int(match.group(1)) + if UTM_ZONE_MIN <= zone <= UTM_ZONE_MAX: + return zone + + raise UnsupportedUtmZone( + f"Unsupported UTM zone: {utm_zone}. Must be a northern-hemisphere zone " + f"from {UTM_ZONE_MIN}N to {UTM_ZONE_MAX}N." + ) + + def srid_for_utm_zone(utm_zone: str | None) -> int: """Return the EPSG code for a supported UTM zone label.""" - try: - return UTM_ZONE_SRIDS[utm_zone] - except KeyError: - raise UnsupportedUtmZone(f"Unsupported UTM zone: {utm_zone}") from None + return SRID_NAD83_UTM_BASE + utm_zone_number(utm_zone) def elevation_m_from_ft(elevation_ft: float | str | None) -> float: diff --git a/tests/test_domain_wells.py b/tests/test_domain_wells.py index 5429f10d8..96a41a8f4 100644 --- a/tests/test_domain_wells.py +++ b/tests/test_domain_wells.py @@ -19,7 +19,7 @@ import pytest -from core.constants import SRID_UTM_ZONE_12N, SRID_UTM_ZONE_13N +from core.constants import SRID_NAD83_UTM_BASE from domain.wells import ( AUTOGEN_DEFAULT_PREFIX, ConflictingMeasuringPointHeight, @@ -32,6 +32,7 @@ release_status, resolve_measuring_point_height, srid_for_utm_zone, + utm_zone_number, well_purposes, ) @@ -71,14 +72,28 @@ def test_autogen_prefix_leaves_real_ids_alone(well_id): # -------------------------------------------------------------------------- -# srid_for_utm_zone +# srid_for_utm_zone / utm_zone_number # -------------------------------------------------------------------------- -def test_srid_for_utm_zone_maps_supported_zones(): - assert srid_for_utm_zone("13N") == SRID_UTM_ZONE_13N - assert srid_for_utm_zone("12N") == SRID_UTM_ZONE_12N +@pytest.mark.parametrize("zone_number", range(10, 20)) +def test_srid_for_utm_zone_maps_conus_zones(zone_number): + assert srid_for_utm_zone(f"{zone_number}N") == SRID_NAD83_UTM_BASE + zone_number -@pytest.mark.parametrize("zone", ["11N", "13n", "", None]) +@pytest.mark.parametrize( + "zone, expected", + [ + ("13N", 13), + ("13n", 13), # case-insensitive + (" 13N ", 13), # tolerant of surrounding whitespace + ("10N", 10), + ("19N", 19), + ], +) +def test_utm_zone_number_normalizes_supported_zones(zone, expected): + assert utm_zone_number(zone) == expected + + +@pytest.mark.parametrize("zone", ["9N", "20N", "13S", "", None, "13"]) def test_srid_for_utm_zone_rejects_unsupported_zones(zone): with pytest.raises(UnsupportedUtmZone, match=f"Unsupported UTM zone: {zone}"): srid_for_utm_zone(zone) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 8f405b172..adf561d6c 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -1058,6 +1058,23 @@ def test_make_location_utm_zone_12n(self): assert location.point is not None assert location.elevation is not None + def test_make_location_utm_zone_19n(self): + """A zone outside NM's historical 12N/13N range still projects.""" + from services.well_inventory_csv import _make_location + from unittest.mock import MagicMock + + model = MagicMock() + model.utm_easting = 500000.0 + model.utm_northing = 4700000.0 + model.utm_zone = "19N" + model.elevation_ft = 200.0 + + location = _make_location(model) + + assert location is not None + assert location.point is not None + assert location.elevation is not None + def test_make_contact_with_full_info(self): """Test contact dict creation with all fields populated.""" from services.well_inventory_csv import _make_contact From 89a1ca7c6eb8309ec4e18f5d142f96df046d85da Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Tue, 11 Aug 2026 08:51:38 -0600 Subject: [PATCH 3/6] feat(well-inventory): drop New Mexico restriction The importer rejected any row whose UTM zone or resulting lat/lon fell outside New Mexico, even though nothing downstream (geometry storage, county/state lookup) actually depends on the well being in NM. This blocked importing wells from neighboring states. Replace the NM-specific zone allowlist and bounding box with the domain layer's CONUS-wide zone parser and a generic coordinate sanity range. The remaining check is a plausibility guard against transposed easting/northing or feet-vs-meters entry mistakes, not a border check. Worldwide support was considered and deferred: UTM is not a single global grid, so supporting every zone would mean picking a datum per hemisphere rather than reusing NAD83, and there's no current need for it outside North America. --- core/constants.py | 6 +++ schemas/well_inventory.py | 32 ++++++++++------ .../data/well-inventory-invalid-utm.csv | 2 +- .../steps/well-inventory-csv-given.py | 2 +- .../well-inventory-csv-validation-error.py | 4 +- tests/features/well-inventory-csv.feature | 9 +++-- tests/test_well_inventory.py | 38 ++++++++++++++++++- 7 files changed, 74 insertions(+), 19 deletions(-) diff --git a/core/constants.py b/core/constants.py index 75ce975cd..f5c75f835 100644 --- a/core/constants.py +++ b/core/constants.py @@ -23,6 +23,12 @@ UTM_ZONE_MIN = 10 UTM_ZONE_MAX = 19 +# A coarse sanity range, not a national border: it catches transposed +# easting/northing and feet-vs-meters entry mistakes. It is wider than the US +# (e.g. it admits Mexico City) -- do not use it to decide "is this the US". +COORD_LAT_MIN, COORD_LAT_MAX = 18.0, 72.0 +COORD_LON_MIN, COORD_LON_MAX = -180.0, -66.0 + STATE_CODES = ( "AL", "AK", diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index f0fef6dee..c17395034 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -19,7 +19,13 @@ import phonenumbers import utm -from core.constants import STATE_CODES +from core.constants import ( + COORD_LAT_MAX, + COORD_LAT_MIN, + COORD_LON_MAX, + COORD_LON_MIN, + STATE_CODES, +) from core.enums import ( ElevationMethod, Role, @@ -47,6 +53,7 @@ Field, AliasChoices, ) +from domain.wells import utm_zone_number from schemas import past_or_today_validator, PastOrTodayDatetime from services.util import normalize_datetime_to_utc @@ -385,20 +392,23 @@ def normalize_utm_zone(cls, value: str) -> str: @model_validator(mode="after") def validate_model(self): - # verify utm in NM - utm_zone_value = (self.utm_zone or "").upper() - if utm_zone_value not in ("12N", "13N"): - raise ValueError("Invalid utm zone. Must be one of: 12N, 13N") - - zone = int(utm_zone_value[:-1]) - northern = True # only northern hemisphere zones (12N, 13N) are supported + # utm_zone_number raises UnsupportedUtmZone (a ValueError) for anything + # outside the supported northern-hemisphere CONUS range; Pydantic wraps it + # with its own "Value error, " prefix, so no message is composed here. + zone = utm_zone_number(self.utm_zone) + northern = True # utm_zone_number rejects anything but an "N" suffix lat, lon = utm.to_latlon( self.utm_easting, self.utm_northing, zone, northern=northern ) - if not ((31.33 <= lat <= 37.00) and (-109.05 <= lon <= -103.00)): + # A coarse sanity check, not a border check -- catches transposed + # easting/northing and feet-vs-meters entry mistakes. + if not ( + COORD_LAT_MIN <= lat <= COORD_LAT_MAX + and COORD_LON_MIN <= lon <= COORD_LON_MAX + ): raise ValueError( - f"UTM coordinates are outside of the NM. E={self.utm_easting} N={self.utm_northing}" - f" Zone={self.utm_zone}" + f"UTM coordinates are outside the expected range. E={self.utm_easting}" + f" N={self.utm_northing} Zone={self.utm_zone}" ) if self.depth_to_water_ft is not None: diff --git a/tests/features/data/well-inventory-invalid-utm.csv b/tests/features/data/well-inventory-invalid-utm.csv index e8f14b2bb..b97852e6a 100644 --- a/tests/features/data/well-inventory-invalid-utm.csv +++ b/tests/features/data/well-inventory-invalid-utm.csv @@ -1,4 +1,4 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,457100,4159020,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,500000,1000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13S,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"Reported by person other than driller owner agency",Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False Middle Rio Grande Groundwater Monitoring,MRG-005_MP1,Valid Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True diff --git a/tests/features/steps/well-inventory-csv-given.py b/tests/features/steps/well-inventory-csv-given.py index 4f6b62789..fd97fb0d2 100644 --- a/tests/features/steps/well-inventory-csv-given.py +++ b/tests/features/steps/well-inventory-csv-given.py @@ -227,7 +227,7 @@ def step_step_step_11(context: Context): @given( - "my CSV file contains a row with utm_easting utm_northing and utm_zone values that are not within New Mexico" + "my CSV file contains a row with utm_easting utm_northing and utm_zone values that are outside the expected coordinate range" ) def step_step_step_12(context: Context): _set_file_content(context, "well-inventory-invalid-utm.csv") diff --git a/tests/features/steps/well-inventory-csv-validation-error.py b/tests/features/steps/well-inventory-csv-validation-error.py index 0c3900099..ab7ac2cf9 100644 --- a/tests/features/steps/well-inventory-csv-validation-error.py +++ b/tests/features/steps/well-inventory-csv-validation-error.py @@ -85,11 +85,11 @@ def step_then_the_response_includes_a_validation_error_indicating_the_invalid_ut expected_errors = [ { "field": "composite field error", - "error": "Value error, UTM coordinates are outside of the NM. E=457100.0 N=4159020.0 Zone=13N", + "error": "Value error, UTM coordinates are outside the expected range. E=500000.0 N=1000000.0 Zone=13N", }, { "field": "composite field error", - "error": "Value error, Invalid utm zone. Must be one of: 12N, 13N", + "error": "Value error, Unsupported UTM zone: 13S", }, ] _handle_validation_error(context, expected_errors) diff --git a/tests/features/well-inventory-csv.feature b/tests/features/well-inventory-csv.feature index e5442cc5a..a12ba60c3 100644 --- a/tests/features/well-inventory-csv.feature +++ b/tests/features/well-inventory-csv.feature @@ -150,7 +150,10 @@ Feature: Bulk upload well inventory from CSV via CLI # And all optional date fields contain valid ISO 8601 timestamps when provided When I run the well inventory bulk upload command - # assumes users are entering datetimes as Mountain Time because location is restricted to New Mexico + # America/Denver is an explicit convention for timezone-naive datetimes, not + # a consequence of a geographic restriction -- imports are no longer limited + # to New Mexico. Submitters outside Mountain Time should include a UTC + # offset (see kas/docs/WELL_INVENTORY_INGESTION.md). Then all datetime objects are normalized to UTC And timezone-naive datetimes are interpreted as Mountain Time before conversion And timezone-aware datetimes are converted to UTC using their provided offset @@ -324,8 +327,8 @@ Feature: Bulk upload well inventory from CSV via CLI And 1 well is imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has utm_easting utm_northing and utm_zone values that are not within New Mexico - Given my CSV file contains a row with utm_easting utm_northing and utm_zone values that are not within New Mexico + Scenario: Upload fails when a row has utm_easting utm_northing and utm_zone values that are outside the expected coordinate range + Given my CSV file contains a row with utm_easting utm_northing and utm_zone values that are outside the expected coordinate range When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating the invalid UTM coordinates diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index adf561d6c..2aef428be 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -939,7 +939,7 @@ def test_upload_invalid_phone_number(self): assert result.exit_code == 1 def test_upload_invalid_utm_coordinates(self): - """Upload fails when UTM coordinates are outside New Mexico.""" + """Upload fails when UTM coordinates are outside the expected range.""" file_path = Path("tests/features/data/well-inventory-invalid-utm.csv") if file_path.exists(): result = well_inventory_csv(file_path) @@ -1397,6 +1397,42 @@ def test_lowercase_zone_is_accepted(self): assert model.utm_zone == "13N" + def test_zone_outside_conus_range_is_rejected(self): + row = _minimal_valid_well_inventory_row() + row["utm_zone"] = "20N" + + with pytest.raises(ValueError, match="Unsupported UTM zone"): + WellInventoryRow(**row) + + def test_coordinates_far_outside_expected_range_are_rejected(self): + # Zone 13N easting/northing well south of the sanity range (~9N lat). + row = _minimal_valid_well_inventory_row() + row["utm_easting"] = 500000 + row["utm_northing"] = 1000000 + + with pytest.raises(ValueError, match="outside the expected range"): + WellInventoryRow(**row) + + def test_badly_scaled_coordinates_raise_out_of_range_error(self): + # utm.to_latlon itself rejects an easting/northing outside its valid + # domain (e.g. a value entered in the wrong units). This becomes + # reachable for more zones now that the NM-only allowlist is gone. + row = _minimal_valid_well_inventory_row() + row["utm_easting"] = 99999999 + + with pytest.raises(ValueError, match="easting out of range"): + WellInventoryRow(**row) + + def test_zone_outside_nm_but_inside_conus_is_accepted(self): + row = _minimal_valid_well_inventory_row() + row["utm_zone"] = "11N" + row["utm_easting"] = 500000 + row["utm_northing"] = 4000000 + + model = WellInventoryRow(**row) + + assert model.utm_zone == "11N" + class TestWellInventoryRowAliases: """Schema alias handling for well inventory CSV field names.""" From 8d4baf5d49d3e7c5e133d322825fdcc9d9636cec Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Tue, 11 Aug 2026 09:52:40 -0600 Subject: [PATCH 4/6] fix(location): use the point's actual UTM zone in GeoJSON GeoJSONUTMCoordinates hardcoded utm_zone = "13N" and populate_fields always reprojected the stored WGS84 point to EPSG 26913, regardless of where the point actually is. Any well outside zone 13N read back with a confidently wrong easting/northing labeled "13N": this was already latent for existing 12N wells in western NM, and became a live concern once wells from neighboring states could be imported. Add domain/geospatial.py, computing the UTM zone a longitude actually falls in, clamped to the same 10N-19N range domain/wells.py supports on import. Clamping matters because EPSG only defines "NAD83 / UTM zone nN" for n = 1..23; past that, 269xx codes name unrelated NAD83 state-plane systems, so an uncapped zone number could resolve to a valid but wrong CRS instead of failing loudly. schemas/location.py now derives the SRID and the utm_zone label from the point itself instead of a fixed default. --- domain/geospatial.py | 49 ++++++++++++++++++++++++++++ schemas/location.py | 19 ++++++----- tests/test_domain_geospatial.py | 58 +++++++++++++++++++++++++++++++++ tests/test_location.py | 31 ++++++++++++++++++ 4 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 domain/geospatial.py create mode 100644 tests/test_domain_geospatial.py diff --git a/domain/geospatial.py b/domain/geospatial.py new file mode 100644 index 000000000..1b15efc43 --- /dev/null +++ b/domain/geospatial.py @@ -0,0 +1,49 @@ +# =============================================================================== +# Copyright 2026 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +""" +UTM zone lookup for a stored point, used on the read path. + +``domain/wells.py`` parses a UTM zone label supplied on import. This module is +the read-side counterpart: given a point already stored as WGS84, find the UTM +zone it actually falls in, so the GeoJSON response can report real coordinates +instead of a fixed zone. See ``schemas/location.py``. +""" + +from core.constants import SRID_NAD83_UTM_BASE, UTM_ZONE_MAX, UTM_ZONE_MIN + + +def utm_zone_for_longitude(longitude: float) -> int: + """ + Return the standard UTM zone number for a longitude, clamped to the + continental US range this system supports (``UTM_ZONE_MIN..UTM_ZONE_MAX``). + + UTM zones are 6 degrees wide starting at -180 (zone 1 covers -180..-174). + Clamping keeps the result inside the range EPSG defines as "NAD83 / UTM + zone nN" (n = 1..23); above that, 269xx codes belong to unrelated NAD83 + state-plane systems, so an uncapped zone number could resolve to a valid + but wrong CRS instead of failing loudly. Worldwide support is out of + scope -- see domain/wells.py. + """ + zone = int((longitude + 180) // 6) + 1 + return max(UTM_ZONE_MIN, min(UTM_ZONE_MAX, zone)) + + +def srid_for_longitude(longitude: float) -> int: + """Return the NAD83 UTM EPSG code for the zone a longitude falls in.""" + return SRID_NAD83_UTM_BASE + utm_zone_for_longitude(longitude) + + +# ============= EOF ============================================= diff --git a/schemas/location.py b/schemas/location.py index e96a2474f..f4277c5c5 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -20,9 +20,10 @@ from geoalchemy2.shape import to_shape from pydantic import BaseModel, model_validator, field_validator, Field, ConfigDict -from core.constants import SRID_WGS84, SRID_UTM_ZONE_13N +from core.constants import SRID_WGS84 from core.enums import ElevationMethod, CoordinateMethod from core.enums import ReleaseStatus +from domain.geospatial import srid_for_longitude, utm_zone_for_longitude from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from schemas.notes import NoteResponse, CreateNote, UpdateNote from services.util import convert_m_to_ft, transform_srid @@ -172,14 +173,16 @@ def populate_fields(cls, data: Any) -> Any: data_dict["properties"]["nma_date_created"] = data_dict.get("nma_date_created") data_dict["properties"]["nma_site_date"] = data_dict.get("nma_site_date") - # populate UTM coordinates - point_utm_zone_13n_wkt = transform_srid( - point_wgs84_wkt, SRID_WGS84, SRID_UTM_ZONE_13N + # populate UTM coordinates using the zone the point actually falls in, + # not a fixed NM zone -- a well outside 12N/13N previously read back + # with a confidently wrong easting/northing labeled "13N". + zone = utm_zone_for_longitude(point_wgs84_wkt.x) + point_utm_wkt = transform_srid( + point_wgs84_wkt, SRID_WGS84, srid_for_longitude(point_wgs84_wkt.x) ) - data_dict["properties"]["utm_coordinates"]["easting"] = point_utm_zone_13n_wkt.x - data_dict["properties"]["utm_coordinates"][ - "northing" - ] = point_utm_zone_13n_wkt.y + data_dict["properties"]["utm_coordinates"]["easting"] = point_utm_wkt.x + data_dict["properties"]["utm_coordinates"]["northing"] = point_utm_wkt.y + data_dict["properties"]["utm_coordinates"]["utm_zone"] = f"{zone}N" return data_dict diff --git a/tests/test_domain_geospatial.py b/tests/test_domain_geospatial.py new file mode 100644 index 000000000..d7fcb234b --- /dev/null +++ b/tests/test_domain_geospatial.py @@ -0,0 +1,58 @@ +# =============================================================================== +# Copyright 2026 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +"""Read-side UTM zone lookup. No database, no fixtures.""" + +import pytest + +from core.constants import SRID_NAD83_UTM_BASE +from domain.geospatial import srid_for_longitude, utm_zone_for_longitude + + +@pytest.mark.parametrize( + "longitude, expected_zone", + [ + (-105.0, 13), # Albuquerque-ish, NM's eastern half + (-107.949533, 13), + (-109.05, 12), # just west of NM's western border + (-117.0, 11), # Nevada + (-69.0, 19), # Maine, top of the CONUS range + (-123.0, 10), # Pacific coast, bottom of the CONUS range + ], +) +def test_utm_zone_for_longitude_matches_the_true_zone(longitude, expected_zone): + assert utm_zone_for_longitude(longitude) == expected_zone + + +@pytest.mark.parametrize( + "longitude, expected_zone", + [ + (-170.0, 10), # far west of CONUS -- clamps rather than picking zone 3 + (170.0, 19), # far east of CONUS -- clamps rather than picking zone 51 + ], +) +def test_utm_zone_for_longitude_clamps_outside_conus(longitude, expected_zone): + # Clamping avoids handing pyproj a 269xx code outside the "NAD83 / UTM + # zone nN" series, where the number is reused for unrelated state-plane + # systems (see domain/geospatial.py). + assert utm_zone_for_longitude(longitude) == expected_zone + + +def test_srid_for_longitude_derives_from_the_zone(): + assert srid_for_longitude(-105.0) == SRID_NAD83_UTM_BASE + 13 + assert srid_for_longitude(-117.0) == SRID_NAD83_UTM_BASE + 11 + + +# ============= EOF ============================================= diff --git a/tests/test_location.py b/tests/test_location.py index e849d297b..c1467123d 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -325,4 +325,35 @@ def test_ampapi_fields_independent_of_created_at(): cleanup_post_test(Location, data["id"]) +def test_geojson_response_uses_the_points_actual_utm_zone(): + """A well outside 12N/13N must not read back with a hardcoded 13N label.""" + from shapely import Point + + from core.constants import SRID_WGS84 + from db.engine import session_ctx + from schemas.location import LocationGeoJSONResponse + from services.util import transform_srid + + lon, lat = -117.0, 36.144718 # zone 11N (Nevada), outside 12N/13N + expected = transform_srid(Point(lon, lat), SRID_WGS84, 26911) + + with session_ctx() as session: + loc = Location(point=f"POINT({lon} {lat})", elevation=0, release_status="draft") + session.add(loc) + session.commit() + session.refresh(loc) + location_id = loc.id + + response = LocationGeoJSONResponse.model_validate(loc) + + try: + utm = response.properties.utm_coordinates + assert utm.utm_zone == "11N" + assert utm.easting == pytest.approx(expected.x) + assert utm.northing == pytest.approx(expected.y) + assert utm.horizontal_datum == "NAD83" + finally: + cleanup_post_test(Location, location_id) + + # ============= EOF ============================================= From cfcfa70e104496990fe0eef6d82e1c9686c5fc0e Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 24 Aug 2026 21:12:54 -0600 Subject: [PATCH 5/6] refactor(constants): scope UTM/coord bounds to AMP ingestion PR review on #822 pointed out that UTM_ZONE_MIN/MAX and COORD_LAT/LON_MIN/MAX read as general-purpose limits, but they are AMP water-well ingestion policy: domain/wells.py uses them correctly to gate CSV imports to CONUS, while domain/geospatial.py (the GeoJSON read path, which must serve any stored point) imported the same names as if they were a projection limit. Two modules doing UTM zone work with opposite scopes sharing one generic-named constant is exactly what let a CONUS bound apply to the worldwide read path. Rename to AMP_UTM_ZONE_MIN/MAX and AMP_COORD_LAT/LON_MIN/MAX, with a comment stating the policy explicitly and pointing at domain/geospatial.py as the module that must not import them. Also corrects the EPSG comment: "269xx == NAD83 / UTM zone xxN" only holds for zones 1-23; 26924-26928 don't exist, and 26929+ names unrelated NAD83 state-plane systems. domain/wells.py's rejection message now names the policy directly ("AMP well submissions are limited to CONUS zones 10N-19N") instead of a bare "unsupported", since the range is a deliberate choice, not an implementation limit. No behavior change. domain/geospatial.py still uses the (renamed) CONUS bound here; the next commit removes that dependency entirely. --- core/constants.py | 22 +++++++++++++++------- domain/geospatial.py | 6 +++--- domain/wells.py | 20 +++++++++++--------- schemas/well_inventory.py | 12 ++++++------ 4 files changed, 35 insertions(+), 25 deletions(-) diff --git a/core/constants.py b/core/constants.py index f5c75f835..d9d0a02e5 100644 --- a/core/constants.py +++ b/core/constants.py @@ -18,16 +18,24 @@ SRID_UTM_ZONE_13N = 26913 SRID_UTM_ZONE_12N = 26912 -# EPSG 269xx == NAD83 / UTM zone xxN. Zones 10N-19N span the continental US. +# EPSG 269xx == NAD83 / UTM zone xxN, but only for xx = 01..23; 26924-26928 +# don't exist, and 26929+ names unrelated NAD83 state-plane systems. Safe here +# because AMP's 10N-19N range sits well inside 1-23. SRID_NAD83_UTM_BASE = 26900 -UTM_ZONE_MIN = 10 -UTM_ZONE_MAX = 19 + +# AMP water-well ingestion policy: submissions are limited to the continental +# US. Not a projection limit -- domain/geospatial.py serves Location points +# stored anywhere on earth and must not import these; that coupling is what +# let a CONUS bound apply to the worldwide read path once before. +AMP_UTM_ZONE_MIN = 10 +AMP_UTM_ZONE_MAX = 19 # A coarse sanity range, not a national border: it catches transposed -# easting/northing and feet-vs-meters entry mistakes. It is wider than the US -# (e.g. it admits Mexico City) -- do not use it to decide "is this the US". -COORD_LAT_MIN, COORD_LAT_MAX = 18.0, 72.0 -COORD_LON_MIN, COORD_LON_MAX = -180.0, -66.0 +# easting/northing and feet-vs-meters entry mistakes on AMP well submissions. +# It is CONUS-shaped, not global -- e.g. it excludes the entire eastern +# hemisphere -- which is fine for this policy but wrong for anything else. +AMP_COORD_LAT_MIN, AMP_COORD_LAT_MAX = 18.0, 72.0 +AMP_COORD_LON_MIN, AMP_COORD_LON_MAX = -180.0, -66.0 STATE_CODES = ( "AL", diff --git a/domain/geospatial.py b/domain/geospatial.py index 1b15efc43..5a8483278 100644 --- a/domain/geospatial.py +++ b/domain/geospatial.py @@ -22,13 +22,13 @@ instead of a fixed zone. See ``schemas/location.py``. """ -from core.constants import SRID_NAD83_UTM_BASE, UTM_ZONE_MAX, UTM_ZONE_MIN +from core.constants import AMP_UTM_ZONE_MAX, AMP_UTM_ZONE_MIN, SRID_NAD83_UTM_BASE def utm_zone_for_longitude(longitude: float) -> int: """ Return the standard UTM zone number for a longitude, clamped to the - continental US range this system supports (``UTM_ZONE_MIN..UTM_ZONE_MAX``). + continental US range (``AMP_UTM_ZONE_MIN..AMP_UTM_ZONE_MAX``). UTM zones are 6 degrees wide starting at -180 (zone 1 covers -180..-174). Clamping keeps the result inside the range EPSG defines as "NAD83 / UTM @@ -38,7 +38,7 @@ def utm_zone_for_longitude(longitude: float) -> int: scope -- see domain/wells.py. """ zone = int((longitude + 180) // 6) + 1 - return max(UTM_ZONE_MIN, min(UTM_ZONE_MAX, zone)) + return max(AMP_UTM_ZONE_MIN, min(AMP_UTM_ZONE_MAX, zone)) def srid_for_longitude(longitude: float) -> int: diff --git a/domain/wells.py b/domain/wells.py index 314aa6d7a..ce98ea5fe 100644 --- a/domain/wells.py +++ b/domain/wells.py @@ -27,7 +27,7 @@ import re -from core.constants import SRID_NAD83_UTM_BASE, UTM_ZONE_MAX, UTM_ZONE_MIN +from core.constants import AMP_UTM_ZONE_MAX, AMP_UTM_ZONE_MIN, SRID_NAD83_UTM_BASE from domain.units import convert_ft_to_m from domain.values import enum_value @@ -90,21 +90,23 @@ def utm_zone_number(utm_zone: str | None) -> int: """ Parse a UTM zone label (e.g. ``"13N"``) into its zone number. - Accepts any northern-hemisphere zone from ``UTM_ZONE_MIN`` to ``UTM_ZONE_MAX`` - (10N-19N spans the continental US), case-insensitively and tolerant of - surrounding whitespace. Anything else -- a bad shape, a southern-hemisphere - suffix, or a zone outside that range -- raises ``UnsupportedUtmZone``, which - also names the supported range so the message is useful on its own. + Accepts any northern-hemisphere zone from ``AMP_UTM_ZONE_MIN`` to + ``AMP_UTM_ZONE_MAX`` (10N-19N spans the continental US), case-insensitively + and tolerant of surrounding whitespace. This is AMP water-well ingestion + policy, not a projection limit -- see domain/geospatial.py for the + worldwide read path. Anything else -- a bad shape, a southern-hemisphere + suffix, or a zone outside that range -- raises ``UnsupportedUtmZone``. """ match = UTM_ZONE_REGEX.match(utm_zone or "") if match: zone = int(match.group(1)) - if UTM_ZONE_MIN <= zone <= UTM_ZONE_MAX: + if AMP_UTM_ZONE_MIN <= zone <= AMP_UTM_ZONE_MAX: return zone raise UnsupportedUtmZone( - f"Unsupported UTM zone: {utm_zone}. Must be a northern-hemisphere zone " - f"from {UTM_ZONE_MIN}N to {UTM_ZONE_MAX}N." + f"Unsupported UTM zone: {utm_zone}. AMP well submissions are limited " + f"to CONUS zones {AMP_UTM_ZONE_MIN}N-{AMP_UTM_ZONE_MAX}N " + f"(southern-hemisphere zones are not accepted)." ) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index c17395034..6bb1c7ac9 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -20,10 +20,10 @@ import phonenumbers import utm from core.constants import ( - COORD_LAT_MAX, - COORD_LAT_MIN, - COORD_LON_MAX, - COORD_LON_MIN, + AMP_COORD_LAT_MAX, + AMP_COORD_LAT_MIN, + AMP_COORD_LON_MAX, + AMP_COORD_LON_MIN, STATE_CODES, ) from core.enums import ( @@ -403,8 +403,8 @@ def validate_model(self): # A coarse sanity check, not a border check -- catches transposed # easting/northing and feet-vs-meters entry mistakes. if not ( - COORD_LAT_MIN <= lat <= COORD_LAT_MAX - and COORD_LON_MIN <= lon <= COORD_LON_MAX + AMP_COORD_LAT_MIN <= lat <= AMP_COORD_LAT_MAX + and AMP_COORD_LON_MIN <= lon <= AMP_COORD_LON_MAX ): raise ValueError( f"UTM coordinates are outside the expected range. E={self.utm_easting}" From 4d3f23fa1d29434b179dd6b5a8c9a30155783a53 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 24 Aug 2026 21:27:33 -0600 Subject: [PATCH 6/6] fix(location): use pyproj for worldwide UTM zones Review on #822 caught a real defect in the clamp added by the previous commit: it made the bug worse, not better. A Berlin point reprojected through NAD83 zone 19N and came back as E=4961478.4 N=9352242.0, "19N", NAD83. That's a real coordinate elsewhere on Earth, returned with HTTP 200 and no error. I reproduced this against the actual code before trusting the review's numbers. The root problem was scope, not arithmetic. domain/geospatial.py backs the GeoJSON read path for every stored Location, and the product intends those to sit anywhere on earth. But the module computed a NAD83 EPSG code, valid only for zones 1-23 (a slice of North America), and clamped anything outside that range instead of rejecting it. NAD83 has no southern-hemisphere zones at all, and the old function took longitude only, so hemisphere could never be derived. AMP's water-well ingestion (domain/wells.py) keeps NAD83 and a CONUS-only zone range; that half of the split was already correct and stays as-is. Replace the arithmetic with a CRS lookup against pyproj's EPSG database (query_utm_crs_info), memoized per (zone, hemisphere) since each lookup costs about 33ms uncached. Take (longitude, latitude) instead of longitude alone, so hemisphere comes from the point rather than an assumption. Add a -80/84 latitude gate matching UTM's actual domain, raising OutsideUtmDomain past it instead of guessing; polar regions need UPS/Polar Stereographic, which this doesn't implement. The zone-number formula had its own edge case the clamp was hiding: unnormalized longitude (180.0, 185, -190) returned zone 61, 61, and -1. Normalizing onto [-180, 180) first fixes it. schemas/location.py now derives horizontal_datum from the same lookup (WGS84, not a hardcoded NAD83) and makes utm_coordinates optional, omitting it for points OutsideUtmDomain instead of inventing a label. The geometry member already carries WGS84 lon/lat for those. Making the field optional now, while every stored point is CONUS, avoids a breaking API change later once a client depends on it being present. Antarctic and polar support (EPSG 3031/32761 for below -80) is left for later. Raising there instead of silently mislabeling keeps the gap visible until that lands. --- domain/geospatial.py | 86 ++++++++++++++----- schemas/location.py | 41 +++++---- tests/features/steps/well-core-information.py | 10 ++- tests/features/well-core-information.feature | 2 +- tests/test_domain_geospatial.py | 60 ++++++++++--- tests/test_location.py | 53 +++++++++--- 6 files changed, 186 insertions(+), 66 deletions(-) diff --git a/domain/geospatial.py b/domain/geospatial.py index 5a8483278..1097cff98 100644 --- a/domain/geospatial.py +++ b/domain/geospatial.py @@ -14,36 +14,82 @@ # limitations under the License. # =============================================================================== """ -UTM zone lookup for a stored point, used on the read path. +UTM zone/CRS lookup for a stored point, used on the read path. -``domain/wells.py`` parses a UTM zone label supplied on import. This module is -the read-side counterpart: given a point already stored as WGS84, find the UTM -zone it actually falls in, so the GeoJSON response can report real coordinates -instead of a fixed zone. See ``schemas/location.py``. +``domain/wells.py`` parses a UTM zone label supplied on import, scoped to +AMP's CONUS-only water-well ingestion policy. This module is the read-side +counterpart and has no such scope: a ``Location`` can be stored anywhere on +earth, so this resolves the true WGS84 UTM zone for any point against +pyproj's CRS database rather than computing an EPSG code, and raises +``OutsideUtmDomain`` for the polar latitudes UTM doesn't cover. See +``schemas/location.py``. + +Deliberately does not import ``AMP_UTM_ZONE_MIN/MAX`` or ``AMP_COORD_*``: +those are AMP ingestion policy, not a projection limit, and importing them +here is exactly the coupling that let a CONUS bound silently clamp this +worldwide path before. """ -from core.constants import AMP_UTM_ZONE_MAX, AMP_UTM_ZONE_MIN, SRID_NAD83_UTM_BASE +from functools import lru_cache + +from pyproj.aoi import AreaOfInterest +from pyproj.database import query_utm_crs_info + +# UTM is undefined outside this band; polar points need UPS/Polar +# Stereographic (EPSG 32661/32761 or 3413/3031), which is not implemented +# here. Raising rather than clamping keeps that gap visible. +UTM_LAT_MIN, UTM_LAT_MAX = -80.0, 84.0 + + +class OutsideUtmDomain(ValueError): + """Raised when a point falls outside the latitude band UTM is defined for.""" def utm_zone_for_longitude(longitude: float) -> int: """ - Return the standard UTM zone number for a longitude, clamped to the - continental US range (``AMP_UTM_ZONE_MIN..AMP_UTM_ZONE_MAX``). - - UTM zones are 6 degrees wide starting at -180 (zone 1 covers -180..-174). - Clamping keeps the result inside the range EPSG defines as "NAD83 / UTM - zone nN" (n = 1..23); above that, 269xx codes belong to unrelated NAD83 - state-plane systems, so an uncapped zone number could resolve to a valid - but wrong CRS instead of failing loudly. Worldwide support is out of - scope -- see domain/wells.py. + Return the UTM zone number (1-60) for a longitude. + + UTM zones are 6 degrees wide starting at -180. The longitude is + normalized onto [-180, 180) first so values at or past the antimeridian + (180.0, 185, -190, ...) still land in range instead of returning 61, 62, + or a negative zone. """ - zone = int((longitude + 180) // 6) + 1 - return max(AMP_UTM_ZONE_MIN, min(AMP_UTM_ZONE_MAX, zone)) + return int(((longitude + 180) % 360) // 6) + 1 -def srid_for_longitude(longitude: float) -> int: - """Return the NAD83 UTM EPSG code for the zone a longitude falls in.""" - return SRID_NAD83_UTM_BASE + utm_zone_for_longitude(longitude) +@lru_cache(maxsize=128) +def _utm_epsg(zone: int, northern: bool) -> int: + """Look up the WGS84 UTM EPSG code for a zone/hemisphere pair.""" + # Resolved against the EPSG database rather than computed, so a zone that + # doesn't exist raises instead of landing on an unrelated CRS. + lon = (zone - 1) * 6 - 177 # zone central meridian + lat = 1.0 if northern else -1.0 + suffix = f"{zone}{'N' if northern else 'S'}" + for info in query_utm_crs_info( + datum_name="WGS 84", + area_of_interest=AreaOfInterest(lon, lat, lon, lat), + ): + if info.name.endswith(suffix): + return int(info.code) + raise OutsideUtmDomain(f"No WGS 84 UTM CRS for zone {suffix}") + + +def utm_crs_for_point(longitude: float, latitude: float) -> tuple[int, str]: + """ + Return the ``(EPSG code, zone label)`` of the UTM zone containing a point. + + Hemisphere is derived from latitude, not assumed, since this path serves + points anywhere on earth (unlike the AMP importer, which only ever + accepts northern-hemisphere zones by policy). + """ + if not UTM_LAT_MIN <= latitude <= UTM_LAT_MAX: + raise OutsideUtmDomain( + f"Latitude {latitude} is outside the UTM domain " + f"({UTM_LAT_MIN} to {UTM_LAT_MAX}); polar points need UPS." + ) + zone = utm_zone_for_longitude(longitude) + northern = latitude >= 0 + return _utm_epsg(zone, northern), f"{zone}{'N' if northern else 'S'}" # ============= EOF ============================================= diff --git a/schemas/location.py b/schemas/location.py index f4277c5c5..a9161f5a5 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -23,7 +23,7 @@ from core.constants import SRID_WGS84 from core.enums import ElevationMethod, CoordinateMethod from core.enums import ReleaseStatus -from domain.geospatial import srid_for_longitude, utm_zone_for_longitude +from domain.geospatial import OutsideUtmDomain, utm_crs_for_point from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel from schemas.notes import NoteResponse, CreateNote, UpdateNote from services.util import convert_m_to_ft, transform_srid @@ -89,8 +89,8 @@ class GeoJSONGeometry(BaseModel): class GeoJSONUTMCoordinates(BaseModel): easting: float northing: float - utm_zone: str = "13N" - horizontal_datum: str = "NAD83" + utm_zone: str + horizontal_datum: str = "WGS84" model_config = ConfigDict( from_attributes=True, @@ -106,9 +106,9 @@ class GeoJSONProperties(BaseModel): county: str | None = None state: str | None = None quad_name: str | None = None - utm_coordinates: GeoJSONUTMCoordinates = Field( - default_factory=GeoJSONUTMCoordinates - ) + # None for points outside the UTM domain (poles) -- there is no valid + # zone to report, so the field is omitted rather than given a fake one. + utm_coordinates: GeoJSONUTMCoordinates | None = None notes: list[NoteResponse] = [] nma_location_notes: str | None = None nma_data_reliability: str | None = None @@ -147,7 +147,6 @@ def populate_fields(cls, data: Any) -> Any: # add empty fields as necessary data_dict["geometry"] = {} data_dict["properties"] = {} - data_dict["properties"]["utm_coordinates"] = {} # populate coordinates point_wgs84_wkb = data_dict.get("point") @@ -173,16 +172,24 @@ def populate_fields(cls, data: Any) -> Any: data_dict["properties"]["nma_date_created"] = data_dict.get("nma_date_created") data_dict["properties"]["nma_site_date"] = data_dict.get("nma_site_date") - # populate UTM coordinates using the zone the point actually falls in, - # not a fixed NM zone -- a well outside 12N/13N previously read back - # with a confidently wrong easting/northing labeled "13N". - zone = utm_zone_for_longitude(point_wgs84_wkt.x) - point_utm_wkt = transform_srid( - point_wgs84_wkt, SRID_WGS84, srid_for_longitude(point_wgs84_wkt.x) - ) - data_dict["properties"]["utm_coordinates"]["easting"] = point_utm_wkt.x - data_dict["properties"]["utm_coordinates"]["northing"] = point_utm_wkt.y - data_dict["properties"]["utm_coordinates"]["utm_zone"] = f"{zone}N" + # Populate UTM coordinates using the zone/hemisphere the point actually + # falls in, not a fixed NM zone -- a well outside 12N/13N previously + # read back with a confidently wrong easting/northing labeled "13N". + # A point outside the UTM domain (poles) has no valid zone to report; + # the geometry member already carries WGS84 lon/lat for those, so the + # block is omitted rather than given a fake label. + try: + srid, zone_label = utm_crs_for_point(point_wgs84_wkt.x, point_wgs84_wkt.y) + except OutsideUtmDomain: + data_dict["properties"]["utm_coordinates"] = None + else: + point_utm_wkt = transform_srid(point_wgs84_wkt, SRID_WGS84, srid) + data_dict["properties"]["utm_coordinates"] = { + "easting": point_utm_wkt.x, + "northing": point_utm_wkt.y, + "utm_zone": zone_label, + "horizontal_datum": "WGS84", + } return data_dict diff --git a/tests/features/steps/well-core-information.py b/tests/features/steps/well-core-information.py index cdd2cf340..b4e4e9ffd 100644 --- a/tests/features/steps/well-core-information.py +++ b/tests/features/steps/well-core-information.py @@ -1,5 +1,5 @@ from behave import then -from core.constants import SRID_WGS84, SRID_UTM_ZONE_13N +from core.constants import SRID_WGS84 from geoalchemy2.shape import to_shape from services.util import ( transform_srid, @@ -278,7 +278,7 @@ def step_step_step_6(context): @then( - "the response should include the UTM coordinates with datum NAD83 in the properties" + "the response should include the UTM coordinates with datum WGS84 in the properties" ) def step_step_step_7(context): @@ -288,7 +288,9 @@ def step_step_step_7(context): point_wkb = context.objects["locations"][0].point point_wkt = to_shape(point_wkb) - point_utm_zone_13 = transform_srid(point_wkt, SRID_WGS84, SRID_UTM_ZONE_13N) + # Fixture location is genuinely zone 13N; the read path resolves this via + # WGS84 UTM (EPSG 32613), not the NAD83 code (26913) it used to assume. + point_utm_zone_13 = transform_srid(point_wkt, SRID_WGS84, 32613) assert context.water_well_data["current_location"]["properties"][ "utm_coordinates" @@ -296,7 +298,7 @@ def step_step_step_7(context): "easting": point_utm_zone_13.x, "northing": point_utm_zone_13.y, "utm_zone": "13N", - "horizontal_datum": "NAD83", + "horizontal_datum": "WGS84", } diff --git a/tests/features/well-core-information.feature b/tests/features/well-core-information.feature index a1d9598e0..2c98feb89 100644 --- a/tests/features/well-core-information.feature +++ b/tests/features/well-core-information.feature @@ -43,7 +43,7 @@ Feature: Retrieve core well information by well ID And the response should include a geometry object with type "Point" and coordinates array [longitude, latitude, elevation] And the response should include the elevation in feet with vertical datum NAVD88 in the properties And the response should include the elevation method (i.e. interpolated from digital elevation model) in the properties - And the response should include the UTM coordinates with datum NAD83 in the properties + And the response should include the UTM coordinates with datum WGS84 in the properties # Alternate Identifiers And the response should include any alternate IDs for the well like the NMBGMR site_name (i.e. John Smith Well), USGS site number, or the OSE well ID and OSE well tag ID diff --git a/tests/test_domain_geospatial.py b/tests/test_domain_geospatial.py index d7fcb234b..d0af0e08b 100644 --- a/tests/test_domain_geospatial.py +++ b/tests/test_domain_geospatial.py @@ -13,12 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -"""Read-side UTM zone lookup. No database, no fixtures.""" +"""Read-side UTM zone/CRS lookup. No database, no fixtures.""" import pytest -from core.constants import SRID_NAD83_UTM_BASE -from domain.geospatial import srid_for_longitude, utm_zone_for_longitude +from domain.geospatial import ( + OutsideUtmDomain, + utm_crs_for_point, + utm_zone_for_longitude, +) @pytest.mark.parametrize( @@ -28,8 +31,8 @@ (-107.949533, 13), (-109.05, 12), # just west of NM's western border (-117.0, 11), # Nevada - (-69.0, 19), # Maine, top of the CONUS range - (-123.0, 10), # Pacific coast, bottom of the CONUS range + (13.4, 33), # Berlin + (139.7, 54), # Tokyo ], ) def test_utm_zone_for_longitude_matches_the_true_zone(longitude, expected_zone): @@ -39,20 +42,49 @@ def test_utm_zone_for_longitude_matches_the_true_zone(longitude, expected_zone): @pytest.mark.parametrize( "longitude, expected_zone", [ - (-170.0, 10), # far west of CONUS -- clamps rather than picking zone 3 - (170.0, 19), # far east of CONUS -- clamps rather than picking zone 51 + (180.0, 1), # antimeridian + (185.0, 1), # past the antimeridian, unnormalized + (-180.0, 1), + (-190.0, 59), # past -180, unnormalized + (179.999, 60), ], ) -def test_utm_zone_for_longitude_clamps_outside_conus(longitude, expected_zone): - # Clamping avoids handing pyproj a 269xx code outside the "NAD83 / UTM - # zone nN" series, where the number is reused for unrelated state-plane - # systems (see domain/geospatial.py). +def test_utm_zone_for_longitude_normalizes_out_of_range_input(longitude, expected_zone): + # int((lon + 180) // 6) + 1 alone returns 61/62/-1/-2 for these -- clamping + # used to hide it. Normalizing onto [-180, 180) first fixes it at the root. assert utm_zone_for_longitude(longitude) == expected_zone -def test_srid_for_longitude_derives_from_the_zone(): - assert srid_for_longitude(-105.0) == SRID_NAD83_UTM_BASE + 13 - assert srid_for_longitude(-117.0) == SRID_NAD83_UTM_BASE + 11 +def test_utm_crs_for_point_resolves_northern_hemisphere(): + srid, zone_label = utm_crs_for_point(-105.0, 35.0) + assert (srid, zone_label) == (32613, "13N") + + +def test_utm_crs_for_point_resolves_southern_hemisphere(): + # Buenos Aires-ish. Hemisphere can only come from latitude. + srid, zone_label = utm_crs_for_point(-58.4, -34.6) + assert (srid, zone_label) == (32721, "21S") + + +def test_utm_crs_for_point_resolves_southern_hemisphere_pacific(): + # Sydney-ish. + srid, zone_label = utm_crs_for_point(151.2, -33.9) + assert (srid, zone_label) == (32756, "56S") + + +@pytest.mark.parametrize("latitude", [84.0, -80.0]) +def test_utm_crs_for_point_accepts_the_domain_edges(latitude): + utm_crs_for_point(-105.0, latitude) # must not raise + + +@pytest.mark.parametrize("latitude", [84.1, -80.1, 90.0, -90.0]) +def test_utm_crs_for_point_rejects_outside_the_latitude_domain(latitude): + with pytest.raises(OutsideUtmDomain, match="outside the UTM domain"): + utm_crs_for_point(-105.0, latitude) + + +def test_outside_utm_domain_is_a_value_error(): + assert issubclass(OutsideUtmDomain, ValueError) # ============= EOF ============================================= diff --git a/tests/test_location.py b/tests/test_location.py index c1467123d..e9adcca8b 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -325,17 +325,10 @@ def test_ampapi_fields_independent_of_created_at(): cleanup_post_test(Location, data["id"]) -def test_geojson_response_uses_the_points_actual_utm_zone(): - """A well outside 12N/13N must not read back with a hardcoded 13N label.""" - from shapely import Point - - from core.constants import SRID_WGS84 +def _geojson_response_for_point(lon: float, lat: float) -> tuple: + """Build a real Location row at (lon, lat) and return its GeoJSON response.""" from db.engine import session_ctx from schemas.location import LocationGeoJSONResponse - from services.util import transform_srid - - lon, lat = -117.0, 36.144718 # zone 11N (Nevada), outside 12N/13N - expected = transform_srid(Point(lon, lat), SRID_WGS84, 26911) with session_ctx() as session: loc = Location(point=f"POINT({lon} {lat})", elevation=0, release_status="draft") @@ -346,12 +339,52 @@ def test_geojson_response_uses_the_points_actual_utm_zone(): response = LocationGeoJSONResponse.model_validate(loc) + return response, location_id + + +def test_geojson_response_uses_the_points_actual_utm_zone(): + """A well outside 12N/13N must not read back with a hardcoded 13N label.""" + from shapely import Point + + from core.constants import SRID_WGS84 + from services.util import transform_srid + + lon, lat = -117.0, 36.144718 # zone 11N (Nevada), outside 12N/13N + expected = transform_srid(Point(lon, lat), SRID_WGS84, 32611) + + response, location_id = _geojson_response_for_point(lon, lat) try: utm = response.properties.utm_coordinates assert utm.utm_zone == "11N" assert utm.easting == pytest.approx(expected.x) assert utm.northing == pytest.approx(expected.y) - assert utm.horizontal_datum == "NAD83" + assert utm.horizontal_datum == "WGS84" + finally: + cleanup_post_test(Location, location_id) + + +def test_geojson_response_resolves_southern_hemisphere_zone(): + """A point south of the equator must get an 'S' zone, not a fake northern one.""" + lon, lat = -58.4, -34.6 # Buenos Aires-ish + + response, location_id = _geojson_response_for_point(lon, lat) + try: + utm = response.properties.utm_coordinates + assert utm.utm_zone == "21S" + assert utm.horizontal_datum == "WGS84" + finally: + cleanup_post_test(Location, location_id) + + +def test_geojson_response_omits_utm_coordinates_beyond_the_utm_domain(): + """A point past the UTM latitude domain has no valid zone to report.""" + lon, lat = 0.0, -90.0 # South Pole + + response, location_id = _geojson_response_for_point(lon, lat) + try: + assert response.properties.utm_coordinates is None + # The geometry member still carries WGS84 lon/lat regardless. + assert response.geometry.coordinates[:2] == [lon, lat] finally: cleanup_post_test(Location, location_id)