Skip to content

Commit ee787d1

Browse files
committed
feat(transfer): Add aquifer system and geologic formation associations to well transfer
- Implement _extract_aquifer_type_codes() to parse compound codes (e.g., "FC" -> Fractured + Confined) - Add get_or_create_aquifer() helper to manage unique aquifer system records - Add get_or_create_formation() helper to manage geologic formation records - Integrate aquifer association logic in `transfer_wells()` to create ThingAquiferAssociation and AquiferType records - Integrate formation association logic to create ThingGeologicFormationAssociation records with depth data - Support lexicon mapping for both AqClass (aquifer name) and AquiferType (characteristics) fields - Add comprehensive error handling and logging for aquifer/formation associations This enables proper tracking of wells' aquifer systems with multiple type characteristics and their associated geologic formations, preserving all source data from NM_Aquifer.
1 parent dc9b1ef commit ee787d1

1 file changed

Lines changed: 231 additions & 0 deletions

File tree

transfers/well_transfer.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
StatusHistory,
3737
MonitoringFrequencyHistory,
3838
MeasuringPointHistory,
39+
AquiferSystem,
40+
AquiferType,
41+
GeologicFormation,
42+
ThingAquiferAssociation,
43+
ThingGeologicFormationAssociation,
3944
)
4045
from schemas.thing import CreateWell, CreateWellScreen
4146
from services.gcs_helper import get_storage_bucket
@@ -117,6 +122,107 @@ def _extract_casing_materials(row) -> list[str]:
117122
return materials
118123

119124

125+
# Parse aquifer codes
126+
def _extract_aquifer_type_codes(aquifer_code: str) -> list[str]:
127+
"""
128+
Parse aquifer type codes that may contain multiple values.
129+
130+
Args:
131+
aquifer_code: Raw code from AquiferType field
132+
133+
Returns:
134+
List of individual codes
135+
"""
136+
if not aquifer_code:
137+
return []
138+
# clean the code
139+
code = aquifer_code.strip().upper()
140+
# split into individual characters. This handles cases like "FC" -> ["F", "C"]
141+
individual_codes = list(code)
142+
return individual_codes
143+
144+
145+
# Get or create aquifer system
146+
def get_or_create_aquifer_system(
147+
session: Session, aquifer_name: str, primary_type: str
148+
) -> AquiferSystem | None:
149+
"""
150+
Get existing aquifer or create new one if it doesn't exist.
151+
152+
With the new AquiferType model, we create ONE aquifer record per named
153+
aquifer (e.g., one "Santa Fe Group"), not multiple variants.
154+
155+
Args:
156+
session: Database session
157+
aquifer_name: Name of the aquifer (from AqClass or type name)
158+
primary_type: Primary aquifer type for the aquifer_type field
159+
"""
160+
# Try to find existing aquifer by name
161+
aquifer = (
162+
session.query(AquiferSystem).filter(AquiferSystem.name == aquifer_name).first()
163+
)
164+
165+
if aquifer:
166+
return aquifer
167+
168+
# Create new aquifer
169+
try:
170+
logger.info(
171+
f"Creating new aquifer system: {aquifer_name} (primary type: {primary_type})"
172+
)
173+
174+
aquifer = AquiferSystem(
175+
name=aquifer_name,
176+
aquifer_type=primary_type, # Primary type
177+
geographic_scale=None, # Default
178+
)
179+
session.add(aquifer)
180+
session.flush() # Get the ID
181+
return aquifer
182+
except Exception as e:
183+
logger.critical(f"Error creating aquifer {aquifer_name}: {e}")
184+
return None
185+
186+
187+
def get_or_create_geologic_formation(
188+
session: Session, formation_code: str
189+
) -> GeologicFormation | None:
190+
"""
191+
Get existing geologic formation or create new one if it doesn't exist.
192+
193+
Args:
194+
session: Database session
195+
formation_code: The formation code from FormationZone field
196+
197+
Returns:
198+
GeologicFormation object or None if creation fails
199+
"""
200+
# Try to find existing formation
201+
formation = (
202+
session.query(GeologicFormation)
203+
.filter(GeologicFormation.formation_code == formation_code)
204+
.first()
205+
)
206+
207+
if formation:
208+
return formation
209+
210+
# If not found, create new formation
211+
try:
212+
logger.info(f"Creating new geologic formation: {formation_code}")
213+
formation = GeologicFormation(
214+
formation_code=formation_code,
215+
description=None,
216+
lithology=None,
217+
)
218+
session.add(formation)
219+
session.flush()
220+
return formation
221+
except Exception as e:
222+
logger.critical(f"Error creating formation {formation_code}: {e}")
223+
return None
224+
225+
120226
def get_wells_to_transfer(
121227
sess: Session, flags: dict = None
122228
) -> tuple[pd.DataFrame, pd.DataFrame]:
@@ -330,6 +436,131 @@ def transfer_wells(session: Session, flags: dict = None, limit: int = 0) -> None
330436
assoc.thing = well
331437
session.add(assoc)
332438

439+
# --- Create Aquifer Association with AquiferType records ---
440+
if hasattr(row, "AquiferType") and not isna(row.AquiferType):
441+
try:
442+
# Parse codes (handles multi-character codes like "FC")
443+
aquifer_codes = _extract_aquifer_type_codes(row.AquiferType)
444+
445+
if not aquifer_codes:
446+
logger.warning(
447+
f"Well {row.PointID}: Empty aquifer codes after parsing '{row.AquiferType}'"
448+
)
449+
else:
450+
# Map AqClass code to aquifer name using lexicon mapper
451+
if hasattr(row, "AqClass") and not isna(row.AqClass):
452+
try:
453+
aquifer_name = lexicon_mapper.map_value(
454+
f"LU_AquiferClass:{row.AqClass}"
455+
)
456+
except KeyError:
457+
logger.warning(
458+
f"Unknown AqClass code '{row.AqClass}' for well {row.PointID}, using first type as name"
459+
)
460+
aquifer_name = lexicon_mapper.map_value(
461+
f"LU_AquiferType:{aquifer_codes[0]}"
462+
)
463+
else:
464+
# No AqClass - use first code's mapped name as aquifer name
465+
aquifer_name = lexicon_mapper.map_value(
466+
f"LU_AquiferType:{aquifer_codes[0]}"
467+
)
468+
469+
# Determine primary type
470+
try:
471+
primary_type = lexicon_mapper.map_value(
472+
f"LU_AquiferType:{aquifer_codes[0]}"
473+
)
474+
except KeyError:
475+
logger.warning(
476+
f"Unknown aquifer type code '{aquifer_codes[0]}' for well {row.PointID}"
477+
)
478+
primary_type = None
479+
480+
if primary_type:
481+
# Get or create the aquifer
482+
aquifer = get_or_create_aquifer_system(
483+
session, aquifer_name, primary_type
484+
)
485+
486+
if aquifer:
487+
# Check if association already exists
488+
existing_assoc = (
489+
session.query(ThingAquiferAssociation)
490+
.filter(
491+
ThingAquiferAssociation.thing_id == well.id,
492+
ThingAquiferAssociation.aquifer_system_id
493+
== aquifer.id,
494+
)
495+
.first()
496+
)
497+
498+
if not existing_assoc:
499+
# Create the association
500+
aquifer_assoc = ThingAquiferAssociation(
501+
thing=well, aquifer_system=aquifer
502+
)
503+
session.add(aquifer_assoc)
504+
session.flush()
505+
506+
# Create AquiferType records for EACH characteristic
507+
aquifer_type_names = []
508+
for aquifer_code in aquifer_codes:
509+
try:
510+
type_name = lexicon_mapper.map_value(
511+
f"LU_AquiferType:{aquifer_code}"
512+
)
513+
aquifer_type = AquiferType(
514+
thing_aquifer_association=aquifer_assoc,
515+
aquifer_type=type_name,
516+
)
517+
session.add(aquifer_type)
518+
aquifer_type_names.append(type_name)
519+
except KeyError:
520+
logger.warning(
521+
f"Unknown aquifer code '{aquifer_code}' from AquiferType='{row.AquiferType}' "
522+
f"for well {well.name}. Skipping this code."
523+
)
524+
525+
logger.info(
526+
f"Associated well {well.name} with aquifer {aquifer.name} "
527+
f"(types: {', '.join(aquifer_type_names)})"
528+
)
529+
530+
except Exception as e:
531+
logger.critical(
532+
f"Error creating aquifer associations for {well.name}: {e}"
533+
)
534+
535+
# --- Create Formation Association (if FormationZone exists) ---
536+
if hasattr(row, "FormationZone") and not isna(row.FormationZone):
537+
try:
538+
formation_code = row.FormationZone
539+
formation = get_or_create_geologic_formation(session, formation_code)
540+
541+
if formation:
542+
top_depth = 0.0
543+
bottom_depth = (
544+
row.WellDepth
545+
if row.WellDepth and not isna(row.WellDepth)
546+
else 100.0
547+
)
548+
549+
formation_assoc = ThingGeologicFormationAssociation(
550+
thing=well,
551+
geologic_formation=formation,
552+
top_depth=top_depth,
553+
bottom_depth=bottom_depth,
554+
)
555+
session.add(formation_assoc)
556+
logger.info(
557+
f"Associated well {well.name} with formation {formation.formation_code} (0-{bottom_depth} ft)"
558+
)
559+
except Exception as e:
560+
logger.critical(
561+
f"Error creating formation association for {well.name}: {e}"
562+
)
563+
333564
session.commit()
334565

335566
# add things thate need well id

0 commit comments

Comments
 (0)