diff --git a/core/constants.py b/core/constants.py index 5938d0d6a..d9d0a02e5 100644 --- a/core/constants.py +++ b/core/constants.py @@ -18,6 +18,25 @@ SRID_UTM_ZONE_13N = 26913 SRID_UTM_ZONE_12N = 26912 +# 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 + +# 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 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", "AK", diff --git a/domain/geospatial.py b/domain/geospatial.py new file mode 100644 index 000000000..1097cff98 --- /dev/null +++ b/domain/geospatial.py @@ -0,0 +1,95 @@ +# =============================================================================== +# 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/CRS lookup for a stored point, used on the read path. + +``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 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 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. + """ + return int(((longitude + 180) % 360) // 6) + 1 + + +@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/domain/wells.py b/domain/wells.py index fcabf0ba4..ce98ea5fe 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 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 @@ -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,33 @@ 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 ``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 AMP_UTM_ZONE_MIN <= zone <= AMP_UTM_ZONE_MAX: + return zone + + raise UnsupportedUtmZone( + 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)." + ) + + 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/schemas/location.py b/schemas/location.py index 11139c84a..82b752cef 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 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 @@ -88,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, @@ -105,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 @@ -154,7 +155,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") @@ -180,14 +180,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 - point_utm_zone_13n_wkt = transform_srid( - point_wgs84_wkt, SRID_WGS84, SRID_UTM_ZONE_13N - ) - data_dict["properties"]["utm_coordinates"]["easting"] = point_utm_zone_13n_wkt.x - data_dict["properties"]["utm_coordinates"][ - "northing" - ] = point_utm_zone_13n_wkt.y + # 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/schemas/well_inventory.py b/schemas/well_inventory.py index 2b2dba970..6bb1c7ac9 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 ( + AMP_COORD_LAT_MAX, + AMP_COORD_LAT_MIN, + AMP_COORD_LON_MAX, + AMP_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 @@ -375,22 +382,33 @@ 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 - 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 ( + 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 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-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/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-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/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_domain_geospatial.py b/tests/test_domain_geospatial.py new file mode 100644 index 000000000..d0af0e08b --- /dev/null +++ b/tests/test_domain_geospatial.py @@ -0,0 +1,90 @@ +# =============================================================================== +# 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/CRS lookup. No database, no fixtures.""" + +import pytest + +from domain.geospatial import ( + OutsideUtmDomain, + utm_crs_for_point, + 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 + (13.4, 33), # Berlin + (139.7, 54), # Tokyo + ], +) +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", + [ + (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_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_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_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_location.py b/tests/test_location.py index e849d297b..e9adcca8b 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -325,4 +325,68 @@ def test_ampapi_fields_independent_of_created_at(): cleanup_post_test(Location, data["id"]) +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 + + 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) + + 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 == "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) + + # ============= EOF ============================================= diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index aa16afddb..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) @@ -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 @@ -1366,6 +1383,57 @@ 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" + + 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."""