Skip to content

feat(well-inventory): allow wells outside New Mexico (BDMS-1109) - #822

Merged
ksmuczynski merged 7 commits into
stagingfrom
kas-fix-nm-state-location-restriction-bdms-1109
Aug 28, 2026
Merged

feat(well-inventory): allow wells outside New Mexico (BDMS-1109)#822
ksmuczynski merged 7 commits into
stagingfrom
kas-fix-nm-state-location-restriction-bdms-1109

Conversation

@ksmuczynski

@ksmuczynski ksmuczynski commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Drops the New Mexico-only restriction on well inventory CSV imports and expands supported UTM zones from just 12N/13N to the full continental US range (10N-19N, NAD83). Along the way I also fixed two bugs I ran into: a lowercase zone value like "13n" was slipping past schema validation but blowing up later at persist time, and the location GeoJSON endpoint was hardcoding UTM zone "13N" no matter where a well actually was. Review on this PR caught that my first pass at that second fix was itself wrong in a bigger way, so the GeoJSON read path now resolves any point on Earth correctly instead of just NM.

Why

This PR addresses the following problem / context:
schemas/well_inventory.py's validate_model had two hardcoded, coupled checks: an allowlist of exactly {"12N", "13N"}, and a lat/lon bounding box matching New Mexico's borders. Anything outside either one got rejected at validation. NMBGMR needs to bring in wells from neighboring states now, and this check was really just an input gate. Nothing downstream actually cares whether the well is in NM: transform_srid is just generic pyproj, Location.county/.state are nullable free text pulled from Census TIGERWeb rather than an NM-specific lookup, and there's no CheckConstraint on the geometry column enforcing this either. domain/wells.py even has a # TODO: this needs to be more sophisticated in the future. Likely more than 13N and 12N will be used sitting in it, which is basically calling out the exact limitation this PR removes.

I also found a pre-existing bug in the same function: validate_model upper-cases utm_zone into a local variable but never actually writes it back to self.utm_zone. So a row with "13n" would pass schema validation fine, then fail case-sensitively down in services/well_inventory_csv.py at persist time with a not-very-helpful "Unsupported UTM zone: 13n" error. That's unrelated to the NM restriction, so I split it into its own commit rather than bundling it with the feature change.

A second bug turned up during review as well: schemas/location.py's GeoJSONUTMCoordinates hardcodes utm_zone = "13N" and always reprojects the stored point to EPSG 26913, regardless of where the point actually is. This was already wrong for 12N wells out in western NM, but it was a quiet bug since it never really surfaced. It stops being quiet the moment this PR starts allowing wells outside 12N/13N. Those wells would import fine and then read back with a confidently wrong easting/northing labeled "13N."

My first fix for that, adding a CONUS clamp so any longitude maps to a zone between 10N-19N, turned out to be a worse version of the same bug, and review on this PR caught it: 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. The actual scope issue is that domain/geospatial.py backs the GeoJSON read path for every stored Location, and the product intends those to live anywhere on Earth. domain/wells.py's CONUS-only, NAD83-based zone range is correct as-is for AMP water-well ingestion specifically; it was never meant to bound the read path too. Two follow-up commits fix that (see Changes).

Worldwide import support is still out of scope on purpose: UTM isn't one global grid, so accepting every zone on ingestion would mean picking a datum per hemisphere instead of just reusing NAD83, and there's no real ingestion need for that outside North America right now. The read path is a different story, covered below.

Changes

Implementation summary - the following was changed / added / removed:

  • Casing bug fix. Added a field_validator that strips and upper-cases utm_zone once, so every downstream consumer sees the canonical value the schema already validated instead of persisting something different from what it checked.
  • Widened zone support in the domain layer. domain/wells.py's fixed {"13N": 26913, "12N": 26912} dict is gone. utm_zone_number() replaces it with a regex parser accepting any northern-hemisphere zone from 10N-19N, and srid_for_utm_zone() derives the EPSG code as SRID_NAD83_UTM_BASE + zone, since NAD83 covers the whole range on one datum.
  • Dropped the NM restriction. validate_model no longer checks against NM's lat/lon box or the old two-zone allowlist. It now calls domain.wells.utm_zone_number and checks against a generic coordinate sanity range, a plausibility guard against transposed easting/northing or feet-vs-meters entry mistakes. The comment says explicitly that it's not a border check.
  • BDD and fixture updates. Renamed the "not within New Mexico" scenario and its Given step to "outside the expected coordinate range." Swapped the invalid-UTM fixture's coordinate for one genuinely outside the sanity range (the old one decoded to lat 37.58, southern Colorado, which is legal now), and updated the expected error strings to match.
  • Stale comment and doc corrected. The feature file said naive datetimes are assumed Mountain Time "because location is restricted to New Mexico." That justification no longer holds. I reworded it to describe a fixed convention and pointed submitters outside Mountain Time to the already-supported option of an explicit UTC offset.
  • Constants renamed to make ingestion scope explicit. UTM_ZONE_MIN/MAX and COORD_LAT/LON_MIN/MAX are now AMP_UTM_ZONE_MIN/MAX and AMP_COORD_LAT/LON_MIN/MAX, with a comment stating plainly that this is AMP water-well ingestion policy, not a projection limit. I also fixed a comment that overclaimed EPSG 269xx coverage: it's only valid for zones 1-23, and past that the numbers belong to unrelated NAD83 state-plane systems. domain/wells.py's rejection message now names the policy directly instead of just saying "unsupported."
  • Worldwide UTM resolution on the GeoJSON read path. domain/geospatial.py now resolves the true UTM zone and EPSG code for any point with a pyproj CRS lookup (query_utm_crs_info, memoized per zone/hemisphere), instead of computing a NAD83 code and clamping it. It takes (longitude, latitude) since hemisphere can't be derived from longitude alone, and it raises OutsideUtmDomain for the polar latitudes outside -80°/84° that UTM doesn't cover. Support for those is deferred, but now the gap is visible instead of silently wrong. schemas/location.py derives horizontal_datum dynamically ("WGS84", not a hardcoded "NAD83") and makes utm_coordinates optional, omitting it for out-of-domain points instead of inventing a label. Doing that now, while every stored point is CONUS, avoids a breaking API change later.

Verification

  • pytest tests/test_domain_wells.py tests/test_well_inventory.py tests/test_cli_commands.py tests/test_domain_geospatial.py tests/test_location.py: 176 passed, including TestWellInventoryRowUtmValidation (lowercase normalization, CONUS-boundary rejection, generic-range rejection, the utm library's own OutOfRangeError surfacing as a per-row error rather than an aborted run), the rewritten test_domain_geospatial.py (zone/CRS resolution for both hemispheres, the -80/84 latitude gate, the zone-formula edge cases at ±180°), and test_location.py cases proving an 11N well reports correctly, a southern-hemisphere point gets an "S" zone instead of a fake northern one, and a polar point omits utm_coordinates instead of erroring.
  • behave tests/features/well-inventory-csv.feature: 45/45 passing. behave tests/features/well-core-information.feature: same single pre-existing, unrelated error as before this PR (a KeyError in an unrelated Given step, confirmed by reproducing it with these changes stashed out).
  • Full pytest suite: 834 passed, 81 skipped, 6 xpassed, 0 failed (excluding two pre-existing, unrelated collection errors from a missing openpyxl dependency in the local venv).

Notes

Any special considerations, workarounds, or follow-up work to note?

  • I found one related issue and deliberately left it out of scope, since fixing it means touching shared code well beyond this PR's footprint: services/util.py's normalize_datetime_to_utc still assumes timezone-naive CSV timestamps are Mountain Time. The original justification for that ("location is restricted to New Mexico") is gone now, but the conversion logic is shared with the water-level CSV importer too, so I didn't want to change behavior there as a side effect of this PR. Covered for now with the doc and comment update above; the real fix, deriving timezone from the coordinate or requiring an explicit offset, deserves its own ticket.
  • Antarctic and polar support (EPSG 3031/32761 for points below -80°) is deferred. Raising OutsideUtmDomain there instead of silently mislabeling keeps the gap visible until that's implemented. Worth flagging since Ocotillo expects to store Antarctic samples soon.

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.
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.
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.
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.

@jirhiker jirhiker left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amended after clarifying scope with Jake — the original version of this review argued for expanding UTM support worldwide across the board. That was the wrong frame. The actual constraint is a split:

layer scope code
Location storage + GeoJSON read path anywhere on earth schemas/location.py, domain/geospatial.py
AMP water-well CSV ingestion CONUS schemas/well_inventory.py, domain/wells.py

That split resolves most of what I originally flagged, and sharpens the rest. Retracting and strengthening below; the two bug fixes in this PR — normalizing utm_zone casing, and unhardcoding 13N in the GeoJSON response — are still clearly right.

What I'm retracting

These were flagged as limitations. Under the split they're correct as written, and should stay:

  • domain/wells.py's 10N–19N range. This is the AMP ingestion path. CONUS is the intended scope. Keep it.
  • The N-only zone regex. No southern-hemisphere zone should ever come through the well-inventory importer. Keep it.
  • northern = True in validate_model. Permanently correct on this path, not a latent assumption.
  • NAD83 269xx on the import side. Right datum for the right region. The 10N–19N range sits comfortably inside EPSG's 1–23 NAD83 UTM series, so the arithmetic is safe here.
  • COORD_LAT_MIN/MAX, COORD_LON_MIN/MAX. A CONUS-shaped gate on AMP ingestion is exactly what's wanted. See the inline comment for the one change I'd still make (naming and placement, not values).

What this sharpens

The clamp in domain/geospatial.py is now unambiguously a defect rather than a scope decision. That module is on the worldwide side — it serves the GeoJSON read path for every stored Location. Clamping a worldwide input to a CONUS range means a Berlin point reads back as E=4961478.4 N=9352242.0 "19N" NAD83, HTTP 200, no log line. Details in the inline comment.

The real bug generator is that both layers share constants. UTM_ZONE_MIN/UTM_ZONE_MAX live in core/constants.py and are imported by both domain/wells.py (correctly, as an ingestion policy) and domain/geospatial.py (incorrectly, as a projection limit). Two modules sitting adjacent in domain/, both doing UTM zone work, with opposite scopes and a shared constant between them — that coupling is precisely how a CONUS bound ended up applied to the worldwide read path. Worth splitting the constants so the boundary is visible:

# core/constants.py

# AMP water-well ingestion policy: submissions are limited to the
# continental US. Not a projection limit -- see domain/geospatial.py,
# which serves points anywhere on earth.
AMP_UTM_ZONE_MIN = 10
AMP_UTM_ZONE_MAX = 19
AMP_COORD_LAT_MIN, AMP_COORD_LAT_MAX = 18.0, 72.0
AMP_COORD_LON_MIN, AMP_COORD_LON_MAX = -180.0, -66.0

Then domain/geospatial.py imports none of them, and the next person can't reach for the wrong bound by accident.

What the worldwide read path needs

domain/geospatial.py has to handle any point we can store. Concretely:

Use WGS 84 UTM, not NAD83. 32600 + zone north, 32700 + zone south, zones 1–60. NAD83 is a North American datum — a NAD83 UTM lookup at Berlin returns zero matching CRS from pyproj, and the 269xx series has real holes: 26924–26928 don't exist, and 26929 is NAD83 / Alabama East (state plane). The WGS 84 series has no holes across 1–60, which means the clamp deletes outright rather than needing a replacement guard.

Datum choice costs nothing numerically — measured the same easting/northing through 26913 vs. the utm library (WGS84 ellipsoid) at 0.17 mm apart, since pyproj applies a ballpark NAD83↔WGS84 transform with no grid shift. NAD83 stays where it belongs, on the AMP import side.

Take the point, not just the longitude. Hemisphere can't be derived from longitude, and UTM is undefined outside 80°S–84°N.

Would pyproj be more robust? On this side, yes — and more than I originally credited. Not for the arithmetic: (lon + 180) // 6 + 1 is exact and matches EPSG, and the Norway/Svalbard exceptions don't apply (checked — pyproj returns 32631 for lon 5° / lat 60°, same as plain arithmetic, because the 32V/31X widening is an MGRS grid-zone convention, not an EPSG CRS definition). What it buys is:

  1. The EPSG code is looked up, never invented — no possibility of landing on a real-but-unrelated CRS.
  2. Out-of-domain input fails loudly for free. query_utm_crs_info(datum_name="WGS 84", area_of_interest=AreaOfInterest(0, 85, 0, 85)) returns []. On a worldwide read path that receives arbitrary stored points, that's the difference between a clear error and a plausible wrong number.
  3. Hemisphere derived from the point. (-58.4, -34.6)32721; (151.2, -33.9)32756.

~33 ms per uncached query (measured 200 at 6.5 s), so memoize on (zone, northern) — not raw lon/lat, or every point misses.

UTM_LAT_MIN, UTM_LAT_MAX = -80.0, 84.0


class OutsideUtmDomain(ValueError):
    """Point falls outside the latitude band UTM is defined for."""


def utm_zone_for_longitude(longitude: float) -> int:
    """Zone number 1-60, with longitude normalized onto [-180, 180)."""
    return int(((longitude + 180) % 360) // 6) + 1


@lru_cache(maxsize=128)
def _utm_epsg(zone: int, northern: bool) -> int:
    # Resolve against the EPSG database rather than computing a code, so an
    # undefined zone 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."""
    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'}"

Note the zone math also needs % 360 independent of everything else — int((longitude + 180) // 6) + 1 returns 61 at longitude == 180.0, and 62 / −2 for unnormalized input like 185 / -190. The clamp masks all of it today, so removing the clamp surfaces a crash without this.

Antarctica

We have no Antarctic data yet but expect to store Antarctic samples. Under the split this lands squarely on the worldwide read path, and not at all on the AMP importer — those wells are CONUS by policy, so nothing there needs to change.

Two bands:

band status sites
60°S – 80°S UTM valid; southern zones exist (32720, 32758, 32712, 32748) Palmer Station, McMurdo, WAIS Divide, Vostok, Dome C
below 80°S UTM undefined. utm.from_latlon raises OutOfRangeError; pyproj returns no UTM CRS South Pole, Ross Ice Shelf interior, most of the plateau

Below 80°S needs UPS South (EPSG 32761) or, more usefully for polar science, Antarctic Polar Stereographic (EPSG 3031). Fine to defer the implementation — but the read path should raise on those points rather than clamp, so the gap is visible the day the data arrives instead of six months later.

Suggested scope for this PR

Small, in scope, and they turn the eventual polar work into a normal ticket rather than a breaking change:

  1. Replace the clamp with a raise, and rename the shared constants so the CONUS bound can't be reached from the worldwide side.
  2. Add an explicit -80.0 / 84.0 latitude gate in domain/geospatial.py, with a comment naming EPSG 3031/32761 as the future path.
  3. Make utm_coordinates optional in the GeoJSON response. Turning a required response object optional later, after clients depend on it, is a breaking API change. Doing it now — while every stored point is CONUS and the field is populated on every response anyway — is invisible to every consumer.

The full worldwide WGS 84 UTM switch can be its own ticket. Items 1–3 are what keep this PR from making that ticket harder.

Comment thread domain/geospatial.py Outdated
scope -- see domain/wells.py.
"""
zone = int((longitude + 180) // 6) + 1
return max(UTM_ZONE_MIN, min(UTM_ZONE_MAX, zone))

@jirhiker jirhiker Aug 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amended after clarifying scope with Jake. Ocotillo should store locations anywhere on earth; AMP water-well ingestion should stay CONUS-only. This module is on the worldwide side, which makes the clamp a defect rather than a scope decision.

The hardcoded 13N was wrong-by-one-zone within New Mexico. This is wrong-by-a-continent, and equally silent. Ran the PR's read path on points outside 10N–19N:

point GeoJSON utm_coordinates
Berlin (13.4, 52.5) E=4961478.4 N=9352242.0 "19N" NAD83
Tokyo (139.7, 35.7) E=-2128591.4 N=15641211.8 "19N" NAD83
McMurdo (166.7, -77.8) E=-623450.0 N=-10771051.7 "19N" NAD83

Negative eastings, northings past the pole distance, HTTP 200, no log line. Every one of those is a point Ocotillo is supposed to be able to store.

The docstring frames clamping as avoiding "a valid but wrong CRS instead of failing loudly" — but the uncapped version doesn't silently succeed either. CRS.from_epsg(26924) raises crs not found (26924–26928 are simply absent; the state-plane collision only starts at 26929). So the choice was never "clamp or silent garbage" — it was "clamp, which is silent garbage" versus "raise, which is loud."

Two ways out, either fine:

  • Raise an OutsideUtmDomain here for anything outside the supported range. Cheapest, and makes the gap visible.
  • Switch this module to WGS 84 UTM (32600/32700 + zone), which has no holes across zones 1–60 — the clamp then deletes outright and only a latitude guard is needed.

Worth noting the CONUS bound arrives here via UTM_ZONE_MIN/UTM_ZONE_MAX, imported from core/constants.py — the same constants domain/wells.py uses correctly as an AMP ingestion policy. Sharing them across that boundary is what let a CONUS limit land on the worldwide path.

Comment thread domain/geospatial.py
from core.constants import SRID_NAD83_UTM_BASE, UTM_ZONE_MAX, UTM_ZONE_MIN


def utm_zone_for_longitude(longitude: float) -> int:

@jirhiker jirhiker Aug 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amended after clarifying scope with Jake. Ocotillo should store locations anywhere on earth; AMP water-well ingestion should stay CONUS-only. Taking longitude only forecloses two things this path now needs, since it has to serve any stored point:

  • Hemisphere. N vs S can't be derived from longitude, so this can never label a southern-hemisphere point. (The mirror assumption in schemas/well_inventory.py is fine — that path is CONUS by policy. This one isn't.)
  • Latitude bounds. UTM is undefined outside 80°S–84°N, and nothing in the codebase checks that today.

Suggest (longitude, latitude) — the call site in schemas/location.py already has the full point in hand, so it costs one argument and lets the polar branch slot in later without touching callers.

Separate from all of the above, the zone math has an edge case the clamp is currently hiding: int((longitude + 180) // 6) + 1 returns 61 at longitude == 180.0, and unnormalized input (185, -190) yields 62 and −2. So removing the clamp surfaces a crash unless this changes too. int(((longitude + 180) % 360) // 6) + 1 holds 1–60 across the range I tested (−190 … 185).

Comment thread domain/geospatial.py Outdated

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)

@jirhiker jirhiker Aug 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amended after clarifying scope with Jake. Ocotillo should store locations anywhere on earth; AMP water-well ingestion should stay CONUS-only. NAD83 is the wrong datum for this module specifically. It's correct in domain/wells.py — AMP wells are CONUS, and 10N–19N sits comfortably inside EPSG's 1–23 NAD83 UTM series. But this path serves points anywhere on earth, and a NAD83 UTM lookup outside North America returns zero matching CRS from pyproj.

SRID_NAD83_UTM_BASE + zone also computes a code rather than looking one up, which is the root reason the clamp above has to exist: 26924–26928 don't exist and 26929+ are state plane, so unguarded arithmetic can land on a real-but-unrelated CRS.

WGS 84 UTM (32600 + zone north, 32700 + zone south) has no holes across 1–60, so switching this module removes both problems at once. The datum change is numerically free — measured 0.17 mm difference through 26913 vs. the WGS84-ellipsoid utm library, since pyproj applies a ballpark NAD83↔WGS84 transform with no grid shift.

Comment thread core/constants.py Outdated
# 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

@jirhiker jirhiker Aug 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amended after clarifying scope with Jake. Ocotillo should store locations anywhere on earth; AMP water-well ingestion should stay CONUS-only. Retracting my earlier comment here — I originally flagged this range as blocking worldwide support. It isn't. This gates AMP water-well ingestion, which is CONUS by policy, so a CONUS-shaped bound is exactly right. Values stay.

One change I'd still suggest, though: name and place them so they read as ingestion policy rather than as a general-purpose coordinate limit.

# AMP water-well ingestion policy: submissions are limited to the
# continental US. Not a projection limit -- see domain/geospatial.py,
# which serves points anywhere on earth.
AMP_COORD_LAT_MIN, AMP_COORD_LAT_MAX = 18.0, 72.0
AMP_COORD_LON_MIN, AMP_COORD_LON_MAX = -180.0, -66.0

Same argument applies more urgently to UTM_ZONE_MIN/UTM_ZONE_MAX two lines up: those are imported by both domain/wells.py (correct — ingestion policy) and domain/geospatial.py (incorrect — applied as a projection limit on the worldwide read path). Generic names on a shared constant are what made that mistake available. An AMP_ prefix, and domain/geospatial.py importing none of them, closes it off.

Minor accuracy note while here: the existing comment calls this "a coarse sanity range, not a national border." Accurate in spirit, but it is CONUS-shaped — -180.0 … -66.0 excludes the entire eastern hemisphere. Saying so plainly makes it obvious why the read path must not borrow it.

Comment thread core/constants.py
SRID_UTM_ZONE_12N = 26912

# EPSG 269xx == NAD83 / UTM zone xxN. Zones 10N-19N span the continental US.
SRID_NAD83_UTM_BASE = 26900

Copy link
Copy Markdown
Member

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.

Comment thread schemas/location.py Outdated
data_dict["properties"]["utm_coordinates"][
"northing"
] = point_utm_zone_13n_wkt.y
data_dict["properties"]["utm_coordinates"]["easting"] = point_utm_wkt.x

@jirhiker jirhiker Aug 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amended after clarifying scope with Jake. Ocotillo should store locations anywhere on earth; AMP water-well ingestion should stay CONUS-only. This is the right fix for the hardcoded 13N — good catch on it being a quiet bug that stops being quiet under this PR.

One ask while this block is already being touched: make utm_coordinates optional on GeoJSONProperties.

utm_coordinates: GeoJSONUTMCoordinates | None = None

Today it's Field(default_factory=GeoJSONUTMCoordinates) — always present, never null. Since Ocotillo stores points anywhere on earth, this response has to cope with points that have no UTM coordinates at all: anything below 80°S or above 84°N, where UTM is undefined. The right answer for those is to omit the block, not invent a label. Nothing is lost — the geometry member already carries WGS84 lon/lat.

The reason to do it in this PR rather than later is purely API compatibility: changing a required response object to optional after clients depend on it is breaking. Right now every stored point is CONUS and this field is populated on every response, so the change is invisible to every consumer. That window closes as soon as the first non-CONUS location lands.

Related: GeoJSONUTMCoordinates.horizontal_datum is hardcoded "NAD83" (line 92, outside this diff). Correct for everything stored today, but it becomes wrong for any point outside North America, so it should be derived alongside utm_zone whenever this is revisited.

Comment thread tests/test_domain_geospatial.py Outdated
(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):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test pins the clamp as intended behavior, which is what makes the clamp expensive to remove later — the failing test reads as a regression rather than the fix.

If the clamp becomes a raise, this inverts to something like:

@pytest.mark.parametrize("longitude", [-170.0, 170.0])
def test_utm_zone_for_longitude_rejects_outside_conus(longitude):
    with pytest.raises(UnsupportedUtmZone):
        utm_zone_for_longitude(longitude)

Worth adding a 180.0 case either way — the current implementation returns zone 61 for it, which only looks fine because the clamp swallows it.

Comment thread domain/wells.py
"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)

@jirhiker jirhiker Aug 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amended after clarifying scope with Jake. Ocotillo should store locations anywhere on earth; AMP water-well ingestion should stay CONUS-only. Retracting the main point I made here. I'd suggested widening this to [NS] for southern-hemisphere support. That's wrong for this module — domain/wells.py serves AMP water-well ingestion, which is CONUS by policy, so rejecting an S suffix outright is correct and should stay. Same for the 10N–19N bound below.

The one thing I'd still keep from the original comment is much smaller: the rejection message. Unsupported UTM zone: 13S gives the submitter no signal about whether the problem is the 13 or the S. Since the range is now a deliberate policy rather than an implementation limit, it's worth saying so — something like "southern-hemisphere zones are not accepted; AMP well submissions are limited to CONUS zones 10N-19N."

\d{1,2} admitting up to 99 is fine given the range check downstream. Not worth changing.

Comment thread schemas/well_inventory.py
# 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

@jirhiker jirhiker Aug 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amended after clarifying scope with Jake. Ocotillo should store locations anywhere on earth; AMP water-well ingestion should stay CONUS-only. Softening this one. northern = True is not a latent assumption on this path — AMP ingestion is CONUS by policy, so it's permanently correct. The comment explaining it is accurate. Nothing to change.

The observation about test_badly_scaled_coordinates_raise_out_of_range_error still holds, though, as a note on what's actually guaranteeing correctness here. It's true that utm.to_latlon rejects an out-of-domain easting, but the library validates northing range and zone number, never latitude:

utm.to_latlon(500000, 500000, 58, northern=False) -> lat -85.540   no error
utm.to_latlon(500000,      0, 58, northern=False) -> lat -90.018   no error

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.

@ksmuczynski
ksmuczynski marked this pull request as draft August 14, 2026 16:50
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.
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.
@github-actions

Copy link
Copy Markdown
Contributor

Coverage

79.54% total — gate is 75%.

Coverage for the Python files changed in this PR
Name Stmts Miss Cover Missing
core/constants.py 9 0 100%
domain/geospatial.py 22 1 95% 74
domain/wells.py 66 0 100%
schemas/location.py 122 4 97% 41, 235-238
schemas/well_inventory.py 264 19 93% 68, 72-75, 86, 93, 96, 103, 109-110, 140, 357, 375, 382, 449, 466, 472, 480
TOTAL 483 24 95%

@ksmuczynski

ksmuczynski commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

@jirhiker I pushed two follow-up commits addressing the review:

  • cfcfa70e refactor(constants): scope UTM/coord bounds to AMP ingestion
  • 4d3f23fa fix(location): use pyproj for worldwide UTM zones
# Comment Resolution
1 domain/geospatial.py:41 — clamp is "wrong-by-a-continent" Fixed: switched to pyproj WGS84 lookup, no more clamp
2 domain/geospatial.py:28 — longitude-only can't derive hemisphere; zone formula breaks at 180.0/185/-190 Fixed: utm_crs_for_point(lon, lat) takes both; zone formula normalized via % 360
3 domain/geospatial.py:46 — NAD83 is the wrong datum here Fixed: module no longer touches NAD83 at all
4 core/constants.py:30 — rename/reframe as AMP policy, note the range is CONUS-shaped Fixed: AMP_COORD_LAT/LON_MIN/MAX, comment states the CONUS shape plainly
5 core/constants.py:22 — EPSG 269xx comment overclaims Fixed: comment now states the real 1-23 bound
6 schemas/location.py:183 — make utm_coordinates optional; fix hardcoded horizontal_datum Fixed: field is Optional, datum derived dynamically as "WGS84"
7 tests/test_domain_geospatial.py:46 — clamp test pins wrong behavior; add a 180.0 case Fixed: rewrote the test file, clamp tests replaced with raise tests, 180.0 covered
8 domain/wells.py:40 — improve rejection message wording Fixed: message now states the AMP CONUS policy explicitly
9 schemas/well_inventory.py:399 — informational only, explicitly "nothing to change" Left as-is, per the review's own note

The table above also covers all three "suggested scope for this PR" items from the review summary: raise instead of clamp plus rename the shared constants, add the -80°/84° latitude gate with a comment naming the future EPSG path, and make utm_coordinates optional.

Full pytest suite: 834 passed, 0 failed. Both directly-affected feature files (well-inventory-csv.feature, well-core-information.feature) came back green under behave.

Antarctic and polar support (EPSG 3031/32761, below -80°) is intentionally deferred. The read path raises there now instead of silently mislabeling, so the gap stays visible until that's implemented.

@ksmuczynski
ksmuczynski requested a review from jirhiker August 25, 2026 03:42
@ksmuczynski
ksmuczynski marked this pull request as ready for review August 25, 2026 14:51
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Coverage

79.36% total — gate is 75%.

Coverage for the Python files changed in this PR
Name Stmts Miss Cover Missing
core/constants.py 9 0 100%
domain/geospatial.py 22 1 95% 74
domain/wells.py 66 0 100%
schemas/location.py 122 4 97% 41, 235-238
schemas/well_inventory.py 264 19 93% 68, 72-75, 86, 93, 96, 103, 109-110, 140, 357, 375, 382, 449, 466, 472, 480
TOTAL 483 24 95%

@ksmuczynski
ksmuczynski merged commit 15ed781 into staging Aug 28, 2026
11 checks passed
@ksmuczynski
ksmuczynski deleted the kas-fix-nm-state-location-restriction-bdms-1109 branch August 28, 2026 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants