Source code for climagrid.features.soil

"""
SoilSaturationIndex: ground stability proxy for buried cables and pole foundations.

High soil saturation increases risk of:
- Pole foundation heave and subsidence (distribution poles)
- Underground cable sheath damage from soil movement
- Increased corrosion rate for grounding systems
- Reduced insulation resistance in underground duct banks

The index combines USDA NRCS soil moisture with precipitation accumulation
when direct soil moisture readings are unavailable.
"""

from __future__ import annotations

import numpy as np
import pandas as pd


[docs] class SoilSaturationIndex: """ Computes normalized soil saturation index [0, 1]. Strategy -------- 1. If NRCS soil moisture % is available, normalize against field capacity (nominally 40% VWC for loam soils; configurable). 2. If NRCS data is unavailable, estimate from cumulative precipitation using a simple linear proxy (saturates at 100mm cumulative precip over the rolling window). Parameters ---------- soil_col: NRCS volumetric soil moisture column (%). precip_col: Precipitation column for fallback estimation. field_capacity_pct: Volumetric water content at field capacity (%). Default 40%. wilting_point_pct: Volumetric water content at wilting point (%). Default 12%. rolling_window_h: Rolling window for precipitation accumulation fallback. Example ------- >>> ssi = SoilSaturationIndex() >>> df = ssi.compute(env_df) >>> df["feat_soil_saturation_index"] """ def __init__( self, soil_col: str = "nrcs_soil_moisture_pct", precip_col: str = "hrrr_precipitation_rate", field_capacity_pct: float = 40.0, wilting_point_pct: float = 12.0, rolling_window_h: int = 72, precip_saturation_mm: float = 100.0, ): self._soil_col = soil_col self._precip_col = precip_col self._field_capacity_pct = field_capacity_pct self._wilting_point_pct = wilting_point_pct self._rolling_window_h = rolling_window_h self._precip_saturation_mm = precip_saturation_mm
[docs] def compute(self, df: pd.DataFrame) -> pd.DataFrame: """Add feat_soil_saturation_index column [0, 1]. Returns a copy.""" df = df.copy() _PRECIP_FALLBACKS = ["nasa_precipitation", "ncei_precipitation_daily"] if self._soil_col in df.columns and df[self._soil_col].notna().any(): moisture = df[self._soil_col] # Normalize between wilting point (0) and field capacity (1) available_water = self._field_capacity_pct - self._wilting_point_pct index = (moisture - self._wilting_point_pct) / available_water df["feat_soil_saturation_index"] = np.clip(index, 0.0, 1.0) else: precip_col = self._precip_col if self._precip_col in df.columns else next( (c for c in _PRECIP_FALLBACKS if c in df.columns), None ) if precip_col is not None: precip = df[precip_col].fillna(0.0) cumulative = precip.rolling(self._rolling_window_h, min_periods=1).sum() df["feat_soil_saturation_index"] = np.clip( cumulative / self._precip_saturation_mm, 0.0, 1.0 ) else: df["feat_soil_saturation_index"] = float("nan") return df