Source code for imod.prepare.topsystem.conductance

from enum import Enum
from typing import Optional

import numpy as np

from imod.prepare.topsystem.allocation import (
    _enforce_layered_top,
    get_above_lower_bound,
)
from imod.schemata import DimsSchema
from imod.typing import GridDataArray
from imod.typing.grid import ones_like, preserve_gridtype, zeros_like
from imod.util.dims import enforced_dim_order


[docs] class DISTRIBUTING_OPTION(Enum): """ Enumerator containing settings to distribute 2D conductance grids over vertical layers for the RIV, DRN or GHB package. * ``by_corrected_thickness``: RIV, DRN. Distribute the conductance by corrected thicknesses. The crosscut thicknesses is computed based on the overlap of bottom_elevation over the bottom allocated layer. Same holds for the stage and top allocated layer. Furthermore the method corrects distribution weights for the mismatch between the midpoints of crosscut areas and model layer midpoints. This is the default method in iMOD 5.6, thus DISTRCOND = 0. * ``by_corrected_transmissivity``: RIV, DRN. Distribute the conductance by corrected transmissivities. Crosscut thicknesses are used to compute transmissivities. The crosscut thicknesses is computed based on the overlap of bottom_elevation over the bottom allocated layer. Same holds for the stage and top allocated layer. Furthermore the method corrects distribution weights for the mismatch between the midpoints of crosscut areas and model layer midpoints. This matches iMOD 5.6 DISTRCOND = -1 option. * ``equally``: RIV, DRN, GHB. Distribute conductances equally over layers. This matches iMOD 5.6 DISTRCOND = 1 option. * ``by_crosscut_thickness``: RIV, DRN. Distribute the conductance by crosscut thicknesses. The crosscut thicknesses is computed based on the overlap of bottom_elevation over the bottom allocated layer. Same holds for the stage and top allocated layer. This matches iMOD 5.6 DISTRCOND = 2 option. * ``by_layer_thickness``: RIV, DRN, GHB. Distribute the conductance by model layer thickness. This matches iMOD 5.6 DISTRCOND = 3 option. * ``by_crosscut_transmissivity``: RIV, DRN. Distribute the conductance by crosscut transmissivity. Crosscut thicknesses are used to compute transmissivities. The crosscut thicknesses is computed based on the overlap of bottom_elevation over the bottom allocated layer. Same holds for the stage and top allocated layer. This matches iMOD 5.6 DISTRCOND = 4 option. * ``by_conductivity``: RIV, DRN, GHB. Distribute the conductance weighted by model layer hydraulic conductivities. This matches iMOD 5.6 DISTRCOND = 5 option. * ``by_layer_transmissivity``: RIV, DRN, GHB. Distribute the conductance by model layer transmissivity. This has no equivalent in iMOD 5.6. """ by_corrected_transmissivity = -1 by_corrected_thickness = 0 equally = 1 by_crosscut_thickness = 2 by_layer_thickness = 3 by_crosscut_transmissivity = 4 by_conductivity = 5 by_layer_transmissivity = 9 # Not an iMOD 5.6 option
PLANAR_GRID = ( DimsSchema("time", "y", "x") | DimsSchema("y", "x") | DimsSchema("time", "{face_dim}") | DimsSchema("{face_dim}") )
[docs] @enforced_dim_order def distribute_riv_conductance( distributing_option: DISTRIBUTING_OPTION, allocated: GridDataArray, conductance: GridDataArray, top: GridDataArray, bottom: GridDataArray, k: GridDataArray, stage: GridDataArray, bottom_elevation: GridDataArray, ) -> GridDataArray: """ Function to distribute 2D conductance over vertical layers for the RIV package. Multiple options are available, which need to be selected in the DISTRIBUTING_OPTION enumerator. Parameters ---------- distributing_option : DISTRIBUTING_OPTION Distributing option available in the DISTRIBUTING_OPTION enumerator. allocated: DataArray | UgridDataArray 3D boolean array with river cell locations. This can be made with the :func:`imod.prepare.allocate_riv_cells` function. conductance: DataArray | UgridDataArray Planar grid with conductances that need to be distributed over layers, so grid cannot contain a layer dimension. Can contain a time dimension. top: DataArray | UgridDataArray Model top bottom: DataArray | UgridDataArray Model layer bottoms k: DataArray | UgridDataArray Hydraulic conductivities stage: DataArray | UgridDataArray Planar grid with river stages, cannot contain a layer dimension. Can contain a time dimension. bottom_elevation: DataArray | UgridDataArray Planar grid with river bottom elevations, cannot contain a layer dimension. Can contain a time dimension. Returns ------- Conductances distributed over depth. Examples -------- >>> from imod.prepare import allocate_riv_cells, distribute_riv_conductance, ALLOCATION_OPTION, DISTRIBUTING_OPTION >>> allocated = allocate_riv_cells( ALLOCATION_OPTION.stage_to_riv_bot, active, top, bottom, stage, bottom_elevation ) >>> conductances_distributed = distribute_riv_conductance( DISTRIBUTING_OPTION.by_corrected_transmissivity, allocated, conductance, top, bottom, stage, bottom_elevation, k ) """ PLANAR_GRID.validate(conductance) match distributing_option: case DISTRIBUTING_OPTION.equally: weights = _distribute_weights__equally(allocated) case DISTRIBUTING_OPTION.by_layer_thickness: weights = _distribute_weights__by_layer_thickness(allocated, top, bottom) case DISTRIBUTING_OPTION.by_layer_transmissivity: weights = _distribute_weights__by_layer_transmissivity( allocated, top, bottom, k ) case DISTRIBUTING_OPTION.by_conductivity: weights = _distribute_weights__by_conductivity(allocated, k) case DISTRIBUTING_OPTION.by_crosscut_thickness: weights = _distribute_weights__by_crosscut_thickness( allocated, top, bottom, stage, bottom_elevation ) case DISTRIBUTING_OPTION.by_crosscut_transmissivity: weights = _distribute_weights__by_crosscut_transmissivity( allocated, top, bottom, k, stage, bottom_elevation ) case DISTRIBUTING_OPTION.by_corrected_thickness: weights = _distribute_weights__by_corrected_thickness( allocated, top, bottom, stage, bottom_elevation ) case DISTRIBUTING_OPTION.by_corrected_transmissivity: weights = _distribute_weights__by_corrected_transmissivity( allocated, top, bottom, k, stage, bottom_elevation ) case _: raise ValueError( "Received incompatible setting for rivers, only" f"'{DISTRIBUTING_OPTION.equally.name}', " f"'{DISTRIBUTING_OPTION.by_layer_thickness.name}', " f"'{DISTRIBUTING_OPTION.by_layer_transmissivity.name}', " f"'{DISTRIBUTING_OPTION.by_conductivity.name}', " f"'{DISTRIBUTING_OPTION.by_crosscut_thickness.name}', " f"'{DISTRIBUTING_OPTION.by_crosscut_transmissivity.name}', " f"'{DISTRIBUTING_OPTION.by_corrected_thickness.name}', and " f"'{DISTRIBUTING_OPTION.by_corrected_transmissivity.name}' supported. " f"Got: '{distributing_option.name}'" ) return (weights * conductance).where(allocated)
[docs] @enforced_dim_order def distribute_drn_conductance( distributing_option: DISTRIBUTING_OPTION, allocated: GridDataArray, conductance: GridDataArray, top: GridDataArray, bottom: GridDataArray, k: GridDataArray, elevation: GridDataArray, ) -> GridDataArray: """ Function to distribute 2D conductance over vertical layers for the DRN package. Multiple options are available, which need to be selected in the DISTRIBUTING_OPTION enumerator. Parameters ---------- distributing_option : DISTRIBUTING_OPTION Distributing option available in the DISTRIBUTING_OPTION enumerator. allocated: DataArray | UgridDataArray 3D boolean array with drain cell locations. This can be made with the :func:`imod.prepare.allocate_drn_cells` function. conductance: DataArray | UgridDataArray Planar grid with conductances that need to be distributed over layers, so grid cannot contain a layer dimension. Can contain a time dimension. top: DataArray | UgridDataArray Model top bottom: DataArray | UgridDataArray Model layer bottoms k: DataArray | UgridDataArray Hydraulic conductivities elevation: DataArray | UgridDataArray Drain elevation Returns ------- Conductances distributed over depth. Examples -------- >>> from imod.prepare import allocate_drn_cells, distribute_drn_conductance, ALLOCATION_OPTION, DISTRIBUTING_OPTION >>> allocated = allocate_drn_cells( ALLOCATION_OPTION.at_elevation, active, top, bottom, drain_elevation ) >>> conductances_distributed = distribute_drn_conductance( DISTRIBUTING_OPTION.by_layer_transmissivity, allocated, conductance, top, bottom, k, drain_elevation ) """ PLANAR_GRID.validate(conductance) match distributing_option: case DISTRIBUTING_OPTION.equally: weights = _distribute_weights__equally(allocated) case DISTRIBUTING_OPTION.by_layer_thickness: weights = _distribute_weights__by_layer_thickness(allocated, top, bottom) case DISTRIBUTING_OPTION.by_layer_transmissivity: weights = _distribute_weights__by_layer_transmissivity( allocated, top, bottom, k ) case DISTRIBUTING_OPTION.by_conductivity: weights = _distribute_weights__by_conductivity(allocated, k) case DISTRIBUTING_OPTION.by_crosscut_thickness: weights = _distribute_weights__by_crosscut_thickness( allocated, top, bottom, bc_bottom=elevation ) case DISTRIBUTING_OPTION.by_crosscut_transmissivity: weights = _distribute_weights__by_crosscut_transmissivity( allocated, top, bottom, k, bc_bottom=elevation ) case DISTRIBUTING_OPTION.by_corrected_thickness: weights = _distribute_weights__by_corrected_thickness( allocated, top, bottom, bc_bottom=elevation ) case DISTRIBUTING_OPTION.by_corrected_transmissivity: weights = _distribute_weights__by_corrected_transmissivity( allocated, top, bottom, k, bc_bottom=elevation ) case _: raise ValueError( "Received incompatible setting for drains, only" f"'{DISTRIBUTING_OPTION.equally.name}', " f"'{DISTRIBUTING_OPTION.by_layer_thickness.name}', " f"'{DISTRIBUTING_OPTION.by_layer_transmissivity.name}', " f"'{DISTRIBUTING_OPTION.by_conductivity.name}', " f"'{DISTRIBUTING_OPTION.by_crosscut_thickness.name}', " f"'{DISTRIBUTING_OPTION.by_crosscut_transmissivity.name}', " f"'{DISTRIBUTING_OPTION.by_corrected_thickness.name}', and " f"'{DISTRIBUTING_OPTION.by_corrected_transmissivity.name}' supported. " f"Got: '{distributing_option.name}'" ) return (weights * conductance).where(allocated)
[docs] @enforced_dim_order def distribute_ghb_conductance( distributing_option: DISTRIBUTING_OPTION, allocated: GridDataArray, conductance: GridDataArray, top: GridDataArray, bottom: GridDataArray, k: GridDataArray, ) -> GridDataArray: PLANAR_GRID.validate(conductance) """ Function to distribute 2D conductance over vertical layers for the GHB package. Multiple options are available, which need to be selected in the DISTRIBUTING_OPTION enumerator. Parameters ---------- distributing_option : DISTRIBUTING_OPTION Distributing option available in the DISTRIBUTING_OPTION enumerator. allocated: DataArray | UgridDataArray 3D boolean array with GHB cell locations. This can be made with the :func:`imod.prepare.allocate_ghb_cells` function. conductance: DataArray | UgridDataArray Planar grid with conductances that need to be distributed over layers, so grid cannot contain a layer dimension. Can contain a time dimension. top: DataArray | UgridDataArray Model top bottom: DataArray | UgridDataArray Model layer bottoms k: DataArray | UgridDataArray Hydraulic conductivities Returns ------- Conductances distributed over depth. Examples -------- >>> from imod.prepare import allocate_ghb_cells, distribute_drn_conductance, ALLOCATION_OPTION, DISTRIBUTING_OPTION >>> allocated = allocate_ghb_cells( ALLOCATION_OPTION.at_elevation, active, top, bottom, ghb_head ) >>> conductances_distributed = distribute_ghb_conductance( DISTRIBUTING_OPTION.by_layer_transmissivity, allocated, conductance, top, bottom, k ) """ match distributing_option: case DISTRIBUTING_OPTION.equally: weights = _distribute_weights__equally(allocated) case DISTRIBUTING_OPTION.by_layer_thickness: weights = _distribute_weights__by_layer_thickness(allocated, top, bottom) case DISTRIBUTING_OPTION.by_layer_transmissivity: weights = _distribute_weights__by_layer_transmissivity( allocated, top, bottom, k ) case DISTRIBUTING_OPTION.by_conductivity: weights = _distribute_weights__by_conductivity(allocated, k) case _: raise ValueError( "Received incompatible setting for general head boundary, only" f"'{DISTRIBUTING_OPTION.equally.name}', " f"'{DISTRIBUTING_OPTION.by_layer_thickness.name}', " f"'{DISTRIBUTING_OPTION.by_layer_transmissivity.name}', and " f"'{DISTRIBUTING_OPTION.by_conductivity.name}' supported. " f"Got: '{distributing_option.name}'" ) return (weights * conductance).where(allocated)
@preserve_gridtype def _compute_layer_thickness( allocated: GridDataArray, top: GridDataArray, bottom: GridDataArray ): """ Compute 3D grid of thicknesses in allocated cells """ top_layered = _enforce_layered_top(top, bottom) thickness = top_layered - bottom return thickness.where(allocated) @preserve_gridtype def _compute_crosscut_thickness( allocated: GridDataArray, top: GridDataArray, bottom: GridDataArray, bc_top: Optional[GridDataArray] = None, bc_bottom: Optional[GridDataArray] = None, ): """ Compute 3D grid of thicknesses crosscut by boundary condition (river/drain) in allocated cells. So the thickness in the upper allocated layer is bc_top - bottom and the lower allocated layer is top - bc_bottom. """ if (bc_top is None) & (bc_bottom is None): raise ValueError("`bc_top` and `bc_bottom` cannot both be None.") top_layered = _enforce_layered_top(top, bottom) layer_thickness = _compute_layer_thickness(allocated, top, bottom) thickness = layer_thickness.copy() outside = zeros_like(allocated).astype(bool) if bc_top is not None: top_is_above_lower_bound = get_above_lower_bound(bc_top, top_layered) upper_layer_bc = top_is_above_lower_bound & (bc_top > bottom) outside = outside | (bc_top < bottom) thickness = thickness.where(~upper_layer_bc, thickness - (top_layered - bc_top)) if bc_bottom is not None: bot_is_above_lower_bound = get_above_lower_bound(bc_bottom, top_layered) lower_layer_bc = bot_is_above_lower_bound & (bc_bottom > bottom) outside = outside | ~bot_is_above_lower_bound corrected_thickness = thickness - (bc_bottom - bottom) # Set top layer to 1.0, where top exceeds bc_bottom top_layer_label = {"layer": min(top_layered.coords["layer"])} is_below_surface = top_layered.loc[top_layer_label] > bc_bottom corrected_thickness.loc[top_layer_label] = corrected_thickness.loc[ top_layer_label ].where(is_below_surface, 1.0) thickness = thickness.where(~lower_layer_bc, corrected_thickness) # Deal with case where bc_top and bc_bottom are equal. if (bc_top is not None) and (bc_bottom is not None): bc_top_equals_bc_bottom = bc_top == bc_bottom thickness = thickness.where(~bc_top_equals_bc_bottom, layer_thickness) thickness = thickness.where(~outside, 0.0) return thickness def _compute_correction_factor( allocated: GridDataArray, top: GridDataArray, bottom: GridDataArray, bc_top: Optional[GridDataArray] = None, bc_bottom: Optional[GridDataArray] = None, ): """ Compute correction factor to correct for a mismatch between the centre of the partially penetrated model layer and the vertical midpoint of the penetrating boundary condition. Correction is only applied to layers which are only partially crosscut. Follows to what is described on page 690 of the iMOD 5.6 manual (but then to distribute WEL rates). """ top_layered = _enforce_layered_top(top, bottom) layer_thickness = _compute_layer_thickness(allocated, top, bottom) midpoints = (top_layered + bottom) / 2 Fc = midpoints.copy() if bc_top is not None: PLANAR_GRID.validate(bc_top) top_is_above_lower_bound = get_above_lower_bound(bc_top, top_layered) upper_layer_bc = top_is_above_lower_bound & (bc_top > bottom) # Computing vertical midpoint of river crosscutting layers. Fc = Fc.where(~upper_layer_bc, (bottom + bc_top) / 2) if bc_bottom is not None: PLANAR_GRID.validate(bc_bottom) bot_is_above_lower_bound = get_above_lower_bound(bc_bottom, top_layered) lower_layer_bc = bot_is_above_lower_bound & (bc_bottom > bottom) # Computing vertical midpoint of river crosscutting layers. Fc = Fc.where(~lower_layer_bc, (top_layered + bc_bottom) / 2) # Correction factor for mismatch between midpoints of crosscut layers and # layer midpoints. F = 1.0 - np.abs(midpoints - Fc) / (layer_thickness * 0.5) # Negative values can be introduced when elevation above surface level, set # these to 1.0. top_layer_index = {"layer": min(top_layered.coords["layer"])} F_top_layer = F.loc[top_layer_index] F.loc[top_layer_index] = F_top_layer.where(F_top_layer > 0.0, 1.0) return F def _distribute_weights__by_corrected_transmissivity( allocated: GridDataArray, top: GridDataArray, bottom: GridDataArray, k: GridDataArray, bc_top: Optional[GridDataArray] = None, bc_bottom: Optional[GridDataArray] = None, ): """ Distribute conductances according to default method in iMOD 5.6, as described on page 690 of the iMOD 5.6 manual (but then to distribute WEL rates). The method uses crosscut thicknesses to compute transmissivities. Furthermore it corrects distribution weights for the mismatch between the midpoints of crosscut areas and layer midpoints. """ crosscut_thickness = _compute_crosscut_thickness( allocated, top, bottom, bc_top=bc_top, bc_bottom=bc_bottom ) transmissivity = crosscut_thickness * k F = _compute_correction_factor(allocated, top, bottom, bc_top, bc_bottom) transmissivity_corrected = transmissivity * F return transmissivity_corrected / transmissivity_corrected.sum(dim="layer") def _distribute_weights__by_corrected_thickness( allocated: GridDataArray, top: GridDataArray, bottom: GridDataArray, bc_top: Optional[GridDataArray] = None, bc_bottom: Optional[GridDataArray] = None, ): """ Distribute conductances according to default method in iMOD 5.6, as described on page 690 of the iMOD 5.6 manual (but then to distribute WEL rates). The method uses crosscut thicknesses to compute transmissivities. Furthermore it corrects distribution weights for the mismatch between the midpoints of crosscut areas and layer midpoints. """ crosscut_thickness = _compute_crosscut_thickness( allocated, top, bottom, bc_top=bc_top, bc_bottom=bc_bottom ) F = _compute_correction_factor(allocated, top, bottom, bc_top, bc_bottom) corrected_thickness = crosscut_thickness * F return corrected_thickness / corrected_thickness.sum(dim="layer") def _distribute_weights__equally(allocated: GridDataArray): """Compute weights over layers equally.""" return ones_like(allocated) / allocated.sum(dim="layer") def _distribute_weights__by_layer_thickness( allocated: GridDataArray, top: GridDataArray, bottom: GridDataArray, ): """Compute weights based on layer thickness""" layer_thickness = _compute_layer_thickness(allocated, top, bottom) return layer_thickness / layer_thickness.sum(dim="layer") def _distribute_weights__by_crosscut_thickness( allocated: GridDataArray, top: GridDataArray, bottom: GridDataArray, bc_top: Optional[GridDataArray] = None, bc_bottom: Optional[GridDataArray] = None, ): """Compute weights based on crosscut thickness""" if bc_top is not None: PLANAR_GRID.validate(bc_top) if bc_bottom is not None: PLANAR_GRID.validate(bc_bottom) crosscut_thickness = _compute_crosscut_thickness( allocated, top, bottom, bc_top, bc_bottom ).where(allocated) return crosscut_thickness / crosscut_thickness.sum(dim="layer") def _distribute_weights__by_layer_transmissivity( allocated: GridDataArray, top: GridDataArray, bottom: GridDataArray, k: GridDataArray, ): """Compute weights based on layer transmissivity""" layer_thickness = _compute_layer_thickness(allocated, top, bottom) transmissivity = layer_thickness * k return transmissivity / transmissivity.sum(dim="layer") def _distribute_weights__by_crosscut_transmissivity( allocated: GridDataArray, top: GridDataArray, bottom: GridDataArray, k: GridDataArray, bc_top: Optional[GridDataArray] = None, bc_bottom: Optional[GridDataArray] = None, ): """Compute weights based on crosscut transmissivity""" if bc_top is not None: PLANAR_GRID.validate(bc_top) if bc_bottom is not None: PLANAR_GRID.validate(bc_bottom) crosscut_thickness = _compute_crosscut_thickness( allocated, top, bottom, bc_top=bc_top, bc_bottom=bc_bottom ) transmissivity = crosscut_thickness * k return transmissivity / transmissivity.sum(dim="layer") def _distribute_weights__by_conductivity(allocated: GridDataArray, k: GridDataArray): """Compute weights based on hydraulic conductivity""" k_allocated = allocated * k return k_allocated / k_allocated.sum(dim="layer")
[docs] def split_conductance_with_infiltration_factor( conductance: GridDataArray, infiltration_factor: GridDataArray ) -> tuple[GridDataArray, GridDataArray]: """ Seperates (exfiltration) conductance with an infiltration factor (iMODFLOW) into a drainage conductance and a river conductance following methods explained in Zaadnoordijk (2009). Parameters ---------- conductance : xr.DataArray or float Exfiltration conductance. Is the default conductance provided to the iMODFLOW river package infiltration_factor : xr.DataArray or float Infiltration factor. The exfiltration conductance is multiplied with this factor to compute the infiltration conductance. If 0, no infiltration takes place; if 1, infiltration is equal to exfiltration Returns ------- drainage_conductance : xr.DataArray conductance for the drainage package river_conductance : xr.DataArray conductance for the river package Note ---- Derivation of the formulas from Zaadnoordijk (2009): * [1] cond_RIV = A/ci * [2] cond_DRN = A * (ci-cd) / (ci*cd) Where cond_RIV and cond_DRN repsectively are the River and Drainage conductance [L^2/T], A is the cell area [L^2] and ci and cd respectively are the infiltration and exfiltration resistance [T] Taking f as the infiltration factor and cond_d as the exfiltration conductance, we can write (iMOD manual): * [3] ci = cd * (1/f) * [4] cond_d = A/cd We can then rewrite equations 1 and 2 to: * [5] cond_RIV = f * cond_d * [6] cond_DRN = (1-f) * cond_d References ---------- Zaadnoordijk, W. (2009). Simulating Piecewise-Linear Surface Water and Ground Water Interactions with MODFLOW. Ground Water. https://ngwa.onlinelibrary.wiley.com/doi/10.1111/j.1745-6584.2009.00582.x iMOD manual v5.2 (2020) https://oss.deltares.nl/web/imod/ """ if np.any(infiltration_factor > 1): raise ValueError("The infiltration factor should not exceed 1") drainage_conductance = conductance * (1 - infiltration_factor) river_conductance = conductance * infiltration_factor # clean up the packages drainage_conductance = drainage_conductance.where(drainage_conductance > 0) river_conductance = river_conductance.where(river_conductance > 0) return drainage_conductance, river_conductance