-
Notifications
You must be signed in to change notification settings - Fork 5
feat(well-inventory): allow wells outside New Mexico (BDMS-1109) #822
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4a96814
0fb0553
89a1ca7
8d4baf5
cfcfa70
4d3f23f
7d90980
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggest Separate from all of the above, the zone math has an edge case the clamp is currently hiding: |
||
| """ | ||
| 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 ============================================= | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<prefix>[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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The one thing I'd still keep from the original comment is much smaller: the rejection message.
|
||
|
|
||
| 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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The observation about That last return value isn't a latitude. Not reachable through this importer — the CONUS coordinate range gates it well before that — so this is informational rather than a request. Just worth knowing that the bounding box, not the library, is doing the real work, in case the box is ever loosened. |
||
| 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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Small doc accuracy point: the comment says "EPSG 269xx == NAD83 / UTM zone xxN," which holds for 26901–26923 but not past it. 26924–26928 don't exist, and 26929+ are NAD83 state plane (26929 = Alabama East). Worth stating the 1–23 bound here since this constant is what makes the arithmetic possible, and the bound is the reason the range check downstream isn't optional.