From 5ed4488514087d18e26065959ad71001d86f3628 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 17 Aug 2026 13:50:50 -0700 Subject: [PATCH 01/55] Add script to convert from weertman to coulomb friction law --- .../mesh_tools_li/friction_law_conversion.py | 434 ++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 landice/mesh_tools_li/friction_law_conversion.py diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py new file mode 100644 index 000000000..e15e1ace9 --- /dev/null +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +""" +Convert a MALI Weertman basal-friction initial condition to parameters +for Albany's Regularized Coulomb friction law. + +Computes +-------- +1. Downs & Johnson-style effective pressure N +2. Area-weighted optimal scalar C: + + C = integral[ uc**q * mu / N dA ] / integral[dA] + +3. Albany bed-roughness/Lambda field: + + Lambda = uc / (A * N**n) + +The output is a copy of the input MALI initial-condition file with +`Lambda` and optionally `effectivePressure` added. + +Notes +----- +Albany's RC law is + + beta = C * N * |u|^(q-1) / + (|u| + Lambda * A * N^n)^q + +so Lambda * A * N^n has units of velocity. + +All quantities supplied here must use a mutually consistent unit system. +For typical MALI/Albany configurations: + velocity : m yr^-1 + N : check whether the Albany interface expects Pa or kPa + A : consistent with N and yr +""" + +import argparse +import shutil + +import numpy as np +import xarray as xr + + +def downs_johnson_effective_pressure( + thickness, + bed, + min_fraction_overburden, + length_scale, + rho_i=910.0, + rho_w=1028.0, + gravity=9.80616): + """ + Reproduce Albany's Downs & Johnson-style effective pressure. + + Parameters + ---------- + thickness : ndarray + Ice thickness H [m]. + bed : ndarray + Bed elevation b [m], positive above sea level. + min_fraction_overburden : float + Albany "Minimum Fraction Overburden Pressure". + length_scale : float + Albany "Length Scale Factor". + + IMPORTANT: use the same units as `bed`. If the Albany parameter + is entered in km but bed is in m here, convert it to meters before + calling this function. + rho_i : float + Ice density [kg m^-3]. + rho_w : float + Water density [kg m^-3]. + gravity : float + Gravitational acceleration [m s^-2]. + + Returns + ------- + N : ndarray + Effective pressure [Pa]. + """ + H = np.asarray(thickness, dtype=np.float64) + b = np.asarray(bed, dtype=np.float64) + + # Albany: + # f_p = 1 / (1 + exp(-bed / length_scale)) + # + # Use a numerically stable clipped argument. + arg = np.clip(b / length_scale, -700.0, 700.0) + fp = 1.0 / (1.0 + np.exp(-arg)) + + overburden_term = min_fraction_overburden * rho_i * H * fp + marine_term = (1.0 - fp) * np.maximum(-rho_w * b, 0.0) + + N = gravity * np.maximum( + rho_i * H - (overburden_term + marine_term), + 0.0 + ) + + return N + + +def area_weighted_optimal_C(mu, N, area, uc, q, mask): + """ + C = integral(uc^q * mu / N dA) / integral(dA) + """ + valid = ( + mask + & np.isfinite(mu) + & np.isfinite(N) + & np.isfinite(area) + & (N > 0.0) + & (area > 0.0) + ) + + if not np.any(valid): + raise ValueError("No valid grounded cells available for C calculation.") + + integrand = (uc ** q) * mu[valid] / N[valid] + + C = np.sum(area[valid] * integrand) / np.sum(area[valid]) + + return C, valid + + +def main(): + parser = argparse.ArgumentParser( + description="Convert MALI Weertman friction IC to Regularized Coulomb." + ) + + parser.add_argument("input", help="Input MALI initial-condition NetCDF file") + parser.add_argument("output", help="Output NetCDF file") + + parser.add_argument( + "--critical-velocity", "--uc", + type=float, + required=True, + help="Critical velocity u_c, e.g. in m/yr" + ) + parser.add_argument( + "--q", + type=float, + default=0.2, + help="Sliding-law exponent q (default: 0.2)" + ) + + # Downs & Johnson / Albany parameters + parser.add_argument( + "--min-fraction-overburden", + type=float, + required=True, + help='Albany "Minimum Fraction Overburden Pressure"' + ) + parser.add_argument( + "--pressure-length-scale", + type=float, + required=True, + help=( + 'Albany "Length Scale Factor", converted to the same ' + "length units as bedTopography (normally m)" + ) + ) + + parser.add_argument("--rho-ice", type=float, default=910.0) + parser.add_argument("--rho-water", type=float, default=1028.0) + parser.add_argument("--gravity", type=float, default=9.80616) + + # Needed for Lambda = uc / (A N^n) + parser.add_argument( + "--flow-rate", + type=float, + required=True, + help=( + "Constant Glen flow rate A, in units consistent with " + "critical velocity and effective pressure" + ) + ) + parser.add_argument( + "--glen-n", + type=float, + default=3.0, + help="Glen-law exponent n (default: 3)" + ) + + # MALI field names + parser.add_argument( + "--mu-field", + default="muFriction", + help="Weertman friction field (default: muFriction)" + ) + parser.add_argument( + "--thickness-field", + default="thickness", + help="Ice thickness field (default: thickness)" + ) + parser.add_argument( + "--bed-field", + default="bedTopography", + help="Bed elevation field (default: bedTopography)" + ) + parser.add_argument( + "--area-field", + default="areaCell", + help="MPAS cell area field (default: areaCell)" + ) + + parser.add_argument( + "--lambda-field", + default="Lambda", + help="Name for output bed-roughness field (default: Lambda)" + ) + parser.add_argument( + "--effective-pressure-field", + default="effectivePressure", + help="Name for diagnostic N field" + ) + + parser.add_argument( + "--time-index", + type=int, + default=0, + help="Time index for Time-dependent IC fields (default: 0)" + ) + + args = parser.parse_args() + + # ------------------------------------------------------------- + # Read IC + # ------------------------------------------------------------- + ds = xr.open_dataset(args.input) + + required = [ + args.mu_field, + args.thickness_field, + args.bed_field, + args.area_field, + ] + + missing = [name for name in required if name not in ds] + if missing: + raise KeyError( + f"Input file is missing required fields: {', '.join(missing)}" + ) + + def cell_field(name): + """Extract nCells field, dropping Time if present.""" + da = ds[name] + + if "Time" in da.dims: + da = da.isel(Time=args.time_index) + + values = np.asarray(da.values).squeeze() + + if values.ndim != 1: + raise ValueError( + f"{name} must reduce to a 1-D nCells field; " + f"got shape {values.shape}" + ) + + return values.astype(np.float64) + + mu = cell_field(args.mu_field) + H = cell_field(args.thickness_field) + bed = cell_field(args.bed_field) + area = cell_field(args.area_field) + + # ------------------------------------------------------------- + # Effective pressure + # ------------------------------------------------------------- + N = downs_johnson_effective_pressure( + thickness=H, + bed=bed, + min_fraction_overburden=args.min_fraction_overburden, + length_scale=args.pressure_length_scale, + rho_i=args.rho_ice, + rho_w=args.rho_water, + gravity=args.gravity, + ) + + # Grounded-ice test used by Albany: + # + # rho_i H + rho_w b > 0 + # + # Also exclude ice-free cells. + grounded = ( + (H > 0.0) + & (args.rho_ice * H + args.rho_water * bed > 0.0) + ) + + # ------------------------------------------------------------- + # Optimal C + # ------------------------------------------------------------- + C, fit_mask = area_weighted_optimal_C( + mu=mu, + N=N, + area=area, + uc=args.critical_velocity, + q=args.q, + mask=grounded, + ) + + # ------------------------------------------------------------- + # Lambda / Albany Bed Roughness + # + # uc = Lambda * A * N^n + # ------------------------------------------------------------- + Lambda = np.zeros_like(N) + + lambda_mask = grounded & np.isfinite(N) & (N > 0.0) + + Lambda[lambda_mask] = ( + args.critical_velocity + / (args.flow_rate * N[lambda_mask] ** args.glen_n) + ) + + # Floating/ice-free cells are deliberately zero. + Lambda[~lambda_mask] = 0.0 + + # ------------------------------------------------------------- + # Diagnostics + # ------------------------------------------------------------- + local_C = np.full_like(N, np.nan) + local_C[fit_mask] = ( + args.critical_velocity ** args.q + * mu[fit_mask] + / N[fit_mask] + ) + + print() + print("MALI Weertman -> Regularized Coulomb conversion") + print("------------------------------------------------") + print(f"Input file : {args.input}") + print(f"Critical velocity, uc : {args.critical_velocity:g}") + print(f"Power exponent, q : {args.q:g}") + print(f"Glen exponent, n : {args.glen_n:g}") + print(f"Flow rate, A : {args.flow_rate:.10e}") + print(f"Cells used in C fit : {np.count_nonzero(fit_mask)}") + print(f"Grounded area used : {np.sum(area[fit_mask]):.10e}") + print() + print(f"Optimal C : {C:.16e}") + print() + print( + "N range on fit domain : " + f"{np.nanmin(N[fit_mask]):.6e} -- " + f"{np.nanmax(N[fit_mask]):.6e}" + ) + print( + "Lambda range grounded : " + f"{np.nanmin(Lambda[lambda_mask]):.6e} -- " + f"{np.nanmax(Lambda[lambda_mask]):.6e}" + ) + print( + "Local C range : " + f"{np.nanmin(local_C[fit_mask]):.6e} -- " + f"{np.nanmax(local_C[fit_mask]):.6e}" + ) + print() + + # ------------------------------------------------------------- + # Write copy of original IC. + # Use shutil first so unrelated variables/encoding remain intact. + # ------------------------------------------------------------- + ds.close() + shutil.copy2(args.input, args.output) + + out = xr.open_dataset(args.output) + + ncell_dim = ds_dims = None + + # Determine nCells dimension from areaCell. + for dim in out[args.area_field].dims: + if dim.lower() == "ncells": + ncell_dim = dim + break + + if ncell_dim is None: + # Standard MALI name is nCells; this gives a useful fallback. + if "nCells" in out.dims: + ncell_dim = "nCells" + else: + raise ValueError("Could not identify the nCells dimension.") + + out.load() + out.close() + + # Re-open writable through xarray and rewrite. + # For very large production files, netCDF4.Dataset can instead be + # used to modify the copied file in-place. + out = xr.open_dataset(args.output).load() + + out[args.lambda_field] = xr.DataArray( + Lambda, + dims=(ncell_dim,), + attrs={ + "long_name": "Albany regularized-Coulomb bed roughness Lambda", + "description": "Lambda = u_c / (A N^n)", + }, + ) + + out[args.effective_pressure_field] = xr.DataArray( + N, + dims=(ncell_dim,), + attrs={ + "long_name": "Downs-Johnson effective pressure", + "units": "Pa", + }, + ) + + # Save conversion information globally. + out.attrs["regularizedCoulomb_C"] = float(C) + out.attrs["regularizedCoulomb_criticalVelocity"] = ( + float(args.critical_velocity) + ) + out.attrs["regularizedCoulomb_q"] = float(args.q) + out.attrs["regularizedCoulomb_GlenN"] = float(args.glen_n) + out.attrs["regularizedCoulomb_flowRate"] = float(args.flow_rate) + out.attrs["regularizedCoulomb_minFractionOverburden"] = ( + float(args.min_fraction_overburden) + ) + out.attrs["regularizedCoulomb_pressureLengthScale"] = ( + float(args.pressure_length_scale) + ) + + # xarray cannot safely overwrite an open source file, so use temp. + tmp = args.output + ".tmp" + out.to_netcdf(tmp) + out.close() + + shutil.move(tmp, args.output) + + print(f"Wrote converted IC: {args.output}") + print(f"Use C = {C:.16e} in the Albany RC configuration.") + + +if __name__ == "__main__": + main() From 8639404be6c594a9a4064580b17e748a6f573207 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 17 Aug 2026 14:20:03 -0700 Subject: [PATCH 02/55] add yaml section --- .../mesh_tools_li/friction_law_conversion.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) mode change 100644 => 100755 landice/mesh_tools_li/friction_law_conversion.py diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py old mode 100644 new mode 100755 index e15e1ace9..a48af06f5 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -427,7 +427,30 @@ def cell_field(name): shutil.move(tmp, args.output) print(f"Wrote converted IC: {args.output}") - print(f"Use C = {C:.16e} in the Albany RC configuration.") + + print() + print("Suggested Albany YAML section") + print("-----------------------------") + print( + f""" + LandIce BCs: + Basal Friction Coefficient: + Type: Regularized Coulomb + Given Constant Beta: false + + Coulomb Friction Coefficient: {C:.16e} + Power Exponent: {args.q:.16e} + + Effective Pressure: + Type: From Surface + Minimum Fraction Overburden Pressure: {args.min_fraction_overburden:.16e} + Length Scale Factor: {args.pressure_length_scale:.16e} + + Bed Roughness: + Type: Field + Field Name: {args.lambda_field} + """ + ) if __name__ == "__main__": From 652ed3278f4272aec7aaccd3992984c98060cbdf Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 17 Aug 2026 14:31:14 -0700 Subject: [PATCH 03/55] Use separate exponents for weertman and RC --- .../mesh_tools_li/friction_law_conversion.py | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index a48af06f5..6ed05ee6d 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -8,7 +8,7 @@ 1. Downs & Johnson-style effective pressure N 2. Area-weighted optimal scalar C: - C = integral[ uc**q * mu / N dA ] / integral[dA] + C = integral[ uc**qW * mu / N dA ] / integral[dA] 3. Albany bed-roughness/Lambda field: @@ -19,10 +19,21 @@ Notes ----- -Albany's RC law is +The input Weertman/Power-Law friction law is - beta = C * N * |u|^(q-1) / - (|u| + Lambda * A * N^n)^q + beta = mu * N * |u|^(qW-1) + +with its own "Power Exponent" qW (the exponent MALI's `muFriction` +field was calibrated with). Albany's RC law is + + beta = C * N * |u|^(qR-1) / + (|u| + Lambda * A * N^n)^qR + +with its own, independent, "Power Exponent" qR. qW and qR need not +match: qW describes the input Weertman law used to derive C, while +qR is the exponent Albany will actually use for the RC law. Per +project convention, qR is always fixed at 1/3 (see RC_POWER_EXPONENT +below) and is not user-configurable. so Lambda * A * N^n has units of velocity. @@ -39,6 +50,11 @@ import numpy as np import xarray as xr +# Albany's Regularized Coulomb law always uses a fixed Power Exponent +# of 1/3. This is independent of the input Weertman/Power-Law exponent +# (--weertman-q), which is used only to derive the optimal C. +RC_POWER_EXPONENT = 1.0 / 3.0 + def downs_johnson_effective_pressure( thickness, @@ -101,6 +117,9 @@ def downs_johnson_effective_pressure( def area_weighted_optimal_C(mu, N, area, uc, q, mask): """ C = integral(uc^q * mu / N dA) / integral(dA) + + `q` here is the input Weertman/Power-Law exponent (qW), not the + Regularized Coulomb exponent. """ valid = ( mask @@ -136,10 +155,16 @@ def main(): help="Critical velocity u_c, e.g. in m/yr" ) parser.add_argument( - "--q", + "--weertman-q", "--q", + dest="weertman_q", type=float, default=0.2, - help="Sliding-law exponent q (default: 0.2)" + help=( + "Input Weertman/Power-Law sliding exponent qW, used to " + "derive the optimal C (default: 0.2). This is independent " + "of the Regularized Coulomb law's Power Exponent, which is " + f"always {RC_POWER_EXPONENT:g} (see RC_POWER_EXPONENT)." + ) ) # Downs & Johnson / Albany parameters @@ -293,7 +318,7 @@ def cell_field(name): N=N, area=area, uc=args.critical_velocity, - q=args.q, + q=args.weertman_q, mask=grounded, ) @@ -319,7 +344,7 @@ def cell_field(name): # ------------------------------------------------------------- local_C = np.full_like(N, np.nan) local_C[fit_mask] = ( - args.critical_velocity ** args.q + args.critical_velocity ** args.weertman_q * mu[fit_mask] / N[fit_mask] ) @@ -329,7 +354,8 @@ def cell_field(name): print("------------------------------------------------") print(f"Input file : {args.input}") print(f"Critical velocity, uc : {args.critical_velocity:g}") - print(f"Power exponent, q : {args.q:g}") + print(f"Weertman power exponent, qW : {args.weertman_q:g}") + print(f"RC power exponent, qR : {RC_POWER_EXPONENT:g}") print(f"Glen exponent, n : {args.glen_n:g}") print(f"Flow rate, A : {args.flow_rate:.10e}") print(f"Cells used in C fit : {np.count_nonzero(fit_mask)}") @@ -409,7 +435,8 @@ def cell_field(name): out.attrs["regularizedCoulomb_criticalVelocity"] = ( float(args.critical_velocity) ) - out.attrs["regularizedCoulomb_q"] = float(args.q) + out.attrs["regularizedCoulomb_q"] = float(RC_POWER_EXPONENT) + out.attrs["weertman_q"] = float(args.weertman_q) out.attrs["regularizedCoulomb_GlenN"] = float(args.glen_n) out.attrs["regularizedCoulomb_flowRate"] = float(args.flow_rate) out.attrs["regularizedCoulomb_minFractionOverburden"] = ( @@ -439,7 +466,7 @@ def cell_field(name): Given Constant Beta: false Coulomb Friction Coefficient: {C:.16e} - Power Exponent: {args.q:.16e} + Power Exponent: {RC_POWER_EXPONENT:.16e} Effective Pressure: Type: From Surface From b7c4aae331eabc843ac1727948e5cf9dc3b5c4ae Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 17 Aug 2026 14:51:45 -0700 Subject: [PATCH 04/55] Use temperature-based A calculation --- .../mesh_tools_li/friction_law_conversion.py | 151 ++++++++++++++++-- 1 file changed, 140 insertions(+), 11 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 6ed05ee6d..48d372fed 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -37,11 +37,19 @@ so Lambda * A * N^n has units of velocity. +The Glen flow-rate factor A is always computed with Albany's +"Temperature Based" Flow Rate Type (LandIce_FlowRate_Def.hpp), i.e. a +two-branch Arrhenius law keyed on ice temperature, evaluated using the +basal-most (last) vertical level of the MALI `temperature` field as an +approximation of basal temperature. A constant/scalar flow rate is no +longer supported; the Temperature Based constants below assume a +Glen's Law n of 3 (see ALBANY_FLOW_RATE_* constants). + All quantities supplied here must use a mutually consistent unit system. For typical MALI/Albany configurations: velocity : m yr^-1 N : check whether the Albany interface expects Pa or kPa - A : consistent with N and yr + A : Pa^-3 s^-1 (Albany's Temperature Based flow rate is SI) """ import argparse @@ -55,6 +63,16 @@ # (--weertman-q), which is used only to derive the optimal C. RC_POWER_EXPONENT = 1.0 / 3.0 +# Albany's "Temperature Based" Flow Rate Type constants, reproduced +# exactly from LandIce_FlowRate_Def.hpp. These are only valid for a +# Glen's Law n of 3 (arrmlh/arrmll are in Pa^-3 s^-1). +ALBANY_FLOW_RATE_ACTENH = 1.39e5 # [J mol-1] +ALBANY_FLOW_RATE_ACTENL = 6.0e4 # [J mol-1] +ALBANY_FLOW_RATE_GASCON = 8.314 # [J mol-1 K-1] +ALBANY_FLOW_RATE_SWITCHING_T = 263.15 # [K] +ALBANY_FLOW_RATE_ARRMLH = 1.733e3 # [Pa-3 s-1] +ALBANY_FLOW_RATE_ARRMLL = 3.613e-13 # [Pa-3 s-1] + def downs_johnson_effective_pressure( thickness, @@ -114,6 +132,36 @@ def downs_johnson_effective_pressure( return N +def albany_temperature_based_flow_rate(temperature): + """ + Reproduce Albany's "Temperature Based" Flow Rate Type exactly + (LandIce_FlowRate_Def.hpp, TEMPERATURE_BASED case): + + A(T) = arrmll * exp(-actenl / (gascon * T)) if T < switchingT + A(T) = arrmlh * exp(-actenh / (gascon * T)) otherwise + + Parameters + ---------- + temperature : ndarray + Ice temperature [K]. Only valid for a Glen's Law n of 3. + + Returns + ------- + A : ndarray + Glen flow-rate factor [Pa^-3 s^-1]. + """ + T = np.asarray(temperature, dtype=np.float64) + + A_low = ALBANY_FLOW_RATE_ARRMLL * np.exp( + -ALBANY_FLOW_RATE_ACTENL / (ALBANY_FLOW_RATE_GASCON * T) + ) + A_high = ALBANY_FLOW_RATE_ARRMLH * np.exp( + -ALBANY_FLOW_RATE_ACTENH / (ALBANY_FLOW_RATE_GASCON * T) + ) + + return np.where(T < ALBANY_FLOW_RATE_SWITCHING_T, A_low, A_high) + + def area_weighted_optimal_C(mu, N, area, uc, q, mask): """ C = integral(uc^q * mu / N dA) / integral(dA) @@ -188,21 +236,28 @@ def main(): parser.add_argument("--rho-water", type=float, default=1028.0) parser.add_argument("--gravity", type=float, default=9.80616) - # Needed for Lambda = uc / (A N^n) + # Needed for Lambda = uc / (A N^n); A is always computed via + # Albany's Temperature Based Flow Rate Type. parser.add_argument( - "--flow-rate", - type=float, - required=True, + "--temperature-field", + default="temperature", help=( - "Constant Glen flow rate A, in units consistent with " - "critical velocity and effective pressure" + "MALI ice temperature field [K] (default: temperature). " + "Used to compute the Glen flow rate A via Albany's " + "Temperature Based Flow Rate Type. If the field has a " + "nVertLevels dimension, the last (basal-most) level is " + "used as an approximation of basal temperature." ) ) parser.add_argument( "--glen-n", type=float, default=3.0, - help="Glen-law exponent n (default: 3)" + help=( + "Glen-law exponent n (default: 3). Albany's Temperature " + "Based Flow Rate Type constants are only valid for n=3; " + "a warning is issued if a different value is used." + ) ) # MALI field names @@ -237,6 +292,11 @@ def main(): default="effectivePressure", help="Name for diagnostic N field" ) + parser.add_argument( + "--flow-rate-field", + default="flowRateA", + help="Name for diagnostic Glen flow-rate A field" + ) parser.add_argument( "--time-index", @@ -247,6 +307,15 @@ def main(): args = parser.parse_args() + if args.glen_n != 3.0: + print( + "WARNING: Albany's Temperature Based Flow Rate Type " + "constants (arrmlh/arrmll) are only valid for a Glen's " + f"Law n of 3; --glen-n was set to {args.glen_n:g}. The " + "computed flow rate A will be physically inconsistent " + "with this exponent." + ) + # ------------------------------------------------------------- # Read IC # ------------------------------------------------------------- @@ -257,6 +326,7 @@ def main(): args.thickness_field, args.bed_field, args.area_field, + args.temperature_field, ] missing = [name for name in required if name not in ds] @@ -282,10 +352,42 @@ def cell_field(name): return values.astype(np.float64) + def basal_cell_field(name): + """ + Extract nCells field from a (Time, nCells, nVertLevels) or + (nCells, nVertLevels) field, taking the last vertical level as + an approximation of the basal-most value. + """ + da = ds[name] + + if "Time" in da.dims: + da = da.isel(Time=args.time_index) + + vert_dims = [d for d in da.dims if d.lower().startswith("nvertlevel")] + if vert_dims: + da = da.isel({vert_dims[0]: -1}) + + values = np.asarray(da.values).squeeze() + + if values.ndim != 1: + raise ValueError( + f"{name} must reduce to a 1-D nCells field; " + f"got shape {values.shape}" + ) + + return values.astype(np.float64) + mu = cell_field(args.mu_field) H = cell_field(args.thickness_field) bed = cell_field(args.bed_field) area = cell_field(args.area_field) + basal_temperature = basal_cell_field(args.temperature_field) + + # ------------------------------------------------------------- + # Glen flow-rate factor A, via Albany's Temperature Based Flow + # Rate Type, using basal temperature as an approximation. + # ------------------------------------------------------------- + A = albany_temperature_based_flow_rate(basal_temperature) # ------------------------------------------------------------- # Effective pressure @@ -333,7 +435,7 @@ def cell_field(name): Lambda[lambda_mask] = ( args.critical_velocity - / (args.flow_rate * N[lambda_mask] ** args.glen_n) + / (A[lambda_mask] * N[lambda_mask] ** args.glen_n) ) # Floating/ice-free cells are deliberately zero. @@ -357,7 +459,15 @@ def cell_field(name): print(f"Weertman power exponent, qW : {args.weertman_q:g}") print(f"RC power exponent, qR : {RC_POWER_EXPONENT:g}") print(f"Glen exponent, n : {args.glen_n:g}") - print(f"Flow rate, A : {args.flow_rate:.10e}") + print( + "Flow rate A (Temperature Based) : " + f"{np.min(A):.10e} -- {np.max(A):.10e}" + ) + print( + "Basal temperature range : " + f"{np.min(basal_temperature):.6f} -- " + f"{np.max(basal_temperature):.6f} K" + ) print(f"Cells used in C fit : {np.count_nonzero(fit_mask)}") print(f"Grounded area used : {np.sum(area[fit_mask]):.10e}") print() @@ -430,6 +540,18 @@ def cell_field(name): }, ) + out[args.flow_rate_field] = xr.DataArray( + A, + dims=(ncell_dim,), + attrs={ + "long_name": ( + "Glen flow-rate factor A from Albany's Temperature " + "Based Flow Rate Type, evaluated at basal temperature" + ), + "units": "Pa-3 s-1", + }, + ) + # Save conversion information globally. out.attrs["regularizedCoulomb_C"] = float(C) out.attrs["regularizedCoulomb_criticalVelocity"] = ( @@ -438,7 +560,7 @@ def cell_field(name): out.attrs["regularizedCoulomb_q"] = float(RC_POWER_EXPONENT) out.attrs["weertman_q"] = float(args.weertman_q) out.attrs["regularizedCoulomb_GlenN"] = float(args.glen_n) - out.attrs["regularizedCoulomb_flowRate"] = float(args.flow_rate) + out.attrs["regularizedCoulomb_flowRateType"] = "Temperature Based" out.attrs["regularizedCoulomb_minFractionOverburden"] = ( float(args.min_fraction_overburden) ) @@ -478,6 +600,13 @@ def cell_field(name): Field Name: {args.lambda_field} """ ) + print( + "NOTE: Lambda was derived assuming Albany's \"Flow Rate Type\": " + "\"Temperature Based\" (in the Viscosity/Flow Rate section of " + "the Albany YAML, applied to the basal ice temperature); ensure " + "that setting is used at run time, or Lambda will be " + "inconsistent with the actual A used by Albany." + ) if __name__ == "__main__": From ce33746ef356bff655c79957006af9c037cf4e68 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 17 Aug 2026 14:56:15 -0700 Subject: [PATCH 05/55] update friction field name --- landice/mesh_tools_li/friction_law_conversion.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 48d372fed..d774f4941 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -284,8 +284,11 @@ def main(): parser.add_argument( "--lambda-field", - default="Lambda", - help="Name for output bed-roughness field (default: Lambda)" + default="bedRoughnessRC", + help=( + "Name for output Regularized Coulomb bed-roughness field " + "(default: bedRoughnessRC)" + ) ) parser.add_argument( "--effective-pressure-field", @@ -522,6 +525,11 @@ def basal_cell_field(name): # used to modify the copied file in-place. out = xr.open_dataset(args.output).load() + # The Weertman muFriction field is not used by the Regularized + # Coulomb law; drop it from the converted IC. + if args.mu_field in out: + out = out.drop_vars(args.mu_field) + out[args.lambda_field] = xr.DataArray( Lambda, dims=(ncell_dim,), From cbbc647463efbdb9989bd4c5c7bb421c846c22e7 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 17 Aug 2026 15:21:43 -0700 Subject: [PATCH 06/55] Support both temp-based A and constant A for basal friction law --- .../mesh_tools_li/friction_law_conversion.py | 157 +++++++++++++----- 1 file changed, 114 insertions(+), 43 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index d774f4941..af9b83f0c 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -37,13 +37,17 @@ so Lambda * A * N^n has units of velocity. -The Glen flow-rate factor A is always computed with Albany's -"Temperature Based" Flow Rate Type (LandIce_FlowRate_Def.hpp), i.e. a -two-branch Arrhenius law keyed on ice temperature, evaluated using the -basal-most (last) vertical level of the MALI `temperature` field as an -approximation of basal temperature. A constant/scalar flow rate is no -longer supported; the Temperature Based constants below assume a -Glen's Law n of 3 (see ALBANY_FLOW_RATE_* constants). +The Glen flow-rate factor A can be obtained in one of two ways, +selected via --flow-rate-type: + +- "temperature" (default): computed with Albany's "Temperature Based" + Flow Rate Type (LandIce_FlowRate_Def.hpp), i.e. a two-branch + Arrhenius law keyed on ice temperature, evaluated using the + basal-most (last) vertical level of the MALI `temperature` field as + an approximation of basal temperature. This is only valid for a + Glen's Law n of 3 (see ALBANY_FLOW_RATE_* constants). +- "constant": a single scalar value supplied via --flow-rate is used + for all cells (Albany Flow Rate Type: Constant). All quantities supplied here must use a mutually consistent unit system. For typical MALI/Albany configurations: @@ -190,7 +194,13 @@ def area_weighted_optimal_C(mu, N, area, uc, q, mask): def main(): parser = argparse.ArgumentParser( - description="Convert MALI Weertman friction IC to Regularized Coulomb." + description="Convert MALI Weertman friction IC to Regularized Coulomb.", + # Disable prefix-abbreviation matching. Without this, obsolete or + # mistyped flags (e.g. a leftover "--flow-rate VALUE" from before + # flow rate became Temperature Based-only) can silently be matched + # as an unambiguous prefix of another option (e.g. + # "--flow-rate-field"), corrupting its value instead of erroring. + allow_abbrev=False, ) parser.add_argument("input", help="Input MALI initial-condition NetCDF file") @@ -236,19 +246,40 @@ def main(): parser.add_argument("--rho-water", type=float, default=1028.0) parser.add_argument("--gravity", type=float, default=9.80616) - # Needed for Lambda = uc / (A N^n); A is always computed via - # Albany's Temperature Based Flow Rate Type. + # Needed for Lambda = uc / (A N^n). + parser.add_argument( + "--flow-rate-type", + choices=["temperature", "constant"], + default="temperature", + help=( + 'How to obtain the Glen flow rate A (default: temperature). ' + '"temperature" computes a per-cell A from ice temperature ' + "via Albany's Temperature Based Flow Rate Type " + '(see --temperature-field); "constant" uses a single ' + "scalar value supplied via --flow-rate for all cells." + ) + ) parser.add_argument( "--temperature-field", default="temperature", help=( "MALI ice temperature field [K] (default: temperature). " "Used to compute the Glen flow rate A via Albany's " - "Temperature Based Flow Rate Type. If the field has a " + "Temperature Based Flow Rate Type when " + "--flow-rate-type=temperature. If the field has a " "nVertLevels dimension, the last (basal-most) level is " "used as an approximation of basal temperature." ) ) + parser.add_argument( + "--flow-rate", + type=float, + default=None, + help=( + "Constant Glen flow rate A [Pa^-3 s^-1], required when " + "--flow-rate-type=constant. Ignored otherwise." + ) + ) parser.add_argument( "--glen-n", type=float, @@ -256,7 +287,8 @@ def main(): help=( "Glen-law exponent n (default: 3). Albany's Temperature " "Based Flow Rate Type constants are only valid for n=3; " - "a warning is issued if a different value is used." + "a warning is issued if a different value is used with " + "--flow-rate-type=temperature." ) ) @@ -310,14 +342,25 @@ def main(): args = parser.parse_args() - if args.glen_n != 3.0: - print( - "WARNING: Albany's Temperature Based Flow Rate Type " - "constants (arrmlh/arrmll) are only valid for a Glen's " - f"Law n of 3; --glen-n was set to {args.glen_n:g}. The " - "computed flow rate A will be physically inconsistent " - "with this exponent." - ) + if args.flow_rate_type == "constant": + if args.flow_rate is None: + parser.error( + "--flow-rate is required when --flow-rate-type=constant" + ) + else: + if args.flow_rate is not None: + parser.error( + "--flow-rate is only used with --flow-rate-type=constant " + "(got --flow-rate-type=temperature)" + ) + if args.glen_n != 3.0: + print( + "WARNING: Albany's Temperature Based Flow Rate Type " + "constants (arrmlh/arrmll) are only valid for a Glen's " + f"Law n of 3; --glen-n was set to {args.glen_n:g}. The " + "computed flow rate A will be physically inconsistent " + "with this exponent." + ) # ------------------------------------------------------------- # Read IC @@ -329,8 +372,9 @@ def main(): args.thickness_field, args.bed_field, args.area_field, - args.temperature_field, ] + if args.flow_rate_type == "temperature": + required.append(args.temperature_field) missing = [name for name in required if name not in ds] if missing: @@ -384,13 +428,16 @@ def basal_cell_field(name): H = cell_field(args.thickness_field) bed = cell_field(args.bed_field) area = cell_field(args.area_field) - basal_temperature = basal_cell_field(args.temperature_field) # ------------------------------------------------------------- - # Glen flow-rate factor A, via Albany's Temperature Based Flow - # Rate Type, using basal temperature as an approximation. + # Glen flow-rate factor A. # ------------------------------------------------------------- - A = albany_temperature_based_flow_rate(basal_temperature) + if args.flow_rate_type == "temperature": + basal_temperature = basal_cell_field(args.temperature_field) + A = albany_temperature_based_flow_rate(basal_temperature) + else: + basal_temperature = None + A = np.full_like(H, args.flow_rate) # ------------------------------------------------------------- # Effective pressure @@ -462,15 +509,14 @@ def basal_cell_field(name): print(f"Weertman power exponent, qW : {args.weertman_q:g}") print(f"RC power exponent, qR : {RC_POWER_EXPONENT:g}") print(f"Glen exponent, n : {args.glen_n:g}") - print( - "Flow rate A (Temperature Based) : " - f"{np.min(A):.10e} -- {np.max(A):.10e}" - ) - print( - "Basal temperature range : " - f"{np.min(basal_temperature):.6f} -- " - f"{np.max(basal_temperature):.6f} K" - ) + print(f"Flow rate type : {args.flow_rate_type}") + print(f"Flow rate A range : {np.min(A):.10e} -- {np.max(A):.10e}") + if basal_temperature is not None: + print( + "Basal temperature range : " + f"{np.min(basal_temperature):.6f} -- " + f"{np.max(basal_temperature):.6f} K" + ) print(f"Cells used in C fit : {np.count_nonzero(fit_mask)}") print(f"Grounded area used : {np.sum(area[fit_mask]):.10e}") print() @@ -548,14 +594,24 @@ def basal_cell_field(name): }, ) + if args.flow_rate_type == "temperature": + flow_rate_long_name = ( + "Glen flow-rate factor A from Albany's Temperature " + "Based Flow Rate Type, evaluated at basal temperature" + ) + albany_flow_rate_type = "Temperature Based" + else: + flow_rate_long_name = ( + "Glen flow-rate factor A, constant value supplied by the " + "user (Albany Flow Rate Type: Constant)" + ) + albany_flow_rate_type = "Constant" + out[args.flow_rate_field] = xr.DataArray( A, dims=(ncell_dim,), attrs={ - "long_name": ( - "Glen flow-rate factor A from Albany's Temperature " - "Based Flow Rate Type, evaluated at basal temperature" - ), + "long_name": flow_rate_long_name, "units": "Pa-3 s-1", }, ) @@ -568,7 +624,7 @@ def basal_cell_field(name): out.attrs["regularizedCoulomb_q"] = float(RC_POWER_EXPONENT) out.attrs["weertman_q"] = float(args.weertman_q) out.attrs["regularizedCoulomb_GlenN"] = float(args.glen_n) - out.attrs["regularizedCoulomb_flowRateType"] = "Temperature Based" + out.attrs["regularizedCoulomb_flowRateType"] = albany_flow_rate_type out.attrs["regularizedCoulomb_minFractionOverburden"] = ( float(args.min_fraction_overburden) ) @@ -585,6 +641,16 @@ def basal_cell_field(name): print(f"Wrote converted IC: {args.output}") + if args.flow_rate_type == "constant": + flow_rate_yaml_lines = ( + f" Flow Rate Type: Constant\n" + f" Flow Rate: {args.flow_rate:.16e}\n" + ) + else: + flow_rate_yaml_lines = ( + f" Flow Rate Type: Temperature Based\n" + ) + print() print("Suggested Albany YAML section") print("-----------------------------") @@ -597,7 +663,7 @@ def basal_cell_field(name): Coulomb Friction Coefficient: {C:.16e} Power Exponent: {RC_POWER_EXPONENT:.16e} - +{flow_rate_yaml_lines} Effective Pressure: Type: From Surface Minimum Fraction Overburden Pressure: {args.min_fraction_overburden:.16e} @@ -610,9 +676,14 @@ def basal_cell_field(name): ) print( "NOTE: Lambda was derived assuming Albany's \"Flow Rate Type\": " - "\"Temperature Based\" (in the Viscosity/Flow Rate section of " - "the Albany YAML, applied to the basal ice temperature); ensure " - "that setting is used at run time, or Lambda will be " + f"\"{albany_flow_rate_type}\" (in the Viscosity/Flow Rate section " + "of the Albany YAML" + + ( + ", applied to the basal ice temperature" + if albany_flow_rate_type == "Temperature Based" + else f", with \"Flow Rate\": {args.flow_rate:.16e}" + ) + + "); ensure that setting is used at run time, or Lambda will be " "inconsistent with the actual A used by Albany." ) From 66d683c7747c0f501f6d35a82f292818ea0671f6 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 17 Aug 2026 15:58:51 -0700 Subject: [PATCH 07/55] Fix unit errors in Regularized Coulomb friction conversion - Add SECONDS_PER_YEAR constant and use it in the Lambda formula (Lambda = uc / (SECONDS_PER_YEAR * A * N^n)); it was previously missing, making Lambda ~3.15e7x too large (excess friction/no-slip). - Scale N by ALBANY_EFFECTIVE_PRESSURE_PA_PER_UNIT (1000) when deriving the Coulomb Friction Coefficient C, to match Albany's internal km/kPa-scaled effective pressure representation; C was previously 1000x too small relative to Albany's runtime N. - Fix suggested YAML: Effective Pressure Type corrected from the invalid "From Surface" to "Hydrostatic". - Add "Use Pressurized Bed Above Sea Level: true" to the suggested YAML, without which Albany ignores Minimum Fraction Overburden Pressure / Length Scale Factor entirely. - Convert the printed Length Scale Factor to km to match Albany's internally km-scaled bed field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 67 +++++++++++++++++-- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index af9b83f0c..e226baa15 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -67,6 +67,28 @@ # (--weertman-q), which is used only to derive the optimal C. RC_POWER_EXPONENT = 1.0 / 3.0 +# Seconds per year, matching Albany's own hardcoded conversion factor +# exactly (LandIce_BasalFrictionCoefficient_Def.hpp: "secsInYr = 365 * +# 24 * 3600"). Albany's Regularized Coulomb evaluator computes the +# Glen flow rate A in SI units (Pa^-3 s^-1, per second) but solves for +# velocity in m/yr; this factor converts between the two so that a +# critical velocity supplied in m/yr and a flow rate in Pa^-3 s^-1 +# combine correctly when deriving Lambda (bedRoughnessRC). +SECONDS_PER_YEAR = 365.0 * 24.0 * 3600.0 + +# Albany's internal effective pressure representation (used e.g. by +# its "Hydrostatic" Effective Pressure Type) is computed from bed/ +# thickness fields that MALI's coupling interface has already divided +# by 1000 (m -> km) before Albany ever sees them +# (Interface_velocity_solver.cpp: "unit_length = 1000"). Combined with +# SI density/gravity, this makes Albany's internal effective-pressure +# number equal to the physical pressure in Pa divided by 1000 (i.e. +# numerically "kPa", matching Albany's own documentation: "Effective +# Pressure [kPa]"). C must be derived against this same kPa-scaled N +# so that it is dimensionally consistent with Albany's own +# "beta = C * N * |u|^(q-1)" evaluation at runtime. +ALBANY_EFFECTIVE_PRESSURE_PA_PER_UNIT = 1000.0 + # Albany's "Temperature Based" Flow Rate Type constants, reproduced # exactly from LandIce_FlowRate_Def.hpp. These are only valid for a # Glen's Law n of 3 (arrmlh/arrmll are in Pa^-3 s^-1). @@ -172,6 +194,13 @@ def area_weighted_optimal_C(mu, N, area, uc, q, mask): `q` here is the input Weertman/Power-Law exponent (qW), not the Regularized Coulomb exponent. + + IMPORTANT: `N` must already be expressed in the same units Albany + will actually use at runtime for its own internal effective + pressure (numerically equal to physical Pa / 1000, i.e. "kPa"; + see ALBANY_EFFECTIVE_PRESSURE_PA_PER_UNIT), not raw SI Pascals. + Passing raw-Pa N here would make C inconsistent with how Albany + multiplies C against its own internal N at runtime. """ valid = ( mask @@ -452,6 +481,15 @@ def basal_cell_field(name): gravity=args.gravity, ) + # Albany's own internal effective pressure (e.g. "Hydrostatic" + # Effective Pressure Type) is computed from bed/thickness fields + # that MALI's coupling interface has already divided by 1000 (m -> + # km) before Albany sees them, which numerically makes Albany's + # internal N equal to physical N[Pa] / 1000. C must be derived + # against this same scale for consistency with Albany's own + # "beta = C * N * |u|^(q-1)" evaluation. + N_albany = N / ALBANY_EFFECTIVE_PRESSURE_PA_PER_UNIT + # Grounded-ice test used by Albany: # # rho_i H + rho_w b > 0 @@ -467,7 +505,7 @@ def basal_cell_field(name): # ------------------------------------------------------------- C, fit_mask = area_weighted_optimal_C( mu=mu, - N=N, + N=N_albany, area=area, uc=args.critical_velocity, q=args.weertman_q, @@ -477,7 +515,16 @@ def basal_cell_field(name): # ------------------------------------------------------------- # Lambda / Albany Bed Roughness # - # uc = Lambda * A * N^n + # uc[m/yr] = Lambda[m] * A[Pa^-3 s^-1] * N[Pa]^n * SECONDS_PER_YEAR + # + # Note N here (unlike in the C calculation above) is used in raw + # Pa: Albany's own hardcoded "scaling" factor in + # LandIce_BasalFrictionCoefficient_Def.hpp already accounts for + # its internal km/kPa nondimensionalization once Lambda is + # expressed in physical meters and N in physical Pa, so long as + # the SECONDS_PER_YEAR factor below is included to convert the + # per-second Glen flow rate A to match a critical velocity given + # in m/yr. # ------------------------------------------------------------- Lambda = np.zeros_like(N) @@ -485,7 +532,7 @@ def basal_cell_field(name): Lambda[lambda_mask] = ( args.critical_velocity - / (A[lambda_mask] * N[lambda_mask] ** args.glen_n) + / (SECONDS_PER_YEAR * A[lambda_mask] * N[lambda_mask] ** args.glen_n) ) # Floating/ice-free cells are deliberately zero. @@ -498,7 +545,7 @@ def basal_cell_field(name): local_C[fit_mask] = ( args.critical_velocity ** args.weertman_q * mu[fit_mask] - / N[fit_mask] + / N_albany[fit_mask] ) print() @@ -581,7 +628,12 @@ def basal_cell_field(name): dims=(ncell_dim,), attrs={ "long_name": "Albany regularized-Coulomb bed roughness Lambda", - "description": "Lambda = u_c / (A N^n)", + "description": ( + "Lambda = u_c / (SECONDS_PER_YEAR * A * N^n), with u_c " + "in m/yr, A in Pa^-3 s^-1, N in Pa, matching Albany's " + "internal secsInYr scaling in " + "LandIce_BasalFrictionCoefficient_Def.hpp" + ), }, ) @@ -665,9 +717,10 @@ def basal_cell_field(name): Power Exponent: {RC_POWER_EXPONENT:.16e} {flow_rate_yaml_lines} Effective Pressure: - Type: From Surface + Type: Hydrostatic + Use Pressurized Bed Above Sea Level: true Minimum Fraction Overburden Pressure: {args.min_fraction_overburden:.16e} - Length Scale Factor: {args.pressure_length_scale:.16e} + Length Scale Factor: {args.pressure_length_scale / 1000.0:.16e} Bed Roughness: Type: Field From 0ac53c673fb23f0ccf6ca694550824955c3bb318 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 17 Aug 2026 16:02:42 -0700 Subject: [PATCH 08/55] Update/correct yaml snippet --- landice/mesh_tools_li/friction_law_conversion.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index e226baa15..9d4350e49 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -711,20 +711,14 @@ def basal_cell_field(name): LandIce BCs: Basal Friction Coefficient: Type: Regularized Coulomb - Given Constant Beta: false - Coulomb Friction Coefficient: {C:.16e} Power Exponent: {RC_POWER_EXPONENT:.16e} {flow_rate_yaml_lines} - Effective Pressure: - Type: Hydrostatic - Use Pressurized Bed Above Sea Level: true - Minimum Fraction Overburden Pressure: {args.min_fraction_overburden:.16e} - Length Scale Factor: {args.pressure_length_scale / 1000.0:.16e} - - Bed Roughness: - Type: Field - Field Name: {args.lambda_field} + Bed Roughness Type: Field + Effective Pressure Type: Hydrostatic At Nodes + Use Pressurized Bed Above Sea Level: true + Minimum Fraction Overburden Pressure: {args.min_fraction_overburden:.16e} + Length Scale Factor: {args.pressure_length_scale / 1000.0:.16e} """ ) print( From 2e3afed609eb7c29d3612e74bae27c3f6b1f9a47 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 17 Aug 2026 20:46:47 -0700 Subject: [PATCH 09/55] Solve RC law exactly for Lambda using actual sliding speed - Replace the previous critical-velocity-based Lambda formula with an exact per-cell solve: Lambda is chosen so that Albany's Regularized Coulomb law reproduces the same basal shear stress the Weertman law would produce at the cell's actual current sliding speed (from uReconstructX/uReconstructY, last nVertInterfaces level, converted m/s -> m/yr). - Weertman basal shear stress is computed as Tau_b = mu * u^qW, with NO effective-pressure term, matching MALI's actual Weertman sliding law (muFriction was calibrated for this convention, not Albany's generic N-dependent Power Law). - Cells where the Weertman stress at the current speed already meets or exceeds the Coulomb limit C*N have no valid non-negative Lambda; these are set to 0 (maximal Coulomb sliding) and counted/reported. - Generalized the vertical-level extraction helper to also match nVertInterfaces (not just nVertLevels), and added --velocity-x-field/--velocity-y-field CLI options. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 161 +++++++++++++++--- 1 file changed, 139 insertions(+), 22 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 9d4350e49..4abfb329d 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -342,6 +342,22 @@ def main(): default="areaCell", help="MPAS cell area field (default: areaCell)" ) + parser.add_argument( + "--velocity-x-field", + default="uReconstructX", + help=( + "MALI x-velocity field [m s^-1] (default: uReconstructX). " + "Used, together with --velocity-y-field, to compute the " + "basal sliding speed (last nVertInterfaces level) that " + "Lambda is solved against. Units are assumed m/s, matching " + "standard MALI output/restart files." + ) + ) + parser.add_argument( + "--velocity-y-field", + default="uReconstructY", + help="MALI y-velocity field [m s^-1] (default: uReconstructY)" + ) parser.add_argument( "--lambda-field", @@ -401,6 +417,8 @@ def main(): args.thickness_field, args.bed_field, args.area_field, + args.velocity_x_field, + args.velocity_y_field, ] if args.flow_rate_type == "temperature": required.append(args.temperature_field) @@ -430,16 +448,17 @@ def cell_field(name): def basal_cell_field(name): """ - Extract nCells field from a (Time, nCells, nVertLevels) or - (nCells, nVertLevels) field, taking the last vertical level as - an approximation of the basal-most value. + Extract nCells field from a (Time, nCells, nVertLevels), + (Time, nCells, nVertInterfaces), (nCells, nVertLevels), or + (nCells, nVertInterfaces) field, taking the last vertical + level/interface as an approximation of the basal-most value. """ da = ds[name] if "Time" in da.dims: da = da.isel(Time=args.time_index) - vert_dims = [d for d in da.dims if d.lower().startswith("nvertlevel")] + vert_dims = [d for d in da.dims if d.lower().startswith("nvert")] if vert_dims: da = da.isel({vert_dims[0]: -1}) @@ -458,6 +477,21 @@ def basal_cell_field(name): bed = cell_field(args.bed_field) area = cell_field(args.area_field) + # ------------------------------------------------------------- + # Basal sliding speed, from the input file's basal-most + # (last nVertInterfaces level) horizontal velocity components. + # This is the *actual* current sliding speed used to solve the + # Regularized Coulomb law for Lambda below (as opposed to an + # assumed critical velocity). + # ------------------------------------------------------------- + uX = basal_cell_field(args.velocity_x_field) + uY = basal_cell_field(args.velocity_y_field) + # Convert from m/s (standard MALI units) to m/yr, matching + # Albany's internal u_norm convention (see + # LandIce_BasalFrictionCoefficient_Def.hpp: "Sliding Velocity + # Regularization [m yr^-1]"). + speed = np.sqrt(uX ** 2 + uY ** 2) * SECONDS_PER_YEAR + # ------------------------------------------------------------- # Glen flow-rate factor A. # ------------------------------------------------------------- @@ -515,28 +549,91 @@ def basal_cell_field(name): # ------------------------------------------------------------- # Lambda / Albany Bed Roughness # - # uc[m/yr] = Lambda[m] * A[Pa^-3 s^-1] * N[Pa]^n * SECONDS_PER_YEAR + # Rather than assuming the sliding speed equals a prescribed + # critical velocity, Lambda is now solved for exactly, per cell, + # by requiring that Albany's Regularized Coulomb law reproduce + # the *same basal shear stress* Tau_b that the original + # Weertman/Power-Law would produce at the cell's actual current + # sliding speed (from --velocity-x-field/--velocity-y-field). + # + # NOTE: MALI's Weertman sliding law (which muFriction was + # calibrated for) has NO effective pressure term: + # + # Weertman: beta_W = mu * u^(qW-1) + # Tau_b = beta_W * u = mu * u^qW + # + # Regularized Coulomb (LandIce_BasalFrictionCoefficient_Def.hpp): + # beta_RC = C * N * u^(p-1) + # / (u + Lambda*scaling*A*N^n)^p + # Tau_b = beta_RC * u + # = C * N * u^p + # / (u + Lambda*scaling*A*N^n)^p + # + # Setting Tau_b_RC == Tau_b_W and solving for Lambda: + # + # u + Lambda*scaling*A*N^n = u * (C*N / Tau_b_W)^(1/p) + # = u * (C*N / (mu * u^qW))^(1/p) + # + # Lambda = u * [(C*N / (mu * u^qW))^(1/p) - 1] + # / (SECONDS_PER_YEAR * A * N^n) + # + # This is exactly the same premise used by area_weighted_optimal_C + # above (which likewise assumes Tau_b_W = mu * uc^qW with no N + # term, matched against the RC Coulomb limit C*N). + # + # where u is in m/yr, A is in Pa^-3 s^-1, N (raw Pa) is used here + # exactly as in the previous uc-based derivation (Albany's + # internal km/kPa/yr "scaling" factor reduces to the plain + # SECONDS_PER_YEAR factor once Lambda is expressed in meters and N + # in Pa). N_albany (the kPa-equivalent convention) is used for the + # "C*N" Coulomb-limit term, matching how C was itself fit. # - # Note N here (unlike in the C calculation above) is used in raw - # Pa: Albany's own hardcoded "scaling" factor in - # LandIce_BasalFrictionCoefficient_Def.hpp already accounts for - # its internal km/kPa nondimensionalization once Lambda is - # expressed in physical meters and N in physical Pa, so long as - # the SECONDS_PER_YEAR factor below is included to convert the - # per-second Glen flow rate A to match a critical velocity given - # in m/yr. + # Because Albany's Regularized Coulomb law can never produce a + # shear stress above the Coulomb limit C*N (attained only in the + # Lambda -> 0 limit), cells where the Weertman law's Tau_b at the + # current speed already meets or exceeds C*N have no valid + # (non-negative) solution for Lambda; these are set to 0 (maximal + # Coulomb sliding) and reported below. # ------------------------------------------------------------- Lambda = np.zeros_like(N) - lambda_mask = grounded & np.isfinite(N) & (N > 0.0) + valid_speed = ( + grounded + & np.isfinite(N) & (N > 0.0) + & np.isfinite(mu) & (mu > 0.0) + & np.isfinite(speed) & (speed > 0.0) + ) + + tau_b_weertman = np.full_like(N, np.nan) + tau_b_weertman[valid_speed] = ( + mu[valid_speed] * speed[valid_speed] ** args.weertman_q + ) + + stress_ratio = np.full_like(N, np.nan) + stress_ratio[valid_speed] = ( + (C * N_albany[valid_speed]) / tau_b_weertman[valid_speed] + ) + + lambda_mask = valid_speed & np.isfinite(stress_ratio) & (stress_ratio > 1.0) Lambda[lambda_mask] = ( - args.critical_velocity + speed[lambda_mask] + * (stress_ratio[lambda_mask] ** (1.0 / RC_POWER_EXPONENT) - 1.0) / (SECONDS_PER_YEAR * A[lambda_mask] * N[lambda_mask] ** args.glen_n) ) - # Floating/ice-free cells are deliberately zero. - Lambda[~lambda_mask] = 0.0 + n_unreachable = int(np.count_nonzero(valid_speed & ~lambda_mask)) + if n_unreachable > 0: + print( + f"WARNING: {n_unreachable} grounded cells have a Weertman " + "basal shear stress (mu*u^qW) at the current sliding " + "speed that meets or exceeds the Coulomb limit C*N; " + "Lambda set to 0.0 (maximal Coulomb sliding) at these " + "cells." + ) + + # Floating/ice-free/invalid-speed/unreachable-stress cells are + # deliberately left at zero (see Lambda initialization above). # ------------------------------------------------------------- # Diagnostics @@ -574,10 +671,25 @@ def basal_cell_field(name): f"{np.nanmin(N[fit_mask]):.6e} -- " f"{np.nanmax(N[fit_mask]):.6e}" ) + print( + "Basal sliding speed range : " + + ( + f"{np.nanmin(speed[valid_speed]):.6e} -- " + f"{np.nanmax(speed[valid_speed]):.6e} m/yr" + if np.any(valid_speed) else "n/a (no valid cells)" + ) + ) + print( + f"Cells with valid Lambda solve : {int(np.count_nonzero(lambda_mask))} " + f"/ {int(np.count_nonzero(grounded))} grounded" + ) print( "Lambda range grounded : " - f"{np.nanmin(Lambda[lambda_mask]):.6e} -- " - f"{np.nanmax(Lambda[lambda_mask]):.6e}" + + ( + f"{np.nanmin(Lambda[lambda_mask]):.6e} -- " + f"{np.nanmax(Lambda[lambda_mask]):.6e}" + if np.any(lambda_mask) else "n/a (no valid cells)" + ) ) print( "Local C range : " @@ -629,9 +741,14 @@ def basal_cell_field(name): attrs={ "long_name": "Albany regularized-Coulomb bed roughness Lambda", "description": ( - "Lambda = u_c / (SECONDS_PER_YEAR * A * N^n), with u_c " - "in m/yr, A in Pa^-3 s^-1, N in Pa, matching Albany's " - "internal secsInYr scaling in " + "Lambda solved exactly so that the Regularized Coulomb " + "law reproduces the Weertman law's basal shear stress " + "(mu*u^qW, no effective-pressure term) at the cell's " + "actual current sliding speed u (from velocity-x/y-" + "field, last nVertInterfaces level): Lambda = u * " + "[(C*N/(mu*u^qW))^(1/qR) - 1] / (SECONDS_PER_YEAR * A " + "* N^n), with u in m/yr, A in Pa^-3 s^-1, N in Pa, " + "matching Albany's internal secsInYr scaling in " "LandIce_BasalFrictionCoefficient_Def.hpp" ), }, From 430f5cea0312be6322f1701628d3dbc4fde1a16f Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 18 Aug 2026 19:48:38 -0700 Subject: [PATCH 10/55] Fit C from fast-flowing/full-Coulomb region instead of critical velocity Replace area_weighted_optimal_C() with fit_coulomb_C_fast_region(): - Identify the fast-flowing region as grounded cells with actual current sliding speed (from velocity-x/y-field) greater than --critical-velocity. - Assume that region is already in the fully-plastic Coulomb regime of the RC law (Tau_b = C * N), and fit C as the area-weighted least-squares best match of C * N to the Weertman law's basal shear stress (mu * speed^qW, no effective-pressure term) in that region. - Lambda is then solved exactly per cell (unchanged logic) so the RC law reproduces the Weertman shear stress at the cell's actual current speed, using this new C. - Cells where the Weertman shear stress at the current speed already meets or exceeds the fitted Coulomb limit C*N have no valid Lambda solution; Lambda is set to 0 there, which is not an arbitrary filler but the physically-correct full-Coulomb limit (Tau_b_RC saturates at C*N as Lambda -> 0, independent of speed). - Updated diagnostics/local_C to reflect the new fast-region fit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 87 ++++++++++++++----- 1 file changed, 66 insertions(+), 21 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 4abfb329d..f1c66938c 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -188,9 +188,29 @@ def albany_temperature_based_flow_rate(temperature): return np.where(T < ALBANY_FLOW_RATE_SWITCHING_T, A_low, A_high) -def area_weighted_optimal_C(mu, N, area, uc, q, mask): +def fit_coulomb_C_fast_region(mu, N, area, speed, q, mask): """ - C = integral(uc^q * mu / N dA) / integral(dA) + Fit a single scalar Regularized Coulomb "Coulomb Friction + Coefficient" C by assuming that, in the fast-flowing region + identified by `mask` (typically grounded cells with a current + sliding speed above some critical velocity), the ice is already in + the fully-plastic Coulomb regime of the RC law, i.e. + + Tau_b_RC = C * N + + C is chosen to be the area-weighted least-squares best fit of this + relation against the Weertman law's basal shear stress at the + cell's actual current sliding speed, + + Tau_b_Weertman = mu * speed^qW + + (no effective-pressure term -- MALI's Weertman sliding law has + none; muFriction was calibrated against this convention). + + Minimizing sum_i area_i * (C * N_i - Tau_b_Weertman_i)^2 over C + gives the normal-equations solution + + C = sum(area * N * Tau_b_Weertman) / sum(area * N^2) `q` here is the input Weertman/Power-Law exponent (qW), not the Regularized Coulomb exponent. @@ -207,16 +227,24 @@ def area_weighted_optimal_C(mu, N, area, uc, q, mask): & np.isfinite(mu) & np.isfinite(N) & np.isfinite(area) + & np.isfinite(speed) & (N > 0.0) & (area > 0.0) + & (speed > 0.0) ) if not np.any(valid): - raise ValueError("No valid grounded cells available for C calculation.") + raise ValueError( + "No valid fast-flowing cells (speed > critical velocity) " + "available for C calculation." + ) + + tau_b_weertman = mu[valid] * speed[valid] ** q - integrand = (uc ** q) * mu[valid] / N[valid] + numerator = np.sum(area[valid] * N[valid] * tau_b_weertman) + denominator = np.sum(area[valid] * N[valid] ** 2) - C = np.sum(area[valid] * integrand) / np.sum(area[valid]) + C = numerator / denominator return C, valid @@ -535,15 +563,19 @@ def basal_cell_field(name): ) # ------------------------------------------------------------- - # Optimal C + # Optimal C: fit against the fast-flowing region only, assuming + # it is already in the fully-plastic Coulomb regime of the RC law + # (Tau_b = C * N). # ------------------------------------------------------------- - C, fit_mask = area_weighted_optimal_C( + fast_flowing = grounded & (speed > args.critical_velocity) + + C, fit_mask = fit_coulomb_C_fast_region( mu=mu, N=N_albany, area=area, - uc=args.critical_velocity, + speed=speed, q=args.weertman_q, - mask=grounded, + mask=fast_flowing, ) # ------------------------------------------------------------- @@ -577,9 +609,10 @@ def basal_cell_field(name): # Lambda = u * [(C*N / (mu * u^qW))^(1/p) - 1] # / (SECONDS_PER_YEAR * A * N^n) # - # This is exactly the same premise used by area_weighted_optimal_C - # above (which likewise assumes Tau_b_W = mu * uc^qW with no N - # term, matched against the RC Coulomb limit C*N). + # This is the same Weertman shear-stress convention used by + # fit_coulomb_C_fast_region above (Tau_b_W = mu * u^qW, no N term, + # matched in the fast-flowing region against the RC Coulomb limit + # C*N). # # where u is in m/yr, A is in Pa^-3 s^-1, N (raw Pa) is used here # exactly as in the previous uc-based derivation (Albany's @@ -592,8 +625,13 @@ def basal_cell_field(name): # shear stress above the Coulomb limit C*N (attained only in the # Lambda -> 0 limit), cells where the Weertman law's Tau_b at the # current speed already meets or exceeds C*N have no valid - # (non-negative) solution for Lambda; these are set to 0 (maximal - # Coulomb sliding) and reported below. + # (non-negative) solution for Lambda. Physically, Lambda -> 0 is + # *exactly* the fully-plastic Coulomb regime (Tau_b_RC saturates + # at its maximum achievable value, C*N, independent of speed), so + # setting Lambda = 0 at these cells is not an arbitrary filler + # value -- it is the correct behavior for cells that the fast- + # flowing/full-Coulomb assumption used to fit C was designed to + # describe in the first place. These cells are reported below. # ------------------------------------------------------------- Lambda = np.zeros_like(N) @@ -625,7 +663,7 @@ def basal_cell_field(name): n_unreachable = int(np.count_nonzero(valid_speed & ~lambda_mask)) if n_unreachable > 0: print( - f"WARNING: {n_unreachable} grounded cells have a Weertman " + f"NOTE: {n_unreachable} grounded cells have a Weertman " "basal shear stress (mu*u^qW) at the current sliding " "speed that meets or exceeds the Coulomb limit C*N; " "Lambda set to 0.0 (maximal Coulomb sliding) at these " @@ -638,11 +676,15 @@ def basal_cell_field(name): # ------------------------------------------------------------- # Diagnostics # ------------------------------------------------------------- + # "Local" implied C: the per-cell ratio of the actual Weertman + # shear stress (at the cell's current sliding speed) to N, i.e. + # what C would have to be for that cell alone to be exactly in + # the full-Coulomb regime. Its spread within the fast-flowing fit + # region gives a sense of how well a single scalar C fits that + # region. local_C = np.full_like(N, np.nan) - local_C[fit_mask] = ( - args.critical_velocity ** args.weertman_q - * mu[fit_mask] - / N_albany[fit_mask] + local_C[valid_speed] = ( + tau_b_weertman[valid_speed] / N_albany[valid_speed] ) print() @@ -661,8 +703,11 @@ def basal_cell_field(name): f"{np.min(basal_temperature):.6f} -- " f"{np.max(basal_temperature):.6f} K" ) - print(f"Cells used in C fit : {np.count_nonzero(fit_mask)}") - print(f"Grounded area used : {np.sum(area[fit_mask]):.10e}") + print( + f"Fast-flowing (speed > uc) cells used in C fit : " + f"{np.count_nonzero(fit_mask)} / {int(np.count_nonzero(grounded))} grounded" + ) + print(f"Fast-flowing area used : {np.sum(area[fit_mask]):.10e}") print() print(f"Optimal C : {C:.16e}") print() From 0d3aa44ff42e244488421fb80433dcff4c05efce Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 18 Aug 2026 20:28:12 -0700 Subject: [PATCH 11/55] Use area-weighted mean for C fit; force full-Coulomb Lambda in fast region; add diagnostic masks - fit_coulomb_C_fast_region(): replace N^2-weighted least-squares fit (which was dominated by a small number of high-N, high-leverage cells) with a straight area-weighted mean of the per-cell implied C = Tau_b_Weertman / N over the fast-flowing region. - Lambda/bedRoughnessRC: fast-flowing cells (speed > critical velocity, the same region used to fit C) are now always assumed to be in the full-Coulomb regime and forced to a reference value (new --lambda-reference-value, default 0.0), rather than attempting (and failing) an exact per-cell solve there. The exact per-cell solve is now only attempted for the slow-flowing grounded region. - Added three new int8 mask fields to the output NetCDF for evaluation: maskGrounded, maskFastFlowing, and maskValidBedRoughnessRC (marks cells where Lambda was solved exactly vs. set to the full-Coulomb reference value). - Fixed a diagnostics bug (local_C/basal sliding speed range) that arose from separating the fast-flowing and slow-flowing regions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 167 ++++++++++++++---- 1 file changed, 132 insertions(+), 35 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index f1c66938c..9bac131f2 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -198,19 +198,23 @@ def fit_coulomb_C_fast_region(mu, N, area, speed, q, mask): Tau_b_RC = C * N - C is chosen to be the area-weighted least-squares best fit of this - relation against the Weertman law's basal shear stress at the - cell's actual current sliding speed, + C is chosen to be the area-weighted mean, over the fast-flowing + region, of the per-cell "local C" implied by that relation against + the Weertman law's basal shear stress at the cell's actual current + sliding speed, Tau_b_Weertman = mu * speed^qW (no effective-pressure term -- MALI's Weertman sliding law has none; muFriction was calibrated against this convention). - Minimizing sum_i area_i * (C * N_i - Tau_b_Weertman_i)^2 over C - gives the normal-equations solution + local_C = Tau_b_Weertman / N - C = sum(area * N * Tau_b_Weertman) / sum(area * N^2) + C = sum(area * local_C) / sum(area) + + A straight area-weighted mean is used (rather than an N^2-weighted + least-squares fit through the origin) so that a small number of + high-N, high-leverage cells cannot dominate the result. `q` here is the input Weertman/Power-Law exponent (qW), not the Regularized Coulomb exponent. @@ -240,11 +244,9 @@ def fit_coulomb_C_fast_region(mu, N, area, speed, q, mask): ) tau_b_weertman = mu[valid] * speed[valid] ** q + local_C = tau_b_weertman / N[valid] - numerator = np.sum(area[valid] * N[valid] * tau_b_weertman) - denominator = np.sum(area[valid] * N[valid] ** 2) - - C = numerator / denominator + C = np.sum(area[valid] * local_C) / np.sum(area[valid]) return C, valid @@ -282,6 +284,25 @@ def main(): ) ) + parser.add_argument( + "--lambda-reference-value", + type=float, + default=0.0, + help=( + "Value assigned to bedRoughnessRC (Lambda) at cells " + "assumed to be in the full-Coulomb regime: all " + "fast-flowing cells (speed > critical velocity, the same " + "region used to fit C), plus any other grounded cell " + "where the exact per-cell Lambda solve is ill-defined " + "(Weertman Tau_b already meets or exceeds the Coulomb " + "limit C*N). Lambda -> 0 exactly reproduces the " + "full-Coulomb limit, so 0.0 (default) is physically " + "correct; a small positive reference value can be used " + "instead if a strictly-zero bed roughness is undesirable " + "for other reasons (default: 0.0)." + ) + ) + # Downs & Johnson / Albany parameters parser.add_argument( "--min-fraction-overburden", @@ -628,14 +649,28 @@ def basal_cell_field(name): # (non-negative) solution for Lambda. Physically, Lambda -> 0 is # *exactly* the fully-plastic Coulomb regime (Tau_b_RC saturates # at its maximum achievable value, C*N, independent of speed), so - # setting Lambda = 0 at these cells is not an arbitrary filler - # value -- it is the correct behavior for cells that the fast- - # flowing/full-Coulomb assumption used to fit C was designed to - # describe in the first place. These cells are reported below. + # setting Lambda = args.lambda_reference_value at these cells is + # not an arbitrary filler value -- it is the correct behavior for + # cells that the fast-flowing/full-Coulomb assumption is designed + # to describe in the first place. + # + # Fast-flowing cells (the same region used to fit C, i.e. + # speed > critical_velocity) are *always* assumed to be in this + # full-Coulomb regime and are therefore always forced to + # Lambda = args.lambda_reference_value, regardless of what the + # per-cell algebraic solve above would otherwise give -- the exact + # per-cell solve is not attempted there at all, since matching the + # Weertman law exactly at high speed is not the goal (the fast + # region is assumed C-limited by construction). # ------------------------------------------------------------- - Lambda = np.zeros_like(N) - - valid_speed = ( + Lambda = np.full_like(N, args.lambda_reference_value) + + # tau_b_weertman/local_C are computed over all grounded cells with + # a well-defined speed and mu (independent of the fast/slow split) + # so that diagnostics (local_C) remain meaningful for the + # fast-flowing fit region even though the exact Lambda solve below + # is only attempted for the slow-flowing cells. + speed_defined = ( grounded & np.isfinite(N) & (N > 0.0) & np.isfinite(mu) & (mu > 0.0) @@ -643,10 +678,12 @@ def basal_cell_field(name): ) tau_b_weertman = np.full_like(N, np.nan) - tau_b_weertman[valid_speed] = ( - mu[valid_speed] * speed[valid_speed] ** args.weertman_q + tau_b_weertman[speed_defined] = ( + mu[speed_defined] * speed[speed_defined] ** args.weertman_q ) + valid_speed = speed_defined & ~fast_flowing + stress_ratio = np.full_like(N, np.nan) stress_ratio[valid_speed] = ( (C * N_albany[valid_speed]) / tau_b_weertman[valid_speed] @@ -663,12 +700,17 @@ def basal_cell_field(name): n_unreachable = int(np.count_nonzero(valid_speed & ~lambda_mask)) if n_unreachable > 0: print( - f"NOTE: {n_unreachable} grounded cells have a Weertman " - "basal shear stress (mu*u^qW) at the current sliding " - "speed that meets or exceeds the Coulomb limit C*N; " - "Lambda set to 0.0 (maximal Coulomb sliding) at these " - "cells." + f"NOTE: {n_unreachable} slow-flowing grounded cells have a " + "Weertman basal shear stress (mu*u^qW) at the current " + "sliding speed that meets or exceeds the Coulomb limit " + f"C*N; Lambda set to {args.lambda_reference_value:g} " + "(maximal Coulomb sliding) at these cells." ) + print( + f"Fast-flowing cells forced to Lambda = " + f"{args.lambda_reference_value:g} (full-Coulomb assumption) : " + f"{int(np.count_nonzero(fast_flowing))}" + ) # Floating/ice-free/invalid-speed/unreachable-stress cells are # deliberately left at zero (see Lambda initialization above). @@ -683,8 +725,8 @@ def basal_cell_field(name): # region gives a sense of how well a single scalar C fits that # region. local_C = np.full_like(N, np.nan) - local_C[valid_speed] = ( - tau_b_weertman[valid_speed] / N_albany[valid_speed] + local_C[speed_defined] = ( + tau_b_weertman[speed_defined] / N_albany[speed_defined] ) print() @@ -717,11 +759,11 @@ def basal_cell_field(name): f"{np.nanmax(N[fit_mask]):.6e}" ) print( - "Basal sliding speed range : " + "Basal sliding speed range (all grounded) : " + ( - f"{np.nanmin(speed[valid_speed]):.6e} -- " - f"{np.nanmax(speed[valid_speed]):.6e} m/yr" - if np.any(valid_speed) else "n/a (no valid cells)" + f"{np.nanmin(speed[speed_defined]):.6e} -- " + f"{np.nanmax(speed[speed_defined]):.6e} m/yr" + if np.any(speed_defined) else "n/a (no valid cells)" ) ) print( @@ -786,11 +828,17 @@ def basal_cell_field(name): attrs={ "long_name": "Albany regularized-Coulomb bed roughness Lambda", "description": ( - "Lambda solved exactly so that the Regularized Coulomb " - "law reproduces the Weertman law's basal shear stress " - "(mu*u^qW, no effective-pressure term) at the cell's " - "actual current sliding speed u (from velocity-x/y-" - "field, last nVertInterfaces level): Lambda = u * " + "Fast-flowing cells (speed > critical velocity) and " + "any other grounded cell where the solve below is " + "ill-defined are assumed to be in the full-Coulomb " + f"regime and set to {args.lambda_reference_value:g} " + "(see maskFastFlowing/maskValidBedRoughnessRC). " + "Elsewhere, Lambda is solved exactly so that the " + "Regularized Coulomb law reproduces the Weertman " + "law's basal shear stress (mu*u^qW, no effective-" + "pressure term) at the cell's actual current sliding " + "speed u (from velocity-x/y-field, last " + "nVertInterfaces level): Lambda = u * " "[(C*N/(mu*u^qW))^(1/qR) - 1] / (SECONDS_PER_YEAR * A " "* N^n), with u in m/yr, A in Pa^-3 s^-1, N in Pa, " "matching Albany's internal secsInYr scaling in " @@ -808,6 +856,55 @@ def basal_cell_field(name): }, ) + out["maskGrounded"] = xr.DataArray( + grounded.astype(np.int8), + dims=(ncell_dim,), + attrs={ + "long_name": "Mask of grounded ice (Albany grounded-ice test)", + "description": ( + "1 where rho_i*H + rho_w*bed > 0 and H > 0, else 0" + ), + }, + ) + + out["maskFastFlowing"] = xr.DataArray( + fast_flowing.astype(np.int8), + dims=(ncell_dim,), + attrs={ + "long_name": ( + "Mask of grounded, fast-flowing cells assumed to be in " + "the full-Coulomb regime (used to fit C)" + ), + "description": ( + "1 where maskGrounded and speed (from velocity-x/y-" + "field, last nVertInterfaces level) > critical " + "velocity, else 0. bedRoughnessRC is forced to " + f"{args.lambda_reference_value:g} at these cells." + ), + }, + ) + + out["maskValidBedRoughnessRC"] = xr.DataArray( + lambda_mask.astype(np.int8), + dims=(ncell_dim,), + attrs={ + "long_name": ( + "Mask of cells where bedRoughnessRC (Lambda) was " + "solved exactly, rather than set to the full-Coulomb " + f"reference value ({args.lambda_reference_value:g})" + ), + "description": ( + "1 where the cell is grounded, not fast-flowing, and " + "the Weertman basal shear stress at the cell's " + "current sliding speed is strictly below the Coulomb " + "limit C*N (a valid, non-negative Lambda solution " + "exists); 0 otherwise (includes maskFastFlowing " + "cells and any slow-flowing grounded cell where the " + "solve is ill-defined)." + ), + }, + ) + if args.flow_rate_type == "temperature": flow_rate_long_name = ( "Glen flow-rate factor A from Albany's Temperature " From 51b6114bf1643cdbe367517d8c404335c69506ab Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 24 Aug 2026 19:24:04 -0700 Subject: [PATCH 12/55] Add alternate 'transition' effective-pressure parameterization option - Fixed broken syntax (missing commas in signature, stray duplicate closing paren/return-type annotation) and a real bug (q_start = min(q_start, q_inland) used Python's builtin min() on numpy arrays, which does not broadcast correctly; replaced with np.minimum()) in the user-supplied effective_pressure4() function. - Guarded against division by zero at ice-free cells (H == 0). - Exposed h_ocean as a new parameter/CLI flag (--transition-h-ocean, default 0.025) instead of a hardcoded magic number; changed the function to return only N (matching downs_johnson_effective_pressure()'s convention), dropping the previously-unused N_inland/N_ocean tuple outputs. - Added --effective-pressure-type {downs-johnson,transition} to select between Albany's own internal Hydrostatic-At-Nodes N formula (default, unchanged) and this new near-ocean/inland-transition parameterization; added --transition-alpha/--transition-length- scale/--transition-h-ocean CLI args (required only for 'transition'), with mutual-exclusivity validation against the downs-johnson-specific args. - Since Albany does not yet support consuming a precomputed effective-pressure Field for the Regularized Coulomb law, the suggested YAML for 'transition' prints a placeholder 'Effective Pressure Type: TRANSITION OPTION TO BE ADDED' line and a NOTE explaining this is offline-evaluation-only pending upstream Albany support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 323 ++++++++++++++++-- 1 file changed, 300 insertions(+), 23 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 9bac131f2..dc9564105 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -99,6 +99,141 @@ ALBANY_FLOW_RATE_ARRMLH = 1.733e3 # [Pa-3 s-1] ALBANY_FLOW_RATE_ARRMLL = 3.613e-13 # [Pa-3 s-1] +def effective_pressure4( + thickness, + bed, + alpha, + length_scale, + rho_i=910.0, + rho_w=1028.0, + gravity=9.80616, + h_ocean=0.025): + """ + Effective pressure with a near-ocean region followed by a bounded + transition to a prescribed inland fraction of overburden. + + Unlike downs_johnson_effective_pressure() (Albany's own internal + "Hydrostatic"/"Hydrostatic At Nodes" formula), this is *not* + reproduced from an Albany-internal formula. As of this writing, + Albany has no way to consume a precomputed N field directly for + the Regularized Coulomb law (there is no equivalent to an + "Effective Pressure Type: Field" option here yet), so this + parameterization cannot currently be used to run Albany at all -- + it is included for offline evaluation/comparison purposes only, + pending upstream Albany support. + + Parameters + ---------- + thickness : ndarray + Ice thickness H [m]. + bed : ndarray + Bed elevation b [m], positive above sea level. + alpha : float + 1 minus the prescribed inland effective-pressure fraction of + overburden, i.e. the inland effective pressure is + N_inland = gravity * rho_i * H * (1 - alpha). Analogous in + spirit to downs_johnson_effective_pressure()'s + "min_fraction_overburden", but as a deficit fraction rather + than a retained fraction. + length_scale : float + Distance L [m, same units as `bed`/`thickness`] over which + half the remaining difference between the near-ocean value and + the inland target fraction is removed, moving inland from the + point where height above flotation first exceeds `h_ocean`. + L == 0 disables the smooth transition (a step function is used + instead). + rho_i : float + Ice density [kg m^-3]. + rho_w : float + Water density [kg m^-3]. + gravity : float + Gravitational acceleration [m s^-2]. + h_ocean : float + Height above flotation [m] below which the effective pressure + is assumed to be set purely by the ocean-connected + (hydrostatic) fraction, with no inland transition applied + (default: 0.025 m). + + Returns + ------- + N : ndarray + Effective pressure [Pa]. + """ + H = np.asarray(thickness, dtype=np.float64) + b = np.asarray(bed, dtype=np.float64) + + ice_term = rho_i * H + ocean_term = np.maximum(-rho_w * b, 0.0) + + # Height above flotation in bed-elevation coordinates. + height_above_flotation = np.maximum( + b + (rho_i / rho_w) * H, + 0.0, + ) + + # Guard against division by zero at ice-free cells (H == 0, so + # ice_term == 0); N will end up 0 there regardless of q, since N + # is proportional to ice_term below. + safe_ice_term = np.where(ice_term > 0.0, ice_term, 1.0) + + # Ocean-connected effective-pressure fraction. + q_ocean = np.where( + ice_term > 0.0, + np.maximum(1.0 - ocean_term / safe_ice_term, 0.0), + 0.0, + ) + + # Prescribed inland effective-pressure fraction. + q_inland = 1.0 - alpha + + # Ocean-connected value at the end of the fixed region. + q_start = np.where( + ice_term > 0.0, + rho_w * h_ocean / safe_ice_term, + 0.0, + ) + + # This cap guarantees that the transition begins at or below the + # prescribed inland value. Use np.minimum (not the Python builtin + # min()), since q_start/q_inland are arrays/broadcastable, not + # plain scalars. + q_start = np.minimum(q_start, q_inland) + # Keep the near-grounding-line branch bounded as well. + q_near_ocean = np.minimum(q_ocean, q_inland) + + if length_scale == 0.0: + q = np.where( + height_above_flotation <= h_ocean, + q_near_ocean, + q_inland, + ) + else: + distance_into_transition = np.maximum( + height_above_flotation - h_ocean, + 0.0, + ) + + transition_q = ( + q_inland + - (q_inland - q_start) + * np.exp( + -np.log(2.0) + * distance_into_transition / length_scale + ) + ) + + q = np.where( + height_above_flotation <= h_ocean, + q_near_ocean, + transition_q, + ) + + # Roundoff safeguard. + q = np.clip(q, 0.0, q_inland) + + N = gravity * ice_term * q + + return N def downs_johnson_effective_pressure( thickness, @@ -303,20 +438,87 @@ def main(): ) ) - # Downs & Johnson / Albany parameters + # Effective pressure N + parser.add_argument( + "--effective-pressure-type", + choices=["downs-johnson", "transition"], + default="downs-johnson", + help=( + "How to compute the effective pressure N (default: " + "downs-johnson). \"downs-johnson\" reproduces Albany's " + "own internal \"Hydrostatic At Nodes\" Effective Pressure " + "Type formula exactly (see --min-fraction-overburden/" + "--pressure-length-scale); Albany recomputes N itself at " + "runtime from thickness/bed, so no N field needs to be " + "supplied. \"transition\" computes N here using a " + "near-ocean/inland-transition parameterization (see " + "--transition-alpha/--transition-length-scale/" + "--transition-h-ocean) and writes it to the output for " + "reference; NOTE: Albany does not yet have a way to " + "consume this precomputed N directly for the Regularized " + "Coulomb law, so \"transition\" cannot currently be used " + "to actually run Albany (offline evaluation only, pending " + "upstream Albany support)." + ) + ) + + # Downs & Johnson / Albany parameters (required only when + # --effective-pressure-type=downs-johnson). parser.add_argument( "--min-fraction-overburden", type=float, - required=True, - help='Albany "Minimum Fraction Overburden Pressure"' + default=None, + help=( + 'Albany "Minimum Fraction Overburden Pressure" (required ' + "when --effective-pressure-type=downs-johnson)" + ) ) parser.add_argument( "--pressure-length-scale", type=float, - required=True, + default=None, help=( 'Albany "Length Scale Factor", converted to the same ' - "length units as bedTopography (normally m)" + "length units as bedTopography (normally m) (required " + "when --effective-pressure-type=downs-johnson)" + ) + ) + + # "transition" effective-pressure parameters (required only when + # --effective-pressure-type=transition). + parser.add_argument( + "--transition-alpha", + type=float, + default=None, + help=( + "1 minus the prescribed inland effective-pressure " + "fraction of overburden (see effective_pressure4()); " + "required when --effective-pressure-type=transition" + ) + ) + parser.add_argument( + "--transition-length-scale", + type=float, + default=None, + help=( + "Distance [m] over which half the remaining difference " + "between the near-ocean value and the inland target " + "fraction is removed moving inland (see " + "effective_pressure4()); 0 disables the smooth " + "transition (step function). Required when " + "--effective-pressure-type=transition." + ) + ) + parser.add_argument( + "--transition-h-ocean", + type=float, + default=0.025, + help=( + "Height above flotation [m] below which N is assumed set " + "purely by the ocean-connected fraction, with no inland " + "transition applied (see effective_pressure4()); only " + "used when --effective-pressure-type=transition " + "(default: 0.025)" ) ) @@ -456,6 +658,32 @@ def main(): "with this exponent." ) + if args.effective_pressure_type == "downs-johnson": + if args.min_fraction_overburden is None or args.pressure_length_scale is None: + parser.error( + "--min-fraction-overburden and --pressure-length-scale " + "are required when " + "--effective-pressure-type=downs-johnson" + ) + if args.transition_alpha is not None or args.transition_length_scale is not None: + parser.error( + "--transition-alpha/--transition-length-scale are " + "only used with --effective-pressure-type=transition " + "(got --effective-pressure-type=downs-johnson)" + ) + else: + if args.transition_alpha is None or args.transition_length_scale is None: + parser.error( + "--transition-alpha and --transition-length-scale are " + "required when --effective-pressure-type=transition" + ) + if args.min_fraction_overburden is not None or args.pressure_length_scale is not None: + parser.error( + "--min-fraction-overburden/--pressure-length-scale are " + "only used with --effective-pressure-type=downs-johnson " + "(got --effective-pressure-type=transition)" + ) + # ------------------------------------------------------------- # Read IC # ------------------------------------------------------------- @@ -554,15 +782,27 @@ def basal_cell_field(name): # ------------------------------------------------------------- # Effective pressure # ------------------------------------------------------------- - N = downs_johnson_effective_pressure( - thickness=H, - bed=bed, - min_fraction_overburden=args.min_fraction_overburden, - length_scale=args.pressure_length_scale, - rho_i=args.rho_ice, - rho_w=args.rho_water, - gravity=args.gravity, - ) + if args.effective_pressure_type == "downs-johnson": + N = downs_johnson_effective_pressure( + thickness=H, + bed=bed, + min_fraction_overburden=args.min_fraction_overburden, + length_scale=args.pressure_length_scale, + rho_i=args.rho_ice, + rho_w=args.rho_water, + gravity=args.gravity, + ) + else: + N = effective_pressure4( + thickness=H, + bed=bed, + alpha=args.transition_alpha, + length_scale=args.transition_length_scale, + rho_i=args.rho_ice, + rho_w=args.rho_water, + gravity=args.gravity, + h_ocean=args.transition_h_ocean, + ) # Albany's own internal effective pressure (e.g. "Hydrostatic" # Effective Pressure Type) is computed from bed/thickness fields @@ -936,12 +1176,26 @@ def basal_cell_field(name): out.attrs["weertman_q"] = float(args.weertman_q) out.attrs["regularizedCoulomb_GlenN"] = float(args.glen_n) out.attrs["regularizedCoulomb_flowRateType"] = albany_flow_rate_type - out.attrs["regularizedCoulomb_minFractionOverburden"] = ( - float(args.min_fraction_overburden) - ) - out.attrs["regularizedCoulomb_pressureLengthScale"] = ( - float(args.pressure_length_scale) + out.attrs["regularizedCoulomb_effectivePressureType"] = ( + args.effective_pressure_type ) + if args.effective_pressure_type == "downs-johnson": + out.attrs["regularizedCoulomb_minFractionOverburden"] = ( + float(args.min_fraction_overburden) + ) + out.attrs["regularizedCoulomb_pressureLengthScale"] = ( + float(args.pressure_length_scale) + ) + else: + out.attrs["regularizedCoulomb_transitionAlpha"] = ( + float(args.transition_alpha) + ) + out.attrs["regularizedCoulomb_transitionLengthScale"] = ( + float(args.transition_length_scale) + ) + out.attrs["regularizedCoulomb_transitionHOcean"] = ( + float(args.transition_h_ocean) + ) # xarray cannot safely overwrite an open source file, so use temp. tmp = args.output + ".tmp" @@ -962,6 +1216,20 @@ def basal_cell_field(name): f" Flow Rate Type: Temperature Based\n" ) + if args.effective_pressure_type == "downs-johnson": + effective_pressure_yaml_lines = ( + " Effective Pressure Type: Hydrostatic At Nodes\n" + " Use Pressurized Bed Above Sea Level: true\n" + " Minimum Fraction Overburden Pressure: " + f"{args.min_fraction_overburden:.16e}\n" + " Length Scale Factor: " + f"{args.pressure_length_scale / 1000.0:.16e}" + ) + else: + effective_pressure_yaml_lines = ( + " Effective Pressure Type: TRANSITION OPTION TO BE ADDED" + ) + print() print("Suggested Albany YAML section") print("-----------------------------") @@ -974,10 +1242,7 @@ def basal_cell_field(name): Power Exponent: {RC_POWER_EXPONENT:.16e} {flow_rate_yaml_lines} Bed Roughness Type: Field - Effective Pressure Type: Hydrostatic At Nodes - Use Pressurized Bed Above Sea Level: true - Minimum Fraction Overburden Pressure: {args.min_fraction_overburden:.16e} - Length Scale Factor: {args.pressure_length_scale / 1000.0:.16e} +{effective_pressure_yaml_lines} """ ) print( @@ -992,6 +1257,18 @@ def basal_cell_field(name): + "); ensure that setting is used at run time, or Lambda will be " "inconsistent with the actual A used by Albany." ) + if args.effective_pressure_type == "transition": + print( + "NOTE: The \"transition\" effective-pressure " + "parameterization (--effective-pressure-type=transition) " + "is not yet implemented in Albany, so no valid " + "\"Effective Pressure Type\" YAML setting exists for it " + "yet -- the placeholder above must be replaced once " + "Albany supports reading a precomputed N field directly " + f"(e.g. from the \"{args.effective_pressure_field}\" field " + f"written to {args.output}) for this parameterization." + ) + if __name__ == "__main__": From 6468bbf28bdca0c5eebd6536e3c512bd5d9b86fe Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 24 Aug 2026 19:25:52 -0700 Subject: [PATCH 13/55] Fix h_ocean default units in effective_pressure4 (km -> m) The default h_ocean value of 0.025 was originally specified in km (25 m), but effective_pressure4() compares it directly against height_above_flotation, which is computed in meters (consistent with thickness/bed elsewhere in the script). Changed the default to 25.0 m so it is self-consistent with the rest of the script's unit conventions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index dc9564105..4c9e99965 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -107,7 +107,7 @@ def effective_pressure4( rho_i=910.0, rho_w=1028.0, gravity=9.80616, - h_ocean=0.025): + h_ocean=25.0): """ Effective pressure with a near-ocean region followed by a bounded transition to a prescribed inland fraction of overburden. @@ -149,10 +149,11 @@ def effective_pressure4( gravity : float Gravitational acceleration [m s^-2]. h_ocean : float - Height above flotation [m] below which the effective pressure - is assumed to be set purely by the ocean-connected - (hydrostatic) fraction, with no inland transition applied - (default: 0.025 m). + Height above flotation [m, same units as `bed`/`thickness`] + below which the effective pressure is assumed to be set + purely by the ocean-connected (hydrostatic) fraction, with no + inland transition applied (default: 25.0 m; originally + specified as 0.025 km). Returns ------- @@ -512,13 +513,13 @@ def main(): parser.add_argument( "--transition-h-ocean", type=float, - default=0.025, + default=25.0, help=( - "Height above flotation [m] below which N is assumed set " - "purely by the ocean-connected fraction, with no inland " - "transition applied (see effective_pressure4()); only " - "used when --effective-pressure-type=transition " - "(default: 0.025)" + "Height above flotation [m, same units as bedTopography] " + "below which N is assumed set purely by the " + "ocean-connected fraction, with no inland transition " + "applied (see effective_pressure4()); only used when " + "--effective-pressure-type=transition (default: 25.0)" ) ) From c7f18ef3b25c061793066c8f0e78c1f3bd86c000 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 24 Aug 2026 19:34:24 -0700 Subject: [PATCH 14/55] Add --diagnostics/--no-diagnostics flag; add floatationFraction and hydropotential fields - New --diagnostics/--no-diagnostics mutually-exclusive flag (default: --diagnostics, i.e. enabled) controls whether diagnostic fields (effectivePressure, flowRateA, floatationFraction, hydropotential, maskGrounded, maskFastFlowing, maskValidBedRoughnessRC) are written to the output NetCDF. The required bedRoughnessRC field is always written regardless of this flag; muFriction is always removed regardless of this flag. - Added two new diagnostic fields: - floatationFraction = Pw / Pice, with Pw = Pice - N (basal water pressure) and Pice = rho_i * gravity * thickness (ice overburden pressure). - hydropotential: Shreve hydraulic potential, phi = rho_water * gravity * bedTopography + Pw. - Added --floatation-fraction-field/--hydropotential-field to name these new output fields (defaults: floatationFraction, hydropotential). - effectivePressure's long_name now reflects which --effective-pressure-type was actually used (Downs-Johnson vs. the near-ocean/inland-transition parameterization), rather than always saying 'Downs-Johnson'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 206 ++++++++++++------ 1 file changed, 144 insertions(+), 62 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 4c9e99965..2f18a7d0c 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -629,6 +629,41 @@ def main(): default="flowRateA", help="Name for diagnostic Glen flow-rate A field" ) + parser.add_argument( + "--floatation-fraction-field", + default="floatationFraction", + help="Name for diagnostic floatation fraction field (Pw/Pice)" + ) + parser.add_argument( + "--hydropotential-field", + default="hydropotential", + help="Name for diagnostic hydraulic potential field" + ) + + diagnostics_group = parser.add_mutually_exclusive_group() + diagnostics_group.add_argument( + "--diagnostics", + dest="diagnostics", + action="store_true", + default=True, + help=( + "Include diagnostic fields (effectivePressure, flowRateA, " + "floatationFraction, hydropotential, maskGrounded, " + "maskFastFlowing, maskValidBedRoughnessRC) in the output " + "NetCDF, useful for debugging/evaluation/visualization " + "(default: enabled)." + ) + ) + diagnostics_group.add_argument( + "--no-diagnostics", + dest="diagnostics", + action="store_false", + help=( + "Omit diagnostic fields from the output NetCDF, e.g. for " + "production runs where only bedRoughnessRC is needed and " + "extra fields are unwanted clutter." + ) + ) parser.add_argument( "--time-index", @@ -1088,63 +1123,109 @@ def basal_cell_field(name): }, ) - out[args.effective_pressure_field] = xr.DataArray( - N, - dims=(ncell_dim,), - attrs={ - "long_name": "Downs-Johnson effective pressure", - "units": "Pa", - }, - ) + if args.diagnostics: + if args.effective_pressure_type == "downs-johnson": + effective_pressure_long_name = "Downs-Johnson effective pressure" + else: + effective_pressure_long_name = ( + "Effective pressure (near-ocean/inland-transition " + "parameterization; see effective_pressure4())" + ) - out["maskGrounded"] = xr.DataArray( - grounded.astype(np.int8), - dims=(ncell_dim,), - attrs={ - "long_name": "Mask of grounded ice (Albany grounded-ice test)", - "description": ( - "1 where rho_i*H + rho_w*bed > 0 and H > 0, else 0" - ), - }, - ) + out[args.effective_pressure_field] = xr.DataArray( + N, + dims=(ncell_dim,), + attrs={ + "long_name": effective_pressure_long_name, + "units": "Pa", + }, + ) - out["maskFastFlowing"] = xr.DataArray( - fast_flowing.astype(np.int8), - dims=(ncell_dim,), - attrs={ - "long_name": ( - "Mask of grounded, fast-flowing cells assumed to be in " - "the full-Coulomb regime (used to fit C)" - ), - "description": ( - "1 where maskGrounded and speed (from velocity-x/y-" - "field, last nVertInterfaces level) > critical " - "velocity, else 0. bedRoughnessRC is forced to " - f"{args.lambda_reference_value:g} at these cells." - ), - }, - ) + # Floatation fraction: Pw / Pice, where Pw is basal water + # pressure (Pice - N) and Pice is the ice overburden pressure + # (rho_i * g * H). 0 at ice-free cells (Pice == 0). + Pice = args.rho_ice * args.gravity * H + Pw = Pice - N + floatation_fraction = np.where(Pice > 0.0, Pw / np.where(Pice > 0.0, Pice, 1.0), 0.0) + + out[args.floatation_fraction_field] = xr.DataArray( + floatation_fraction, + dims=(ncell_dim,), + attrs={ + "long_name": "Floatation fraction (Pw / Pice)", + "description": ( + "Pw = Pice - N (basal water pressure), Pice = " + "rho_i * gravity * thickness (ice overburden " + "pressure); 0 at ice-free cells." + ), + "units": "1", + }, + ) - out["maskValidBedRoughnessRC"] = xr.DataArray( - lambda_mask.astype(np.int8), - dims=(ncell_dim,), - attrs={ - "long_name": ( - "Mask of cells where bedRoughnessRC (Lambda) was " - "solved exactly, rather than set to the full-Coulomb " - f"reference value ({args.lambda_reference_value:g})" - ), - "description": ( - "1 where the cell is grounded, not fast-flowing, and " - "the Weertman basal shear stress at the cell's " - "current sliding speed is strictly below the Coulomb " - "limit C*N (a valid, non-negative Lambda solution " - "exists); 0 otherwise (includes maskFastFlowing " - "cells and any slow-flowing grounded cell where the " - "solve is ill-defined)." - ), - }, - ) + # Shreve hydraulic potential: phi = rho_w * g * bed + Pw. + hydropotential = args.rho_water * args.gravity * bed + Pw + + out[args.hydropotential_field] = xr.DataArray( + hydropotential, + dims=(ncell_dim,), + attrs={ + "long_name": "Shreve hydraulic potential", + "description": ( + "phi = rho_water * gravity * bedTopography + Pw, " + "with Pw = Pice - N" + ), + "units": "Pa", + }, + ) + + out["maskGrounded"] = xr.DataArray( + grounded.astype(np.int8), + dims=(ncell_dim,), + attrs={ + "long_name": "Mask of grounded ice (Albany grounded-ice test)", + "description": ( + "1 where rho_i*H + rho_w*bed > 0 and H > 0, else 0" + ), + }, + ) + + out["maskFastFlowing"] = xr.DataArray( + fast_flowing.astype(np.int8), + dims=(ncell_dim,), + attrs={ + "long_name": ( + "Mask of grounded, fast-flowing cells assumed to be in " + "the full-Coulomb regime (used to fit C)" + ), + "description": ( + "1 where maskGrounded and speed (from velocity-x/y-" + "field, last nVertInterfaces level) > critical " + "velocity, else 0. bedRoughnessRC is forced to " + f"{args.lambda_reference_value:g} at these cells." + ), + }, + ) + + out["maskValidBedRoughnessRC"] = xr.DataArray( + lambda_mask.astype(np.int8), + dims=(ncell_dim,), + attrs={ + "long_name": ( + "Mask of cells where bedRoughnessRC (Lambda) was " + "solved exactly, rather than set to the full-Coulomb " + f"reference value ({args.lambda_reference_value:g})" + ), + "description": ( + "1 where the cell is grounded, not fast-flowing, and " + "the Weertman basal shear stress at the cell's " + "current sliding speed is strictly below the Coulomb " + "limit C*N (a valid, non-negative Lambda solution " + "exists); 0 otherwise (includes maskFastFlowing " + "cells and any slow-flowing grounded cell where the " + "solve is ill-defined)." + ), + }, + ) if args.flow_rate_type == "temperature": flow_rate_long_name = ( @@ -1159,14 +1240,15 @@ def basal_cell_field(name): ) albany_flow_rate_type = "Constant" - out[args.flow_rate_field] = xr.DataArray( - A, - dims=(ncell_dim,), - attrs={ - "long_name": flow_rate_long_name, - "units": "Pa-3 s-1", - }, - ) + if args.diagnostics: + out[args.flow_rate_field] = xr.DataArray( + A, + dims=(ncell_dim,), + attrs={ + "long_name": flow_rate_long_name, + "units": "Pa-3 s-1", + }, + ) # Save conversion information globally. out.attrs["regularizedCoulomb_C"] = float(C) From a2035e764666d508a2564b86690cc3ddcad59c6a Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 24 Aug 2026 19:52:44 -0700 Subject: [PATCH 15/55] Add optional flowline-transect plots of N, floatation fraction, hydropotential - New load_transect()/project_transect()/plot_transects() functions: reads a geometric_features-style transect.geojson directly (no dependency on the geometric_features python package, since these flowline transects are not yet exposed by it), projects the lon/lat LineString into the MALI mesh's planar CRS (EPSG:3031 for Antarctica, matching the convention used elsewhere in MPAS-Tools' ismip7_postprocessing/grid_and_mapping.py), samples effectivePressure/floatationFraction/hydropotential at the nearest MALI cell center (scipy cKDTree) along the transect, and saves a stacked-panel PNG plot vs. along-transect distance. - New CLI args: --transects-dir, --transect-names (default: Thwaites, Totten, Lambert, Foundation, Bindschadler), --plot-dir, and a --plot-transects/--no-plot-transects flag pair (default: disabled). - --plot-transects requires --diagnostics (the fields plotted are diagnostic-only) and --transects-dir; validated via parser.error(). - pyproj/scipy/matplotlib are only imported lazily inside plot_transects(), so the rest of the script has no new hard dependencies when --plot-transects is not used. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 2f18a7d0c..c6e2076d1 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -57,6 +57,8 @@ """ import argparse +import json +import os import shutil import numpy as np @@ -387,6 +389,167 @@ def fit_coulomb_C_fast_region(mu, N, area, speed, q, mask): return C, valid +def load_transect(name, transects_dir): + """ + Load a flowline transect's lon/lat coordinates directly from a + geometric_features-style geojson file, without depending on the + geometric_features python package (which does not yet expose + these newer flowline transects). + + Expects `//transect.geojson`, containing a + single Feature with a LineString geometry of [lon, lat] pairs in + degrees (geometric_features convention). + + Returns + ------- + lon, lat : 1-D numpy arrays, in degrees. + """ + path = os.path.join(transects_dir, name, "transect.geojson") + + with open(path) as f: + geojson = json.load(f) + + feature = geojson["features"][0] + geom = feature["geometry"] + + if geom["type"] != "LineString": + raise ValueError( + f"Transect {name!r} ({path}) has unsupported geometry " + f"type {geom['type']!r}; only LineString is supported." + ) + + coords = np.asarray(geom["coordinates"], dtype=np.float64) + lon = coords[:, 0] + lat = coords[:, 1] + + return lon, lat + + +def project_transect(lon, lat, transformer): + """ + Project a transect's lon/lat coordinates (degrees) into the + planar x/y coordinate system used by the MALI mesh (via the + supplied pyproj Transformer, e.g. EPSG:4326 -> EPSG:3031 for + Antarctica), and compute the cumulative along-transect distance + from the first point. + + Returns + ------- + x, y : 1-D numpy arrays, meters, in the MALI mesh's planar CRS. + distance : 1-D numpy array, meters, cumulative arc length along + the transect starting from 0 at the first point. + """ + x, y = transformer.transform(lon, lat) + x = np.asarray(x, dtype=np.float64) + y = np.asarray(y, dtype=np.float64) + + segment_length = np.hypot(np.diff(x), np.diff(y)) + distance = np.concatenate(([0.0], np.cumsum(segment_length))) + + return x, y, distance + + +def plot_transects( + transect_names, + transects_dir, + plot_dir, + x_cell, + y_cell, + fields, +): + """ + For each named transect, sample the given cell-centered fields + (nearest-neighbor, via a KD-tree on MALI cell centers) along the + transect and save a stacked-panel PNG plot vs. along-transect + distance. + + Parameters + ---------- + transect_names : list of str + Names of subdirectories under `transects_dir`, each expected + to contain a `transect.geojson` (geometric_features + convention; see load_transect()). + transects_dir : str + Path to a geometric_features `landice/transect` directory + (e.g. `.../geometric_features/geometric_data/landice/ + transect`). + plot_dir : str + Directory to write output PNGs to (created if needed). + x_cell, y_cell : 1-D numpy arrays + MALI mesh cell-center coordinates, meters, in the same planar + CRS the transects will be projected into (Antarctic MALI + meshes: EPSG:3031 polar stereographic). + fields : dict of str -> (1-D numpy array, str, str) + Mapping of field label -> (values on MALI cells, units + string, long_name string) to sample and plot along each + transect, e.g. {"N": (N, "Pa", "Effective pressure")}. + """ + try: + import pyproj + except ImportError as e: + raise ImportError( + "Plotting transects requires the 'pyproj' package " + "(projects transect lon/lat into the MALI mesh's planar " + "CRS). Install it (e.g. `conda install pyproj`) or " + "disable transect plotting with --no-plot-transects." + ) from e + + try: + from scipy.spatial import cKDTree + except ImportError as e: + raise ImportError( + "Plotting transects requires the 'scipy' package " + "(nearest-neighbor sampling of MALI cell fields onto " + "transect points). Install it or disable transect " + "plotting with --no-plot-transects." + ) from e + + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + os.makedirs(plot_dir, exist_ok=True) + + # Antarctic MALI meshes use EPSG:3031 polar stereographic (see + # e.g. MPAS-Tools' ismip7_postprocessing/grid_and_mapping.py); + # transects are stored in geographic lon/lat (EPSG:4326). + transformer = pyproj.Transformer.from_crs( + "epsg:4326", "epsg:3031", always_xy=True + ) + + tree = cKDTree(np.column_stack((x_cell, y_cell))) + + for name in transect_names: + lon, lat = load_transect(name, transects_dir) + x, y, distance = project_transect(lon, lat, transformer) + + _, cell_indices = tree.query(np.column_stack((x, y))) + + fig, axes = plt.subplots( + len(fields), 1, sharex=True, figsize=(8, 2.5 * len(fields)) + ) + if len(fields) == 1: + axes = [axes] + + for ax, (label, (values, units, long_name)) in zip( + axes, fields.items() + ): + ax.plot(distance / 1000.0, values[cell_indices]) + ax.set_ylabel(f"{label} [{units}]") + ax.set_title(long_name, fontsize=10) + ax.grid(True, alpha=0.3) + + axes[-1].set_xlabel("Along-transect distance [km]") + fig.suptitle(f"{name} transect") + fig.tight_layout() + + out_path = os.path.join(plot_dir, f"{name}.png") + fig.savefig(out_path, dpi=150) + plt.close(fig) + + print(f"Wrote transect plot: {out_path}") + + def main(): parser = argparse.ArgumentParser( description="Convert MALI Weertman friction IC to Regularized Coulomb.", @@ -640,6 +803,62 @@ def main(): help="Name for diagnostic hydraulic potential field" ) + parser.add_argument( + "--transects-dir", + default=None, + help=( + "Path to a geometric_features landice/transect directory " + "(e.g. .../geometric_features/geometric_data/landice/" + "transect), containing one subdirectory per named " + "transect, each with a transect.geojson (LineString " + "lon/lat, degrees). Required if --plot-transects is set." + ) + ) + parser.add_argument( + "--transect-names", + nargs="+", + default=[ + "Thwaites", "Totten", "Lambert", "Foundation", "Bindschadler" + ], + help=( + "Names of transects to plot (subdirectory names under " + "--transects-dir). Default: Thwaites Totten Lambert " + "Foundation Bindschadler." + ) + ) + parser.add_argument( + "--plot-dir", + default="transect_plots", + help=( + "Directory to write transect PNG plots to (default: " + "transect_plots, created if needed)." + ) + ) + + plot_transects_group = parser.add_mutually_exclusive_group() + plot_transects_group.add_argument( + "--plot-transects", + dest="plot_transects", + action="store_true", + default=False, + help=( + "Plot effectivePressure, floatationFraction, and " + "hydropotential along the transects in --transect-names, " + "sampled at MALI cell centers nearest each transect " + "point (projected from lon/lat into the MALI mesh's " + "planar CRS, EPSG:3031 for Antarctica). Requires " + "--diagnostics (the fields plotted are diagnostic " + "fields) and --transects-dir, plus the pyproj/scipy/" + "matplotlib packages. Default: disabled." + ) + ) + plot_transects_group.add_argument( + "--no-plot-transects", + dest="plot_transects", + action="store_false", + help="Do not plot transects (default)." + ) + diagnostics_group = parser.add_mutually_exclusive_group() diagnostics_group.add_argument( "--diagnostics", @@ -720,6 +939,20 @@ def main(): "(got --effective-pressure-type=transition)" ) + if args.plot_transects: + if not args.diagnostics: + parser.error( + "--plot-transects requires --diagnostics (the fields " + "plotted -- effectivePressure, floatationFraction, " + "hydropotential -- are only computed when diagnostics " + "are enabled)" + ) + if args.transects_dir is None: + parser.error( + "--transects-dir is required when --plot-transects " + "is set" + ) + # ------------------------------------------------------------- # Read IC # ------------------------------------------------------------- @@ -1289,6 +1522,27 @@ def basal_cell_field(name): print(f"Wrote converted IC: {args.output}") + if args.plot_transects: + x_cell = np.asarray(ds["xCell"].values, dtype=np.float64) + y_cell = np.asarray(ds["yCell"].values, dtype=np.float64) + + plot_transects( + transect_names=args.transect_names, + transects_dir=args.transects_dir, + plot_dir=args.plot_dir, + x_cell=x_cell, + y_cell=y_cell, + fields={ + "N": (N, "Pa", effective_pressure_long_name), + "floatation fraction": ( + floatation_fraction, "1", "Floatation fraction (Pw / Pice)" + ), + "hydropotential": ( + hydropotential, "Pa", "Shreve hydraulic potential" + ), + }, + ) + if args.flow_rate_type == "constant": flow_rate_yaml_lines = ( f" Flow Rate Type: Constant\n" From 23ca24d70f4d16cf63baa723e0f20006ef362955 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 24 Aug 2026 20:50:17 -0700 Subject: [PATCH 16/55] Merge downs-johnson/transition effective-pressure CLI args - --min-fraction-overburden and --pressure-length-scale are now shared between --effective-pressure-type=downs-johnson and --effective-pressure-type=transition, instead of duplicating them as --transition-alpha/--transition-length-scale. This makes it convenient to switch --effective-pressure-type without having to re-specify a second, differently-named set of equivalent args. - effective_pressure4()'s alpha parameter (a deficit fraction with no particular meaning beyond a quick prototype) is replaced with min_fraction_overburden (a retained fraction), matching downs_johnson_effective_pressure()'s convention exactly: N_inland = rho_i * gravity * H * min_fraction_overburden. - --transition-h-ocean remains transition-only (no analog exists in the downs-johnson formula). - Simplified the effective-pressure-type CLI validation and output NetCDF global-attribute writing accordingly, since both types now share the same two required args. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 138 ++++++++---------- 1 file changed, 58 insertions(+), 80 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index c6e2076d1..0d555cc15 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -104,7 +104,7 @@ def effective_pressure4( thickness, bed, - alpha, + min_fraction_overburden, length_scale, rho_i=910.0, rho_w=1028.0, @@ -130,13 +130,14 @@ def effective_pressure4( Ice thickness H [m]. bed : ndarray Bed elevation b [m], positive above sea level. - alpha : float - 1 minus the prescribed inland effective-pressure fraction of - overburden, i.e. the inland effective pressure is - N_inland = gravity * rho_i * H * (1 - alpha). Analogous in - spirit to downs_johnson_effective_pressure()'s - "min_fraction_overburden", but as a deficit fraction rather - than a retained fraction. + min_fraction_overburden : float + Prescribed inland effective-pressure fraction of overburden, + i.e. the inland effective pressure is + N_inland = gravity * rho_i * H * min_fraction_overburden. + Same convention as downs_johnson_effective_pressure()'s + parameter of the same name (a retained fraction, not a + deficit), so the same CLI value can be reused across both + effective-pressure parameterizations. length_scale : float Distance L [m, same units as `bed`/`thickness`] over which half the remaining difference between the near-ocean value and @@ -186,8 +187,11 @@ def effective_pressure4( 0.0, ) - # Prescribed inland effective-pressure fraction. - q_inland = 1.0 - alpha + # Prescribed inland effective-pressure fraction. min_fraction_ + # overburden is already a retained fraction (same convention as + # downs_johnson_effective_pressure()), so no deficit conversion + # is needed here. + q_inland = min_fraction_overburden # Ocean-connected value at the end of the fixed region. q_start = np.where( @@ -616,7 +620,7 @@ def main(): "runtime from thickness/bed, so no N field needs to be " "supplied. \"transition\" computes N here using a " "near-ocean/inland-transition parameterization (see " - "--transition-alpha/--transition-length-scale/" + "--min-fraction-overburden/--pressure-length-scale/" "--transition-h-ocean) and writes it to the output for " "reference; NOTE: Albany does not yet have a way to " "consume this precomputed N directly for the Regularized " @@ -628,13 +632,25 @@ def main(): # Downs & Johnson / Albany parameters (required only when # --effective-pressure-type=downs-johnson). + # Effective-pressure parameters shared between the "downs-johnson" + # and "transition" parameterizations (see --effective-pressure- + # type below): both use a retained fraction of overburden inland + # (min_fraction_overburden) and a length scale in meters + # (pressure_length_scale), even though each parameterization uses + # them in a different formula. Overloading the same CLI flags + # across both types makes it convenient to switch between them. parser.add_argument( "--min-fraction-overburden", type=float, default=None, help=( - 'Albany "Minimum Fraction Overburden Pressure" (required ' - "when --effective-pressure-type=downs-johnson)" + "Prescribed inland effective-pressure fraction of " + 'overburden. For --effective-pressure-type=downs-johnson, ' + 'this is Albany\'s "Minimum Fraction Overburden Pressure". ' + "For --effective-pressure-type=transition, this is the " + "inland target fraction used by effective_pressure4() " + "(N_inland = rho_i * gravity * H * " + "min_fraction_overburden). Required in either case." ) ) parser.add_argument( @@ -642,37 +658,24 @@ def main(): type=float, default=None, help=( - 'Albany "Length Scale Factor", converted to the same ' - "length units as bedTopography (normally m) (required " - "when --effective-pressure-type=downs-johnson)" - ) - ) - - # "transition" effective-pressure parameters (required only when - # --effective-pressure-type=transition). - parser.add_argument( - "--transition-alpha", - type=float, - default=None, - help=( - "1 minus the prescribed inland effective-pressure " - "fraction of overburden (see effective_pressure4()); " - "required when --effective-pressure-type=transition" - ) - ) - parser.add_argument( - "--transition-length-scale", - type=float, - default=None, - help=( - "Distance [m] over which half the remaining difference " + "Length scale [m, same units as bedTopography] used by " + "the effective-pressure parameterization selected via " + "--effective-pressure-type. For downs-johnson, this is " + 'Albany\'s "Length Scale Factor" (width of the bed-' + "elevation sigmoid). For transition, this is the " + "distance over which half the remaining difference " "between the near-ocean value and the inland target " "fraction is removed moving inland (see " "effective_pressure4()); 0 disables the smooth " - "transition (step function). Required when " - "--effective-pressure-type=transition." + "transition (step function) in that case. The two " + "parameterizations use this value in different formulas " + "-- a value tuned for one is not automatically " + "appropriate for the other. Required in either case." ) ) + + # "transition"-only effective-pressure parameter (required only + # when --effective-pressure-type=transition). parser.add_argument( "--transition-h-ocean", type=float, @@ -913,31 +916,13 @@ def main(): "with this exponent." ) - if args.effective_pressure_type == "downs-johnson": - if args.min_fraction_overburden is None or args.pressure_length_scale is None: - parser.error( - "--min-fraction-overburden and --pressure-length-scale " - "are required when " - "--effective-pressure-type=downs-johnson" - ) - if args.transition_alpha is not None or args.transition_length_scale is not None: - parser.error( - "--transition-alpha/--transition-length-scale are " - "only used with --effective-pressure-type=transition " - "(got --effective-pressure-type=downs-johnson)" - ) - else: - if args.transition_alpha is None or args.transition_length_scale is None: - parser.error( - "--transition-alpha and --transition-length-scale are " - "required when --effective-pressure-type=transition" - ) - if args.min_fraction_overburden is not None or args.pressure_length_scale is not None: - parser.error( - "--min-fraction-overburden/--pressure-length-scale are " - "only used with --effective-pressure-type=downs-johnson " - "(got --effective-pressure-type=transition)" - ) + if args.min_fraction_overburden is None or args.pressure_length_scale is None: + parser.error( + "--min-fraction-overburden and --pressure-length-scale " + "are required (used by both " + "--effective-pressure-type=downs-johnson and " + "--effective-pressure-type=transition)" + ) if args.plot_transects: if not args.diagnostics: @@ -1065,8 +1050,8 @@ def basal_cell_field(name): N = effective_pressure4( thickness=H, bed=bed, - alpha=args.transition_alpha, - length_scale=args.transition_length_scale, + min_fraction_overburden=args.min_fraction_overburden, + length_scale=args.pressure_length_scale, rho_i=args.rho_ice, rho_w=args.rho_water, gravity=args.gravity, @@ -1495,20 +1480,13 @@ def basal_cell_field(name): out.attrs["regularizedCoulomb_effectivePressureType"] = ( args.effective_pressure_type ) - if args.effective_pressure_type == "downs-johnson": - out.attrs["regularizedCoulomb_minFractionOverburden"] = ( - float(args.min_fraction_overburden) - ) - out.attrs["regularizedCoulomb_pressureLengthScale"] = ( - float(args.pressure_length_scale) - ) - else: - out.attrs["regularizedCoulomb_transitionAlpha"] = ( - float(args.transition_alpha) - ) - out.attrs["regularizedCoulomb_transitionLengthScale"] = ( - float(args.transition_length_scale) - ) + out.attrs["regularizedCoulomb_minFractionOverburden"] = ( + float(args.min_fraction_overburden) + ) + out.attrs["regularizedCoulomb_pressureLengthScale"] = ( + float(args.pressure_length_scale) + ) + if args.effective_pressure_type == "transition": out.attrs["regularizedCoulomb_transitionHOcean"] = ( float(args.transition_h_ocean) ) From 976d75362f211785f97f78ea60c7d6fe43f12d75 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 24 Aug 2026 20:55:08 -0700 Subject: [PATCH 17/55] Shade floating ice, ice-free ocean, and ice-free land on transect plots - plot_transects() now takes thickness/bed/rho_i/rho_w and classifies each MALI cell as grounded ice (unshaded), floating ice, ice-free ocean, or ice-free land, using the same grounded-ice test used elsewhere in the script (rho_i * H + rho_w * bed > 0). - Background shading (axvspan) is drawn on every panel for contiguous along-transect runs of each non-grounded class, with a shared legend at the top of the figure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 93 ++++++++++++++++++- 1 file changed, 88 insertions(+), 5 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 0d555cc15..94c3bf674 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -460,12 +460,17 @@ def plot_transects( x_cell, y_cell, fields, + thickness, + bed, + rho_i, + rho_w, ): """ For each named transect, sample the given cell-centered fields (nearest-neighbor, via a KD-tree on MALI cell centers) along the transect and save a stacked-panel PNG plot vs. along-transect - distance. + distance. Background shading indicates floating ice, ice-free + ocean, and ice-free land (grounded ice is left unshaded). Parameters ---------- @@ -487,6 +492,14 @@ def plot_transects( Mapping of field label -> (values on MALI cells, units string, long_name string) to sample and plot along each transect, e.g. {"N": (N, "Pa", "Effective pressure")}. + thickness, bed : 1-D numpy arrays + MALI cell-centered ice thickness [m] and bed elevation [m], + used to classify each cell as grounded ice, floating ice, + ice-free ocean, or ice-free land for background shading + (same grounded-ice test used elsewhere in this script: + rho_i * H + rho_w * bed > 0). + rho_i, rho_w : float + Ice/water density [kg m^-3], for the grounded-ice test above. """ try: import pyproj @@ -510,6 +523,7 @@ def plot_transects( import matplotlib matplotlib.use("Agg") + import matplotlib.patches as mpatches import matplotlib.pyplot as plt os.makedirs(plot_dir, exist_ok=True) @@ -523,11 +537,36 @@ def plot_transects( tree = cKDTree(np.column_stack((x_cell, y_cell))) + # Classify each MALI cell for background shading: grounded ice is + # left unshaded; floating ice, ice-free ocean, and ice-free land + # are shaded. Uses the same grounded-ice test as the rest of this + # script (rho_i * H + rho_w * bed > 0). + ice_free = thickness <= 0.0 + grounded = (~ice_free) & (rho_i * thickness + rho_w * bed > 0.0) + floating = (~ice_free) & (~grounded) + ice_free_ocean = ice_free & (bed < 0.0) + ice_free_land = ice_free & (bed >= 0.0) + + # 0 = grounded ice (unshaded), 1 = floating ice, 2 = ice-free + # ocean, 3 = ice-free land. + region_class = np.zeros(thickness.shape, dtype=np.int8) + region_class[floating] = 1 + region_class[ice_free_ocean] = 2 + region_class[ice_free_land] = 3 + + shading = { + 1: ("Floating ice", "tab:blue"), + 2: ("Ice-free ocean", "tab:cyan"), + 3: ("Ice-free land", "tab:brown"), + } + for name in transect_names: lon, lat = load_transect(name, transects_dir) x, y, distance = project_transect(lon, lat, transformer) _, cell_indices = tree.query(np.column_stack((x, y))) + distance_km = distance / 1000.0 + class_along_transect = region_class[cell_indices] fig, axes = plt.subplots( len(fields), 1, sharex=True, figsize=(8, 2.5 * len(fields)) @@ -535,20 +574,60 @@ def plot_transects( if len(fields) == 1: axes = [axes] + # Shade contiguous along-transect runs of each non-grounded + # class, on every panel. + change_indices = np.flatnonzero( + np.diff(class_along_transect) != 0 + ) + 1 + run_starts = np.concatenate(([0], change_indices)) + run_ends = np.concatenate( + (change_indices, [len(class_along_transect) - 1]) + ) + classes_used = set() + for run_start, run_end in zip(run_starts, run_ends): + cls = class_along_transect[run_start] + if cls == 0: + continue + classes_used.add(cls) + _, color = shading[cls] + x0 = distance_km[run_start] + x1 = distance_km[run_end] + for ax in axes: + ax.axvspan(x0, x1, color=color, alpha=0.2, zorder=0) + for ax, (label, (values, units, long_name)) in zip( axes, fields.items() ): - ax.plot(distance / 1000.0, values[cell_indices]) + ax.plot(distance_km, values[cell_indices], zorder=2) ax.set_ylabel(f"{label} [{units}]") ax.set_title(long_name, fontsize=10) - ax.grid(True, alpha=0.3) + ax.grid(True, alpha=0.3, zorder=1) axes[-1].set_xlabel("Along-transect distance [km]") - fig.suptitle(f"{name} transect") + + if classes_used: + legend_handles = [ + mpatches.Patch( + color=shading[cls][1], alpha=0.2, label=shading[cls][0] + ) + for cls in sorted(classes_used) + ] + fig.legend( + handles=legend_handles, + loc="upper center", + ncol=len(legend_handles), + bbox_to_anchor=(0.5, 1.02), + frameon=False, + ) + + if classes_used: + fig.suptitle(f"{name} transect", y=1.08) + else: + fig.suptitle(f"{name} transect") fig.tight_layout() out_path = os.path.join(plot_dir, f"{name}.png") - fig.savefig(out_path, dpi=150) + fig.savefig(out_path, dpi=150, bbox_inches="tight") plt.close(fig) print(f"Wrote transect plot: {out_path}") @@ -1519,6 +1598,10 @@ def basal_cell_field(name): hydropotential, "Pa", "Shreve hydraulic potential" ), }, + thickness=H, + bed=bed, + rho_i=args.rho_ice, + rho_w=args.rho_water, ) if args.flow_rate_type == "constant": From 03f52979b9ab4d4f33f1d4f59b009d5572b5a27f Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 24 Aug 2026 20:57:49 -0700 Subject: [PATCH 18/55] Plot --min-fraction-overburden bound on floatation fraction transect panel - plot_transects() now accepts min_fraction_overburden and draws a horizontal dashed reference line at 1 - min_fraction_overburden on the floatation-fraction panel: the maximum floatation fraction (Pw/Pice) --min-fraction-overburden implies inland, since floatation_fraction = 1 - N/Pice and N/Pice is bounded below by min_fraction_overburden. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 94c3bf674..d1a308969 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -464,6 +464,8 @@ def plot_transects( bed, rho_i, rho_w, + min_fraction_overburden=None, + floatation_fraction_label="floatation fraction", ): """ For each named transect, sample the given cell-centered fields @@ -500,6 +502,17 @@ def plot_transects( rho_i * H + rho_w * bed > 0). rho_i, rho_w : float Ice/water density [kg m^-3], for the grounded-ice test above. + min_fraction_overburden : float, optional + If provided, and `fields` contains an entry keyed + `floatation_fraction_label`, draw a horizontal reference line + at 1 - min_fraction_overburden on that panel: the maximum + floatation fraction --min-fraction-overburden allows N to + imply inland (floatation_fraction = Pw/Pice = 1 - N/Pice, and + N/Pice is bounded below by min_fraction_overburden inland). + floatation_fraction_label : str + Key in `fields` identifying the floatation-fraction panel + (default: "floatation fraction"), used to place the + `min_fraction_overburden` reference line above. """ try: import pyproj @@ -603,6 +616,24 @@ def plot_transects( ax.set_title(long_name, fontsize=10) ax.grid(True, alpha=0.3, zorder=1) + if ( + label == floatation_fraction_label + and min_fraction_overburden is not None + ): + bound = 1.0 - min_fraction_overburden + ax.axhline( + bound, + color="k", + linestyle="--", + linewidth=1, + zorder=3, + label=( + "1 - min-fraction-overburden bound " + f"({bound:.3g})" + ), + ) + ax.legend(loc="best", fontsize=8) + axes[-1].set_xlabel("Along-transect distance [km]") if classes_used: @@ -1602,6 +1633,7 @@ def basal_cell_field(name): bed=bed, rho_i=args.rho_ice, rho_w=args.rho_water, + min_fraction_overburden=args.min_fraction_overburden, ) if args.flow_rate_type == "constant": From c7ce2fc6f0def3e6a220eb4a694171fb99e7378c Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 24 Aug 2026 21:09:19 -0700 Subject: [PATCH 19/55] Fix min-fraction-overburden sign convention and hydropotential density Two bugs found by inspecting real output: 1. effective_pressure4() and the transect-plot bound line used min_fraction_overburden as the *retained* overburden fraction, but Albany's actual HYDROSTATIC formula treats it as the *subtracted* fraction, so the floatation-fraction floor attained far inland is min_fraction_overburden itself, not its complement. This was introduced when --min-fraction-overburden/--transition-alpha were merged: effective_pressure4()'s q_inland is now 1.0 - min_fraction_overburden (was min_fraction_overburden), and the plotted bound line is now min_fraction_overburden directly (was 1 - min_fraction_overburden). Docstrings/help text corrected to match. 2. Shreve hydraulic potential reused --rho-water (ocean/seawater density, 1028 kg/m^3) for the bed-elevation term, but subglacial water is fresh, not salty. Added a new --rho-freshwater CLI arg (default 1000.0 kg/m^3) used only in the hydropotential formula; --rho-water is now documented as ocean/seawater-only. Also documented (via a NetCDF attribute on effectivePressure) that some floating cells can retain nonzero N/floatationFraction != 1 near the grounding line: this is expected Albany HYDROSTATIC behavior (f_p depends on bed elevation only, not the true flotation criterion), not a bug. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 123 +++++++++++++----- 1 file changed, 90 insertions(+), 33 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index d1a308969..fa7ee7961 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -131,12 +131,17 @@ def effective_pressure4( bed : ndarray Bed elevation b [m], positive above sea level. min_fraction_overburden : float - Prescribed inland effective-pressure fraction of overburden, + Prescribed inland *minimum floatation fraction* (Pw/Pice), i.e. the inland effective pressure is - N_inland = gravity * rho_i * H * min_fraction_overburden. + N_inland = gravity * rho_i * H * (1 - min_fraction_overburden). Same convention as downs_johnson_effective_pressure()'s - parameter of the same name (a retained fraction, not a - deficit), so the same CLI value can be reused across both + parameter of the same name: Albany's "Minimum Fraction + Overburden Pressure" is subtracted from a retained-overburden + fraction of 1 (not applied directly as a retained fraction), + so the inland floatation fraction approaches + min_fraction_overburden itself (its floor), not + 1 - min_fraction_overburden. Using the same convention here + lets the same CLI value be reused across both effective-pressure parameterizations. length_scale : float Distance L [m, same units as `bed`/`thickness`] over which @@ -187,11 +192,13 @@ def effective_pressure4( 0.0, ) - # Prescribed inland effective-pressure fraction. min_fraction_ - # overburden is already a retained fraction (same convention as - # downs_johnson_effective_pressure()), so no deficit conversion - # is needed here. - q_inland = min_fraction_overburden + # Prescribed inland effective-pressure fraction. Matches Albany's + # actual "Minimum Fraction Overburden Pressure" convention (see + # downs_johnson_effective_pressure()): it is subtracted from a + # retained-overburden fraction of 1, not applied directly as a + # retained fraction, so N/Pice_inland = 1 - min_fraction_overburden + # and floatation_fraction_inland = min_fraction_overburden. + q_inland = 1.0 - min_fraction_overburden # Ocean-connected value at the end of the fixed region. q_start = np.where( @@ -260,7 +267,16 @@ def downs_johnson_effective_pressure( bed : ndarray Bed elevation b [m], positive above sea level. min_fraction_overburden : float - Albany "Minimum Fraction Overburden Pressure". + Albany "Minimum Fraction Overburden Pressure". Despite the + name, this is *subtracted* from a retained-overburden + fraction of 1 in Albany's actual formula (see + LandIce_BasalFrictionCoefficient_Def.hpp), not applied + directly as a retained fraction: far inland (bed elevation + well above sea level), N/Pice -> 1 - min_fraction_overburden, + i.e. the floatation fraction Pw/Pice -> min_fraction_ + overburden itself (its floor/minimum value, attained inland; + near the grounding line the floatation fraction rises toward + 1 regardless of this parameter). length_scale : float Albany "Length Scale Factor". @@ -505,10 +521,9 @@ def plot_transects( min_fraction_overburden : float, optional If provided, and `fields` contains an entry keyed `floatation_fraction_label`, draw a horizontal reference line - at 1 - min_fraction_overburden on that panel: the maximum - floatation fraction --min-fraction-overburden allows N to - imply inland (floatation_fraction = Pw/Pice = 1 - N/Pice, and - N/Pice is bounded below by min_fraction_overburden inland). + at min_fraction_overburden on that panel: the minimum + floatation fraction (Pw/Pice) approached far inland (see + downs_johnson_effective_pressure()/effective_pressure4()). floatation_fraction_label : str Key in `fields` identifying the floatation-fraction panel (default: "floatation fraction"), used to place the @@ -620,7 +635,7 @@ def plot_transects( label == floatation_fraction_label and min_fraction_overburden is not None ): - bound = 1.0 - min_fraction_overburden + bound = min_fraction_overburden ax.axhline( bound, color="k", @@ -628,7 +643,7 @@ def plot_transects( linewidth=1, zorder=3, label=( - "1 - min-fraction-overburden bound " + "min-fraction-overburden bound " f"({bound:.3g})" ), ) @@ -744,23 +759,28 @@ def main(): # --effective-pressure-type=downs-johnson). # Effective-pressure parameters shared between the "downs-johnson" # and "transition" parameterizations (see --effective-pressure- - # type below): both use a retained fraction of overburden inland - # (min_fraction_overburden) and a length scale in meters - # (pressure_length_scale), even though each parameterization uses - # them in a different formula. Overloading the same CLI flags - # across both types makes it convenient to switch between them. + # type below): both use the same inland-floatation-fraction-floor + # convention (min_fraction_overburden) and a length scale in + # meters (pressure_length_scale), even though each + # parameterization uses them in a different formula. Overloading + # the same CLI flags across both types makes it convenient to + # switch between them. parser.add_argument( "--min-fraction-overburden", type=float, default=None, help=( - "Prescribed inland effective-pressure fraction of " - 'overburden. For --effective-pressure-type=downs-johnson, ' - 'this is Albany\'s "Minimum Fraction Overburden Pressure". ' - "For --effective-pressure-type=transition, this is the " - "inland target fraction used by effective_pressure4() " - "(N_inland = rho_i * gravity * H * " - "min_fraction_overburden). Required in either case." + "Prescribed inland floatation-fraction floor. Matches " + 'Albany\'s "Minimum Fraction Overburden Pressure" ' + "convention exactly: far inland, N/Pice -> " + "1 - min_fraction_overburden, i.e. the floatation " + "fraction Pw/Pice approaches min_fraction_overburden " + "itself (its minimum value; near the grounding line the " + "floatation fraction rises toward 1 regardless of this " + "parameter). Used by both --effective-pressure-type=" + "downs-johnson and --effective-pressure-type=transition " + "(see downs_johnson_effective_pressure()/" + "effective_pressure4()). Required in either case." ) ) parser.add_argument( @@ -800,7 +820,32 @@ def main(): ) parser.add_argument("--rho-ice", type=float, default=910.0) - parser.add_argument("--rho-water", type=float, default=1028.0) + parser.add_argument( + "--rho-water", + type=float, + default=1028.0, + help=( + "Ocean/seawater density [kg m^-3] (default: 1028.0), " + "used for ocean-connectivity terms: the grounded-ice " + "test, the marine term in the effective-pressure " + "parameterizations, and the floatation-fraction " + "denominator. Distinct from --rho-freshwater, used for " + "the subglacial water column in the hydropotential field." + ) + ) + parser.add_argument( + "--rho-freshwater", + type=float, + default=1000.0, + help=( + "Freshwater density [kg m^-3] (default: 1000.0), used " + "only for the bed-elevation term of the Shreve hydraulic " + "potential (hydropotential = rho_freshwater * gravity * " + "bedTopography + Pw), matching the standard convention " + "that subglacial water is fresh, distinct from the " + "ocean/seawater density (--rho-water) used elsewhere." + ) + ) parser.add_argument("--gravity", type=float, default=9.80616) # Needed for Lambda = uc / (A N^n). @@ -1466,6 +1511,14 @@ def basal_cell_field(name): attrs={ "long_name": effective_pressure_long_name, "units": "Pa", + "note": ( + "N is a smooth function of bed elevation only " + "(not the actual flotation criterion, which also " + "depends on thickness), so some floating cells " + "near the grounding line (shallow bed) can have " + "nonzero N/floatationFraction != 1; this is " + "expected Albany behavior, not a bug." + ), }, ) @@ -1490,8 +1543,12 @@ def basal_cell_field(name): }, ) - # Shreve hydraulic potential: phi = rho_w * g * bed + Pw. - hydropotential = args.rho_water * args.gravity * bed + Pw + # Shreve hydraulic potential: phi = rho_freshwater * g * bed + + # Pw. Uses freshwater density for the bed-elevation term + # (subglacial water is fresh), distinct from the ocean/ + # seawater density (rho_water) used elsewhere in this script + # for ocean-connectivity terms. + hydropotential = args.rho_freshwater * args.gravity * bed + Pw out[args.hydropotential_field] = xr.DataArray( hydropotential, @@ -1499,8 +1556,8 @@ def basal_cell_field(name): attrs={ "long_name": "Shreve hydraulic potential", "description": ( - "phi = rho_water * gravity * bedTopography + Pw, " - "with Pw = Pice - N" + "phi = rho_freshwater * gravity * bedTopography " + "+ Pw, with Pw = Pice - N" ), "units": "Pa", }, From 601e38accc1191a6940fc6c1b56d720418f7add0 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Mon, 24 Aug 2026 21:16:43 -0700 Subject: [PATCH 20/55] Add bed/surface elevation panel to transect plots plot_transects() now always shows a first panel with bed elevation and ice-surface elevation (computed via the flotation criterion for floating ice), ahead of the caller-supplied N/floatation-fraction/ hydropotential panels. Useful for interpreting the other panels (e.g. seeing bed troughs that explain elevated floatation fractions inland). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index fa7ee7961..5317f84fc 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -487,8 +487,11 @@ def plot_transects( For each named transect, sample the given cell-centered fields (nearest-neighbor, via a KD-tree on MALI cell centers) along the transect and save a stacked-panel PNG plot vs. along-transect - distance. Background shading indicates floating ice, ice-free - ocean, and ice-free land (grounded ice is left unshaded). + distance. The first panel always shows bed and ice-surface + elevation (computed from `thickness`/`bed`); subsequent panels + show the caller-supplied `fields`. Background shading indicates + floating ice, ice-free ocean, and ice-free land (grounded ice is + left unshaded). Parameters ---------- @@ -588,6 +591,27 @@ def plot_transects( 3: ("Ice-free land", "tab:brown"), } + # Upper-surface elevation, consistent with the same grounded/ + # floating classification used for shading above: grounded ice + # surface = bed + thickness; floating ice surface = thickness * + # (1 - rho_i/rho_w) (flotation criterion); ice-free cells show + # bare bed/sea-level (max(bed, 0)) since there is no ice surface. + surface = np.empty_like(thickness, dtype=np.float64) + surface[grounded] = bed[grounded] + thickness[grounded] + surface[floating] = thickness[floating] * (1.0 - rho_i / rho_w) + surface[ice_free] = np.maximum(bed[ice_free], 0.0) + + # Elevation panel is always shown first, ahead of the + # caller-supplied `fields`. + elevation_fields = { + "elevation": ( + {"surface": surface, "bed": bed}, + "m", + "Bed and surface elevation", + ), + } + all_fields = {**elevation_fields, **fields} + for name in transect_names: lon, lat = load_transect(name, transects_dir) x, y, distance = project_transect(lon, lat, transformer) @@ -597,9 +621,10 @@ def plot_transects( class_along_transect = region_class[cell_indices] fig, axes = plt.subplots( - len(fields), 1, sharex=True, figsize=(8, 2.5 * len(fields)) + len(all_fields), 1, sharex=True, + figsize=(8, 2.5 * len(all_fields)) ) - if len(fields) == 1: + if len(all_fields) == 1: axes = [axes] # Shade contiguous along-transect runs of each non-grounded @@ -624,9 +649,18 @@ def plot_transects( ax.axvspan(x0, x1, color=color, alpha=0.2, zorder=0) for ax, (label, (values, units, long_name)) in zip( - axes, fields.items() + axes, all_fields.items() ): - ax.plot(distance_km, values[cell_indices], zorder=2) + if isinstance(values, dict): + # Multi-line panel (e.g. bed & surface elevation). + for line_label, arr in values.items(): + ax.plot( + distance_km, arr[cell_indices], + label=line_label, zorder=2, + ) + ax.legend(loc="best", fontsize=8) + else: + ax.plot(distance_km, values[cell_indices], zorder=2) ax.set_ylabel(f"{label} [{units}]") ax.set_title(long_name, fontsize=10) ax.grid(True, alpha=0.3, zorder=1) From 2ffe34eaeae285df11483b4a708f886ad5b4c4a7 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 13:24:04 -0700 Subject: [PATCH 21/55] Add Antarctic-wide map plots via mosaic when --plot-transects is used New plot_maps() function uses the 'mosaic' package (available in the e3sm-unified conda environment) to render unstructured-mesh maps directly in the MALI mesh's native planar polar-stereographic coordinates -- no reprojection needed. Automatically produced alongside the transect plots (same --plot-transects trigger, no new CLI flag) in a 'maps' subdirectory of --plot-dir, covering bedRoughnessRC (Lambda), all diagnostic fields (effectivePressure, floatationFraction, hydropotential, flowRateA), and all diagnostic masks (maskGrounded, maskFastFlowing, maskValidBedRoughnessRC). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 110 +++++++++++++++++- 1 file changed, 108 insertions(+), 2 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 5317f84fc..2d4543322 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -305,13 +305,16 @@ def downs_johnson_effective_pressure( arg = np.clip(b / length_scale, -700.0, 700.0) fp = 1.0 / (1.0 + np.exp(-arg)) + print(fp.min(), fp.max()) + overburden_term = min_fraction_overburden * rho_i * H * fp - marine_term = (1.0 - fp) * np.maximum(-rho_w * b, 0.0) + marine_term = (1.0 - fp) * np.maximum(rho_w * b, 0.0) N = gravity * np.maximum( rho_i * H - (overburden_term + marine_term), 0.0 ) + N=gravity * (rho_i*H - np.maximum(-rho_w * b, 0.0)) return N @@ -713,6 +716,65 @@ def plot_transects( print(f"Wrote transect plot: {out_path}") +def plot_maps(mesh_ds, fields, plot_dir): + """ + Plot Antarctic-wide maps of the given cell-centered fields on the + native MALI mesh, using the `mosaic` package (available in the + e3sm-unified conda environment) to render unstructured-mesh + polygons directly in the mesh's native planar (polar + stereographic) coordinates -- no reprojection needed since MALI's + Antarctic meshes are already planar. + + Parameters + ---------- + mesh_ds : xarray.Dataset + A MALI mesh dataset containing the coordinate/connectivity + arrays mosaic.Descriptor needs (xCell/yCell, verticesOnCell, + cellsOnEdge, cellsOnVertex, verticesOnEdge, edgesOnVertex) and + the `on_a_sphere`/`is_periodic` global attributes. Antarctic + MALI meshes are planar (on_a_sphere = "NO"), so no projection/ + transform is needed. + fields : dict of str -> (1-D numpy array, str, str) + Mapping of output filename stem -> (cell-centered values, + units string, title string) to plot, e.g. + {"bedRoughnessRC": (lam, "Pa (m/yr)^-1/3", "RC bed roughness Lambda")}. + plot_dir : str + Directory to write output PNGs to (created if needed). + """ + try: + import mosaic + except ImportError as e: + raise ImportError( + "Plotting maps requires the 'mosaic' package. This is " + "available in the e3sm-unified conda environment (see " + "load_latest_e3sm_unified_*.sh); it is not required for " + "the rest of this script." + ) from e + + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + os.makedirs(plot_dir, exist_ok=True) + + descriptor = mosaic.Descriptor(mesh_ds) + + for stem, (values, units, title) in fields.items(): + fig, ax = plt.subplots(figsize=(7, 7)) + array = xr.DataArray(np.asarray(values), dims=("nCells",)) + coll = mosaic.polypcolor(ax, descriptor, array, cmap="viridis") + ax.set_aspect("equal") + ax.set_title(title, fontsize=11) + fig.colorbar(coll, ax=ax, label=units, shrink=0.8) + fig.tight_layout() + + out_path = os.path.join(plot_dir, f"{stem}.png") + fig.savefig(out_path, dpi=150, bbox_inches="tight") + plt.close(fig) + + print(f"Wrote map plot: {out_path}") + + def main(): parser = argparse.ArgumentParser( description="Convert MALI Weertman friction IC to Regularized Coulomb.", @@ -1041,7 +1103,12 @@ def main(): "planar CRS, EPSG:3031 for Antarctica). Requires " "--diagnostics (the fields plotted are diagnostic " "fields) and --transects-dir, plus the pyproj/scipy/" - "matplotlib packages. Default: disabled." + "matplotlib packages. Also automatically produces " + "Antarctic-wide maps (in a 'maps' subdirectory of " + "--plot-dir) of the same diagnostic fields, the " + "diagnostic masks, and bedRoughnessRC, using the " + "'mosaic' package (available in the e3sm-unified conda " + "environment). Default: disabled." ) ) plot_transects_group.add_argument( @@ -1727,6 +1794,45 @@ def basal_cell_field(name): min_fraction_overburden=args.min_fraction_overburden, ) + # Antarctic-wide maps of the same diagnostic fields plus + # bedRoughnessRC, using mosaic (e3sm-unified environment). + # Always produced alongside the transect plots -- no separate + # CLI flag -- since both are diagnostic/evaluation outputs + # triggered by --plot-transects. + map_fields = { + args.lambda_field: ( + Lambda, "Pa (m yr-1)^-1/3", + "Albany regularized-Coulomb bed roughness Lambda" + ), + args.effective_pressure_field: ( + N, "Pa", effective_pressure_long_name + ), + args.floatation_fraction_field: ( + floatation_fraction, "1", + "Floatation fraction (Pw / Pice)" + ), + args.hydropotential_field: ( + hydropotential, "Pa", "Shreve hydraulic potential" + ), + args.flow_rate_field: (A, "Pa-3 s-1", flow_rate_long_name), + "maskGrounded": ( + grounded.astype(np.int8), "1", "Mask of grounded ice" + ), + "maskFastFlowing": ( + fast_flowing.astype(np.int8), "1", + "Mask of grounded, fast-flowing cells" + ), + "maskValidBedRoughnessRC": ( + lambda_mask.astype(np.int8), "1", + "Mask of cells where bedRoughnessRC was solved exactly" + ), + } + plot_maps( + mesh_ds=ds, + fields=map_fields, + plot_dir=os.path.join(args.plot_dir, "maps"), + ) + if args.flow_rate_type == "constant": flow_rate_yaml_lines = ( f" Flow Rate Type: Constant\n" From 4f4d1907be2e5bc9ee525383353405c404d56369 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 13:29:01 -0700 Subject: [PATCH 22/55] Log-scale bedRoughnessRC map, compare to muFriction, flatten plot dir plot_maps() now supports multi-panel figures with an optional log-scale colormap per panel (non-positive values masked out). bedRoughnessRC is now plotted log-scale, side by side with the original input muFriction field (also log-scale) for comparison. All diagnostic plots (transects and maps) are now written directly into a single --plot-dir (default: diagnostic_plots), with no maps/ subdirectory, per user request for one flat CLI-controlled directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 147 ++++++++++++------ 1 file changed, 97 insertions(+), 50 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 2d4543322..9b96551fe 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -734,10 +734,14 @@ def plot_maps(mesh_ds, fields, plot_dir): the `on_a_sphere`/`is_periodic` global attributes. Antarctic MALI meshes are planar (on_a_sphere = "NO"), so no projection/ transform is needed. - fields : dict of str -> (1-D numpy array, str, str) - Mapping of output filename stem -> (cell-centered values, - units string, title string) to plot, e.g. - {"bedRoughnessRC": (lam, "Pa (m/yr)^-1/3", "RC bed roughness Lambda")}. + fields : dict of str -> list of (values, units, title, log) + Mapping of output filename stem -> a list of one or more + panels to plot side by side in that file, each panel a + (cell-centered values, units string, title string, bool) + tuple, where the bool selects a log-scale colormap (values + <= 0 are masked out for log-scale panels), e.g. + {"bedRoughnessRC": [(lam, "Pa (m/yr)^-1/3", + "RC bed roughness Lambda", True)]}. plot_dir : str Directory to write output PNGs to (created if needed). """ @@ -753,19 +757,41 @@ def plot_maps(mesh_ds, fields, plot_dir): import matplotlib matplotlib.use("Agg") + import matplotlib.colors import matplotlib.pyplot as plt os.makedirs(plot_dir, exist_ok=True) descriptor = mosaic.Descriptor(mesh_ds) - for stem, (values, units, title) in fields.items(): - fig, ax = plt.subplots(figsize=(7, 7)) - array = xr.DataArray(np.asarray(values), dims=("nCells",)) - coll = mosaic.polypcolor(ax, descriptor, array, cmap="viridis") - ax.set_aspect("equal") - ax.set_title(title, fontsize=11) - fig.colorbar(coll, ax=ax, label=units, shrink=0.8) + for stem, panels in fields.items(): + fig, axes = plt.subplots( + 1, len(panels), figsize=(7 * len(panels), 7), squeeze=False + ) + axes = axes[0] + + for ax, (values, units, title, log) in zip(axes, panels): + plot_values = np.asarray(values, dtype=np.float64) + norm = None + if log: + # Log-scale colormaps can't handle non-positive + # values; mask them out (e.g. Lambda's full-Coulomb + # reference cells, typically 0.0). + plot_values = np.where( + plot_values > 0.0, plot_values, np.nan + ) + norm = matplotlib.colors.LogNorm( + vmin=np.nanmin(plot_values), + vmax=np.nanmax(plot_values), + ) + array = xr.DataArray(plot_values, dims=("nCells",)) + coll = mosaic.polypcolor( + ax, descriptor, array, cmap="viridis", norm=norm + ) + ax.set_aspect("equal") + ax.set_title(title, fontsize=11) + fig.colorbar(coll, ax=ax, label=units, shrink=0.8) + fig.tight_layout() out_path = os.path.join(plot_dir, f"{stem}.png") @@ -1082,10 +1108,12 @@ def main(): ) parser.add_argument( "--plot-dir", - default="transect_plots", + default="diagnostic_plots", help=( - "Directory to write transect PNG plots to (default: " - "transect_plots, created if needed)." + "Directory to write all diagnostic PNG plots to (transect " + "plots and Antarctic-wide maps, all directly in this " + "single directory, no subdirectories). Default: " + "diagnostic_plots, created if needed." ) ) @@ -1104,11 +1132,12 @@ def main(): "--diagnostics (the fields plotted are diagnostic " "fields) and --transects-dir, plus the pyproj/scipy/" "matplotlib packages. Also automatically produces " - "Antarctic-wide maps (in a 'maps' subdirectory of " - "--plot-dir) of the same diagnostic fields, the " - "diagnostic masks, and bedRoughnessRC, using the " - "'mosaic' package (available in the e3sm-unified conda " - "environment). Default: disabled." + "Antarctic-wide maps (in --plot-dir) of the same " + "diagnostic fields, the diagnostic masks, bedRoughnessRC " + "(log scale), and the original muFriction field (log " + "scale, for comparison), using the 'mosaic' package " + "(available in the e3sm-unified conda environment). " + "Default: disabled." ) ) plot_transects_group.add_argument( @@ -1795,42 +1824,60 @@ def basal_cell_field(name): ) # Antarctic-wide maps of the same diagnostic fields plus - # bedRoughnessRC, using mosaic (e3sm-unified environment). - # Always produced alongside the transect plots -- no separate - # CLI flag -- since both are diagnostic/evaluation outputs - # triggered by --plot-transects. + # bedRoughnessRC (alongside the original Weertman muFriction + # field for comparison, both log scale), using mosaic + # (e3sm-unified environment). Always produced alongside the + # transect plots -- no separate CLI flag -- since both are + # diagnostic/evaluation outputs triggered by --plot-transects. map_fields = { - args.lambda_field: ( - Lambda, "Pa (m yr-1)^-1/3", - "Albany regularized-Coulomb bed roughness Lambda" - ), - args.effective_pressure_field: ( - N, "Pa", effective_pressure_long_name - ), - args.floatation_fraction_field: ( - floatation_fraction, "1", - "Floatation fraction (Pw / Pice)" - ), - args.hydropotential_field: ( - hydropotential, "Pa", "Shreve hydraulic potential" - ), - args.flow_rate_field: (A, "Pa-3 s-1", flow_rate_long_name), - "maskGrounded": ( - grounded.astype(np.int8), "1", "Mask of grounded ice" - ), - "maskFastFlowing": ( - fast_flowing.astype(np.int8), "1", - "Mask of grounded, fast-flowing cells" - ), - "maskValidBedRoughnessRC": ( - lambda_mask.astype(np.int8), "1", - "Mask of cells where bedRoughnessRC was solved exactly" - ), + args.lambda_field: [ + ( + Lambda, "Pa (m yr-1)^-1/3", + "Albany regularized-Coulomb bed roughness Lambda", + True, + ), + ( + mu, f"Pa (m yr-1)^-{args.weertman_q:g}", + "Original Weertman muFriction (input)", + True, + ), + ], + args.effective_pressure_field: [ + (N, "Pa", effective_pressure_long_name, False) + ], + args.floatation_fraction_field: [ + ( + floatation_fraction, "1", + "Floatation fraction (Pw / Pice)", False, + ) + ], + args.hydropotential_field: [ + (hydropotential, "Pa", "Shreve hydraulic potential", False) + ], + args.flow_rate_field: [ + (A, "Pa-3 s-1", flow_rate_long_name, False) + ], + "maskGrounded": [ + (grounded.astype(np.int8), "1", "Mask of grounded ice", False) + ], + "maskFastFlowing": [ + ( + fast_flowing.astype(np.int8), "1", + "Mask of grounded, fast-flowing cells", False, + ) + ], + "maskValidBedRoughnessRC": [ + ( + lambda_mask.astype(np.int8), "1", + "Mask of cells where bedRoughnessRC was solved exactly", + False, + ) + ], } plot_maps( mesh_ds=ds, fields=map_fields, - plot_dir=os.path.join(args.plot_dir, "maps"), + plot_dir=args.plot_dir, ) if args.flow_rate_type == "constant": From 2f1dc6f6dcda372a8c7c4bda2c3267929fa1cede Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 13:35:52 -0700 Subject: [PATCH 23/55] change transect plot filename --- landice/mesh_tools_li/friction_law_conversion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 9b96551fe..e915c897f 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -709,7 +709,7 @@ def plot_transects( fig.suptitle(f"{name} transect") fig.tight_layout() - out_path = os.path.join(plot_dir, f"{name}.png") + out_path = os.path.join(plot_dir, f"transect_{name}.png") fig.savefig(out_path, dpi=150, bbox_inches="tight") plt.close(fig) From 63907d06f192664e4816166b5cd9a8017d8b4438 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 13:42:00 -0700 Subject: [PATCH 24/55] Use turbo colormap (reversed for muFriction) in bedRoughnessRC map plot_maps() panels now accept an optional per-panel colormap name. bedRoughnessRC and the comparison muFriction panel now use 'turbo' (more perceptually distinct hues than viridis, revealing more spatial detail), with muFriction's colormap reversed ('turbo_r') so that similar colors indicate the physically inverse relationship between the two fields (low mu ~ high Lambda and vice versa), making them easier to visually compare. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index e915c897f..34d1e0e4a 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -734,14 +734,16 @@ def plot_maps(mesh_ds, fields, plot_dir): the `on_a_sphere`/`is_periodic` global attributes. Antarctic MALI meshes are planar (on_a_sphere = "NO"), so no projection/ transform is needed. - fields : dict of str -> list of (values, units, title, log) + fields : dict of str -> list of panel tuples Mapping of output filename stem -> a list of one or more panels to plot side by side in that file, each panel a - (cell-centered values, units string, title string, bool) - tuple, where the bool selects a log-scale colormap (values - <= 0 are masked out for log-scale panels), e.g. + (values, units, title, log, cmap) tuple: cell-centered + values, units string, title string, bool selecting a + log-scale colormap (values <= 0 are masked out for log-scale + panels), and a matplotlib colormap name (default "viridis" + if omitted by passing a 4-tuple instead), e.g. {"bedRoughnessRC": [(lam, "Pa (m/yr)^-1/3", - "RC bed roughness Lambda", True)]}. + "RC bed roughness Lambda", True, "turbo")]}. plot_dir : str Directory to write output PNGs to (created if needed). """ @@ -770,7 +772,10 @@ def plot_maps(mesh_ds, fields, plot_dir): ) axes = axes[0] - for ax, (values, units, title, log) in zip(axes, panels): + for ax, panel in zip(axes, panels): + values, units, title, log = panel[:4] + cmap = panel[4] if len(panel) > 4 else "viridis" + plot_values = np.asarray(values, dtype=np.float64) norm = None if log: @@ -786,7 +791,7 @@ def plot_maps(mesh_ds, fields, plot_dir): ) array = xr.DataArray(plot_values, dims=("nCells",)) coll = mosaic.polypcolor( - ax, descriptor, array, cmap="viridis", norm=norm + ax, descriptor, array, cmap=cmap, norm=norm ) ax.set_aspect("equal") ax.set_title(title, fontsize=11) @@ -1834,12 +1839,12 @@ def basal_cell_field(name): ( Lambda, "Pa (m yr-1)^-1/3", "Albany regularized-Coulomb bed roughness Lambda", - True, + True, "turbo", ), ( mu, f"Pa (m yr-1)^-{args.weertman_q:g}", "Original Weertman muFriction (input)", - True, + True, "turbo_r", ), ], args.effective_pressure_field: [ From 498c2dc5b5912f5fd337cdc00051f7c746372af9 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 13:46:12 -0700 Subject: [PATCH 25/55] Clip floatationFraction map to [0,1] and mask non-grounded cells plot_maps() panel specs are now dicts (values/units/title/log/cmap/ vmin/vmax/mask) instead of positional tuples, adding support for clipping the colormap to a fixed range and masking out cells (set to NaN, not displayed) based on a boolean array. floatationFraction's map now clips the colorbar to [0, 1] (a handful of near-zero-thickness outlier cells previously produced ratios up to ~1500, which is only physically meaningful for grounded ice with non-negligible overburden) and masks out non-grounded cells entirely, since Pw/Pice is not a meaningful/valid quantity there. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 124 ++++++++++++------ 1 file changed, 85 insertions(+), 39 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 34d1e0e4a..4343899e6 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -734,16 +734,27 @@ def plot_maps(mesh_ds, fields, plot_dir): the `on_a_sphere`/`is_periodic` global attributes. Antarctic MALI meshes are planar (on_a_sphere = "NO"), so no projection/ transform is needed. - fields : dict of str -> list of panel tuples + fields : dict of str -> list of dict Mapping of output filename stem -> a list of one or more - panels to plot side by side in that file, each panel a - (values, units, title, log, cmap) tuple: cell-centered - values, units string, title string, bool selecting a - log-scale colormap (values <= 0 are masked out for log-scale - panels), and a matplotlib colormap name (default "viridis" - if omitted by passing a 4-tuple instead), e.g. - {"bedRoughnessRC": [(lam, "Pa (m/yr)^-1/3", - "RC bed roughness Lambda", True, "turbo")]}. + panels to plot side by side in that file. Each panel is a + dict with keys: + values : 1-D array, cell-centered values to plot. + units : str, colorbar label. + title : str, panel title. + log : bool, optional (default False). Use a log-scale + colormap (values <= 0 are masked out). + cmap : str, optional (default "viridis"). Matplotlib + colormap name. + vmin, vmax : float, optional. Clip the colormap to this + range (e.g. [0, 1] for a fraction field with a few + out-of-range outliers); ignored if `log` is True. + mask : 1-D bool array, optional. Cells where mask is + False are set to NaN (not displayed), e.g. to hide + non-grounded cells on a grounded-ice-only field. + e.g. {"bedRoughnessRC": [{"values": lam, + "units": "Pa (m/yr)^-1/3", + "title": "RC bed roughness Lambda", "log": True, + "cmap": "turbo"}]}. plot_dir : str Directory to write output PNGs to (created if needed). """ @@ -773,10 +784,18 @@ def plot_maps(mesh_ds, fields, plot_dir): axes = axes[0] for ax, panel in zip(axes, panels): - values, units, title, log = panel[:4] - cmap = panel[4] if len(panel) > 4 else "viridis" + units = panel["units"] + title = panel["title"] + log = panel.get("log", False) + cmap = panel.get("cmap", "viridis") + vmin = panel.get("vmin") + vmax = panel.get("vmax") + mask = panel.get("mask") + + plot_values = np.asarray(panel["values"], dtype=np.float64) + if mask is not None: + plot_values = np.where(mask, plot_values, np.nan) - plot_values = np.asarray(values, dtype=np.float64) norm = None if log: # Log-scale colormaps can't handle non-positive @@ -789,6 +808,10 @@ def plot_maps(mesh_ds, fields, plot_dir): vmin=np.nanmin(plot_values), vmax=np.nanmax(plot_values), ) + elif vmin is not None or vmax is not None: + norm = matplotlib.colors.Normalize( + vmin=vmin, vmax=vmax, clip=True + ) array = xr.DataArray(plot_values, dims=("nCells",)) coll = mosaic.polypcolor( ax, descriptor, array, cmap=cmap, norm=norm @@ -1836,47 +1859,70 @@ def basal_cell_field(name): # diagnostic/evaluation outputs triggered by --plot-transects. map_fields = { args.lambda_field: [ - ( - Lambda, "Pa (m yr-1)^-1/3", - "Albany regularized-Coulomb bed roughness Lambda", - True, "turbo", - ), - ( - mu, f"Pa (m yr-1)^-{args.weertman_q:g}", - "Original Weertman muFriction (input)", - True, "turbo_r", - ), + { + "values": Lambda, "units": "Pa (m yr-1)^-1/3", + "title": ( + "Albany regularized-Coulomb bed roughness Lambda" + ), + "log": True, "cmap": "turbo", + }, + { + "values": mu, + "units": f"Pa (m yr-1)^-{args.weertman_q:g}", + "title": "Original Weertman muFriction (input)", + "log": True, "cmap": "turbo_r", + }, ], args.effective_pressure_field: [ - (N, "Pa", effective_pressure_long_name, False) + { + "values": N, "units": "Pa", + "title": effective_pressure_long_name, + } ], args.floatation_fraction_field: [ - ( - floatation_fraction, "1", - "Floatation fraction (Pw / Pice)", False, - ) + { + "values": floatation_fraction, "units": "1", + "title": "Floatation fraction (Pw / Pice)", + # A few outlier cells (very thin/no ice) can + # produce huge ratios; clip the colormap to the + # physically meaningful [0, 1] range, and mask + # out non-grounded cells entirely (this field is + # only meaningful for grounded ice). + "vmin": 0.0, "vmax": 1.0, "mask": grounded, + } ], args.hydropotential_field: [ - (hydropotential, "Pa", "Shreve hydraulic potential", False) + { + "values": hydropotential, "units": "Pa", + "title": "Shreve hydraulic potential", + } ], args.flow_rate_field: [ - (A, "Pa-3 s-1", flow_rate_long_name, False) + { + "values": A, "units": "Pa-3 s-1", + "title": flow_rate_long_name, + } ], "maskGrounded": [ - (grounded.astype(np.int8), "1", "Mask of grounded ice", False) + { + "values": grounded.astype(np.int8), "units": "1", + "title": "Mask of grounded ice", + } ], "maskFastFlowing": [ - ( - fast_flowing.astype(np.int8), "1", - "Mask of grounded, fast-flowing cells", False, - ) + { + "values": fast_flowing.astype(np.int8), "units": "1", + "title": "Mask of grounded, fast-flowing cells", + } ], "maskValidBedRoughnessRC": [ - ( - lambda_mask.astype(np.int8), "1", - "Mask of cells where bedRoughnessRC was solved exactly", - False, - ) + { + "values": lambda_mask.astype(np.int8), "units": "1", + "title": ( + "Mask of cells where bedRoughnessRC was solved " + "exactly" + ), + } ], } plot_maps( From be9ead74e45b17b7f48e9a8f1c844f2ca4d303f7 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 13:48:34 -0700 Subject: [PATCH 26/55] Mask effectivePressure map to grounded ice only N is only physically meaningful for grounded ice; hide floating/ ice-free cells on the map (same mask approach as floatationFraction). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- landice/mesh_tools_li/friction_law_conversion.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 4343899e6..7c9e31bd9 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -1877,6 +1877,10 @@ def basal_cell_field(name): { "values": N, "units": "Pa", "title": effective_pressure_long_name, + # N is only physically meaningful for grounded + # ice (see the floating-cell-nonzero-N note on + # this field in the output NetCDF). + "mask": grounded, } ], args.floatation_fraction_field: [ From 0f552963b5dd2835a9ad4c68465bba04a26883c0 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 13:50:33 -0700 Subject: [PATCH 27/55] Mask hydropotential map to grounded ice only Same grounded-ice masking as effectivePressure/floatationFraction, for consistency across the diagnostic maps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- landice/mesh_tools_li/friction_law_conversion.py | 1 + 1 file changed, 1 insertion(+) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 7c9e31bd9..b92fc8621 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -1899,6 +1899,7 @@ def basal_cell_field(name): { "values": hydropotential, "units": "Pa", "title": "Shreve hydraulic potential", + "mask": grounded, } ], args.flow_rate_field: [ From c41a0d92ca9734a148c28e734322b3337e1e3e26 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 13:51:50 -0700 Subject: [PATCH 28/55] add 'map' prefix to map figures --- landice/mesh_tools_li/friction_law_conversion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index b92fc8621..90d377661 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -822,7 +822,7 @@ def plot_maps(mesh_ds, fields, plot_dir): fig.tight_layout() - out_path = os.path.join(plot_dir, f"{stem}.png") + out_path = os.path.join(plot_dir, f"map_{stem}.png") fig.savefig(out_path, dpi=150, bbox_inches="tight") plt.close(fig) From a0a80bf2781a0e8861375a29a591e9d7e20f81e9 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 14:03:28 -0700 Subject: [PATCH 29/55] Overlay transect lines on Antarctic-wide diagnostic maps plot_maps() now accepts an optional transects=[(name, x, y), ...] list (already projected into the mesh's planar CRS) and draws each as a labeled red line on every panel of every map. Enabled by default (--plot-transects-on-maps / --no-plot-transects-on-maps), reusing the existing --transect-names/--transects-dir plumbing and the load_transect()/project_transect() helpers already used for the transect line plots -- no new transect-specific inputs needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 90d377661..f326bbf27 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -716,7 +716,7 @@ def plot_transects( print(f"Wrote transect plot: {out_path}") -def plot_maps(mesh_ds, fields, plot_dir): +def plot_maps(mesh_ds, fields, plot_dir, transects=None): """ Plot Antarctic-wide maps of the given cell-centered fields on the native MALI mesh, using the `mosaic` package (available in the @@ -757,6 +757,11 @@ def plot_maps(mesh_ds, fields, plot_dir): "cmap": "turbo"}]}. plot_dir : str Directory to write output PNGs to (created if needed). + transects : list of (str, ndarray, ndarray), optional + (name, x, y) tuples, already projected into the mesh's planar + CRS (e.g. via project_transect()), overlaid as lines on every + panel of every map, with each transect's name labeled at its + starting point. Omit/None to disable (default: no overlay). """ try: import mosaic @@ -820,6 +825,14 @@ def plot_maps(mesh_ds, fields, plot_dir): ax.set_title(title, fontsize=11) fig.colorbar(coll, ax=ax, label=units, shrink=0.8) + if transects: + for name, x, y in transects: + ax.plot(x, y, color="red", linewidth=1, zorder=3) + ax.annotate( + name, (x[0], y[0]), color="red", fontsize=7, + zorder=4, + ) + fig.tight_layout() out_path = os.path.join(plot_dir, f"map_{stem}.png") @@ -1175,6 +1188,25 @@ def main(): help="Do not plot transects (default)." ) + plot_transects_on_maps_group = parser.add_mutually_exclusive_group() + plot_transects_on_maps_group.add_argument( + "--plot-transects-on-maps", + dest="plot_transects_on_maps", + action="store_true", + default=True, + help=( + "Overlay the --transect-names transect lines (with " + "labels) on every panel of every Antarctic-wide map " + "produced by --plot-transects. Default: enabled." + ) + ) + plot_transects_on_maps_group.add_argument( + "--no-plot-transects-on-maps", + dest="plot_transects_on_maps", + action="store_false", + help="Do not overlay transect lines on the Antarctic-wide maps." + ) + diagnostics_group = parser.add_mutually_exclusive_group() diagnostics_group.add_argument( "--diagnostics", @@ -1930,10 +1962,25 @@ def basal_cell_field(name): } ], } + + map_transects = None + if args.plot_transects_on_maps: + import pyproj + + transformer = pyproj.Transformer.from_crs( + "epsg:4326", "epsg:3031", always_xy=True + ) + map_transects = [] + for name in args.transect_names: + t_lon, t_lat = load_transect(name, args.transects_dir) + t_x, t_y, _ = project_transect(t_lon, t_lat, transformer) + map_transects.append((name, t_x, t_y)) + plot_maps( mesh_ds=ds, fields=map_fields, plot_dir=args.plot_dir, + transects=map_transects, ) if args.flow_rate_type == "constant": From 6b3d48cd49b390eddd3ec11adf89ed3d4ec71874 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 14:15:04 -0700 Subject: [PATCH 30/55] Mark bed elevation=0 crossings with vertical dotted lines on transects Adds interpolated bed=0 crossing distances (a rough grounding- line/coastline proxy) as vertical gray dotted lines on every panel of each transect plot, to help visually correlate feature changes (e.g. in N, floatationFraction, hydropotential) with topographic context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index f326bbf27..10e3e875a 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -494,7 +494,9 @@ def plot_transects( elevation (computed from `thickness`/`bed`); subsequent panels show the caller-supplied `fields`. Background shading indicates floating ice, ice-free ocean, and ice-free land (grounded ice is - left unshaded). + left unshaded). Vertical dotted gray lines mark along-transect + distances where bed elevation crosses zero (interpolated between + sampled points). Parameters ---------- @@ -622,6 +624,23 @@ def plot_transects( _, cell_indices = tree.query(np.column_stack((x, y))) distance_km = distance / 1000.0 class_along_transect = region_class[cell_indices] + bed_along_transect = bed[cell_indices] + + # Distances (interpolated) where bed elevation crosses zero, + # marked with a vertical dotted line on every panel (a rough + # proxy for the coastline/grounding-line vicinity). + bed_sign_changes = np.flatnonzero( + np.diff(np.sign(bed_along_transect)) != 0 + ) + zero_crossing_distances_km = [] + for i in bed_sign_changes: + b0, b1 = bed_along_transect[i], bed_along_transect[i + 1] + if b0 == b1: + continue + frac = -b0 / (b1 - b0) + zero_crossing_distances_km.append( + distance_km[i] + frac * (distance_km[i + 1] - distance_km[i]) + ) fig, axes = plt.subplots( len(all_fields), 1, sharex=True, @@ -651,6 +670,12 @@ def plot_transects( for ax in axes: ax.axvspan(x0, x1, color=color, alpha=0.2, zorder=0) + for x0 in zero_crossing_distances_km: + for ax in axes: + ax.axvline( + x0, color="gray", linestyle=":", linewidth=1, zorder=1 + ) + for ax, (label, (values, units, long_name)) in zip( axes, all_fields.items() ): From 7b64c60cc0b6f5df8323c7a8b6d0efa0cb1acb68 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 16:08:56 -0700 Subject: [PATCH 31/55] Remove temporary N calculation used for debugging --- landice/mesh_tools_li/friction_law_conversion.py | 1 - 1 file changed, 1 deletion(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 10e3e875a..fb7479652 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -314,7 +314,6 @@ def downs_johnson_effective_pressure( rho_i * H - (overburden_term + marine_term), 0.0 ) - N=gravity * (rho_i*H - np.maximum(-rho_w * b, 0.0)) return N From 9331a758dbd62f4e7c3dd82a90bdcdbec1a62935 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 16:09:23 -0700 Subject: [PATCH 32/55] Remove vmin for flotation fraction map --- landice/mesh_tools_li/friction_law_conversion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index fb7479652..8d11eebd5 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -1948,7 +1948,7 @@ def basal_cell_field(name): # physically meaningful [0, 1] range, and mask # out non-grounded cells entirely (this field is # only meaningful for grounded ice). - "vmin": 0.0, "vmax": 1.0, "mask": grounded, + "vmax": 1.0, "mask": grounded, } ], args.hydropotential_field: [ From 555b0e1d030db95663a3bc26da2348231ca5341c Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 16:28:17 -0700 Subject: [PATCH 33/55] Add implied-C diagnostic map Add a new map panel 'impliedC' showing the per-cell implied Coulomb Friction Coefficient (tau_b_weertman / N_albany), masked to the fast-flowing/full-Coulomb fit region (fit_mask) used to fit the single global scalar C. Plotted on a log color scale (turbo colormap) since values can span orders of magnitude; the title also reports the fitted scalar C value for reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- landice/mesh_tools_li/friction_law_conversion.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 8d11eebd5..f8933d6e3 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -1163,11 +1163,11 @@ def main(): "--transect-names", nargs="+", default=[ - "Thwaites", "Totten", "Lambert", "Foundation", "Bindschadler" + "Thwaites", "Totten", "Jutulstraumen", "Foundation", "Bindschadler" ], help=( "Names of transects to plot (subdirectory names under " - "--transects-dir). Default: Thwaites Totten Lambert " + "--transects-dir). Default: Thwaites Totten Jutulstraumen " "Foundation Bindschadler." ) ) @@ -1985,6 +1985,18 @@ def basal_cell_field(name): ), } ], + "impliedC": [ + { + "values": local_C, "units": "1", + "title": ( + "Implied C (Weertman Tau_b / N) in the fast-" + f"flowing/full-Coulomb fit region; fitted " + f"scalar C = {C:.4g}" + ), + "log": True, "cmap": "turbo", + "mask": fit_mask, + } + ], } map_transects = None From 8e002d7d1fd2d0c61a6dc8c428b13a8f8387785d Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 16:32:49 -0700 Subject: [PATCH 34/55] Shade Coulomb C-fit region on transect plots Add fit_mask parameter to plot_transects(); along-transect runs falling inside the fast-flowing/full-Coulomb fit region (used to fit the single global scalar C) are now shaded orange on every panel, with a corresponding legend entry, so the fit region is visible alongside the existing grounded/floating/ice-free shading. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index f8933d6e3..fba967ffa 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -484,6 +484,7 @@ def plot_transects( rho_w, min_fraction_overburden=None, floatation_fraction_label="floatation fraction", + fit_mask=None, ): """ For each named transect, sample the given cell-centered fields @@ -535,6 +536,13 @@ def plot_transects( Key in `fields` identifying the floatation-fraction panel (default: "floatation fraction"), used to place the `min_fraction_overburden` reference line above. + fit_mask : 1-D bool numpy array, optional + MALI cell-centered mask (same shape as `thickness`/`bed`) + marking the region used to fit the single global scalar + Coulomb Friction Coefficient C (i.e. the "fast-flowing"/ + full-Coulomb-regime region). If provided, contiguous + along-transect runs where this mask is True are shaded (on + every panel) to indicate where the fit region is crossed. """ try: import pyproj @@ -675,6 +683,29 @@ def plot_transects( x0, color="gray", linestyle=":", linewidth=1, zorder=1 ) + # Shade contiguous along-transect runs falling inside the + # Coulomb-fit region (fit_mask), on every panel. + fit_region_used = False + if fit_mask is not None: + fit_along_transect = fit_mask[cell_indices] + fit_change_indices = np.flatnonzero( + np.diff(fit_along_transect.astype(np.int8)) != 0 + ) + 1 + fit_run_starts = np.concatenate(([0], fit_change_indices)) + fit_run_ends = np.concatenate( + (fit_change_indices, [len(fit_along_transect) - 1]) + ) + for run_start, run_end in zip(fit_run_starts, fit_run_ends): + if not fit_along_transect[run_start]: + continue + fit_region_used = True + x0 = distance_km[run_start] + x1 = distance_km[run_end] + for ax in axes: + ax.axvspan( + x0, x1, color="tab:orange", alpha=0.2, zorder=0 + ) + for ax, (label, (values, units, long_name)) in zip( axes, all_fields.items() ): @@ -719,6 +750,16 @@ def plot_transects( ) for cls in sorted(classes_used) ] + else: + legend_handles = [] + if fit_region_used: + legend_handles.append( + mpatches.Patch( + color="tab:orange", alpha=0.2, + label="Coulomb C-fit region", + ) + ) + if legend_handles: fig.legend( handles=legend_handles, loc="upper center", @@ -727,7 +768,7 @@ def plot_transects( frameon=False, ) - if classes_used: + if classes_used or fit_region_used: fig.suptitle(f"{name} transect", y=1.08) else: fig.suptitle(f"{name} transect") @@ -1905,6 +1946,7 @@ def basal_cell_field(name): rho_i=args.rho_ice, rho_w=args.rho_water, min_fraction_overburden=args.min_fraction_overburden, + fit_mask=fit_mask, ) # Antarctic-wide maps of the same diagnostic fields plus From fd6ba8d0c009e7d837d5d4c5ff09d29ca73584fb Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Tue, 25 Aug 2026 16:38:35 -0700 Subject: [PATCH 35/55] Add implied-local-C panel to transect plots Add a new 'implied C' panel to plot_transects() showing local_C (Weertman Tau_b / N), restricted to the Coulomb C-fit region (fit_mask) -- the line is NaN'd outside that region so it only draws where meaningful. Overlay a dashed reference line at the fitted global scalar C, restricted to the same along-transect fit-region spans, for direct visual comparison with the local values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index fba967ffa..ebb7fe306 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -485,6 +485,8 @@ def plot_transects( min_fraction_overburden=None, floatation_fraction_label="floatation fraction", fit_mask=None, + implied_c_label="implied C", + fitted_c=None, ): """ For each named transect, sample the given cell-centered fields @@ -543,6 +545,19 @@ def plot_transects( full-Coulomb-regime region). If provided, contiguous along-transect runs where this mask is True are shaded (on every panel) to indicate where the fit region is crossed. + implied_c_label : str + Key in `fields` identifying the implied-local-C panel + (default: "implied C"). On that panel, the line is only + drawn where `fit_mask` is True (elsewhere set to NaN so the + line breaks), and, if `fitted_c` is given, a horizontal + reference line at the fitted scalar C is overlaid but + restricted to the same along-transect fit-region runs (so it + is directly comparable to the local values plotted alongside + it). + fitted_c : float, optional + The single global scalar Coulomb Friction Coefficient C, used + to draw the reference line on the `implied_c_label` panel + described above. """ try: import pyproj @@ -686,6 +701,8 @@ def plot_transects( # Shade contiguous along-transect runs falling inside the # Coulomb-fit region (fit_mask), on every panel. fit_region_used = False + fit_region_ranges = [] + fit_along_transect = None if fit_mask is not None: fit_along_transect = fit_mask[cell_indices] fit_change_indices = np.flatnonzero( @@ -701,6 +718,7 @@ def plot_transects( fit_region_used = True x0 = distance_km[run_start] x1 = distance_km[run_end] + fit_region_ranges.append((x0, x1)) for ax in axes: ax.axvspan( x0, x1, color="tab:orange", alpha=0.2, zorder=0 @@ -717,6 +735,25 @@ def plot_transects( label=line_label, zorder=2, ) ax.legend(loc="best", fontsize=8) + elif label == implied_c_label and fit_along_transect is not None: + # Only meaningful inside the Coulomb-fit region; + # break the line elsewhere. + sampled = values[cell_indices].astype(np.float64) + sampled = np.where(fit_along_transect, sampled, np.nan) + ax.plot(distance_km, sampled, zorder=2) + if fitted_c is not None: + for i, (x0, x1) in enumerate(fit_region_ranges): + ax.hlines( + fitted_c, x0, x1, + color="k", linestyle="--", linewidth=1, + zorder=3, + label=( + f"fitted scalar C ({fitted_c:.3g})" + if i == 0 else None + ), + ) + if fit_region_ranges: + ax.legend(loc="best", fontsize=8) else: ax.plot(distance_km, values[cell_indices], zorder=2) ax.set_ylabel(f"{label} [{units}]") @@ -1940,6 +1977,11 @@ def basal_cell_field(name): "hydropotential": ( hydropotential, "Pa", "Shreve hydraulic potential" ), + "implied C": ( + local_C, "1", + "Implied local C (Weertman Tau_b / N), Coulomb " + "C-fit region only", + ), }, thickness=H, bed=bed, @@ -1947,6 +1989,7 @@ def basal_cell_field(name): rho_w=args.rho_water, min_fraction_overburden=args.min_fraction_overburden, fit_mask=fit_mask, + fitted_c=C, ) # Antarctic-wide maps of the same diagnostic fields plus From a01099971e1bfd5e67b95604cf623ee167b4e544 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Wed, 26 Aug 2026 09:17:36 -0700 Subject: [PATCH 36/55] Add standalone pure-Coulomb N-vs-C sensitivity script New coulomb_N_sensitivity.py: a lightweight, tangential diagnostic that plots the effective pressure N implied by assuming pure Coulomb sliding (Tau_b = C * N) at a range of candidate scalar Coulomb Friction Coefficients C, restricted to the fast-flowing region (same grounded + speed > critical-velocity test friction_law_conversion.py uses to fit its own C), along the same glacier transects. Reuses friction_law_conversion.py's plot_transects() directly via import (including its existing multi-line-panel support, used here to overlay one N(C) curve per candidate C) rather than duplicating the transect-loading/projection/plotting machinery; only the handful of lines needed to read mu/thickness/bed/velocity and compute Tau_b_weertman = mu * speed^qW are duplicated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/coulomb_N_sensitivity.py | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 landice/mesh_tools_li/coulomb_N_sensitivity.py diff --git a/landice/mesh_tools_li/coulomb_N_sensitivity.py b/landice/mesh_tools_li/coulomb_N_sensitivity.py new file mode 100644 index 000000000..4a5441fb5 --- /dev/null +++ b/landice/mesh_tools_li/coulomb_N_sensitivity.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +Tangential "what-if" diagnostic for the Weertman -> Regularized +Coulomb (RC) conversion carried out by friction_law_conversion.py. + +For a user-supplied list of candidate scalar Coulomb Friction +Coefficients C, plot the effective pressure N that *pure* Coulomb +sliding (no regularization: Tau_b = C * N, i.e. the RC law's +fully-plastic limit) would imply at each cell's actual current basal +shear stress: + + Tau_b_weertman = mu * speed^qW (MALI's Weertman law) + N(C) = Tau_b_weertman / C + +This is plotted along the same glacier transects used by +friction_law_conversion.py's --plot-transects, restricted to the same +"fast-flowing" region (grounded cells with speed > critical velocity) +that script uses to fit its own single scalar C -- i.e. exactly the +region where the pure-Coulomb assumption is invoked -- so the curves +can be visually compared against a range of candidate C values, +including (optionally) the one friction_law_conversion.py itself +would fit. + +This script is intentionally lightweight: it reuses +friction_law_conversion.py's transect-loading/projection/plotting +machinery directly (via import) rather than duplicating it, and +duplicates only the handful of lines of physics (mu * speed^qW) needed +to get from the MALI input file to Tau_b_weertman. + +Example +------- + python3 coulomb_N_sensitivity.py relaxed_10yrs_4km.nc \\ + --uc 100 --weertman-q 0.2 \\ + --transects-dir /path/to/geometric_data/landice/transect \\ + --c-values 0.005 0.01 0.0162 0.02 0.05 \\ + --plot-dir coulomb_N_sensitivity_plots +""" + +import argparse + +import numpy as np +import xarray as xr + +from friction_law_conversion import ( + SECONDS_PER_YEAR, + plot_transects, +) + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("input", help="Input MALI initial-condition NetCDF file") + + parser.add_argument( + "--critical-velocity", "--uc", + type=float, + required=True, + help=( + "Critical velocity u_c [m/yr]: cells with a current " + "sliding speed above this, within grounded ice, define " + "the fast-flowing region the pure-Coulomb N(C) curves " + "are restricted to (same convention as " + "friction_law_conversion.py)." + ), + ) + parser.add_argument( + "--weertman-q", "--q", + dest="weertman_q", + type=float, + default=0.2, + help="Input Weertman/Power-Law sliding exponent qW (default: 0.2)", + ) + parser.add_argument( + "--c-values", + type=float, + nargs="+", + required=True, + help=( + "List of candidate scalar Coulomb Friction Coefficients C " + "to evaluate, e.g. --c-values 0.005 0.01 0.02." + ), + ) + + parser.add_argument("--rho-ice", type=float, default=910.0) + parser.add_argument("--rho-water", type=float, default=1028.0) + + parser.add_argument( + "--mu-field", default="muFriction", + help="Weertman friction field (default: muFriction)", + ) + parser.add_argument( + "--thickness-field", default="thickness", + help="Ice thickness field (default: thickness)", + ) + parser.add_argument( + "--bed-field", default="bedTopography", + help="Bed elevation field (default: bedTopography)", + ) + parser.add_argument( + "--velocity-x-field", default="uReconstructX", + help="MALI x-velocity field [m s^-1] (default: uReconstructX)", + ) + parser.add_argument( + "--velocity-y-field", default="uReconstructY", + help="MALI y-velocity field [m s^-1] (default: uReconstructY)", + ) + parser.add_argument( + "--time-index", type=int, default=0, + help="Time index for Time-dependent IC fields (default: 0)", + ) + + parser.add_argument( + "--transects-dir", + required=True, + help=( + "Path to a geometric_features landice/transect directory " + "(see friction_law_conversion.py --transects-dir)." + ), + ) + parser.add_argument( + "--transect-names", + nargs="+", + default=[ + "Thwaites", "Totten", "Jutulstraumen", "Foundation", "Bindschadler" + ], + help=( + "Names of transects to plot (subdirectory names under " + "--transects-dir)." + ), + ) + parser.add_argument( + "--plot-dir", + default="coulomb_N_sensitivity_plots", + help=( + "Directory to write output PNGs to (default: " + "coulomb_N_sensitivity_plots, created if needed)." + ), + ) + + args = parser.parse_args() + + ds = xr.open_dataset(args.input, mask_and_scale=False) + + def cell_field(name): + da = ds[name] + if "Time" in da.dims: + da = da.isel(Time=args.time_index) + vert_dims = [d for d in da.dims if d.lower().startswith("nvert")] + if vert_dims: + da = da.isel({vert_dims[0]: -1}) + return np.asarray(da.values).squeeze().astype(np.float64) + + mu = cell_field(args.mu_field) + H = cell_field(args.thickness_field) + bed = cell_field(args.bed_field) + uX = cell_field(args.velocity_x_field) + uY = cell_field(args.velocity_y_field) + x_cell = np.asarray(ds["xCell"].values, dtype=np.float64) + y_cell = np.asarray(ds["yCell"].values, dtype=np.float64) + + # Basal sliding speed [m/yr], matching Albany's internal + # convention (see friction_law_conversion.py). + speed = np.sqrt(uX ** 2 + uY ** 2) * SECONDS_PER_YEAR + + # MALI's Weertman sliding law has no effective-pressure term: + # Tau_b = mu * speed^qW (same as fit_coulomb_C_fast_region() in + # friction_law_conversion.py). + tau_b_weertman = mu * speed ** args.weertman_q + + # Same grounded-ice/fast-flowing tests friction_law_conversion.py + # uses to define the region assumed to already be in the + # fully-plastic Coulomb regime. + grounded = (H > 0.0) & (args.rho_ice * H + args.rho_water * bed > 0.0) + fast_flowing = grounded & (speed > args.critical_velocity) + + # Pure-Coulomb inversion: Tau_b = C * N => N(C) = Tau_b / C, in + # physical Pa (no Albany-internal kPa rescaling needed here since + # this script works entirely in physical units). + n_of_c = {} + for c in args.c_values: + values = np.full_like(tau_b_weertman, np.nan) + values[fast_flowing] = tau_b_weertman[fast_flowing] / c + n_of_c[f"C={c:g}"] = values + + plot_transects( + transect_names=args.transect_names, + transects_dir=args.transects_dir, + plot_dir=args.plot_dir, + x_cell=x_cell, + y_cell=y_cell, + fields={ + "N(C)": ( + n_of_c, "Pa", + "Pure-Coulomb-implied N for candidate C values " + "(fast-flowing region only)", + ), + }, + thickness=H, + bed=bed, + rho_i=args.rho_ice, + rho_w=args.rho_water, + fit_mask=fast_flowing, + ) + + +if __name__ == "__main__": + main() From 3b25af0f9d6b82a83b8bbafde8fc2c86e345a89b Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Wed, 26 Aug 2026 09:29:57 -0700 Subject: [PATCH 37/55] Add floatation fraction and hydropotential panels to N sensitivity script Extend coulomb_N_sensitivity.py to also derive, for each candidate scalar C, the floatation fraction (Pw/Pice) and Shreve hydraulic potential implied by the pure-Coulomb N(C), using the same formulas as friction_law_conversion.py. Panel order (elevation, N, floatation fraction, hydropotential) now matches the main script's --plot-transects panel ordering; the elevation panel was already included automatically via plot_transects(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/coulomb_N_sensitivity.py | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/landice/mesh_tools_li/coulomb_N_sensitivity.py b/landice/mesh_tools_li/coulomb_N_sensitivity.py index 4a5441fb5..bef282193 100644 --- a/landice/mesh_tools_li/coulomb_N_sensitivity.py +++ b/landice/mesh_tools_li/coulomb_N_sensitivity.py @@ -86,6 +86,15 @@ def main(): parser.add_argument("--rho-ice", type=float, default=910.0) parser.add_argument("--rho-water", type=float, default=1028.0) + parser.add_argument( + "--rho-freshwater", type=float, default=1000.0, + help=( + "Freshwater density [kg m^-3] (default: 1000.0), used " + "for the bed-elevation term of the hydropotential panel " + "(same convention as friction_law_conversion.py)." + ), + ) + parser.add_argument("--gravity", type=float, default=9.80616) parser.add_argument( "--mu-field", default="muFriction", @@ -178,12 +187,33 @@ def cell_field(name): # Pure-Coulomb inversion: Tau_b = C * N => N(C) = Tau_b / C, in # physical Pa (no Albany-internal kPa rescaling needed here since - # this script works entirely in physical units). + # this script works entirely in physical units). For each + # candidate C, also derive the floatation fraction and + # hydropotential that N(C) would imply, using the same formulas + # as friction_law_conversion.py (Pice = rho_i * g * H, + # Pw = Pice - N, floatation_fraction = Pw / Pice, hydropotential = + # rho_freshwater * g * bed + Pw), all restricted to the + # fast-flowing region. + Pice = args.rho_ice * args.gravity * H + n_of_c = {} + floatation_fraction_of_c = {} + hydropotential_of_c = {} for c in args.c_values: - values = np.full_like(tau_b_weertman, np.nan) - values[fast_flowing] = tau_b_weertman[fast_flowing] / c - n_of_c[f"C={c:g}"] = values + label = f"C={c:g}" + + n_c = np.full_like(tau_b_weertman, np.nan) + n_c[fast_flowing] = tau_b_weertman[fast_flowing] / c + n_of_c[label] = n_c + + Pw_c = Pice - n_c + floatation_fraction_of_c[label] = np.where( + fast_flowing & (Pice > 0.0), Pw_c / Pice, np.nan + ) + hydropotential_of_c[label] = np.where( + fast_flowing, args.rho_freshwater * args.gravity * bed + Pw_c, + np.nan, + ) plot_transects( transect_names=args.transect_names, @@ -197,6 +227,16 @@ def cell_field(name): "Pure-Coulomb-implied N for candidate C values " "(fast-flowing region only)", ), + "floatation fraction(C)": ( + floatation_fraction_of_c, "1", + "Pure-Coulomb-implied floatation fraction (Pw / Pice) " + "for candidate C values (fast-flowing region only)", + ), + "hydropotential(C)": ( + hydropotential_of_c, "Pa", + "Pure-Coulomb-implied Shreve hydraulic potential for " + "candidate C values (fast-flowing region only)", + ), }, thickness=H, bed=bed, From 6a2f266822484be1e307cf041401de63cf7cb41b Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Wed, 26 Aug 2026 09:40:25 -0700 Subject: [PATCH 38/55] Fix floatation fraction y-limits in N sensitivity script Add optional per-panel ylim support to plot_transects() (4th tuple element in the fields dict, applied via ax.set_ylim()), and use it in coulomb_N_sensitivity.py to fix the floatation-fraction(C) panel to [0, 1]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- landice/mesh_tools_li/coulomb_N_sensitivity.py | 1 + landice/mesh_tools_li/friction_law_conversion.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/landice/mesh_tools_li/coulomb_N_sensitivity.py b/landice/mesh_tools_li/coulomb_N_sensitivity.py index bef282193..7dd7424c8 100644 --- a/landice/mesh_tools_li/coulomb_N_sensitivity.py +++ b/landice/mesh_tools_li/coulomb_N_sensitivity.py @@ -231,6 +231,7 @@ def cell_field(name): floatation_fraction_of_c, "1", "Pure-Coulomb-implied floatation fraction (Pw / Pice) " "for candidate C values (fast-flowing region only)", + (0.0, 1.0), ), "hydropotential(C)": ( hydropotential_of_c, "Pa", diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index ebb7fe306..1253cc04f 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -516,10 +516,13 @@ def plot_transects( MALI mesh cell-center coordinates, meters, in the same planar CRS the transects will be projected into (Antarctic MALI meshes: EPSG:3031 polar stereographic). - fields : dict of str -> (1-D numpy array, str, str) + fields : dict of str -> (1-D numpy array, str, str[, (float, float)]) Mapping of field label -> (values on MALI cells, units - string, long_name string) to sample and plot along each - transect, e.g. {"N": (N, "Pa", "Effective pressure")}. + string, long_name string, optional (ymin, ymax) tuple) to + sample and plot along each transect, e.g. {"N": (N, "Pa", + "Effective pressure")}. The optional 4th tuple element, if + given, is passed to ax.set_ylim() to fix that panel's y-axis + range (e.g. (0, 1) for a fraction). thickness, bed : 1-D numpy arrays MALI cell-centered ice thickness [m] and bed elevation [m], used to classify each cell as grounded ice, floating ice, @@ -724,9 +727,11 @@ def plot_transects( x0, x1, color="tab:orange", alpha=0.2, zorder=0 ) - for ax, (label, (values, units, long_name)) in zip( + for ax, (label, field_spec) in zip( axes, all_fields.items() ): + values, units, long_name = field_spec[:3] + ylim = field_spec[3] if len(field_spec) > 3 else None if isinstance(values, dict): # Multi-line panel (e.g. bed & surface elevation). for line_label, arr in values.items(): @@ -759,6 +764,8 @@ def plot_transects( ax.set_ylabel(f"{label} [{units}]") ax.set_title(long_name, fontsize=10) ax.grid(True, alpha=0.3, zorder=1) + if ylim is not None: + ax.set_ylim(*ylim) if ( label == floatation_fraction_label From a25e489855c51799fcc46a7b76842ee1d5a94f2e Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Wed, 26 Aug 2026 09:52:15 -0700 Subject: [PATCH 39/55] Add Pine Island Glacier as a default transect Add 'Pine_Island' (matching the geometric_features landice/transect subdirectory name) to the default --transect-names list in both friction_law_conversion.py and coulomb_N_sensitivity.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- landice/mesh_tools_li/coulomb_N_sensitivity.py | 3 ++- landice/mesh_tools_li/friction_law_conversion.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/landice/mesh_tools_li/coulomb_N_sensitivity.py b/landice/mesh_tools_li/coulomb_N_sensitivity.py index 7dd7424c8..faabed97a 100644 --- a/landice/mesh_tools_li/coulomb_N_sensitivity.py +++ b/landice/mesh_tools_li/coulomb_N_sensitivity.py @@ -133,7 +133,8 @@ def main(): "--transect-names", nargs="+", default=[ - "Thwaites", "Totten", "Jutulstraumen", "Foundation", "Bindschadler" + "Thwaites", "Totten", "Jutulstraumen", "Foundation", + "Bindschadler", "Pine_Island", ], help=( "Names of transects to plot (subdirectory names under " diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 1253cc04f..a74466bf5 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -1248,12 +1248,13 @@ def main(): "--transect-names", nargs="+", default=[ - "Thwaites", "Totten", "Jutulstraumen", "Foundation", "Bindschadler" + "Thwaites", "Totten", "Jutulstraumen", "Foundation", + "Bindschadler", "Pine_Island", ], help=( "Names of transects to plot (subdirectory names under " "--transects-dir). Default: Thwaites Totten Jutulstraumen " - "Foundation Bindschadler." + "Foundation Bindschadler Pine_Island." ) ) parser.add_argument( From 5227fad4146d19d9dd89ed147c8fac3f9d9dfd13 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Wed, 26 Aug 2026 09:55:39 -0700 Subject: [PATCH 40/55] Fix floatation-fraction panel ylim clamping bug Change plot_transects()'s optional per-panel ylim from a hard set_ylim() to a clamp on the autoscaled range: get the natural autoscaled y-limits, then bound them to at most the given (ymin, ymax). A hard set_ylim(0, 1) was squashing the floatation-fraction(C) panel's genuinely narrow-but-valid data (e.g. 0.95-1.0) into an invisible sliver hugging the top axis border, making the curves appear to not render at all. Clamping instead preserves the real autoscaled range while still preventing runaway/outlier-driven scaling outside [0, 1]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index a74466bf5..828af2e51 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -518,11 +518,15 @@ def plot_transects( meshes: EPSG:3031 polar stereographic). fields : dict of str -> (1-D numpy array, str, str[, (float, float)]) Mapping of field label -> (values on MALI cells, units - string, long_name string, optional (ymin, ymax) tuple) to + string, long_name string, optional (ymin, ymax) bound) to sample and plot along each transect, e.g. {"N": (N, "Pa", "Effective pressure")}. The optional 4th tuple element, if - given, is passed to ax.set_ylim() to fix that panel's y-axis - range (e.g. (0, 1) for a fraction). + given, clamps that panel's autoscaled y-axis range to be no + wider than (ymin, ymax) -- e.g. (0, 1) for a fraction field -- + without forcing the full range when the real data only span a + narrower band within those bounds (which would otherwise + squash genuinely narrow-but-valid data, e.g. 0.95-1.0, into an + invisible sliver). thickness, bed : 1-D numpy arrays MALI cell-centered ice thickness [m] and bed elevation [m], used to classify each cell as grounded ice, floating ice, @@ -765,7 +769,20 @@ def plot_transects( ax.set_title(long_name, fontsize=10) ax.grid(True, alpha=0.3, zorder=1) if ylim is not None: - ax.set_ylim(*ylim) + # Clamp the autoscaled range to at most `ylim`, rather + # than forcing it outright: this still prevents a + # runaway/outlier-driven range (e.g. a fraction field + # that should be in [0, 1]), while preserving natural + # detail when the real data only span a narrow band + # within those bounds (a hard set_ylim(*ylim) can + # otherwise squash a genuinely narrow-but-valid range, + # like 0.95-1.0, into an invisible sliver). + ax.relim() + ax.autoscale_view() + data_lo, data_hi = ax.get_ylim() + ax.set_ylim( + max(data_lo, ylim[0]), min(data_hi, ylim[1]) + ) if ( label == floatation_fraction_label From 07b2ba4248af6b0c776a727e1eaaccc07971fe16 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Wed, 26 Aug 2026 09:58:21 -0700 Subject: [PATCH 41/55] Add basal shear stress panel to N sensitivity script Add a 'Tau_b (Weertman)' panel to coulomb_N_sensitivity.py, showing the Weertman basal shear stress (mu * speed^qW) restricted to the same fast-flowing region as the N(C)/floatation-fraction(C)/ hydropotential(C) panels below it, so the shear stress driving those pure-Coulomb-implied quantities is visible alongside them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- landice/mesh_tools_li/coulomb_N_sensitivity.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/landice/mesh_tools_li/coulomb_N_sensitivity.py b/landice/mesh_tools_li/coulomb_N_sensitivity.py index faabed97a..6b466db25 100644 --- a/landice/mesh_tools_li/coulomb_N_sensitivity.py +++ b/landice/mesh_tools_li/coulomb_N_sensitivity.py @@ -197,6 +197,12 @@ def cell_field(name): # fast-flowing region. Pice = args.rho_ice * args.gravity * H + # Basal shear stress used to derive N(C) below (masked to the + # same fast-flowing region, for direct comparison against the + # N(C) panel). + tau_b_weertman_masked = np.full_like(tau_b_weertman, np.nan) + tau_b_weertman_masked[fast_flowing] = tau_b_weertman[fast_flowing] + n_of_c = {} floatation_fraction_of_c = {} hydropotential_of_c = {} @@ -223,6 +229,11 @@ def cell_field(name): x_cell=x_cell, y_cell=y_cell, fields={ + "Tau_b (Weertman)": ( + tau_b_weertman_masked, "Pa", + "Weertman basal shear stress (mu * speed^qW) used to " + "derive N(C) below (fast-flowing region only)", + ), "N(C)": ( n_of_c, "Pa", "Pure-Coulomb-implied N for candidate C values " From 2733ee0eea8b94f10809b4da225b29a27d37b8fa Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Wed, 26 Aug 2026 10:31:44 -0700 Subject: [PATCH 42/55] Fix muFriction unit mismatch (kPa, not Pa) in N sensitivity script MALI's muFriction field's correct physical units, per a correction to MPAS-Tools' Registry.xml, are kPa * (yr/m)^qW (not Pa * (yr/m)^qW). mu * speed^qW therefore evaluates to kPa, not Pa. friction_law_conversion.py needed no functional change: it only ever uses mu * speed^qW (Tau_b_weertman) as a ratio against N_albany (N / ALBANY_EFFECTIVE_PRESSURE_PA_PER_UNIT, already kPa-scaled), so the ratio-based C-fit and Lambda-solve computations there are dimensionally correct regardless of mu's absolute unit -- added clarifying comments/docstrings there, and fixed the mislabeled muFriction map-panel colorbar units (was "Pa (m yr-1)^-q", now "kPa (m yr-1)^-q"). coulomb_N_sensitivity.py, however, uses Tau_b_weertman directly as an absolute quantity (N(C), floatation fraction, hydropotential), so it needed a real fix: multiply by ALBANY_EFFECTIVE_PRESSURE_PA_PER_UNIT (1000) to convert kPa -> Pa before using it. This was the source of the reported ~1000x-too-small N(C) values: N(C) is now directly comparable in magnitude to the ice overburden pressure, so the floatation-fraction(C) and hydropotential(C) panels now show physically meaningful variation across candidate C values instead of appearing nearly flat. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/coulomb_N_sensitivity.py | 22 ++++++++++++++++-- .../mesh_tools_li/friction_law_conversion.py | 23 ++++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/landice/mesh_tools_li/coulomb_N_sensitivity.py b/landice/mesh_tools_li/coulomb_N_sensitivity.py index 6b466db25..95697a9d7 100644 --- a/landice/mesh_tools_li/coulomb_N_sensitivity.py +++ b/landice/mesh_tools_li/coulomb_N_sensitivity.py @@ -21,6 +21,15 @@ including (optionally) the one friction_law_conversion.py itself would fit. +NOTE on units: MALI's `muFriction` field's correct physical units -- +per a correction to MPAS-Tools' Registry.xml -- are kPa * (yr/m)^qW +(not Pa * (yr/m)^qW). mu * speed^qW therefore comes out in kPa, so it +is converted to Pa here (via ALBANY_EFFECTIVE_PRESSURE_PA_PER_UNIT) +before being used as an absolute Tau_b/N(C) in Pa (unlike +friction_law_conversion.py, which only ever uses mu * speed^qW as a +ratio against an already-kPa-scaled N, so no explicit conversion is +needed there). + This script is intentionally lightweight: it reuses friction_law_conversion.py's transect-loading/projection/plotting machinery directly (via import) rather than duplicating it, and @@ -42,6 +51,7 @@ import xarray as xr from friction_law_conversion import ( + ALBANY_EFFECTIVE_PRESSURE_PA_PER_UNIT, SECONDS_PER_YEAR, plot_transects, ) @@ -177,8 +187,16 @@ def cell_field(name): # MALI's Weertman sliding law has no effective-pressure term: # Tau_b = mu * speed^qW (same as fit_coulomb_C_fast_region() in - # friction_law_conversion.py). - tau_b_weertman = mu * speed ** args.weertman_q + # friction_law_conversion.py). mu's correct physical units are + # kPa * (yr/m)^qW (see module docstring), so this is in kPa; + # convert to physical Pa here since (unlike + # friction_law_conversion.py) this script uses Tau_b_weertman + # directly as an absolute quantity (N(C), floatation fraction, + # hydropotential), not only as a ratio against an already + # kPa-scaled N. + tau_b_weertman = ( + mu * speed ** args.weertman_q * ALBANY_EFFECTIVE_PRESSURE_PA_PER_UNIT + ) # Same grounded-ice/fast-flowing tests friction_law_conversion.py # uses to define the region assumed to already be in the diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 828af2e51..f0553e03a 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -54,6 +54,11 @@ velocity : m yr^-1 N : check whether the Albany interface expects Pa or kPa A : Pa^-3 s^-1 (Albany's Temperature Based flow rate is SI) + mu : kPa * (yr/m)^qW (per a correction to MPAS-Tools' + Registry.xml; NOT Pa * (yr/m)^qW), so that + mu * speed^qW comes out already in the same kPa scale + as N_albany (see ALBANY_EFFECTIVE_PRESSURE_PA_PER_UNIT + and fit_coulomb_C_fast_region()) """ import argparse @@ -384,7 +389,13 @@ def fit_coulomb_C_fast_region(mu, N, area, speed, q, mask): pressure (numerically equal to physical Pa / 1000, i.e. "kPa"; see ALBANY_EFFECTIVE_PRESSURE_PA_PER_UNIT), not raw SI Pascals. Passing raw-Pa N here would make C inconsistent with how Albany - multiplies C against its own internal N at runtime. + multiplies C against its own internal N at runtime. This is + dimensionally consistent with `mu` (MALI's `muFriction`), whose + correct physical units -- per a correction to MPAS-Tools' + Registry.xml -- are kPa * (yr/m)^qW (not Pa * (yr/m)^qW as one + might otherwise assume): Tau_b_Weertman = mu * speed^qW therefore + comes out in kPa already, matching `N`'s kPa scale here, with no + separate unit conversion needed in this function. """ valid = ( mask @@ -1187,7 +1198,13 @@ def main(): parser.add_argument( "--mu-field", default="muFriction", - help="Weertman friction field (default: muFriction)" + help=( + "Weertman friction field (default: muFriction). Its " + "correct physical units -- per a correction to " + "MPAS-Tools' Registry.xml -- are kPa * (yr/m)^qW (not " + "Pa * (yr/m)^qW); this script's use of mu is dimensionally " + "consistent with that (see fit_coulomb_C_fast_region())." + ) ) parser.add_argument( "--thickness-field", @@ -2034,7 +2051,7 @@ def basal_cell_field(name): }, { "values": mu, - "units": f"Pa (m yr-1)^-{args.weertman_q:g}", + "units": f"kPa (m yr-1)^-{args.weertman_q:g}", "title": "Original Weertman muFriction (input)", "log": True, "cmap": "turbo_r", }, From 6a2e4d9633aa12003c7fc66c3c5dfdbb7d087887 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Wed, 26 Aug 2026 11:53:10 -0700 Subject: [PATCH 43/55] Disable anti-aliasing in mosaic map plots Pass aa=False to mosaic.polypcolor() in plot_maps(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- landice/mesh_tools_li/friction_law_conversion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index f0553e03a..c96926d65 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -956,7 +956,7 @@ def plot_maps(mesh_ds, fields, plot_dir, transects=None): ) array = xr.DataArray(plot_values, dims=("nCells",)) coll = mosaic.polypcolor( - ax, descriptor, array, cmap=cmap, norm=norm + ax, descriptor, array, cmap=cmap, norm=norm, aa=False ) ax.set_aspect("equal") ax.set_title(title, fontsize=11) From 42d58e78b36c78a4da0ddbd0277cd2dab77c5d3c Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Wed, 26 Aug 2026 14:58:21 -0700 Subject: [PATCH 44/55] Fix sign bug in Downs-Johnson marine term The marine (below-sea-level) term in downs_johnson_effective_pressure() had a sign flip introduced in an earlier commit: it computed max(rho_w*bed, 0.0) instead of Albany's max(-rho_w*bed, 0.0) (LandIce_BasalFrictionCoefficient_Def.hpp). Since bed is negative below sea level, the buggy version always evaluated to 0 there, causing N to collapse to the full inland overburden floor and making floatation fraction incorrectly read ~0 (and hydropotential correspondingly too negative) wherever bed < 0. Also removed a leftover debug print statement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- landice/mesh_tools_li/friction_law_conversion.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index c96926d65..ea8f90d21 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -310,10 +310,8 @@ def downs_johnson_effective_pressure( arg = np.clip(b / length_scale, -700.0, 700.0) fp = 1.0 / (1.0 + np.exp(-arg)) - print(fp.min(), fp.max()) - overburden_term = min_fraction_overburden * rho_i * H * fp - marine_term = (1.0 - fp) * np.maximum(rho_w * b, 0.0) + marine_term = (1.0 - fp) * np.maximum(-rho_w * b, 0.0) N = gravity * np.maximum( rho_i * H - (overburden_term + marine_term), From 3a3227f776853a001f219e33d5d67a65e6fee6d5 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Wed, 26 Aug 2026 15:24:02 -0700 Subject: [PATCH 45/55] Fix missing meters-to-km conversion for bedRoughnessRC (Lambda) Albany reads its "Bed Roughness Field Name" field (bedRoughnessRC) verbatim, with no internal unit conversion, but its hardcoded scaling factor (scaling = secsInYr * pow(1000, n+1) in LandIce_BasalFrictionCoefficient_Def.hpp; comment: "//bedRoughness in km") is built assuming that stored value is already expressed in km. The exact-solve derivation for Lambda correctly produces a value in physical meters, but the script wrote that meters-based value directly to the output field without the required division by 1000, making the stored Lambda 1000x too large. This inflated basal resistance across the exact-solve (slow-flowing) region, consistent with the near-zero/no-slip velocities reported downstream in Albany runs. Added ALBANY_LAMBDA_METERS_PER_KM (1000.0) and applied it when computing Lambda; updated related comments/docstrings and the mislabeled "Pa (m yr-1)^-1/3" map units string (now "km"). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index ea8f90d21..c4d964fee 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -35,7 +35,11 @@ project convention, qR is always fixed at 1/3 (see RC_POWER_EXPONENT below) and is not user-configurable. -so Lambda * A * N^n has units of velocity. +so Lambda * A * N^n has units of velocity. Lambda is solved for in +physical meters and then divided by 1000 before being stored, since +Albany's own internal "scaling" factor assumes the stored bed- +roughness field value is expressed in km (see +ALBANY_LAMBDA_METERS_PER_KM below). The Glen flow-rate factor A can be obtained in one of two ways, selected via --flow-rate-type: @@ -83,6 +87,23 @@ # combine correctly when deriving Lambda (bedRoughnessRC). SECONDS_PER_YEAR = 365.0 * 24.0 * 3600.0 +# Albany reads its "Bed Roughness Field Name" field (Lambda, +# bedRoughnessRC here) as-is, with no internal unit conversion, but +# its hardcoded "scaling" factor in +# LandIce_BasalFrictionCoefficient_Def.hpp (scaling = secsInYr * +# pow(1000, n+1); comment: "//bedRoughness in km") is built assuming +# that raw stored value is already expressed in km, not meters. +# Combined with N in Albany's own kPa-scaled internal convention and +# the Glen flow-rate factor A in physical Pa^-n s^-1, this scaling +# factor reduces exactly to Lambda[km] * SECONDS_PER_YEAR * A[Pa^-n +# s^-1] * N[Pa]^n -- i.e. numerically identical to using Lambda in +# physical meters, physical N in Pa, and SECONDS_PER_YEAR, *provided +# the value actually stored in the field is Lambda in km, a factor of +# 1000 smaller than Lambda expressed in meters*. This constant +# converts the meters-based Lambda solved for below into the km value +# Albany actually expects to find in the field. +ALBANY_LAMBDA_METERS_PER_KM = 1000.0 + # Albany's internal effective pressure representation (used e.g. by # its "Hydrostatic" Effective Pressure Type) is computed from bed/ # thickness fields that MALI's coupling interface has already divided @@ -1608,8 +1629,11 @@ def basal_cell_field(name): # u + Lambda*scaling*A*N^n = u * (C*N / Tau_b_W)^(1/p) # = u * (C*N / (mu * u^qW))^(1/p) # - # Lambda = u * [(C*N / (mu * u^qW))^(1/p) - 1] - # / (SECONDS_PER_YEAR * A * N^n) + # Lambda[m] = u * [(C*N / (mu * u^qW))^(1/p) - 1] + # / (SECONDS_PER_YEAR * A * N^n) + # Lambda[km] (the value actually stored in the output field, + # see ALBANY_LAMBDA_METERS_PER_KM) = Lambda[m] / + # 1000 # # This is the same Weertman shear-stress convention used by # fit_coulomb_C_fast_region above (Tau_b_W = mu * u^qW, no N term, @@ -1619,9 +1643,11 @@ def basal_cell_field(name): # where u is in m/yr, A is in Pa^-3 s^-1, N (raw Pa) is used here # exactly as in the previous uc-based derivation (Albany's # internal km/kPa/yr "scaling" factor reduces to the plain - # SECONDS_PER_YEAR factor once Lambda is expressed in meters and N - # in Pa). N_albany (the kPa-equivalent convention) is used for the - # "C*N" Coulomb-limit term, matching how C was itself fit. + # SECONDS_PER_YEAR factor once Lambda is expressed in meters, N in + # Pa, and the meters-based Lambda is then converted to km before + # being stored -- see ALBANY_LAMBDA_METERS_PER_KM). N_albany (the + # kPa-equivalent convention) is used for the "C*N" Coulomb-limit + # term, matching how C was itself fit. # # Because Albany's Regularized Coulomb law can never produce a # shear stress above the Coulomb limit C*N (attained only in the @@ -1676,6 +1702,7 @@ def basal_cell_field(name): speed[lambda_mask] * (stress_ratio[lambda_mask] ** (1.0 / RC_POWER_EXPONENT) - 1.0) / (SECONDS_PER_YEAR * A[lambda_mask] * N[lambda_mask] ** args.glen_n) + / ALBANY_LAMBDA_METERS_PER_KM ) n_unreachable = int(np.count_nonzero(valid_speed & ~lambda_mask)) @@ -1819,11 +1846,13 @@ def basal_cell_field(name): "law's basal shear stress (mu*u^qW, no effective-" "pressure term) at the cell's actual current sliding " "speed u (from velocity-x/y-field, last " - "nVertInterfaces level): Lambda = u * " + "nVertInterfaces level): Lambda[m] = u * " "[(C*N/(mu*u^qW))^(1/qR) - 1] / (SECONDS_PER_YEAR * A " "* N^n), with u in m/yr, A in Pa^-3 s^-1, N in Pa, " "matching Albany's internal secsInYr scaling in " - "LandIce_BasalFrictionCoefficient_Def.hpp" + "LandIce_BasalFrictionCoefficient_Def.hpp; the stored " + "value is Lambda[m] / 1000 (km), matching Albany's " + "'bedRoughness in km' convention for this field" ), }, ) @@ -2041,7 +2070,7 @@ def basal_cell_field(name): map_fields = { args.lambda_field: [ { - "values": Lambda, "units": "Pa (m yr-1)^-1/3", + "values": Lambda, "units": "km", "title": ( "Albany regularized-Coulomb bed roughness Lambda" ), From b5b5d8dedda4108443f24abbf1b8eabd5e3a7541 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Thu, 27 Aug 2026 08:37:25 -0700 Subject: [PATCH 46/55] Add ocean-connection effective pressure type; fix Albany YAML string - Fixed the suggested-YAML "Effective Pressure Type" string, which incorrectly printed "Hydrostatic At Nodes" (not a valid Albany parameter value; Albany's validated string is "Hydrostatic Computed At Nodes", which would throw an exception at runtime otherwise). - Added a new --effective-pressure-type=ocean-connection option: same Albany "Hydrostatic Computed At Nodes" Effective Pressure Type as "downs-johnson", but with "Use Pressurized Bed Above Sea Level: false" -- i.e. full ocean (hydrostatic) water pressure wherever bed is below sea level, with no inland tapering/floor, and (since Albany does not even read them in this mode) no dependence on --min-fraction-overburden/--pressure-length-scale. - Added ocean_connection_effective_pressure(), matching Albany's formula with use_pressurized_bed=false (f_p == 0 identically): N = max(rho_i*g*H - max(-rho_w*g*bed, 0), 0). - Made --min-fraction-overburden/--pressure-length-scale optional when --effective-pressure-type=ocean-connection (not required/used). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 146 ++++++++++++++---- 1 file changed, 118 insertions(+), 28 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index c4d964fee..9dfdb5343 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -342,6 +342,60 @@ def downs_johnson_effective_pressure( return N +def ocean_connection_effective_pressure( + thickness, + bed, + rho_i=910.0, + rho_w=1028.0, + gravity=9.80616): + """ + Reproduce Albany's "Hydrostatic Computed At Nodes" Effective + Pressure Type with "Use Pressurized Bed Above Sea Level: false" + (LandIce_BasalFrictionCoefficient_Def.hpp). + + With `use_pressurized_bed` false, Albany's sigmoid bed-pressure + fraction f_p is identically 0 everywhere (rather than a smooth + function of bed elevation), which removes any dependence on + "Minimum Fraction Overburden Pressure"/"Length Scale Factor" (they + are not even read by Albany in this case) and collapses + downs_johnson_effective_pressure()'s formula to a simple + overburden-minus-ocean-pressure relation: + + N = max(rho_i * g * H - max(-rho_w * g * bed, 0), 0) + + i.e. full ocean (hydrostatic) water pressure wherever the bed is + below sea level, with no inland tapering/floor -- hence + "ocean-connection" here: N depends only on whether/how deeply the + bed sits below sea level, not on distance from the ocean. + + Parameters + ---------- + thickness : ndarray + Ice thickness H [m]. + bed : ndarray + Bed elevation b [m], positive above sea level. + rho_i : float + Ice density [kg m^-3]. + rho_w : float + Water density [kg m^-3]. + gravity : float + Gravitational acceleration [m s^-2]. + + Returns + ------- + N : ndarray + Effective pressure [Pa]. + """ + H = np.asarray(thickness, dtype=np.float64) + b = np.asarray(bed, dtype=np.float64) + + marine_term = np.maximum(-rho_w * b, 0.0) + + N = gravity * np.maximum(rho_i * H - marine_term, 0.0) + + return N + + def albany_temperature_based_flow_rate(temperature): """ Reproduce Albany's "Temperature Based" Flow Rate Type exactly @@ -1053,24 +1107,33 @@ def main(): # Effective pressure N parser.add_argument( "--effective-pressure-type", - choices=["downs-johnson", "transition"], + choices=["downs-johnson", "ocean-connection", "transition"], default="downs-johnson", help=( "How to compute the effective pressure N (default: " "downs-johnson). \"downs-johnson\" reproduces Albany's " - "own internal \"Hydrostatic At Nodes\" Effective Pressure " - "Type formula exactly (see --min-fraction-overburden/" - "--pressure-length-scale); Albany recomputes N itself at " - "runtime from thickness/bed, so no N field needs to be " - "supplied. \"transition\" computes N here using a " - "near-ocean/inland-transition parameterization (see " - "--min-fraction-overburden/--pressure-length-scale/" - "--transition-h-ocean) and writes it to the output for " - "reference; NOTE: Albany does not yet have a way to " - "consume this precomputed N directly for the Regularized " - "Coulomb law, so \"transition\" cannot currently be used " - "to actually run Albany (offline evaluation only, pending " - "upstream Albany support)." + "own internal \"Hydrostatic Computed At Nodes\" Effective " + "Pressure Type formula, with \"Use Pressurized Bed Above " + "Sea Level: true\" (see --min-fraction-overburden/" + "--pressure-length-scale). \"ocean-connection\" reproduces " + "the same Albany \"Hydrostatic Computed At Nodes\" " + "Effective Pressure Type but with \"Use Pressurized Bed " + "Above Sea Level: false\" -- i.e. full ocean (hydrostatic) " + "water pressure wherever bed is below sea level, with no " + "inland tapering/floor, and no dependence on " + "--min-fraction-overburden/--pressure-length-scale (see " + "ocean_connection_effective_pressure()). Both " + "\"downs-johnson\" and \"ocean-connection\" are computed " + "by Albany itself at runtime from thickness/bed, so no N " + "field needs to be supplied for either. \"transition\" " + "computes N here using a near-ocean/inland-transition " + "parameterization (see --min-fraction-overburden/" + "--pressure-length-scale/--transition-h-ocean) and writes " + "it to the output for reference; NOTE: Albany does not " + "yet have a way to consume this precomputed N directly " + "for the Regularized Coulomb law, so \"transition\" cannot " + "currently be used to actually run Albany (offline " + "evaluation only, pending upstream Albany support)." ) ) @@ -1424,13 +1487,18 @@ def main(): "with this exponent." ) - if args.min_fraction_overburden is None or args.pressure_length_scale is None: - parser.error( - "--min-fraction-overburden and --pressure-length-scale " - "are required (used by both " - "--effective-pressure-type=downs-johnson and " - "--effective-pressure-type=transition)" - ) + if args.effective_pressure_type != "ocean-connection": + if ( + args.min_fraction_overburden is None + or args.pressure_length_scale is None + ): + parser.error( + "--min-fraction-overburden and --pressure-length-scale " + "are required (used by both " + "--effective-pressure-type=downs-johnson and " + "--effective-pressure-type=transition; not needed for " + "--effective-pressure-type=ocean-connection)" + ) if args.plot_transects: if not args.diagnostics: @@ -1554,6 +1622,14 @@ def basal_cell_field(name): rho_w=args.rho_water, gravity=args.gravity, ) + elif args.effective_pressure_type == "ocean-connection": + N = ocean_connection_effective_pressure( + thickness=H, + bed=bed, + rho_i=args.rho_ice, + rho_w=args.rho_water, + gravity=args.gravity, + ) else: N = effective_pressure4( thickness=H, @@ -1860,6 +1936,12 @@ def basal_cell_field(name): if args.diagnostics: if args.effective_pressure_type == "downs-johnson": effective_pressure_long_name = "Downs-Johnson effective pressure" + elif args.effective_pressure_type == "ocean-connection": + effective_pressure_long_name = ( + "Ocean-connection effective pressure (Hydrostatic " + "Computed At Nodes, Use Pressurized Bed Above Sea " + "Level: false)" + ) else: effective_pressure_long_name = ( "Effective pressure (near-ocean/inland-transition " @@ -2008,12 +2090,13 @@ def basal_cell_field(name): out.attrs["regularizedCoulomb_effectivePressureType"] = ( args.effective_pressure_type ) - out.attrs["regularizedCoulomb_minFractionOverburden"] = ( - float(args.min_fraction_overburden) - ) - out.attrs["regularizedCoulomb_pressureLengthScale"] = ( - float(args.pressure_length_scale) - ) + if args.effective_pressure_type != "ocean-connection": + out.attrs["regularizedCoulomb_minFractionOverburden"] = ( + float(args.min_fraction_overburden) + ) + out.attrs["regularizedCoulomb_pressureLengthScale"] = ( + float(args.pressure_length_scale) + ) if args.effective_pressure_type == "transition": out.attrs["regularizedCoulomb_transitionHOcean"] = ( float(args.transition_h_ocean) @@ -2185,13 +2268,20 @@ def basal_cell_field(name): if args.effective_pressure_type == "downs-johnson": effective_pressure_yaml_lines = ( - " Effective Pressure Type: Hydrostatic At Nodes\n" + " Effective Pressure Type: Hydrostatic Computed At " + "Nodes\n" " Use Pressurized Bed Above Sea Level: true\n" " Minimum Fraction Overburden Pressure: " f"{args.min_fraction_overburden:.16e}\n" " Length Scale Factor: " f"{args.pressure_length_scale / 1000.0:.16e}" ) + elif args.effective_pressure_type == "ocean-connection": + effective_pressure_yaml_lines = ( + " Effective Pressure Type: Hydrostatic Computed At " + "Nodes\n" + " Use Pressurized Bed Above Sea Level: false" + ) else: effective_pressure_yaml_lines = ( " Effective Pressure Type: TRANSITION OPTION TO BE ADDED" From ddd54a278b522296684399fdef45be9e5e80c5af Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Fri, 28 Aug 2026 13:25:41 -0700 Subject: [PATCH 47/55] Fix yaml syntax --- landice/mesh_tools_li/friction_law_conversion.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 9dfdb5343..f70d5479d 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -2292,10 +2292,10 @@ def basal_cell_field(name): print("-----------------------------") print( f""" - LandIce BCs: Basal Friction Coefficient: Type: Regularized Coulomb - Coulomb Friction Coefficient: {C:.16e} + Mu Type: Constant + Mu: {C:.16e} Power Exponent: {RC_POWER_EXPONENT:.16e} {flow_rate_yaml_lines} Bed Roughness Type: Field From bf1bc3435c8000d224aa6213ad07f4e59fb8ae95 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Fri, 28 Aug 2026 14:27:43 -0700 Subject: [PATCH 48/55] Remove erroneous double m->km conversion for bedRoughnessRC (Lambda) The script was dividing the solved-for Lambda (in meters) by 1000 before writing it to the bedRoughnessRC field, under the assumption that Albany reads bedRoughnessRC as-is and expects it pre-converted to km. That assumption is incorrect. Tracing the actual MALI/Albany coupling: - MALI's Registry.xml declares bedRoughnessRC with units="m". - mode_forward/mpas_li_velocity_external.F passes the field straight through to the C++ interface with no conversion. - mode_forward/Interface_velocity_solver.cpp already divides it by unit_length (1000) before Albany ever sees it: bedRoughnessData[index] = bedRoughnessRC_F[iCell] / unit_length; exactly as it does for bedTopography and thickness. So MALI's own coupling layer performs the m -> km conversion required by Albany's hardcoded "scaling" factor in LandIce_BasalFrictionCoefficient_Def.hpp. The script's additional /1000 was therefore double-converting the value, making the Lambda Albany actually uses at runtime ~1,000,000x too small (1000x from the script, times another 1000x from MALI's interface). Fix: write Lambda directly in meters, with no further scaling, and update the surrounding docstring/comments/NetCDF attrs to document the verified unit pipeline instead of the previous incorrect assumption. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 72 ++++++++++++------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index f70d5479d..a0da0be19 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -36,10 +36,18 @@ below) and is not user-configurable. so Lambda * A * N^n has units of velocity. Lambda is solved for in -physical meters and then divided by 1000 before being stored, since -Albany's own internal "scaling" factor assumes the stored bed- -roughness field value is expressed in km (see -ALBANY_LAMBDA_METERS_PER_KM below). +physical meters and stored in the output `bedRoughnessRC` field +as-is, in meters, matching MALI's Registry.xml declaration of +`bedRoughnessRC` (units="m"). MALI's own Albany coupling interface +(mode_forward/Interface_velocity_solver.cpp) divides the raw, +meters-valued `bedRoughnessRC` field by 1000 before ever handing it +to Albany -- the exact same way it converts `bedTopography` and +`thickness` from meters to km -- so Albany's internal "scaling" +factor (which assumes an already-km-valued field) is satisfied +automatically by MALI's coupling layer. No additional m -> km +conversion should be applied here; doing so would double-convert the +value (dividing by 1000 twice), making Lambda 1000x too small by the +time it reaches Albany. The Glen flow-rate factor A can be obtained in one of two ways, selected via --flow-rate-type: @@ -87,22 +95,26 @@ # combine correctly when deriving Lambda (bedRoughnessRC). SECONDS_PER_YEAR = 365.0 * 24.0 * 3600.0 -# Albany reads its "Bed Roughness Field Name" field (Lambda, -# bedRoughnessRC here) as-is, with no internal unit conversion, but -# its hardcoded "scaling" factor in +# Albany's "Bed Roughness Field Name" evaluator (Lambda, +# bedRoughnessRC here) hardcodes a "scaling" factor in # LandIce_BasalFrictionCoefficient_Def.hpp (scaling = secsInYr * -# pow(1000, n+1); comment: "//bedRoughness in km") is built assuming -# that raw stored value is already expressed in km, not meters. -# Combined with N in Albany's own kPa-scaled internal convention and -# the Glen flow-rate factor A in physical Pa^-n s^-1, this scaling -# factor reduces exactly to Lambda[km] * SECONDS_PER_YEAR * A[Pa^-n -# s^-1] * N[Pa]^n -- i.e. numerically identical to using Lambda in -# physical meters, physical N in Pa, and SECONDS_PER_YEAR, *provided -# the value actually stored in the field is Lambda in km, a factor of -# 1000 smaller than Lambda expressed in meters*. This constant -# converts the meters-based Lambda solved for below into the km value -# Albany actually expects to find in the field. -ALBANY_LAMBDA_METERS_PER_KM = 1000.0 +# pow(1000, n+1); comment: "//bedRoughness in km") that assumes the +# Lambda *value it receives* is expressed in km, not meters. However, +# Albany never reads bedRoughnessRC directly from the MALI NetCDF +# file -- MALI's own coupling interface +# (mode_forward/Interface_velocity_solver.cpp: "bedRoughnessData[index] +# = bedRoughnessRC_F[iCell] / unit_length;", unit_length = 1000) +# divides the raw field by 1000 before Albany ever sees it, exactly +# as it does for bedTopography and thickness. MALI's Registry.xml +# also declares bedRoughnessRC with units="m". So the value that must +# actually be written to the output NetCDF field is Lambda in +# physical meters, with NO additional m -> km conversion applied +# here -- that conversion is already performed by MALI's coupling +# layer. (A previous version of this script divided the solved-for +# meters-based Lambda by 1000 before storing it, under the mistaken +# assumption that Albany reads the field as-is; that produced a +# double conversion, making the stored value -- and therefore the +# Lambda Albany actually uses at runtime -- 1000x too small.) # Albany's internal effective pressure representation (used e.g. by # its "Hydrostatic" Effective Pressure Type) is computed from bed/ @@ -1707,9 +1719,11 @@ def basal_cell_field(name): # # Lambda[m] = u * [(C*N / (mu * u^qW))^(1/p) - 1] # / (SECONDS_PER_YEAR * A * N^n) - # Lambda[km] (the value actually stored in the output field, - # see ALBANY_LAMBDA_METERS_PER_KM) = Lambda[m] / - # 1000 + # Lambda[m] is the value actually stored in the output field + # -- see the module docstring / comment above + # SECONDS_PER_YEAR's definition for why no further m -> km + # conversion is applied here (MALI's own coupling interface + # performs that conversion before Albany ever sees the field). # # This is the same Weertman shear-stress convention used by # fit_coulomb_C_fast_region above (Tau_b_W = mu * u^qW, no N term, @@ -1720,8 +1734,9 @@ def basal_cell_field(name): # exactly as in the previous uc-based derivation (Albany's # internal km/kPa/yr "scaling" factor reduces to the plain # SECONDS_PER_YEAR factor once Lambda is expressed in meters, N in - # Pa, and the meters-based Lambda is then converted to km before - # being stored -- see ALBANY_LAMBDA_METERS_PER_KM). N_albany (the + # Pa, and the meters-based Lambda is stored as-is, in meters, with + # MALI's coupling interface performing the m -> km conversion + # before Albany sees it). N_albany (the # kPa-equivalent convention) is used for the "C*N" Coulomb-limit # term, matching how C was itself fit. # @@ -1778,7 +1793,6 @@ def basal_cell_field(name): speed[lambda_mask] * (stress_ratio[lambda_mask] ** (1.0 / RC_POWER_EXPONENT) - 1.0) / (SECONDS_PER_YEAR * A[lambda_mask] * N[lambda_mask] ** args.glen_n) - / ALBANY_LAMBDA_METERS_PER_KM ) n_unreachable = int(np.count_nonzero(valid_speed & ~lambda_mask)) @@ -1927,8 +1941,12 @@ def basal_cell_field(name): "* N^n), with u in m/yr, A in Pa^-3 s^-1, N in Pa, " "matching Albany's internal secsInYr scaling in " "LandIce_BasalFrictionCoefficient_Def.hpp; the stored " - "value is Lambda[m] / 1000 (km), matching Albany's " - "'bedRoughness in km' convention for this field" + "value is Lambda in meters, as-is (matching MALI's " + "Registry.xml units=\"m\" declaration for this field) " + "-- MALI's own Albany coupling interface " + "(Interface_velocity_solver.cpp) divides this field " + "by 1000 before Albany sees it, satisfying Albany's " + "'bedRoughness in km' scaling convention" ), }, ) From aa96b98a518b95ed26f7d018f9172a15364e788b Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Fri, 28 Aug 2026 14:32:40 -0700 Subject: [PATCH 49/55] Fix map_bedRoughnessRC.png colorbar units label (km -> m) Lambda/bedRoughnessRC is now solved for and stored in meters (see previous commit removing the erroneous double m->km conversion), but the diagnostic map-plot colorbar label for this field was left as "km". Update it to "m" to match the actual stored/plotted units. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- landice/mesh_tools_li/friction_law_conversion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index a0da0be19..173eb80e5 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -2171,7 +2171,7 @@ def basal_cell_field(name): map_fields = { args.lambda_field: [ { - "values": Lambda, "units": "km", + "values": Lambda, "units": "m", "title": ( "Albany regularized-Coulomb bed roughness Lambda" ), From d3da56c602370b1edd140f4e97c7548721c91b72 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Fri, 28 Aug 2026 16:24:09 -0700 Subject: [PATCH 50/55] Write fitted C into muFriction field instead of dropping it Previously the input Weertman muFriction field was dropped from the output NetCDF since it is not used directly by the Regularized Coulomb law. Per updated project plan, the fitted, spatially uniform RC coefficient C (Albany's "Mu") is now written into that same field (--mu-field, default muFriction) instead, so an Albany YAML using "Mu Type: Field" can read it directly from the IC file rather than needing a hardcoded "Mu: " constant. The YAML template/output is intentionally left unchanged here; the YAML update will be made separately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 173eb80e5..140672aa2 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -15,7 +15,10 @@ Lambda = uc / (A * N**n) The output is a copy of the input MALI initial-condition file with -`Lambda` and optionally `effectivePressure` added. +`Lambda` (stored in the bed-roughness field, `--lambda-field`, +default `bedRoughnessRC`) added, the input Weertman `muFriction` +field (`--mu-field`) overwritten with the fitted, spatially uniform +RC coefficient C, and optionally `effectivePressure` added. Notes ----- @@ -1465,8 +1468,8 @@ def main(): action="store_false", help=( "Omit diagnostic fields from the output NetCDF, e.g. for " - "production runs where only bedRoughnessRC is needed and " - "extra fields are unwanted clutter." + "production runs where only bedRoughnessRC and muFriction " + "are needed and extra fields are unwanted clutter." ) ) @@ -1915,10 +1918,23 @@ def basal_cell_field(name): # used to modify the copied file in-place. out = xr.open_dataset(args.output).load() - # The Weertman muFriction field is not used by the Regularized - # Coulomb law; drop it from the converted IC. - if args.mu_field in out: - out = out.drop_vars(args.mu_field) + # The Weertman muFriction field is not used directly by the + # Regularized Coulomb law; overwrite it with the fitted, spatially + # uniform RC coefficient C (Albany's "Mu"/"Mu Field Name"), so + # that an Albany YAML using "Mu Type: Field" (reading this same + # field name, per --mu-field) picks up the fitted value. + out[args.mu_field] = xr.DataArray( + np.full_like(N, C), + dims=(ncell_dim,), + attrs={ + "long_name": ( + "Albany regularized-Coulomb coefficient C (Mu), " + "spatially uniform, area-weighted fit over the " + "fast-flowing region -- see fit_coulomb_C_fast_region()" + ), + "units": "1", + }, + ) out[args.lambda_field] = xr.DataArray( Lambda, From 738ee60c026f7963514536889dd1235cd11c326e Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Fri, 28 Aug 2026 16:25:53 -0700 Subject: [PATCH 51/55] Switch to having mu as a field in the MALI file rather than constant in yaml --- landice/mesh_tools_li/friction_law_conversion.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 140672aa2..b971c2214 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -2328,8 +2328,7 @@ def basal_cell_field(name): f""" Basal Friction Coefficient: Type: Regularized Coulomb - Mu Type: Constant - Mu: {C:.16e} + Mu Type: Field Power Exponent: {RC_POWER_EXPONENT:.16e} {flow_rate_yaml_lines} Bed Roughness Type: Field From c444d7338e646070dd8c21923dea8b100130835c Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Fri, 28 Aug 2026 20:28:46 -0700 Subject: [PATCH 52/55] Add transition-velocity method for Weertman-to-RC conversion Adds an alternative --method transition-velocity for deriving bedRoughnessRC (Lambda) and the Regularized Coulomb coefficient (--mu-field), alongside the existing stress-match-fit method: - Choose a fixed transition velocity u0 (--transition-velocity) and compute, in closed form for every grounded cell: Lambda = u0 / (SECONDS_PER_YEAR * A * N^n) C = tau_b * (ub + u0)^(1/3) / (N * ub^(1/3)) so the RC law exactly reproduces the Weertman law's basal shear stress at each cell's current sliding speed, with no fast/slow region split (see solve_transition_velocity()). - C is spatially uniform for stress-match-fit (unchanged) but spatially varying for transition-velocity. - New --transition-velocity/--u0 and --mu-reference-value CLI args; --critical-velocity is now required only for stress-match-fit. - Diagnostics/masks/plots/output attrs adapted per method: maskFastFlowing means speed > u0 for the new method, maskValidBedRoughnessRC covers every valid closed-form solve, and the "implied C" diagnostic/plot is stress-match-fit only. - Added a map panel showing the actual output --mu-field (C) values alongside Lambda and the original input muFriction, for both methods. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 827 ++++++++++++------ 1 file changed, 579 insertions(+), 248 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index b971c2214..b33dce232 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -3,22 +3,44 @@ Convert a MALI Weertman basal-friction initial condition to parameters for Albany's Regularized Coulomb friction law. -Computes --------- -1. Downs & Johnson-style effective pressure N -2. Area-weighted optimal scalar C: +Two methods are available, selected via --method: - C = integral[ uc**qW * mu / N dA ] / integral[dA] +- "stress-match-fit" (default): computes + 1. Downs & Johnson-style effective pressure N + 2. Area-weighted optimal scalar C: -3. Albany bed-roughness/Lambda field: + C = integral[ uc**qW * mu / N dA ] / integral[dA] - Lambda = uc / (A * N**n) + 3. Albany bed-roughness/Lambda field: + + Lambda = uc / (A * N**n) + + Lambda is solved per-cell so that the RC law matches the + Weertman law's stress outside the fast-flowing (uc) region; C is + a single scalar written uniformly to every cell. + +- "transition-velocity": given a fixed transition velocity u0 + (--transition-velocity), computes, for every grounded cell, + + Lambda = u0 / (A * N**n) + C = tau_b * (ub + u0)**(1/3) / (N * ub**(1/3)) + + (ub = current sliding speed, tau_b = Weertman shear stress + mu*ub**qW), so both Lambda and a spatially-varying C exactly + reproduce the Weertman law's stress at every cell's current + speed, with no fast/slow-region split. See + solve_transition_velocity(). + +In both cases N can be computed with any of the +--effective-pressure-type options below. The output is a copy of the input MALI initial-condition file with `Lambda` (stored in the bed-roughness field, `--lambda-field`, default `bedRoughnessRC`) added, the input Weertman `muFriction` -field (`--mu-field`) overwritten with the fitted, spatially uniform -RC coefficient C, and optionally `effectivePressure` added. +field (`--mu-field`) overwritten with the RC coefficient C (a +spatially-uniform scalar for --method=stress-match-fit, spatially +varying for --method=transition-velocity), and optionally +`effectivePressure` added. Notes ----- @@ -510,6 +532,68 @@ def fit_coulomb_C_fast_region(mu, N, area, speed, q, mask): return C, valid +def solve_transition_velocity( + tau_b_weertman, speed, N, N_albany, A, glen_n, transition_velocity, + lambda_reference_value, mu_reference_value, valid, +): + """ + Alternative to fit_coulomb_C_fast_region() plus the per-cell + Lambda solve: rather than fitting a single scalar C over a + fast-flowing region and solving for Lambda elsewhere, pick a + fixed transition velocity u0 and compute both Lambda and a + spatially-varying Regularized Coulomb coefficient C in closed + form, for every valid cell, such that the RC law exactly + reproduces the Weertman law's basal shear stress + (tau_b_weertman = mu * speed^qW) at that cell's current sliding + speed: + + Lambda = u0 / (SECONDS_PER_YEAR * A * N^n) + C = tau_b_weertman * (speed + u0)^qR + / (N_albany * speed^qR) + + (qR = RC_POWER_EXPONENT). This follows from substituting + Lambda*SECONDS_PER_YEAR*A*N^n = u0 into the RC law's + Tau_b_RC == Tau_b_weertman equation used elsewhere in this + script (see the module-level Lambda-solve comment in main()) -- + i.e. u0 plays the same role as the per-cell solve's "stress + ratio" term, but is fixed instead of solved from a separate + scalar C fit. Unlike fit_coulomb_C_fast_region()/the per-cell + Lambda solve, there is no fast/slow-region split and no + ill-defined-solve case: every cell in `valid` gets an exact + match. + + `N` (raw Pa) is used for Lambda for unit consistency with A + (Pa^-3 s^-1) and SECONDS_PER_YEAR, exactly as in the per-cell + Lambda solve elsewhere in this script. `N_albany` (physical N / + ALBANY_EFFECTIVE_PRESSURE_PA_PER_UNIT) is used for C, matching + how C is fit in fit_coulomb_C_fast_region() (dimensionally + consistent with tau_b_weertman via the kPa-scaled `mu` field). + + Cells outside `valid` (non-grounded, ice-free, or zero/invalid + current sliding speed/N/mu) get `lambda_reference_value` and + `mu_reference_value` respectively, since the closed-form + expressions above are undefined there (division by zero speed, + or an ill-defined tau_b_weertman/N). + + Returns + ------- + Lambda, C : 1-D numpy arrays, same shape as `N` + """ + Lambda = np.full_like(N, lambda_reference_value) + Lambda[valid] = transition_velocity / ( + SECONDS_PER_YEAR * A[valid] * N[valid] ** glen_n + ) + + C = np.full_like(N, mu_reference_value) + C[valid] = ( + tau_b_weertman[valid] + * (speed[valid] + transition_velocity) ** RC_POWER_EXPONENT + / (N_albany[valid] * speed[valid] ** RC_POWER_EXPONENT) + ) + + return Lambda, C + + def load_transect(name, transects_dir): """ Load a flowline transect's lon/lat coordinates directly from a @@ -1081,11 +1165,71 @@ def main(): parser.add_argument("input", help="Input MALI initial-condition NetCDF file") parser.add_argument("output", help="Output NetCDF file") + parser.add_argument( + "--method", + choices=["stress-match-fit", "transition-velocity"], + default="stress-match-fit", + help=( + "How to derive bedRoughnessRC (Lambda) and the Regularized " + "Coulomb friction coefficient (--mu-field) (default: " + "stress-match-fit). \"stress-match-fit\" (the original " + "method) fits a single, spatially-uniform scalar C over " + "the fast-flowing region (speed > --critical-velocity), " + "assumed already in the full-Coulomb regime, then solves " + "per-cell for Lambda elsewhere so the RC law reproduces " + "the Weertman law's basal shear stress at each cell's " + "current sliding speed (see fit_coulomb_C_fast_region()/ " + "the Lambda solve below --critical-velocity/" + "--lambda-reference-value are used by this method). " + "\"transition-velocity\" instead picks a fixed transition " + "velocity u0 (--transition-velocity) and computes, in " + "closed form for every grounded cell, " + "Lambda = u0 / (SECONDS_PER_YEAR * A * N^n) and a " + "spatially-varying C = tau_b * (ub + u0)^(1/3) / " + "(N * ub^(1/3)) (ub = current sliding speed, tau_b = " + "Weertman shear stress mu*ub^qW), so that the RC law " + "exactly reproduces the Weertman law's stress at every " + "grounded cell's current speed, with no fast/slow-region " + "split (--transition-velocity/--mu-reference-value/" + "--lambda-reference-value are used by this method)." + ) + ) parser.add_argument( "--critical-velocity", "--uc", type=float, - required=True, - help="Critical velocity u_c, e.g. in m/yr" + default=None, + help=( + "Critical velocity u_c, e.g. in m/yr. Required, and only " + "used, when --method=stress-match-fit." + ) + ) + parser.add_argument( + "--transition-velocity", "--u0", + dest="transition_velocity", + type=float, + default=None, + help=( + "Transition velocity u0, e.g. in m/yr, used to derive " + "Lambda = u0 / (SECONDS_PER_YEAR * A * N^n) and the " + "per-cell Regularized Coulomb coefficient C = tau_b * " + "(ub + u0)^(1/3) / (N * ub^(1/3)). Required, and only " + "used, when --method=transition-velocity." + ) + ) + parser.add_argument( + "--mu-reference-value", + type=float, + default=0.3, + help=( + "Value assigned to the output --mu-field (the per-cell " + "Regularized Coulomb coefficient C) at cells where the " + "--method=transition-velocity closed-form solve is " + "undefined (non-grounded, ice-free, or zero/invalid " + "current sliding speed, N, or input mu -- the same cells " + "that fall back to --lambda-reference-value for Lambda). " + "Only used when --method=transition-velocity (default: " + "0.3)." + ) ) parser.add_argument( "--weertman-q", "--q", @@ -1105,17 +1249,20 @@ def main(): type=float, default=0.0, help=( - "Value assigned to bedRoughnessRC (Lambda) at cells " - "assumed to be in the full-Coulomb regime: all " - "fast-flowing cells (speed > critical velocity, the same " - "region used to fit C), plus any other grounded cell " - "where the exact per-cell Lambda solve is ill-defined " - "(Weertman Tau_b already meets or exceeds the Coulomb " - "limit C*N). Lambda -> 0 exactly reproduces the " - "full-Coulomb limit, so 0.0 (default) is physically " - "correct; a small positive reference value can be used " - "instead if a strictly-zero bed roughness is undesirable " - "for other reasons (default: 0.0)." + "Value assigned to bedRoughnessRC (Lambda) at cells where " + "no valid Lambda can be computed. For " + "--method=stress-match-fit, this is all fast-flowing " + "cells (speed > critical velocity, the same region used " + "to fit C), plus any other grounded cell where the exact " + "per-cell Lambda solve is ill-defined (Weertman Tau_b " + "already meets or exceeds the Coulomb limit C*N); Lambda " + "-> 0 exactly reproduces the full-Coulomb limit there, so " + "0.0 (default) is physically correct, though a small " + "positive value can be used instead if a strictly-zero " + "bed roughness is undesirable for other reasons. For " + "--method=transition-velocity, this is any non-grounded, " + "ice-free, or zero/invalid current-sliding-speed/N cell " + "(default: 0.0)." ) ) @@ -1482,6 +1629,31 @@ def main(): args = parser.parse_args() + if args.method == "stress-match-fit": + if args.critical_velocity is None: + parser.error( + "--critical-velocity is required when " + "--method=stress-match-fit" + ) + if args.transition_velocity is not None: + parser.error( + "--transition-velocity is only used with " + "--method=transition-velocity (got " + "--method=stress-match-fit)" + ) + else: + if args.transition_velocity is None: + parser.error( + "--transition-velocity is required when " + "--method=transition-velocity" + ) + if args.critical_velocity is not None: + parser.error( + "--critical-velocity is only used with " + "--method=stress-match-fit (got " + "--method=transition-velocity)" + ) + if args.flow_rate_type == "constant": if args.flow_rate is None: parser.error( @@ -1677,100 +1849,16 @@ def basal_cell_field(name): ) # ------------------------------------------------------------- - # Optimal C: fit against the fast-flowing region only, assuming - # it is already in the fully-plastic Coulomb regime of the RC law - # (Tau_b = C * N). - # ------------------------------------------------------------- - fast_flowing = grounded & (speed > args.critical_velocity) - - C, fit_mask = fit_coulomb_C_fast_region( - mu=mu, - N=N_albany, - area=area, - speed=speed, - q=args.weertman_q, - mask=fast_flowing, - ) - - # ------------------------------------------------------------- - # Lambda / Albany Bed Roughness - # - # Rather than assuming the sliding speed equals a prescribed - # critical velocity, Lambda is now solved for exactly, per cell, - # by requiring that Albany's Regularized Coulomb law reproduce - # the *same basal shear stress* Tau_b that the original - # Weertman/Power-Law would produce at the cell's actual current - # sliding speed (from --velocity-x-field/--velocity-y-field). - # - # NOTE: MALI's Weertman sliding law (which muFriction was - # calibrated for) has NO effective pressure term: + # Basal shear stress implied by the input Weertman law at each + # cell's current sliding speed (from --velocity-x-field/ + # --velocity-y-field). Used by both --method options below to + # solve for Lambda/C so that the RC law reproduces this same + # stress: # # Weertman: beta_W = mu * u^(qW-1) # Tau_b = beta_W * u = mu * u^qW # - # Regularized Coulomb (LandIce_BasalFrictionCoefficient_Def.hpp): - # beta_RC = C * N * u^(p-1) - # / (u + Lambda*scaling*A*N^n)^p - # Tau_b = beta_RC * u - # = C * N * u^p - # / (u + Lambda*scaling*A*N^n)^p - # - # Setting Tau_b_RC == Tau_b_W and solving for Lambda: - # - # u + Lambda*scaling*A*N^n = u * (C*N / Tau_b_W)^(1/p) - # = u * (C*N / (mu * u^qW))^(1/p) - # - # Lambda[m] = u * [(C*N / (mu * u^qW))^(1/p) - 1] - # / (SECONDS_PER_YEAR * A * N^n) - # Lambda[m] is the value actually stored in the output field - # -- see the module docstring / comment above - # SECONDS_PER_YEAR's definition for why no further m -> km - # conversion is applied here (MALI's own coupling interface - # performs that conversion before Albany ever sees the field). - # - # This is the same Weertman shear-stress convention used by - # fit_coulomb_C_fast_region above (Tau_b_W = mu * u^qW, no N term, - # matched in the fast-flowing region against the RC Coulomb limit - # C*N). - # - # where u is in m/yr, A is in Pa^-3 s^-1, N (raw Pa) is used here - # exactly as in the previous uc-based derivation (Albany's - # internal km/kPa/yr "scaling" factor reduces to the plain - # SECONDS_PER_YEAR factor once Lambda is expressed in meters, N in - # Pa, and the meters-based Lambda is stored as-is, in meters, with - # MALI's coupling interface performing the m -> km conversion - # before Albany sees it). N_albany (the - # kPa-equivalent convention) is used for the "C*N" Coulomb-limit - # term, matching how C was itself fit. - # - # Because Albany's Regularized Coulomb law can never produce a - # shear stress above the Coulomb limit C*N (attained only in the - # Lambda -> 0 limit), cells where the Weertman law's Tau_b at the - # current speed already meets or exceeds C*N have no valid - # (non-negative) solution for Lambda. Physically, Lambda -> 0 is - # *exactly* the fully-plastic Coulomb regime (Tau_b_RC saturates - # at its maximum achievable value, C*N, independent of speed), so - # setting Lambda = args.lambda_reference_value at these cells is - # not an arbitrary filler value -- it is the correct behavior for - # cells that the fast-flowing/full-Coulomb assumption is designed - # to describe in the first place. - # - # Fast-flowing cells (the same region used to fit C, i.e. - # speed > critical_velocity) are *always* assumed to be in this - # full-Coulomb regime and are therefore always forced to - # Lambda = args.lambda_reference_value, regardless of what the - # per-cell algebraic solve above would otherwise give -- the exact - # per-cell solve is not attempted there at all, since matching the - # Weertman law exactly at high speed is not the goal (the fast - # region is assumed C-limited by construction). # ------------------------------------------------------------- - Lambda = np.full_like(N, args.lambda_reference_value) - - # tau_b_weertman/local_C are computed over all grounded cells with - # a well-defined speed and mu (independent of the fast/slow split) - # so that diagnostics (local_C) remain meaningful for the - # fast-flowing fit region even though the exact Lambda solve below - # is only attempted for the slow-flowing cells. speed_defined = ( grounded & np.isfinite(N) & (N > 0.0) @@ -1783,58 +1871,186 @@ def basal_cell_field(name): mu[speed_defined] * speed[speed_defined] ** args.weertman_q ) - valid_speed = speed_defined & ~fast_flowing + if args.method == "stress-match-fit": + # --------------------------------------------------------- + # Optimal C: fit against the fast-flowing region only, + # assuming it is already in the fully-plastic Coulomb regime + # of the RC law (Tau_b = C * N). + # --------------------------------------------------------- + fast_flowing = grounded & (speed > args.critical_velocity) + + C, fit_mask = fit_coulomb_C_fast_region( + mu=mu, + N=N_albany, + area=area, + speed=speed, + q=args.weertman_q, + mask=fast_flowing, + ) - stress_ratio = np.full_like(N, np.nan) - stress_ratio[valid_speed] = ( - (C * N_albany[valid_speed]) / tau_b_weertman[valid_speed] - ) + # --------------------------------------------------------- + # Lambda / Albany Bed Roughness + # + # Rather than assuming the sliding speed equals a prescribed + # critical velocity, Lambda is solved for exactly, per cell, + # by requiring that Albany's Regularized Coulomb law + # reproduce the same Tau_b computed above. + # + # Regularized Coulomb (LandIce_BasalFrictionCoefficient_Def.hpp): + # beta_RC = C * N * u^(p-1) + # / (u + Lambda*scaling*A*N^n)^p + # Tau_b = beta_RC * u + # = C * N * u^p + # / (u + Lambda*scaling*A*N^n)^p + # + # Setting Tau_b_RC == Tau_b_W and solving for Lambda: + # + # u + Lambda*scaling*A*N^n = u * (C*N / Tau_b_W)^(1/p) + # = u * (C*N / (mu * u^qW))^(1/p) + # + # Lambda[m] = u * [(C*N / (mu * u^qW))^(1/p) - 1] + # / (SECONDS_PER_YEAR * A * N^n) + # Lambda[m] is the value actually stored in the output field + # -- see the module docstring / comment above + # SECONDS_PER_YEAR's definition for why no further m -> km + # conversion is applied here (MALI's own coupling interface + # performs that conversion before Albany ever sees the field). + # + # where u is in m/yr, A is in Pa^-3 s^-1, N (raw Pa) is used + # here exactly as in the previous uc-based derivation + # (Albany's internal km/kPa/yr "scaling" factor reduces to + # the plain SECONDS_PER_YEAR factor once Lambda is expressed + # in meters, N in Pa, and the meters-based Lambda is stored + # as-is, in meters, with MALI's coupling interface performing + # the m -> km conversion before Albany sees it). N_albany + # (the kPa-equivalent convention) is used for the "C*N" + # Coulomb-limit term, matching how C was itself fit. + # + # Because Albany's Regularized Coulomb law can never produce + # a shear stress above the Coulomb limit C*N (attained only + # in the Lambda -> 0 limit), cells where the Weertman law's + # Tau_b at the current speed already meets or exceeds C*N + # have no valid (non-negative) solution for Lambda. + # Physically, Lambda -> 0 is *exactly* the fully-plastic + # Coulomb regime (Tau_b_RC saturates at its maximum + # achievable value, C*N, independent of speed), so setting + # Lambda = args.lambda_reference_value at these cells is not + # an arbitrary filler value -- it is the correct behavior for + # cells that the fast-flowing/full-Coulomb assumption is + # designed to describe in the first place. + # + # Fast-flowing cells (the same region used to fit C, i.e. + # speed > critical_velocity) are *always* assumed to be in + # this full-Coulomb regime and are therefore always forced to + # Lambda = args.lambda_reference_value, regardless of what + # the per-cell algebraic solve above would otherwise give -- + # the exact per-cell solve is not attempted there at all, + # since matching the Weertman law exactly at high speed is + # not the goal (the fast region is assumed C-limited by + # construction). + # --------------------------------------------------------- + Lambda = np.full_like(N, args.lambda_reference_value) + + valid_speed = speed_defined & ~fast_flowing + + stress_ratio = np.full_like(N, np.nan) + stress_ratio[valid_speed] = ( + (C * N_albany[valid_speed]) / tau_b_weertman[valid_speed] + ) - lambda_mask = valid_speed & np.isfinite(stress_ratio) & (stress_ratio > 1.0) + lambda_mask = ( + valid_speed & np.isfinite(stress_ratio) & (stress_ratio > 1.0) + ) - Lambda[lambda_mask] = ( - speed[lambda_mask] - * (stress_ratio[lambda_mask] ** (1.0 / RC_POWER_EXPONENT) - 1.0) - / (SECONDS_PER_YEAR * A[lambda_mask] * N[lambda_mask] ** args.glen_n) - ) + Lambda[lambda_mask] = ( + speed[lambda_mask] + * (stress_ratio[lambda_mask] ** (1.0 / RC_POWER_EXPONENT) - 1.0) + / (SECONDS_PER_YEAR * A[lambda_mask] * N[lambda_mask] ** args.glen_n) + ) - n_unreachable = int(np.count_nonzero(valid_speed & ~lambda_mask)) - if n_unreachable > 0: + n_unreachable = int(np.count_nonzero(valid_speed & ~lambda_mask)) + if n_unreachable > 0: + print( + f"NOTE: {n_unreachable} slow-flowing grounded cells have a " + "Weertman basal shear stress (mu*u^qW) at the current " + "sliding speed that meets or exceeds the Coulomb limit " + f"C*N; Lambda set to {args.lambda_reference_value:g} " + "(maximal Coulomb sliding) at these cells." + ) print( - f"NOTE: {n_unreachable} slow-flowing grounded cells have a " - "Weertman basal shear stress (mu*u^qW) at the current " - "sliding speed that meets or exceeds the Coulomb limit " - f"C*N; Lambda set to {args.lambda_reference_value:g} " - "(maximal Coulomb sliding) at these cells." + f"Fast-flowing cells forced to Lambda = " + f"{args.lambda_reference_value:g} (full-Coulomb assumption) : " + f"{int(np.count_nonzero(fast_flowing))}" ) - print( - f"Fast-flowing cells forced to Lambda = " - f"{args.lambda_reference_value:g} (full-Coulomb assumption) : " - f"{int(np.count_nonzero(fast_flowing))}" - ) - # Floating/ice-free/invalid-speed/unreachable-stress cells are - # deliberately left at zero (see Lambda initialization above). + # Floating/ice-free/invalid-speed/unreachable-stress cells + # are deliberately left at zero (see Lambda initialization + # above). + + # ----------------------------------------------------------- + # Diagnostics + # ----------------------------------------------------------- + # "Local" implied C: the per-cell ratio of the actual + # Weertman shear stress (at the cell's current sliding speed) + # to N, i.e. what C would have to be for that cell alone to + # be exactly in the full-Coulomb regime. Its spread within + # the fast-flowing fit region gives a sense of how well a + # single scalar C fits that region. Only meaningful (and only + # plotted) for --method=stress-match-fit, since + # --method=transition-velocity already writes a spatially- + # varying, exactly-matching C to --mu-field directly. + local_C = np.full_like(N, np.nan) + local_C[speed_defined] = ( + tau_b_weertman[speed_defined] / N_albany[speed_defined] + ) + else: + # --------------------------------------------------------- + # Transition-velocity method: Lambda and a spatially-varying + # C are both computed in closed form from a fixed transition + # velocity u0, with no fast/slow-region split -- see + # solve_transition_velocity(). + # --------------------------------------------------------- + fast_flowing = grounded & (speed > args.transition_velocity) + fit_mask = None + local_C = None + + Lambda, C = solve_transition_velocity( + tau_b_weertman=tau_b_weertman, + speed=speed, + N=N, + N_albany=N_albany, + A=A, + glen_n=args.glen_n, + transition_velocity=args.transition_velocity, + lambda_reference_value=args.lambda_reference_value, + mu_reference_value=args.mu_reference_value, + valid=speed_defined, + ) - # ------------------------------------------------------------- - # Diagnostics - # ------------------------------------------------------------- - # "Local" implied C: the per-cell ratio of the actual Weertman - # shear stress (at the cell's current sliding speed) to N, i.e. - # what C would have to be for that cell alone to be exactly in - # the full-Coulomb regime. Its spread within the fast-flowing fit - # region gives a sense of how well a single scalar C fits that - # region. - local_C = np.full_like(N, np.nan) - local_C[speed_defined] = ( - tau_b_weertman[speed_defined] / N_albany[speed_defined] - ) + # Every grounded cell with a well-defined speed/N/mu gets an + # exact closed-form solve (no fast/slow-region split, unlike + # --method=stress-match-fit). + lambda_mask = speed_defined + + print( + f"Grounded cells with valid closed-form Lambda/C solve : " + f"{int(np.count_nonzero(speed_defined))} " + f"/ {int(np.count_nonzero(grounded))} grounded" + ) + print( + f"Cells above transition velocity, u0 (maskFastFlowing) : " + f"{int(np.count_nonzero(fast_flowing))}" + ) print() print("MALI Weertman -> Regularized Coulomb conversion") print("------------------------------------------------") print(f"Input file : {args.input}") - print(f"Critical velocity, uc : {args.critical_velocity:g}") + print(f"Method : {args.method}") + if args.method == "stress-match-fit": + print(f"Critical velocity, uc : {args.critical_velocity:g}") + else: + print(f"Transition velocity, u0 : {args.transition_velocity:g}") print(f"Weertman power exponent, qW : {args.weertman_q:g}") print(f"RC power exponent, qR : {RC_POWER_EXPONENT:g}") print(f"Glen exponent, n : {args.glen_n:g}") @@ -1846,19 +2062,31 @@ def basal_cell_field(name): f"{np.min(basal_temperature):.6f} -- " f"{np.max(basal_temperature):.6f} K" ) - print( - f"Fast-flowing (speed > uc) cells used in C fit : " - f"{np.count_nonzero(fit_mask)} / {int(np.count_nonzero(grounded))} grounded" - ) - print(f"Fast-flowing area used : {np.sum(area[fit_mask]):.10e}") - print() - print(f"Optimal C : {C:.16e}") - print() - print( - "N range on fit domain : " - f"{np.nanmin(N[fit_mask]):.6e} -- " - f"{np.nanmax(N[fit_mask]):.6e}" - ) + if args.method == "stress-match-fit": + print( + f"Fast-flowing (speed > uc) cells used in C fit : " + f"{np.count_nonzero(fit_mask)} / {int(np.count_nonzero(grounded))} grounded" + ) + print(f"Fast-flowing area used : {np.sum(area[fit_mask]):.10e}") + print() + print(f"Optimal C : {C:.16e}") + print() + print( + "N range on fit domain : " + f"{np.nanmin(N[fit_mask]):.6e} -- " + f"{np.nanmax(N[fit_mask]):.6e}" + ) + else: + print() + print( + "C range (spatially varying) : " + + ( + f"{np.nanmin(C[speed_defined]):.6e} -- " + f"{np.nanmax(C[speed_defined]):.6e}" + if np.any(speed_defined) else "n/a (no valid cells)" + ) + ) + print() print( "Basal sliding speed range (all grounded) : " + ( @@ -1879,11 +2107,12 @@ def basal_cell_field(name): if np.any(lambda_mask) else "n/a (no valid cells)" ) ) - print( - "Local C range : " - f"{np.nanmin(local_C[fit_mask]):.6e} -- " - f"{np.nanmax(local_C[fit_mask]):.6e}" - ) + if args.method == "stress-match-fit": + print( + "Local C range : " + f"{np.nanmin(local_C[fit_mask]):.6e} -- " + f"{np.nanmax(local_C[fit_mask]):.6e}" + ) print() # ------------------------------------------------------------- @@ -1919,51 +2148,86 @@ def basal_cell_field(name): out = xr.open_dataset(args.output).load() # The Weertman muFriction field is not used directly by the - # Regularized Coulomb law; overwrite it with the fitted, spatially - # uniform RC coefficient C (Albany's "Mu"/"Mu Field Name"), so - # that an Albany YAML using "Mu Type: Field" (reading this same - # field name, per --mu-field) picks up the fitted value. + # Regularized Coulomb law; overwrite it with the RC coefficient C + # (Albany's "Mu"/"Mu Field Name"), so that an Albany YAML using + # "Mu Type: Field" (reading this same field name, per --mu-field) + # picks up the fitted/calculated value. For + # --method=stress-match-fit, C is a single scalar, area-weighted + # fit broadcast uniformly to every cell (see + # fit_coulomb_C_fast_region()); for --method=transition-velocity, + # C already varies per cell (see solve_transition_velocity()). + if args.method == "stress-match-fit": + mu_field_values = np.full_like(N, C) + mu_field_long_name = ( + "Albany regularized-Coulomb coefficient C (Mu), " + "spatially uniform, area-weighted fit over the " + "fast-flowing region -- see fit_coulomb_C_fast_region()" + ) + else: + mu_field_values = C + mu_field_long_name = ( + "Albany regularized-Coulomb coefficient C (Mu), computed " + "in closed form per cell from the transition velocity u0 " + "-- see solve_transition_velocity()" + ) + out[args.mu_field] = xr.DataArray( - np.full_like(N, C), + mu_field_values, dims=(ncell_dim,), attrs={ - "long_name": ( - "Albany regularized-Coulomb coefficient C (Mu), " - "spatially uniform, area-weighted fit over the " - "fast-flowing region -- see fit_coulomb_C_fast_region()" - ), + "long_name": mu_field_long_name, "units": "1", }, ) + if args.method == "stress-match-fit": + lambda_field_description = ( + "Fast-flowing cells (speed > critical velocity) and " + "any other grounded cell where the solve below is " + "ill-defined are assumed to be in the full-Coulomb " + f"regime and set to {args.lambda_reference_value:g} " + "(see maskFastFlowing/maskValidBedRoughnessRC). " + "Elsewhere, Lambda is solved exactly so that the " + "Regularized Coulomb law reproduces the Weertman " + "law's basal shear stress (mu*u^qW, no effective-" + "pressure term) at the cell's actual current sliding " + "speed u (from velocity-x/y-field, last " + "nVertInterfaces level): Lambda[m] = u * " + "[(C*N/(mu*u^qW))^(1/qR) - 1] / (SECONDS_PER_YEAR * A " + "* N^n), with u in m/yr, A in Pa^-3 s^-1, N in Pa, " + "matching Albany's internal secsInYr scaling in " + "LandIce_BasalFrictionCoefficient_Def.hpp; the stored " + "value is Lambda in meters, as-is (matching MALI's " + "Registry.xml units=\"m\" declaration for this field) " + "-- MALI's own Albany coupling interface " + "(Interface_velocity_solver.cpp) divides this field " + "by 1000 before Albany sees it, satisfying Albany's " + "'bedRoughness in km' scaling convention" + ) + else: + lambda_field_description = ( + "Non-grounded, ice-free, or zero/invalid current-" + f"sliding-speed/N/mu cells are set to " + f"{args.lambda_reference_value:g} (see " + "maskValidBedRoughnessRC). Elsewhere (every grounded " + "cell with a valid solve), Lambda = u0 / " + "(SECONDS_PER_YEAR * A * N^n), with u0 the transition " + "velocity (--transition-velocity), A in Pa^-3 s^-1, N in " + "Pa -- see solve_transition_velocity(); the stored value " + "is Lambda in meters, as-is (matching MALI's Registry.xml " + "units=\"m\" declaration for this field) -- MALI's own " + "Albany coupling interface (Interface_velocity_solver.cpp) " + "divides this field by 1000 before Albany sees it, " + "satisfying Albany's 'bedRoughness in km' scaling " + "convention" + ) + out[args.lambda_field] = xr.DataArray( Lambda, dims=(ncell_dim,), attrs={ "long_name": "Albany regularized-Coulomb bed roughness Lambda", - "description": ( - "Fast-flowing cells (speed > critical velocity) and " - "any other grounded cell where the solve below is " - "ill-defined are assumed to be in the full-Coulomb " - f"regime and set to {args.lambda_reference_value:g} " - "(see maskFastFlowing/maskValidBedRoughnessRC). " - "Elsewhere, Lambda is solved exactly so that the " - "Regularized Coulomb law reproduces the Weertman " - "law's basal shear stress (mu*u^qW, no effective-" - "pressure term) at the cell's actual current sliding " - "speed u (from velocity-x/y-field, last " - "nVertInterfaces level): Lambda[m] = u * " - "[(C*N/(mu*u^qW))^(1/qR) - 1] / (SECONDS_PER_YEAR * A " - "* N^n), with u in m/yr, A in Pa^-3 s^-1, N in Pa, " - "matching Albany's internal secsInYr scaling in " - "LandIce_BasalFrictionCoefficient_Def.hpp; the stored " - "value is Lambda in meters, as-is (matching MALI's " - "Registry.xml units=\"m\" declaration for this field) " - "-- MALI's own Albany coupling interface " - "(Interface_velocity_solver.cpp) divides this field " - "by 1000 before Albany sees it, satisfying Albany's " - "'bedRoughness in km' scaling convention" - ), + "description": lambda_field_description, }, ) @@ -2051,41 +2315,77 @@ def basal_cell_field(name): }, ) + if args.method == "stress-match-fit": + mask_fast_flowing_long_name = ( + "Mask of grounded, fast-flowing cells assumed to be in " + "the full-Coulomb regime (used to fit C)" + ) + mask_fast_flowing_description = ( + "1 where maskGrounded and speed (from velocity-x/y-" + "field, last nVertInterfaces level) > critical " + "velocity, else 0. bedRoughnessRC is forced to " + f"{args.lambda_reference_value:g} at these cells." + ) + else: + mask_fast_flowing_long_name = ( + "Mask of grounded cells above the transition velocity " + "u0 (diagnostic only; --method=transition-velocity " + "does not use a fast/slow-region split)" + ) + mask_fast_flowing_description = ( + "1 where maskGrounded and speed (from velocity-x/y-" + "field, last nVertInterfaces level) > transition " + "velocity u0 (--transition-velocity), else 0." + ) + out["maskFastFlowing"] = xr.DataArray( fast_flowing.astype(np.int8), dims=(ncell_dim,), attrs={ - "long_name": ( - "Mask of grounded, fast-flowing cells assumed to be in " - "the full-Coulomb regime (used to fit C)" - ), - "description": ( - "1 where maskGrounded and speed (from velocity-x/y-" - "field, last nVertInterfaces level) > critical " - "velocity, else 0. bedRoughnessRC is forced to " - f"{args.lambda_reference_value:g} at these cells." - ), + "long_name": mask_fast_flowing_long_name, + "description": mask_fast_flowing_description, }, ) + if args.method == "stress-match-fit": + mask_valid_long_name = ( + "Mask of cells where bedRoughnessRC (Lambda) was " + "solved exactly, rather than set to the full-Coulomb " + f"reference value ({args.lambda_reference_value:g})" + ) + mask_valid_description = ( + "1 where the cell is grounded, not fast-flowing, and " + "the Weertman basal shear stress at the cell's " + "current sliding speed is strictly below the Coulomb " + "limit C*N (a valid, non-negative Lambda solution " + "exists); 0 otherwise (includes maskFastFlowing " + "cells and any slow-flowing grounded cell where the " + "solve is ill-defined)." + ) + else: + mask_valid_long_name = ( + "Mask of cells where bedRoughnessRC (Lambda) and " + "muFriction (C) were computed exactly, rather than " + f"set to their reference values " + f"({args.lambda_reference_value:g}/" + f"{args.mu_reference_value:g})" + ) + mask_valid_description = ( + "1 where the cell is grounded and has a well-defined " + "current sliding speed, N, and input mu (a valid " + "closed-form solve exists -- see " + "solve_transition_velocity()); 0 otherwise. Unlike " + "--method=stress-match-fit, this is not restricted by " + "maskFastFlowing -- every grounded cell with valid " + "inputs gets an exact solve regardless of speed." + ) + out["maskValidBedRoughnessRC"] = xr.DataArray( lambda_mask.astype(np.int8), dims=(ncell_dim,), attrs={ - "long_name": ( - "Mask of cells where bedRoughnessRC (Lambda) was " - "solved exactly, rather than set to the full-Coulomb " - f"reference value ({args.lambda_reference_value:g})" - ), - "description": ( - "1 where the cell is grounded, not fast-flowing, and " - "the Weertman basal shear stress at the cell's " - "current sliding speed is strictly below the Coulomb " - "limit C*N (a valid, non-negative Lambda solution " - "exists); 0 otherwise (includes maskFastFlowing " - "cells and any slow-flowing grounded cell where the " - "solve is ill-defined)." - ), + "long_name": mask_valid_long_name, + "description": mask_valid_description, }, ) @@ -2113,10 +2413,19 @@ def basal_cell_field(name): ) # Save conversion information globally. - out.attrs["regularizedCoulomb_C"] = float(C) - out.attrs["regularizedCoulomb_criticalVelocity"] = ( - float(args.critical_velocity) - ) + out.attrs["regularizedCoulomb_method"] = args.method + if args.method == "stress-match-fit": + out.attrs["regularizedCoulomb_C"] = float(C) + out.attrs["regularizedCoulomb_criticalVelocity"] = ( + float(args.critical_velocity) + ) + else: + out.attrs["regularizedCoulomb_transitionVelocity"] = ( + float(args.transition_velocity) + ) + out.attrs["regularizedCoulomb_muReferenceValue"] = ( + float(args.mu_reference_value) + ) out.attrs["regularizedCoulomb_q"] = float(RC_POWER_EXPONENT) out.attrs["weertman_q"] = float(args.weertman_q) out.attrs["regularizedCoulomb_GlenN"] = float(args.glen_n) @@ -2149,33 +2458,40 @@ def basal_cell_field(name): x_cell = np.asarray(ds["xCell"].values, dtype=np.float64) y_cell = np.asarray(ds["yCell"].values, dtype=np.float64) + transect_fields = { + "N": (N, "Pa", effective_pressure_long_name), + "floatation fraction": ( + floatation_fraction, "1", "Floatation fraction (Pw / Pice)" + ), + "hydropotential": ( + hydropotential, "Pa", "Shreve hydraulic potential" + ), + } + # "implied C" is only meaningful (and only computed) for + # --method=stress-match-fit -- --method=transition-velocity + # already writes an exactly-matching, spatially-varying C + # directly to --mu-field. + if args.method == "stress-match-fit": + transect_fields["implied C"] = ( + local_C, "1", + "Implied local C (Weertman Tau_b / N), Coulomb " + "C-fit region only", + ) + plot_transects( transect_names=args.transect_names, transects_dir=args.transects_dir, plot_dir=args.plot_dir, x_cell=x_cell, y_cell=y_cell, - fields={ - "N": (N, "Pa", effective_pressure_long_name), - "floatation fraction": ( - floatation_fraction, "1", "Floatation fraction (Pw / Pice)" - ), - "hydropotential": ( - hydropotential, "Pa", "Shreve hydraulic potential" - ), - "implied C": ( - local_C, "1", - "Implied local C (Weertman Tau_b / N), Coulomb " - "C-fit region only", - ), - }, + fields=transect_fields, thickness=H, bed=bed, rho_i=args.rho_ice, rho_w=args.rho_water, min_fraction_overburden=args.min_fraction_overburden, - fit_mask=fit_mask, - fitted_c=C, + fit_mask=fit_mask if args.method == "stress-match-fit" else None, + fitted_c=C if args.method == "stress-match-fit" else None, ) # Antarctic-wide maps of the same diagnostic fields plus @@ -2199,6 +2515,14 @@ def basal_cell_field(name): "title": "Original Weertman muFriction (input)", "log": True, "cmap": "turbo_r", }, + { + "values": mu_field_values, + "units": "1", + "title": ( + f"Output {args.mu_field} (Regularized Coulomb C)" + ), + "log": True, "cmap": "turbo", + }, ], args.effective_pressure_field: [ { @@ -2256,7 +2580,15 @@ def basal_cell_field(name): ), } ], - "impliedC": [ + } + + # "impliedC" is only meaningful (and only computed) for + # --method=stress-match-fit -- --method=transition-velocity + # already writes an exactly-matching, spatially-varying C + # directly to --mu-field (visible via the "muFriction (input)" + # panel above, which shows the *original* input mu instead). + if args.method == "stress-match-fit": + map_fields["impliedC"] = [ { "values": local_C, "units": "1", "title": ( @@ -2267,8 +2599,7 @@ def basal_cell_field(name): "log": True, "cmap": "turbo", "mask": fit_mask, } - ], - } + ] map_transects = None if args.plot_transects_on_maps: From 05ff6bb985c9df1605f74e4f84a6aec5353393cc Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Fri, 28 Aug 2026 20:41:34 -0700 Subject: [PATCH 53/55] Give calculated muFriction its own map plot Move the output --mu-field (calculated Regularized Coulomb C) panel out of the bedRoughnessRC map figure and into its own dedicated map file (map_.png, e.g. map_muFriction.png), alongside the original input muFriction, so it's easy to find for both methods. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- landice/mesh_tools_li/friction_law_conversion.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index b33dce232..4d62ca9ca 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -2509,6 +2509,8 @@ def basal_cell_field(name): ), "log": True, "cmap": "turbo", }, + ], + args.mu_field: [ { "values": mu, "units": f"kPa (m yr-1)^-{args.weertman_q:g}", From dd01e2b4b0ee8d61c578aca53a7bf8dc3c090477 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Sun, 30 Aug 2026 19:24:29 -0700 Subject: [PATCH 54/55] Add whole-domain grounding-line/terminus extrapolation option Add --extrapolate-terminus-cells (default disabled), which discards the computed bedRoughnessRC (Lambda), and muFriction (C) for --method=transition-velocity, at the outermost row of grounded cells adjacent to the grounding line or a grounded marine terminus, plus every non-grounded cell (floating ice, ice-free ocean, ice-free land). Those discarded values are then creep-filled from the remaining grounded interior across MPAS mesh connectivity (cellsOnCell/nEdgesOnCell), using either inverse-distance weighting or a minimum-value rule (--creep-fill-method), adapted from MPAS-Tools' conversion_exodus_init_to_mpasli_mesh.py extrapolation loop. Adds two new helpers, cell_has_neighbor_where() and creep_fill_extrapolate(), a maskTerminusExtrapolated diagnostic output field, and updated field descriptions/console summaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 4d62ca9ca..44cd87fc6 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -594,6 +594,165 @@ def solve_transition_velocity( return Lambda, C +def cell_has_neighbor_where(test_mask, cells_on_cell, n_edges_on_cell): + """ + For every cell, return True if at least one of its mesh neighbors + (per MPAS `cellsOnCell`/`nEdgesOnCell` connectivity) satisfies + `test_mask`. + + Parameters + ---------- + test_mask : 1-D bool array (nCells,) + Per-cell predicate to test neighbors against. + cells_on_cell : 2-D int array (nCells, maxEdges) + MPAS `cellsOnCell` field: 1-based neighbor cell indices, with + 0 used to pad cells with fewer than `maxEdges` edges/ + neighbors (e.g. mesh-boundary cells). + n_edges_on_cell : 1-D int array (nCells,) + MPAS `nEdgesOnCell` field: number of valid (non-padding) + entries in each row of `cells_on_cell`. + + Returns + ------- + 1-D bool array (nCells,) + """ + max_edges = cells_on_cell.shape[1] + edge_index = np.arange(max_edges)[np.newaxis, :] + # A neighbor slot is real only if it is within nEdgesOnCell for + # that row *and* not the 0 padding value cellsOnCell itself uses + # for missing neighbors (mesh-boundary cells). + valid_slot = ( + (edge_index < n_edges_on_cell[:, np.newaxis]) + & (cells_on_cell > 0) + ) + neighbor_index = np.where(valid_slot, cells_on_cell - 1, 0) + neighbor_test = test_mask[neighbor_index] & valid_slot + return np.any(neighbor_test, axis=1) + + +def creep_fill_extrapolate( + values, keep_mask, fill_mask, x_cell, y_cell, + cells_on_cell, n_edges_on_cell, method="idw", + max_iterations=None, +): + """ + Extrapolate `values` into every cell where `fill_mask` is True by + repeatedly propagating values inward from `keep_mask` cells + across MPAS mesh connectivity (`cells_on_cell`/`n_edges_on_cell`), + a "creep fill" adapted from MPAS-Tools' + conversion_exodus_init_to_mpasli_mesh.py (its beta/muFriction/ + stiffnessFactor extrapolation loop). + + Each iteration, every not-yet-filled `fill_mask` cell adjacent to + at least one already-valid cell is assigned a new value derived + from its valid neighbors only (inverse-distance weighted average, + method="idw", or the minimum, method="min"), using the *previous* + iteration's valid set as the source (so a single pass never + chains through cells filled earlier in that same pass); it then + becomes valid itself for the next iteration. This repeats until + every `fill_mask` cell has been filled, `max_iterations` passes + have been made, or a pass fills no new cells (a stall, meaning + some `fill_mask` cells have no path to a `keep_mask` cell through + other `fill_mask` cells) -- in either of the latter two cases, a + warning is printed and any still-unfilled cells are left with + their original `values`. + + Parameters + ---------- + values : 1-D float array (nCells,) + Field to extrapolate. Not modified in place; the filled + array is returned separately. + keep_mask : 1-D bool array (nCells,) + True at cells whose current `values` are already valid and + may be used as an extrapolation source. + fill_mask : 1-D bool array (nCells,) + True at cells whose current `values` should be discarded and + instead derived by creep-fill extrapolation. Must be + disjoint from `keep_mask`. Cells that are neither + `keep_mask` nor `fill_mask` (e.g. non-grounded cells) are + never used as a source and are left unchanged. + x_cell, y_cell : 1-D float arrays (nCells,) + MPAS cell-center coordinates, used for the "idw" method. + cells_on_cell, n_edges_on_cell : see cell_has_neighbor_where() + method : {"idw", "min"} + "idw": inverse-distance-weighted average of valid neighbors. + "min": minimum value among valid neighbors (matches + MPAS-Tools' conversion_exodus_init_to_mpasli_mesh.py "min" + extrapolation option). + max_iterations : int, optional + Maximum number of creep-fill passes. Default (None): no + limit other than a stall (see above). + + Returns + ------- + 1-D float array (nCells,), same shape as `values` + """ + if method not in ("idw", "min"): + raise ValueError(f"Unknown creep-fill method: {method!r}") + + out = np.array(values, dtype=np.float64, copy=True) + valid_mask = np.copy(keep_mask) + remaining = np.copy(fill_mask) + + iteration = 0 + while np.any(remaining): + if max_iterations is not None and iteration >= max_iterations: + print( + f"WARNING: creep-fill extrapolation stopped after " + f"{iteration} iterations with " + f"{int(np.count_nonzero(remaining))} cell(s) still " + "unfilled; leaving their original values unchanged." + ) + break + + newly_filled = np.zeros(out.shape, dtype=bool) + + for i_cell in np.where(remaining)[0]: + n_edges = n_edges_on_cell[i_cell] + neighbor_idx = cells_on_cell[i_cell, :n_edges] - 1 + neighbor_idx = neighbor_idx[neighbor_idx >= 0] + + source_idx = neighbor_idx[valid_mask[neighbor_idx]] + if source_idx.size == 0: + continue + + if method == "idw": + ds = np.sqrt( + (x_cell[i_cell] - x_cell[source_idx]) ** 2 + + (y_cell[i_cell] - y_cell[source_idx]) ** 2 + ) + if np.any(ds == 0.0): + # Degenerate (coincident) cell centers: fall back + # to a plain average rather than dividing by + # zero. + out[i_cell] = np.mean(out[source_idx]) + else: + weights = 1.0 / ds + out[i_cell] = ( + np.sum(weights * out[source_idx]) + / np.sum(weights) + ) + else: # method == "min" + out[i_cell] = np.min(out[source_idx]) + + newly_filled[i_cell] = True + + if not np.any(newly_filled): + print( + f"WARNING: creep-fill extrapolation stalled with " + f"{int(np.count_nonzero(remaining))} cell(s) still " + "unfilled (no remaining cell has a valid neighbor); " + "leaving their original values unchanged." + ) + break + + valid_mask[newly_filled] = True + remaining[newly_filled] = False + iteration += 1 + + return out + + def load_transect(name, transects_dir): """ Load a flowline transect's lon/lat coordinates directly from a @@ -1266,6 +1425,65 @@ def main(): ) ) + extrapolate_terminus_group = parser.add_mutually_exclusive_group() + extrapolate_terminus_group.add_argument( + "--extrapolate-terminus-cells", + dest="extrapolate_terminus_cells", + action="store_true", + default=False, + help=( + "Discard the computed bedRoughnessRC (Lambda) value (and, " + "for --method=transition-velocity, the computed --mu-" + "field/C value) at every grounded cell adjacent to the " + "grounding line (a floating-ice neighbor) or to a " + "grounded marine terminus (an ice-free, bed-below-sea-" + "level neighbor, i.e. a tidewater-glacier-style calving " + "front with no floating shelf), and at every non-grounded " + "cell (floating ice, ice-free ocean, ice-free land) -- " + "i.e. the entire mesh domain outside the grounded " + "interior -- then refill all of those cells by creep-" + "fill extrapolation (--creep-fill-method), sourced " + "purely from the remaining grounded-interior cells -- " + "see creep_fill_extrapolate(). The discarded marginal " + "cells are often the least reliable (e.g. noisiest " + "velocity/thickness/bed data, or most sensitive to the " + "exact grounding-line position), so this discards them " + "in favor of extrapolating from more interior, better-" + "constrained cells, and additionally gives every non-" + "grounded cell a physically-reasonable (rather than a " + "flat reference) value. Cells filled this way are marked " + "in the maskTerminusExtrapolated diagnostic field (see " + "--diagnostics). Default: disabled (use the directly " + "computed values everywhere)." + ) + ) + extrapolate_terminus_group.add_argument( + "--no-extrapolate-terminus-cells", + dest="extrapolate_terminus_cells", + action="store_false", + help=( + "Do not discard/extrapolate grounding-line/grounded-" + "marine-terminus/non-grounded cells; use the directly " + "computed values everywhere (default)." + ) + ) + parser.add_argument( + "--creep-fill-method", + choices=["idw", "min"], + default="idw", + help=( + "Extrapolation method used by --extrapolate-terminus-" + "cells to fill discarded grounding-line/grounded-marine-" + "terminus cells from neighboring valid cells (default: " + "idw). \"idw\": inverse-distance-weighted average of " + "valid neighbors. \"min\": minimum value among valid " + "neighbors (matches MPAS-Tools' " + "conversion_exodus_init_to_mpasli_mesh.py \"min\" " + "extrapolation option). Only used when " + "--extrapolate-terminus-cells is set." + ) + ) + # Effective pressure N parser.add_argument( "--effective-pressure-type", @@ -1716,6 +1934,10 @@ def main(): ] if args.flow_rate_type == "temperature": required.append(args.temperature_field) + if args.extrapolate_terminus_cells: + # MPAS mesh connectivity, needed to identify grounding-line/ + # grounded-marine-terminus cells and to creep-fill them. + required.extend(["cellsOnCell", "nEdgesOnCell", "xCell", "yCell"]) missing = [name for name in required if name not in ds] if missing: @@ -1848,6 +2070,54 @@ def basal_cell_field(name): & (args.rho_ice * H + args.rho_water * bed > 0.0) ) + # ------------------------------------------------------------- + # Grounding-line / grounded-marine-terminus cell identification + # (--extrapolate-terminus-cells only): grounded cells immediately + # adjacent to floating ice (the grounding line proper) or to + # ice-free, bed-below-sea-level ocean (a grounded ice front with + # no floating shelf, e.g. a tidewater glacier calving front). + # Land-terminating margins (ice-free, bed at/above sea level) are + # deliberately not included -- these are neither a grounding line + # nor a marine terminus. + # + # These cells, plus every non-grounded cell (floating ice, + # ice-free ocean, ice-free land), make up `fill_mask`: the + # portion of the *entire mesh domain* whose values are discarded + # and creep-filled by extrapolation, sourced from `keep_mask` + # (the grounded interior, i.e. grounded ice minus its outermost + # terminus row). + # ------------------------------------------------------------- + if args.extrapolate_terminus_cells: + ice_free = H <= 0.0 + floating = (~ice_free) & (~grounded) + ice_free_ocean = ice_free & (bed < 0.0) + + cells_on_cell = np.asarray(ds["cellsOnCell"].values) + n_edges_on_cell = np.asarray(ds["nEdgesOnCell"].values) + x_cell = np.asarray(ds["xCell"].values, dtype=np.float64) + y_cell = np.asarray(ds["yCell"].values, dtype=np.float64) + + terminus_cells = grounded & cell_has_neighbor_where( + floating | ice_free_ocean, cells_on_cell, n_edges_on_cell + ) + keep_mask = grounded & ~terminus_cells + fill_mask = ~keep_mask + + print( + "Grounding-line/grounded-marine-terminus cells discarded " + f": {int(np.count_nonzero(terminus_cells))} " + f"/ {int(np.count_nonzero(grounded))} grounded" + ) + print( + "Total cells to be extrapolated over (terminus + all " + f"non-grounded cells) : {int(np.count_nonzero(fill_mask))} " + f"/ {H.shape[0]} total cells" + ) + else: + terminus_cells = None + keep_mask = None + fill_mask = None + # ------------------------------------------------------------- # Basal shear stress implied by the input Weertman law at each # cell's current sliding speed (from --velocity-x-field/ @@ -2042,6 +2312,70 @@ def basal_cell_field(name): f"{int(np.count_nonzero(fast_flowing))}" ) + # ------------------------------------------------------------- + # Whole-domain extrapolation (--extrapolate-terminus-cells): + # discard the just-computed Lambda (and, for + # --method=transition-velocity, C) everywhere outside the + # grounded interior (`fill_mask` = grounding-line/grounded- + # marine-terminus cells plus every non-grounded cell -- floating + # ice, ice-free ocean, ice-free land) and creep-fill them from + # `keep_mask` (the grounded interior) -- see + # creep_fill_extrapolate(). + # ------------------------------------------------------------- + if args.extrapolate_terminus_cells and np.any(fill_mask): + lambda_before = Lambda[fill_mask].copy() + Lambda = creep_fill_extrapolate( + Lambda, + keep_mask=keep_mask, + fill_mask=fill_mask, + x_cell=x_cell, + y_cell=y_cell, + cells_on_cell=cells_on_cell, + n_edges_on_cell=n_edges_on_cell, + method=args.creep_fill_method, + ) + print( + "Whole-domain bedRoughnessRC (Lambda) discarded and " + f"extrapolated ({args.creep_fill_method}) from the " + "grounded interior: " + f"{np.nanmin(lambda_before):.6e} -- " + f"{np.nanmax(lambda_before):.6e} (before) -> " + f"{np.nanmin(Lambda[fill_mask]):.6e} -- " + f"{np.nanmax(Lambda[fill_mask]):.6e} (after)" + ) + + if args.method == "transition-velocity": + c_before = C[fill_mask].copy() + C = creep_fill_extrapolate( + C, + keep_mask=keep_mask, + fill_mask=fill_mask, + x_cell=x_cell, + y_cell=y_cell, + cells_on_cell=cells_on_cell, + n_edges_on_cell=n_edges_on_cell, + method=args.creep_fill_method, + ) + print( + "Whole-domain muFriction (C) discarded and " + f"extrapolated ({args.creep_fill_method}) from the " + "grounded interior: " + f"{np.nanmin(c_before):.6e} -- " + f"{np.nanmax(c_before):.6e} (before) -> " + f"{np.nanmin(C[fill_mask]):.6e} -- " + f"{np.nanmax(C[fill_mask]):.6e} (after)" + ) + # Not applied to --method=stress-match-fit's C: that C is a + # single scalar broadcast to every cell, so extrapolation + # would have no effect. + + # Cells re-derived by extrapolation are no longer a "valid + # solve" in the maskValidBedRoughnessRC sense (they were + # deliberately discarded, not solved), but they are also not + # simply left at a reference value -- track them separately + # via maskTerminusExtrapolated (see the diagnostics section + # below) rather than folding them into lambda_mask. + print() print("MALI Weertman -> Regularized Coulomb conversion") print("------------------------------------------------") @@ -2171,6 +2505,16 @@ def basal_cell_field(name): "-- see solve_transition_velocity()" ) + if args.extrapolate_terminus_cells and args.method == "transition-velocity": + mu_field_long_name += ( + "; the grounding-line/grounded-marine-terminus band and " + "every non-grounded cell (maskTerminusExtrapolated) were " + "discarded and creep-fill extrapolated " + f"({args.creep_fill_method}) from the grounded interior " + "-- see --extrapolate-terminus-cells/" + "creep_fill_extrapolate()" + ) + out[args.mu_field] = xr.DataArray( mu_field_values, dims=(ncell_dim,), @@ -2222,6 +2566,16 @@ def basal_cell_field(name): "convention" ) + if args.extrapolate_terminus_cells: + lambda_field_description += ( + "; the grounding-line/grounded-marine-terminus band and " + "every non-grounded cell (maskTerminusExtrapolated) were " + "discarded and creep-fill extrapolated " + f"({args.creep_fill_method}) from the grounded interior " + "-- see --extrapolate-terminus-cells/" + "creep_fill_extrapolate()" + ) + out[args.lambda_field] = xr.DataArray( Lambda, dims=(ncell_dim,), @@ -2389,6 +2743,33 @@ def basal_cell_field(name): }, ) + if args.extrapolate_terminus_cells: + out["maskTerminusExtrapolated"] = xr.DataArray( + fill_mask.astype(np.int8), + dims=(ncell_dim,), + attrs={ + "long_name": ( + "Mask of the whole-domain region whose " + "computed bedRoughnessRC (and, for " + "--method=transition-velocity, muFriction) " + "values were discarded and replaced by " + "creep-fill extrapolation from the grounded " + "interior" + ), + "description": ( + "1 where the cell is either a grounding-line/" + "grounded-marine-terminus cell (grounded and " + "adjacent to floating ice, or to ice-free, " + "bed-below-sea-level ocean) or any non-" + "grounded cell (floating ice, ice-free ocean, " + "ice-free land), else 0 (the grounded " + "interior, used as the extrapolation source). " + "See --extrapolate-terminus-cells/" + "--creep-fill-method/creep_fill_extrapolate()." + ), + }, + ) + if args.flow_rate_type == "temperature": flow_rate_long_name = ( "Glen flow-rate factor A from Albany's Temperature " From da2d76459ad617c6f09150916d95dee5af6db456 Mon Sep 17 00:00:00 2001 From: Matthew Hoffman Date: Sun, 30 Aug 2026 19:35:49 -0700 Subject: [PATCH 55/55] Add regime-ratio diagnostic map (Coulomb vs. power-law regime) Add a regimeRatio Antarctic-wide map panel showing u / (SECONDS_PER_YEAR * Lambda * A * N^n), where u is the basal sliding speed computed from the input file's velocity fields. This ratio is the input speed divided by the Regularized Coulomb law's implied critical/transition velocity at each cell, so it directly indicates how close each cell is to the fully-plastic Coulomb regime (ratio >> 1) versus the power-law/Weertman-like regime (ratio << 1). Plotted on a log scale from 0.1 to 10 with a diverging colormap (RdBu_r) centered at 1.0 (the transition itself), masked to grounded ice. Also extends plot_maps() so panel vmin/vmax are honored for log-scale panels (previously ignored, auto-ranged from data only), which this new panel and any future fixed-range log panel needs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mesh_tools_li/friction_law_conversion.py | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/landice/mesh_tools_li/friction_law_conversion.py b/landice/mesh_tools_li/friction_law_conversion.py index 44cd87fc6..6472f4979 100755 --- a/landice/mesh_tools_li/friction_law_conversion.py +++ b/landice/mesh_tools_li/friction_law_conversion.py @@ -1215,7 +1215,10 @@ def plot_maps(mesh_ds, fields, plot_dir, transects=None): colormap name. vmin, vmax : float, optional. Clip the colormap to this range (e.g. [0, 1] for a fraction field with a few - out-of-range outliers); ignored if `log` is True. + out-of-range outliers, or [0.1, 10] for a log-scale + ratio field diverging about 1.0). Also honored when + `log` is True (as the LogNorm's vmin/vmax), not just + for a linear-scale Normalize. mask : 1-D bool array, optional. Cells where mask is False are set to NaN (not displayed), e.g. to hide non-grounded cells on a grounded-ice-only field. @@ -1278,8 +1281,8 @@ def plot_maps(mesh_ds, fields, plot_dir, transects=None): plot_values > 0.0, plot_values, np.nan ) norm = matplotlib.colors.LogNorm( - vmin=np.nanmin(plot_values), - vmax=np.nanmax(plot_values), + vmin=vmin if vmin is not None else np.nanmin(plot_values), + vmax=vmax if vmax is not None else np.nanmax(plot_values), ) elif vmin is not None or vmax is not None: norm = matplotlib.colors.Normalize( @@ -2376,6 +2379,37 @@ def basal_cell_field(name): # via maskTerminusExtrapolated (see the diagnostics section # below) rather than folding them into lambda_mask. + # ------------------------------------------------------------- + # Regularized Coulomb regime ratio: u / (Lambda * A * N^n), + # dimensionally consistent via the same SECONDS_PER_YEAR factor + # used everywhere else in this script to relate Lambda (solved/ + # stored in meters) to a velocity scale (see the Lambda solves + # above and solve_transition_velocity()). The denominator, + # SECONDS_PER_YEAR * Lambda * A * N^n, is exactly the implied + # critical/transition velocity u_c at which the Regularized + # Coulomb law's basal shear stress switches over between its two + # asymptotic regimes: + # + # beta_RC = C * N * u^(p-1) / (u + u_c)^p + # + # u >> u_c (ratio >> 1): (u + u_c)^p ~ u^p, so + # Tau_b = beta_RC * u -> C * N, independent of u -- the + # fully-plastic Coulomb regime. + # u << u_c (ratio << 1): (u + u_c)^p ~ u_c^p, so + # Tau_b ~ (C * N / u_c^p) * u^p -- a power-law (Weertman- + # like) regime. + # + # So this ratio is a direct, per-cell diagnostic of how close a + # cell's current sliding speed places it to either regime: + # ratio >> 1 is Coulomb-like, ratio << 1 is power-law-like, + # ratio ~ 1 is the transition itself. + # ------------------------------------------------------------- + with np.errstate(divide="ignore", invalid="ignore"): + critical_velocity_implied = ( + SECONDS_PER_YEAR * Lambda * A * N ** args.glen_n + ) + regime_ratio = speed / critical_velocity_implied + print() print("MALI Weertman -> Regularized Coulomb conversion") print("------------------------------------------------") @@ -2963,6 +2997,18 @@ def basal_cell_field(name): ), } ], + "regimeRatio": [ + { + "values": regime_ratio, "units": "1", + "title": ( + "u / (Lambda * A * N^n): Coulomb (>1, red) vs. " + "power-law (<1, blue) regime" + ), + "log": True, "cmap": "RdBu_r", + "vmin": 0.1, "vmax": 10.0, + "mask": grounded, + } + ], } # "impliedC" is only meaningful (and only computed) for