diff --git a/api/geothermal.py b/api/geothermal.py index 7d6f96395..0e79368d9 100644 --- a/api/geothermal.py +++ b/api/geothermal.py @@ -13,126 +13,84 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +"""Geothermal well endpoints. + +TEMPORARY BACKING: routes read from the legacy NM_Wells staging mirror +(``db/nmw_legacy.py``) via ``services/geothermal_helper.py``. Once the +NM_Wells -> Ocotillo transform lands these will be backed by the ``thing`` +table and ``thing_id`` will be populated on the response. The route path lives +under ``/thing`` so the URL is stable across that swap. +""" + +from typing import Optional +from uuid import UUID + from fastapi import APIRouter +from fastapi_pagination.ext.sqlalchemy import paginate +from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND -# -# from db.geothermal import ( -# GeothermalTemperatureProfile, -# GeothermalTemperatureProfileObservation, -# GeothermalBottomHoleTemperature, -# GeothermalWellInterval, -# GeothermalHeatFlow, -# GeothermalThermalConductivity, -# GeothermalSampleSet, -# GeothermalBottomHoleTemperatureHeader, -# ) +from api.pagination import CustomPage +from core.dependencies import session_dependency, viewer_dependency +from schemas.geothermal import GeothermalWellResponse +from services.exceptions_helper import PydanticStyleException +from services.geothermal_helper import ( + geothermal_wells_transformer, + get_geothermal_well_by_id, + get_geothermal_wells_query, +) -router = APIRouter(prefix="/geothermal", tags=["geothermal"]) +router = APIRouter(prefix="/thing", tags=["geothermal"]) -# @router.post("/sample_set", status_code=status.HTTP_201_CREATED) -# async def add_geothermal_sample_set( -# sample_set_data: CreateGeothermalSampleSet, # Replace with appropriate schema -# session: session_dependency -# ): -# """ -# Add a new geothermal sample set. -# """ -# # Assuming you have a model for GeothermalSampleSet -# return adder(session, GeothermalSampleSet, sample_set_data) -# -# -# @router.post("/bottom_hole_temperature_header", status_code=status.HTTP_201_CREATED) -# async def add_bottom_hole_temperature_header( -# bottom_hole_temperature_header_data: CreateBottomHoleTemperatureHeader, -# session: session_dependency -# ): -# """ -# Add a new bottom hole temperature header. -# """ -# # Assuming you have a model for GeothermalBottomHoleTemperatureHeader -# return adder( -# session, -# GeothermalBottomHoleTemperatureHeader, -# bottom_hole_temperature_header_data, -# ) -# -# -# @router.post("/temperature_profile", status_code=status.HTTP_201_CREATED) -# async def add_temperature_profile( -# temperature_profile_data: CreateTemperatureProfile, -# session: session_dependency -# ): -# """ -# Add a new temperature profile. -# """ -# return adder(session, GeothermalTemperatureProfile, temperature_profile_data) -# -# -# @router.post("/temperature_profile_observation", status_code=status.HTTP_201_CREATED) -# async def add_temperature_profile_observation( -# temperature_profile_observation_data: CreateTemperatureProfileObservation, -# session: session_dependency -# ): -# """ -# Add a new temperature profile observation. -# """ -# return adder( -# session, -# GeothermalTemperatureProfileObservation, -# temperature_profile_observation_data, -# ) -# -# -# @router.post("/bottom_hole_temperature", status_code=status.HTTP_201_CREATED) -# async def add_bottom_hole_temperature( -# bottom_hole_temperature_data: CreateBottomHoleTemperature, -# session: session_dependency -# ): -# """ -# Add a new bottom hole temperature. -# """ -# return adder( -# session, -# GeothermalBottomHoleTemperature, # Assuming this is the correct model -# bottom_hole_temperature_data, -# ) -# -# -# @router.post("/interval", status_code=status.HTTP_201_CREATED) -# async def add_geothermal_interval( -# interval_data: CreateGeothermalInterval, # Replace with appropriate schema -# session: session_dependency -# ): -# """ -# Add a new geothermal interval. -# """ -# # Assuming you have a model for GeothermalInterval -# return adder(session, GeothermalWellInterval, interval_data) -# -# -# @router.post("/thermal_conductivity", status_code=status.HTTP_201_CREATED) -# async def add_thermal_conductivity( -# thermal_conductivity_data: CreateThermalConductivity, # Replace with appropriate schema -# session: session_dependency -# ): -# """ -# Add a new geothermal thermal conductivity. -# """ -# # Assuming you have a model for GeothermalThermalConductivity -# return adder(session, GeothermalThermalConductivity, thermal_conductivity_data) -# -# -# @router.post("/heat_flow", status_code=status.HTTP_201_CREATED) -# async def add_heat_flow( -# heat_flow_data: CreateHeatFlow, -# session: session_dependency -# ): -# """ -# Add a new geothermal heat flow. -# """ -# # Assuming you have a model for GeothermalHeatFlow -# return adder(session, GeothermalHeatFlow, heat_flow_data) -# +@router.get( + "/geothermal-well", + summary="Get all geothermal wells", + status_code=HTTP_200_OK, +) +def get_geothermal_wells( + user: viewer_dependency, + session: session_dependency, + county: Optional[str] = None, + name_contains: Optional[str] = None, +) -> CustomPage[GeothermalWellResponse]: + """List geothermal wells. + + NOTE: sourced from the legacy NM_Wells mirror (NMW_WellHeaders where + GthrmExist is set). Will be re-pointed at the thing table post-transform. + """ + sql = get_geothermal_wells_query(county=county, name_contains=name_contains) + return paginate(query=sql, conn=session, transformer=geothermal_wells_transformer) + + +@router.get( + "/geothermal-well/{well_data_id}", + summary="Get geothermal well by legacy WellDataID", + status_code=HTTP_200_OK, +) +def get_geothermal_well( + user: viewer_dependency, + well_data_id: UUID, + session: session_dependency, +) -> GeothermalWellResponse: + """Get a single geothermal well by its legacy NMW WellDataID (GUID). + + NOTE: keyed by the legacy GUID because these rows are not yet in the thing + table. Post-transform this becomes an integer thing_id lookup. + """ + well = get_geothermal_well_by_id(session, well_data_id) + if well is None: + raise PydanticStyleException( + status_code=HTTP_404_NOT_FOUND, + detail=[ + { + "loc": ["path", "well_data_id"], + "msg": f"Geothermal well with WellDataID {well_data_id} not found.", + "type": "value_error", + "input": {"well_data_id": str(well_data_id)}, + } + ], + ) + return well + # ============= EOF ============================================= diff --git a/core/initializers.py b/core/initializers.py index 14a246cb4..ee0fecbe2 100644 --- a/core/initializers.py +++ b/core/initializers.py @@ -216,6 +216,7 @@ def register_api_routes(app): from api.ngwmn import router as ngwmn_router from api.feedback import router as feedback_router from api.disclaimer import router as disclaimer_router + from api.geothermal import router as geothermal_router app.include_router(asset_router) app.include_router(author_router) @@ -230,6 +231,9 @@ def register_api_routes(app): app.include_router(sample_router) app.include_router(sensor_router) app.include_router(search_router) + # geothermal shares the /thing prefix; register before thing_router so its + # explicit /thing/geothermal-well routes take precedence over /thing/{id} + app.include_router(geothermal_router) app.include_router(thing_router) app.include_router(ngwmn_router) app.include_router(feedback_router) diff --git a/schemas/geothermal.py b/schemas/geothermal.py index 43b7486e4..1292fa916 100644 --- a/schemas/geothermal.py +++ b/schemas/geothermal.py @@ -13,7 +13,44 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from pydantic import BaseModel +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class GeothermalWellResponse(BaseModel): + """Read model for a geothermal well sourced from the legacy NM_Wells mirror. + + NOTE: This currently reads directly from the ``NMW_WellHeaders`` / + ``NMW_WellLocations`` staging tables (see ``db/nmw_legacy.py``). Once the + NM_Wells -> Ocotillo transform lands, these rows will be backed by the + ``thing`` table and ``thing_id`` will be populated. Until then ``thing_id`` + is always ``None`` and ``well_data_id`` (legacy GUID) is the identifier. + """ + + model_config = ConfigDict(from_attributes=True) + + well_data_id: UUID # legacy NMW_WellHeaders.WellDataID + thing_id: int | None = None # populated after NM_Wells -> thing transform + + api: str | None = None + name: str | None = None # cur_well_nam + well_number: str | None = None # cur_well_num + well_class: str | None = None + well_type: str | None = None + status: str | None = None # cur_status + operator: str | None = None # cur_operatr + owner: str | None = None # cur_owner + total_depth: float | None = None + completion_date: datetime | None = None # compl_date + has_geothermal_data: bool | None = None # gthrm_exist + + # location, joined from NMW_WellLocations on WellDataID + county: str | None = None + state: str | None = None + latitude: float | None = None # lat_dd83 + longitude: float | None = None # long_dd83 class CreateTemperatureProfile(BaseModel): diff --git a/services/geothermal_helper.py b/services/geothermal_helper.py new file mode 100644 index 000000000..5fc10f9fd --- /dev/null +++ b/services/geothermal_helper.py @@ -0,0 +1,99 @@ +# =============================================================================== +# 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 helpers for geothermal wells. + +TEMPORARY SOURCE: these read straight from the legacy NM_Wells staging mirror +(``db/nmw_legacy.py``). A "geothermal well" is a ``NMW_WellHeaders`` row whose +``GthrmExist`` flag is set. Location (lat/long/county/state) is joined from +``NMW_WellLocations`` on ``WellDataID``. + +Once the NM_Wells -> Ocotillo transform exists, swap the source to the ``thing`` +table and populate ``thing_id`` on the response. Keeping the DB access behind +this helper is what makes that swap a one-file change. +""" + +from uuid import UUID + +from sqlalchemy import select + +from db.nmw_legacy import NMW_WellHeaders, NMW_WellLocations +from schemas.geothermal import GeothermalWellResponse + + +def _base_query(): + """Header rows flagged geothermal, left-joined to their location.""" + return ( + select(NMW_WellHeaders, NMW_WellLocations) + .outerjoin( + NMW_WellLocations, + NMW_WellHeaders.well_data_id == NMW_WellLocations.well_data_id, + ) + .where(NMW_WellHeaders.gthrm_exist == 1) + ) + + +def _to_response(header: NMW_WellHeaders, location: NMW_WellLocations | None): + return GeothermalWellResponse( + well_data_id=header.well_data_id, + thing_id=None, # not yet linked; see NM_Wells -> thing transform + api=header.api, + name=header.cur_well_nam, + well_number=header.cur_well_num, + well_class=header.well_class, + well_type=header.well_type, + status=header.cur_status, + operator=header.cur_operatr, + owner=header.cur_owner, + total_depth=header.total_depth, + completion_date=header.compl_date, + has_geothermal_data=bool(header.gthrm_exist), + county=location.county if location else None, + state=location.state if location else None, + latitude=location.lat_dd83 if location else None, + longitude=location.long_dd83 if location else None, + ) + + +def get_geothermal_wells_query( + county: str | None = None, + name_contains: str | None = None, +): + """Build the list query; returned as a SQLAlchemy select for pagination.""" + sql = _base_query() + if county: + sql = sql.where(NMW_WellLocations.county == county) + if name_contains: + sql = sql.where(NMW_WellHeaders.cur_well_nam.ilike(f"%{name_contains}%")) + return sql.order_by(NMW_WellHeaders.cur_well_nam) + + +def geothermal_wells_transformer(rows) -> list[dict]: + """Map (header, location) Rows -> GeothermalWellResponse dicts.""" + return [_to_response(header, location).model_dump() for header, location in rows] + + +def get_geothermal_well_by_id(session, well_data_id: UUID): + """Return a single geothermal well by legacy WellDataID, or None.""" + row = session.execute( + _base_query().where(NMW_WellHeaders.well_data_id == well_data_id) + ).first() + if row is None: + return None + header, location = row + return _to_response(header, location) + + +# ============= EOF ============================================= diff --git a/transfers/seed_geothermal.py b/transfers/seed_geothermal.py new file mode 100644 index 000000000..a6bdddd5f --- /dev/null +++ b/transfers/seed_geothermal.py @@ -0,0 +1,234 @@ +"""Populate the legacy NM_Wells staging mirror with fake geothermal data. + +Seeds the geothermal chain so the /thing/geothermal-well endpoint and the OGC +geothermal views (BHT, temperature-depth, heat-flow) all return data: + + NMW_WellHeaders (GthrmExist=1) + -> NMW_WellLocations (lat/long/county/state) + -> NMW_WellRecords + -> NMW_WellSamples + -> NMW_GtBhtHeaders -> NMW_GtBhtData (bottom-hole temps) + -> NMW_GtTempDepths (temp-vs-depth profile) + -> NMW_GtSumHeatFlow (summary heat flow) + +TEMPORARY: this seeds the staging mirror, not the Ocotillo `thing` table. Once +the NM_Wells -> Ocotillo transform exists, seed `thing` instead (see seed.py). + +Run with: + docker compose exec -T app python -m transfers.seed_geothermal +""" + +import random +import uuid + +from faker import Faker +from sqlalchemy import select + +from db.engine import session_ctx +from db.nmw_legacy import ( + NMW_GtBhtData, + NMW_GtBhtHeaders, + NMW_GtSumHeatFlow, + NMW_GtTempDepths, + NMW_WellHeaders, + NMW_WellLocations, + NMW_WellRecords, + NMW_WellSamples, +) + +fake = Faker() +Faker.seed(42) +random.seed(42) + +# Integer PKs on heap tables (OBJECTID). Base high enough to never collide with +# real dump rows loaded by transfers.nmw_mirror_transfer. +_OID_BASE = 9_000_000 + +# Rough NM bounding-box anchors (lat, lon), mirrors transfers/seed.py. +NEW_MEXICO_BOUNDS = [ + (36.9, -106.6), # Taos + (35.1, -106.6), # Albuquerque + (32.3, -106.8), # Las Cruces + (34.4, -103.2), # Clovis + (36.7, -108.2), # Farmington +] +COUNTIES = ["Bernalillo", "Santa Fe", "Doña Ana", "Sandoval", "Grant", "Otero"] + + +def geothermal_data_exists() -> bool: + with session_ctx() as s: + return ( + s.scalar( + select(NMW_WellHeaders.well_data_id) + .where(NMW_WellHeaders.gthrm_exist == 1) + .limit(1) + ) + is not None + ) + + +def seed_geothermal(n: int = 8, skip_if_exists: bool = True): + """Seed ~`n` geothermal wells and their child measurements.""" + if skip_if_exists and geothermal_data_exists(): + print("Geothermal data exists; skipping seeding.") + return + + oid = _OID_BASE + + with session_ctx() as s: + for i in range(n): + well_data_id = uuid.uuid4() + base_lat, base_lon = random.choice(NEW_MEXICO_BOUNDS) + lat = round(base_lat + random.uniform(-0.3, 0.3), 6) + lon = round(base_lon + random.uniform(-0.3, 0.3), 6) + total_depth = round(random.uniform(800, 12000), 1) + + s.add( + NMW_WellHeaders( + well_data_id=well_data_id, + api=fake.numerify("30-###-#####"), + well_class="Oil & Gas", + well_type=random.choice(["Exploration", "Production", "Wildcat"]), + well_orient="Vertical", + cur_well_nam=f"GEOTHERMAL-{i + 1:04d}", + cur_well_num=str(random.randint(1, 30)), + cur_status=random.choice(["Active", "Plugged", "Abandoned"]), + cur_operatr=fake.company(), + cur_owner=fake.company(), + total_depth=total_depth, + compl_date=fake.date_time_between("-40y", "-1y"), + gthrm_exist=1, # flags this as a geothermal well + comments="Seeded geothermal well (fake data).", + ) + ) + # The mirror columns are plain (no ORM ForeignKey), so SQLAlchemy + # cannot dependency-order inserts. Flush each parent tier before its + # children so the DB-level FK constraints (V10) are satisfied. + s.flush() + + oid += 1 + s.add( + NMW_WellLocations( + object_id=oid, + well_data_id=well_data_id, + state="NM", + county=random.choice(COUNTIES), + lat_dd83=lat, + long_dd83=lon, + comments="Seeded location (fake data).", + ) + ) + + # records -> samples chain + recrd_set_id = uuid.uuid4() + oid += 1 + s.add( + NMW_WellRecords( + object_id=oid, + recrd_set_id=recrd_set_id, + well_data_id=well_data_id, + recrd_class="Geothermal", + action_date=fake.date_time_between("-40y", "-1y"), + well_name=f"GEOTHERMAL-{i + 1:04d}", + comments="Seeded record (fake data).", + ) + ) + s.flush() + + sampl_set_id = uuid.uuid4() + oid += 1 + s.add( + NMW_WellSamples( + object_id=oid, + sampl_set_id=sampl_set_id, + recrdset_id=recrd_set_id, + smp_set_name=f"GT-SAMPLE-{i + 1:04d}", + sampl_class="data", + geothermal=1, + sample_date=fake.date_time_between("-40y", "-1y"), + from_depth=0.0, + to_depth=total_depth, + smp_dp_unt="ft", + notes="Seeded sample set (fake data).", + ) + ) + s.flush() + + # bottom-hole temperature header + readings + bht_guid = uuid.uuid4() + s.add( + NMW_GtBhtHeaders( + bht_guid=bht_guid, + sampl_set_id=sampl_set_id, + bore_dia=round(random.uniform(6, 12), 2), + bore_units="in", + drill_fluid="mud", + temp_unit="F", + notes="Seeded BHT header (fake data).", + ) + ) + s.flush() + for _ in range(random.randint(1, 3)): + oid += 1 + depth = round(random.uniform(500, total_depth), 1) + s.add( + NMW_GtBhtData( + object_id=oid, + bht_guid=bht_guid, + depth=depth, + bht=round(70 + depth * 0.015 + random.uniform(-5, 5), 1), + temp_unit="F", + hrs_snce_cir=round(random.uniform(1, 24), 1), + date_measrd=fake.date_time_between("-40y", "-1y"), + ) + ) + + # temperature-vs-depth profile + for step in range(1, random.randint(3, 6)): + oid += 1 + depth = round(total_depth * step / 6, 1) + s.add( + NMW_GtTempDepths( + object_id=oid, + sampl_set_id=sampl_set_id, + depth=depth, + temp=round(70 + depth * 0.016 + random.uniform(-3, 3), 1), + temp_unit="F", + intrvl_grad=round(random.uniform(15, 40), 2), + ) + ) + + # summary heat flow + oid += 1 + s.add( + NMW_GtSumHeatFlow( + object_id=oid, + recrd_set_id=recrd_set_id, + sampl_set_id=sampl_set_id, + from_depth=0.0, + to_depth=total_depth, + depth_unit="ft", + therml_grad=round(random.uniform(20, 45), 2), + grad_unit="C/k", # GradUnit is varchar(3) + therml_cond=round(random.uniform(1.5, 3.5), 2), + tcond_unit="W/m", # TCondUnit is varchar(3) + heat_flow=round(random.uniform(40, 120), 1), + ht_flow_unit="HFU", # HtFlowUnit is varchar(3) + quality="B", + comments="Seeded heat flow (fake data).", + ) + ) + + try: + s.commit() + print(f"Geothermal seed complete: {n} wells + child measurements.") + except Exception as e: + s.rollback() + print(f"Error committing geothermal seed data: {e}") + raise + + print("Geothermal seeding finished.") + + +if __name__ == "__main__": + seed_geothermal(8, skip_if_exists=True)