From 8d0a7a35cbc6cbd4a07bc0fb5ec2ed41b7c24924 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Wed, 19 Nov 2025 21:19:55 -0800 Subject: [PATCH 01/23] Add separation bubble parameters to DEFAULT_CONFIG in constants.py --- aeolis/constants.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/aeolis/constants.py b/aeolis/constants.py index eae691bc..c36a727f 100644 --- a/aeolis/constants.py +++ b/aeolis/constants.py @@ -244,8 +244,15 @@ 'k' : 0.001, # [m] Bed roughness 'L' : 100., # [m] Typical length scale of dune feature (perturbation) 'l' : 10., # [m] Inner layer height (perturbation) - 'c_b' : 0.2, # [-] Slope at the leeside of the separation bubble # c = 0.2 according to Durán 2010 (Sauermann 2001: c = 0.25 for 14 degrees) - 'mu_b' : 30, # [deg] Minimum required slope for the start of flow separation + + # Separation bubble parameters + 'sep_auto_tune' : True, # [-] Boolean for automatic tuning of separation bubble parameters based on characteristic length scales + 'sep_look_dist' : 50., # [m] Flow separation: Look-ahead distance for upward curvature anticipation + 'sep_k_press_up' : 0.05, # [-] Flow separation: Press-up curvature + 'sep_k_crit_down' : 0.18, # [1/m] Flow separation: Maximum downward curvature + 'sep_s_crit' : 0.18, # [-] Flow separation: Critical bed slope below which reattachment is forced + 'sep_s_leeside' : 0.25, # [-] Maximum downward leeside slope of the streamline + 'buffer_width' : 10, # [m] Width of the bufferzone around the rotational grid for wind perturbation 'sep_filter_iterations' : 0, # [-] Number of filtering iterations on the sep-bubble (0 = no filtering) 'zsep_y_filter' : False, # [-] Boolean for turning on/off the filtering of the separation bubble in y-direction From caae2993053b8725acc7e0bfc38ce8f784ea2016 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Wed, 19 Nov 2025 21:20:43 -0800 Subject: [PATCH 02/23] Add streamline-based separation bubble model for AeoLiS in new file (separation.py) --- aeolis/separation.py | 258 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 aeolis/separation.py diff --git a/aeolis/separation.py b/aeolis/separation.py new file mode 100644 index 00000000..06625b03 --- /dev/null +++ b/aeolis/separation.py @@ -0,0 +1,258 @@ +""" +Streamline-based separation bubble model for AeoLiS + +This module provides a unified 1D/2D separation bubble computation based on +a streamline curvature model. The physics is contained in a single numba- +compiled 1D core. For 2D grids (FFT shear mode), the 1D streamline model +is applied to each wind-aligned row. + +Public Entry Point: + compute_separation(z_bed, dx, wind_sign, p) + +Internal: + _compute_separation_1d(...) + _compute_separation_2d(...) + _streamline_core_1d(...) # numba auto-compiled + +""" + +import numpy as np +from numba import njit + +# Wrapper for 1D vs 2D +def compute_separation(p, z_bed, dx, udir=0): + + # Get all parameters + look_dist = p['sep_look_dist'] + k_press_up = p['sep_k_press_up'] + k_crit_down = p['sep_k_crit_down'] + s_crit = p['sep_s_crit'] + s_leeside = p['sep_s_leeside'] + + # Determine size + ny_, nx = z_bed.shape + + # Flip the bed for negative wind directions (only in 1D) + if udir < 0 and ny_ == 0: + z = z[::-1] + + # Run streamline computation once in 1D + if ny_ == 0: + zsep = _streamline_core_1d( + z_bed, dx, look_dist, # Base input + k_press_up, k_crit_down, # Curviture + s_crit, s_leeside) # Leeside slopes + + + else: # Run streamline computation over every transect in 2D (Numba-compiled over 1D) + zsep = _compute_separation_2d( + z_bed, dx, look_dist, + k_press_up, k_crit_down, + s_crit, s_leeside) + + # Flip back for the 1D case + if udir < 0 and ny_ == 0: + zsep = zsep[::-1] + + return zsep + + +@njit +def _compute_separation_2d( + z_bed, dx, look_dist, + k_press_up, k_crit_down, + s_crit, s_leeside): + + ny_, nx = z_bed.shape + zsep = np.zeros_like(z_bed) + + for j in range(ny_): + + row = z_bed[j, :] + + # Quick skip: slope + curvature checks + skip = True + for i in range(1, nx-1): + s = (row[i] - row[i-1]) / dx + s_prev = (row[i-1] - row[i-2]) / dx + ds = s_prev - s # curvature approx + + if (s < -s_crit) or (ds < -k_crit_down): + skip = False + break + + if skip: + zsep[j, :] = row + else: + zsep[j, :] = _streamline_core_1d( + row, dx, look_dist, + k_press_up, k_crit_down, + s_crit, s_leeside) + + return zsep + + +# Streamline core +@njit +def _streamline_core_1d( + z_bed, dx, look_dist, + k_press_up, k_crit_down, + s_crit, s_leeside): + + # Initialize size and z (= streamline) + N = z_bed.size + z = z_bed.copy() + + # Loop over all points in windward direction + for i in range(1, N - 1): + + # Compute slope of streamline + s_str = (z[i] - z[i-1]) / dx + gap = z[i] - z_bed[i] + + # Compute slope of bed + s_bed = (z_bed[i] - z_bed[i-1]) / dx + s_bed_next = (z_bed[i+1] - z_bed[i]) / dx + ds_bed = s_bed_next - s_bed + + # Determine how far to look ahead for upward curviture + look_n = int(look_dist / dx) + i_end = min(i + look_n, N - 1) + + # Initialize maximum required curviture (k_req_max) and the resulting slope (v_z) + k_req_max = 0.0 + v_z = None + k_press_down_base = 0.05 + + # ----- 1. UPWARD CURVATURE ANTICIPATION ----- + + # Start looking forward for upward curvature anticipation + if i_end > i: + for j in range(i + 1, i_end + 1): + + # Compute difference in distance, height and slope downwind (forward = j) + dxj = (j - i) * dx # Total distance between current (i) and forward (j) + dzj = z[j] - z[i] # Total height difference between current (i) and forward (j) + sbj = (z[j] - z[j-1]) / dx # Slope at forward (j) + + # Required slope (s) to close height difference from (i) to (j) + s_req_height = dzj / dxj + + # Required curviture (k) to close slope difference for height (k_req_height) and slope (k_req_slope) + k_req_height = (s_req_height - s_str) / dxj + k_req_slope = (sbj - s_str) / dxj + + # Prevent that the streamline "overshoots" the bed level due a too steep curviture + z_est = z[i] + s_str * dxj + 0.5 * k_req_slope * dxj * dxj + if z_est > z[j]: + k_req_slope = 0.0 + + # Required distance to reach either height or slope + d_req_height = np.sqrt(2 * max(0.0, dzj - s_str * dxj) / k_press_up) if k_req_height > 0 else 0. + d_req_slope = (sbj - s_str) / k_press_up if k_req_slope > 0 else 0. + d_req = max(d_req_height, d_req_slope) + + # Check whether d_req is within reach (pass if dxj > d_req) + # i.e., if d_req < dxj; it is not necessary to bend upward yet + if d_req > dxj: + k_req_max = max(k_req_max, k_req_slope, k_req_height) + + # Apply curvature anticipation + if k_req_max >= k_press_up: + v_z = s_str + k_req_max + + # ----- 2. DOWNWARD CURVATURE BY DE- and RE-ATTACHMENT ----- + + # Don't apply downward curvature if we are doing upward anticipation + if v_z is None: + + # Check if we are at the first point of detachment + if gap < 1e-6 and (ds_bed < -k_crit_down or s_str < -s_crit): + + # The downward curvature (k_press_down) is based on an estimated attachment length + # This attachment length depends on geometry (L_attach is roughly equal to 6 * H_hill) + r_LH = 6. + s_L = 1 / r_LH + + # An addition to the dettachment length is added based on the distance its takes to bend to leeslope + L_curv = max((s_str + s_leeside) / k_press_down_base, dx) + i_curv = int(i + L_curv / dx) + + # Loop forward going downward with s_L per m and track where it intersects the bed + bed_intersect = [] + for j in range(i_curv, N): + zj = z[i] - s_L * (j - i_curv) * dx + zj_prev = z[i] - s_L * (j - i_curv - 1) * dx + if zj < z_bed[j] and zj_prev >= z_bed[j]: + bed_intersect.append(j) + + # Double crossing could be caused due to a small hill on the leeslide + # If the bed is crossed multiple times, the last one is used + if bed_intersect: + n_intersect = bed_intersect[-1] + else: + n_intersect = N-1 + + # Estimate the height of the feature by taking the max and min bed levels + h_gap = np.max(z_bed[i:n_intersect+1])- np.min(z_bed[i:n_intersect+1]) + + # Estimate attachment length and subsequent downward curvature + L_attach = h_gap * r_LH + L_curv + k_press_down = 1.5 / L_attach + + else: + k_press_down = k_press_down_base + + # Apply downward curvature + if ds_bed < -k_crit_down or s_bed < -s_crit or gap > 0.0: + + # An exponential term drives s → -s_leeside and bend steep upward slopes faster + f_converge = np.exp(1.4*(max(s_str + s_leeside, 0)**0.85)) - 1 + v_z = s_str - k_press_down * f_converge + + # Remains when no anticipation or downward curvature is applied + else: + v_z = s_bed_next + + # ----- 3. ADVANCE STREAMLINE ----- + z[i+1] = max(z[i] + v_z * dx, z_bed[i+1]) + + return z + + +def set_sep_params(p, s): + """ + Set separation–bubble parameters based on perturbation–theory + scaling (Kroy-type). Values are stored inside the main parameter + dictionary 'p'. Returns updated p. + + Inputs + ------ + p : dict + Global model parameter dictionary. + s : dict + Shear fields (contains L, l, z0 via WindShear object). + + Notes + ------ + Upward curvature (k_press_up) follows Kroy-type perturbation theory. + Downward curvature is taken as a fraction of the upward limit. + Other parameters remain simple fixed constants. + """ + + # Characteristic length scales + f_min = 0.3 # [-] Minimum ratio of tau to tau_0 + # gamma_down = 0.4 # [-] Ratio of k_down to k_up + + # --- Upward curvature limit (perturbation theory) --- + p['sep_k_press_up'] = (1 - f_min) * (2*np.pi / (np.sqrt(p['L']) * 40)) + + # # --- Downward curvature limit (softer than upward) --- + # p['sep_k_press_down'] = gamma_down * p['sep_k_press_up'] + + # print(p['sep_k_press_down']) + + # # --- Curvature threshold for detachment trigger --- + # p['sep_curv_limit_down'] = np.maximum(p['sep_s_crit'] / 0.5, 1.5 * p['sep_k_press_down']) + + return p From b3fb74d82eb9dbf0d0ec4883c44fe89072774ff4 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Wed, 19 Nov 2025 21:22:34 -0800 Subject: [PATCH 03/23] Refactor wind (wind.py) and shear (shear.py) modules to connect to new separation bubble approach --- aeolis/shear.py | 171 +++--------------------------------------------- aeolis/wind.py | 57 +++++++--------- 2 files changed, 33 insertions(+), 195 deletions(-) diff --git a/aeolis/shear.py b/aeolis/shear.py index fb3f7485..0256cb12 100644 --- a/aeolis/shear.py +++ b/aeolis/shear.py @@ -38,7 +38,7 @@ # package modules from aeolis.utils import * - +from aeolis.separation import compute_separation # initialize logger logger = logging.getLogger(__name__) @@ -142,10 +142,8 @@ def __init__(self, x, y, z, dx, dy, L, l, z0, self.z0 = z0 - def __call__(self, x, y, z, taux, tauy, u0, udir, - process_separation, c, mu_b, - taus0, taun0, sep_filter_iterations, zsep_y_filter, - plot=False): + def __call__(self, p, x, y, z, u0, udir, + taux, tauy, taus0, taun0, plot=False): '''Compute wind shear for given wind speed and direction @@ -222,10 +220,9 @@ def __call__(self, x, y, z, taux, tauy, u0, udir, # rotate to horizontal computational grid and add to tau0 # ===================================================================== - # Compute separation bubble - if process_separation: - zsep = self.separation(c, mu_b, sep_filter_iterations, zsep_y_filter) - z_origin = gc['z'].copy() + if p['process_separation']: + z_origin = gc['z'][:].copy() + zsep = compute_separation(p, gc['z'], gc['dx']) gc['z'] = np.maximum(gc['z'], zsep) # Compute wind shear stresses on computational grid @@ -238,7 +235,7 @@ def __call__(self, x, y, z, taux, tauy, u0, udir, gc['taux'] = np.maximum(gc['taux'], 0.) # Compute the influence of the separation on the shear stress - if process_separation: + if p['process_separation']: gc['hsep'] = gc['z'] - z_origin self.separation_shear(gc['hsep']) @@ -264,7 +261,7 @@ def __call__(self, x, y, z, taux, tauy, u0, udir, gi['taux'] = self.interpolate(gc['x'], gc['y'], gc['taux'], gi['x'], gi['y'], taus0) gi['tauy'] = self.interpolate(gc['x'], gc['y'], gc['tauy'], gi['x'], gi['y'], taun0) - if process_separation: + if p['process_separation']: gi['hsep'] = self.interpolate(gc['x'], gc['y'], gc['hsep'], gi['x'], gi['y'], 0. ) # Final plots and lay-out @@ -368,158 +365,6 @@ def set_computational_grid(self, udir): return self - - - def separation(self, c, mu_b, sep_filter_iterations, zsep_y_filter): - - # Initialize grid and bed dimensions - gc = self.cgrid - - x = gc['x'] - y = gc['y'] - z = gc['z'] - - nx = len(gc['z'][1]) - ny = len(gc['z'][0]) - dx = gc['dx'] - dy = gc['dy'] - - # Initialize arrays - - dzx = np.zeros(gc['z'].shape) - - dzdx0 = np.zeros(gc['z'].shape) - dzdx1 = np.zeros(gc['z'].shape) - - stall = np.zeros(gc['z'].shape) - bubble = np.zeros(gc['z'].shape) - - k = np.array(range(0, nx)) - - zsep = np.zeros(z.shape) # total separation bubble - zsep_new = np.zeros(z.shape) # first-oder separation bubble surface - - zfft = np.zeros((ny,nx), dtype=complex) - - # Compute bed slope angle in x-dir - dzx[:,:-2] = np.rad2deg(np.arctan((z[:,2:]-z[:,:-2])/(2.*dx))) - dzx[:,-2] = dzx[:,-3] - dzx[:,-1] = dzx[:,-2] - - # Determine location of separation bubbles - '''Separation bubble exist if bed slope angle (lee side) - is larger than max angle that wind stream lines can - follow behind an obstacle (mu_b = ..)''' - - stall += np.logical_and(abs(dzx) > mu_b, dzx < 0.) - - stall[:,1:-1] += np.logical_and(stall[:,1:-1]==0, stall[:,:-2]>0., stall[:,2:]>0.) - - # Define separation bubble - bubble[:,:-1] = (stall[:,:-1] == 0.) * (stall[:,1:] > 0.) - - # Better solution for cleaner separation bubble, but no working Barchan dune (yet) - p = 1 - bubble[:,p:] = bubble[:,:-p] - bubble[:,-p:] = 0 - - bubble = bubble.astype(int) - - # Count separation bubbles - n = np.sum(bubble) - bubble_n = np.asarray(np.where(bubble == True)).T - - - # Walk through all separation bubbles and determine polynoms - j = 9999 - for k in range(0, n): - - i = bubble_n[k,1] - j = bubble_n[k,0] - - #Bart: check for negative wind direction - if np.sum(gc['taux']) >= 0: - idir = 1 - else: - idir = -1 - - ix_neg = (dzx[j, i+idir*5:] >= 0) # i + 5?? - - if np.sum(ix_neg) == 0: - zbrink = z[j,i] # z level of brink at z(x0) - else: - zbrink = z[j,i] - z[j,i+idir*5+idir*np.where(ix_neg)[0][0]] - - # Better solution and cleaner separation bubble, but no working Barchan dune (yet) - dzdx0 = (z[j,i] - z[j,i-3]) / (3.*dx) - - a = dzdx0 / c - ls = np.minimum(np.maximum((3.*zbrink/(2.*c) * (1. + a/4. + a**2/8.)), 0.1), 200.) - - a2 = -3 * zbrink/ls**2 - 2 * dzdx0 / ls - a3 = 2 * zbrink/ls**3 + dzdx0 / ls**2 - - i_max = min(i+int(ls/dx)+1,int(nx-1)) - - if idir == 1: - xs = x[j,i:i_max] - x[j,i] - else: - xs = -(x[j,i:i_max] - x[j,i]) - - zsep_new[j,i:i_max] = (a3*xs**3 + a2*xs**2 + dzdx0*xs + z[j,i]) - - # Choose maximum of bedlevel, previous zseps and new zseps - zsep[j,:] = np.maximum.reduce([z[j,:], zsep[j,:], zsep_new[j,:]]) - - for filter_iter in range(sep_filter_iterations): - - zsep_new = np.zeros(zsep.shape) - - Cut = 1.5 - dk = 2.0 * np.pi / (np.max(x)) - zfft[j,:] = np.fft.fft(zsep[j,:]) - zfft[j,:] *= np.exp(-(dk*k*dx)**2/(2.*Cut**2)) - zsep_fft = np.real(np.fft.ifft(zfft[j,:])) - - if np.sum(ix_neg) == 0: - zbrink = zsep_fft[i] - else: - zbrink = zsep_fft[i] - zsep_fft[i+idir*5+idir*np.where(ix_neg)[0][0]] - - # First order polynom - dzdx1 = (zsep_fft[i] - zsep_fft[i-3])/(3.*dx) - - a = dzdx1 / c - - ls = np.minimum(np.maximum((3.*zbrink/(2.*c) * (1. + a/4. + a**2/8.)), 0.1), 200.) - - - a2 = -3 * zbrink/ls**2 - 2 * dzdx1 / ls - a3 = 2 * zbrink/ls**3 + dzdx1 / ls**2 - - i_max1 = min(i+idir*int(ls/dx),int(nx-1)) - - if idir == 1: - xs1 = x[j,i:i_max1] - x[j,i] - else: - xs1 = -(x[j,i:i_max1] - x[j,i]) - - zsep_new[j, i:i_max1] = (a3*xs1**3 + a2*xs1**2 + dzdx1*xs1 + zbrink) - - # Pick the maximum seperation bubble hieght at all locations - zsep[j,:] = np.maximum.reduce([z[j,:], zsep[j,:], zsep_new[j,:]]) - - - # Smooth surface of separation bubbles over y direction - if zsep_y_filter: - zsep = ndimage.gaussian_filter1d(zsep, sigma=0.2, axis=0) - - #Correct for any seperation bubbles that are below the bed surface following smoothing - ilow = zsep < z - zsep[ilow] = z[ilow] - - return zsep - def compute_shear(self, u0, nfilter=(1., 2.)): '''Compute wind shear perturbation for given free-flow wind diff --git a/aeolis/wind.py b/aeolis/wind.py index 43ad78a2..a471e4f6 100644 --- a/aeolis/wind.py +++ b/aeolis/wind.py @@ -36,7 +36,7 @@ # package modules import aeolis.shear from aeolis.utils import * - +from aeolis.separation import compute_separation, set_sep_params # initialize logger logger = logging.getLogger(__name__) @@ -71,6 +71,11 @@ def initialize(s, p): z0 = calculate_z0(p, s) if p['process_shear']: + + # Get separation parameters based on Perturbation theory settings + if p['process_separation'] and p['sep_auto_tune']: + p = set_sep_params(p, s) + if p['ny'] > 0: if p['method_shear'] == 'fft': s['shear'] = aeolis.shear.WindShear(s['x'], s['y'], s['zb'], @@ -219,10 +224,11 @@ def calculate_z0(p, s): return z0 +# Compute shear velocity field (including separation) def shear(s,p): - # Compute shear velocity field (including separation) + # Quasi-2D shear model (DUNA (?) approach) if 'shear' in s.keys() and p['process_shear'] and p['ny'] > 0 and p['method_shear'] == 'duna2d': shear_params = {'alfa': 3, 'beta': 1, 'c': p['c_b'], 'mu_b': p['mu_b'], 'sep_filter_iterations': p['sep_filter_iterations'], 'zsep_y_filter': p['zsep_y_filter'], 'process_separation': p['process_separation'], 'tau_sep': 0.5, 'slope': 0.2, 'rhoa': p['rhoa'], 'shear_type': p['method_shear']} @@ -235,6 +241,7 @@ def shear(s,p): s = stress_velocity(s, p) + # Quasi-2D shear model (1D analytical over 2D grid) elif 'shear' in s.keys() and p['process_shear'] and p['ny'] > 0 and p['method_shear'] == 'quasi2d': z0 = calculate_z0(p, s) @@ -248,20 +255,16 @@ def shear(s,p): s['taun'] = - s['tau'] * np.cos((-p['alfa'] + s['udir']) / 180. * np.pi) s = stress_velocity(s, p) + # 2D shear model (FFT approach) elif 'shear' in s.keys() and p['process_shear'] and p['ny'] > 0: - s['shear'](x=s['x'], y=s['y'], z=s['zb'], - taux=s['taus'], tauy=s['taun'], - u0=s['uw'][0,0], udir=s['udir'][0,0], - process_separation = p['process_separation'], - c = p['c_b'], - mu_b = p['mu_b'], - taus0 = s['taus0'][0,0], taun0 = s['taun0'][0,0], - sep_filter_iterations=p['sep_filter_iterations'], - zsep_y_filter=p['zsep_y_filter']) + # Run shear model + s['shear'](p=p, x=s['x'], y=s['y'], z=s['zb'], u0=s['uw'][0,0], udir=s['udir'][0,0], + taux=s['taus'], tauy=s['taun'], taus0 = s['taus0'][0,0], taun0 = s['taun0'][0,0]) + + # Get shear stress (tau) from module, compute magnitude and transform to shear velocity s['taus'], s['taun'] = s['shear'].get_shear() s['tau'] = np.hypot(s['taus'], s['taun']) - s = stress_velocity(s,p) # Returns separation surface @@ -269,15 +272,22 @@ def shear(s,p): s['hsep'] = s['shear'].get_separation() s['zsep'] = s['hsep'] + s['zb'] - + # 1D shear model elif p['process_shear'] and p['ny'] == 0: #NTC - Added in 1D only capabilities s = compute_shear1d(s, p) s = stress_velocity(s, p) if p['process_separation']: - zsep = separation1d(s, p) - s['zsep'] = zsep + dx = s['ds'][0, 0] + z_bed = s['zb'][0, :] # 1D bed + udir = s['udir'][0, 0] # wind-aligned direction + + # Compute separation bubble + zsep = compute_separation(p, z_bed, dx, udir) + s['zsep'] = zsep[np.newaxis, :] # shape back to (1, nx) s['hsep'] = s['zsep'] - s['zb'] + + # Compute influence of searation bubble on shear tau_sep = 0.5 slope = 0.2 # according to Durán 2010 (Sauermann 2001: c = 0.25 for 14 degrees) delta = 1. / (slope * tau_sep) @@ -286,23 +296,6 @@ def shear(s,p): s['taun'] *= zsepdelta s = stress_velocity(s, p) - # if p['process_nelayer']: - # if p['th_nelayer']: - - # ustar = s['ustar'].copy() - # ustars = s['ustars'].copy() - # ustarn = s['ustarn'].copy() - - # s['zne'][:,:] = p['ne_file'] - - # ix = s['zb'] <= s['zne'] - # s['ustar'][ix] = np.maximum(0., s['ustar'][ix] - (s['zne'][ix]-s['zb'][ix])* (1/p['layer_thickness']) * s['ustar'][ix]) - - # ix = ustar != 0. - # s['ustars'][ix] = s['ustar'][ix] * (ustars[ix] / ustar[ix]) - # s['ustarn'][ix] = s['ustar'][ix] * (ustarn[ix] / ustar[ix]) - - return s def velocity_stress(s, p): From 74a223b4272eb15d94fff83cf89e1e171cbd5533 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Sun, 23 Nov 2025 20:11:17 -0800 Subject: [PATCH 04/23] Refactor model.py and sweep.py into advection.py - Moved all solver functions from model.py to a new advection.py - Moved the entire sweep function and its associated logic from utils.py. --- aeolis/advection.py | 2243 +++++++++++++++++++++++++++++++++++++++++++ aeolis/model.py | 1799 +--------------------------------- aeolis/utils.py | 425 +------- 3 files changed, 2263 insertions(+), 2204 deletions(-) create mode 100644 aeolis/advection.py diff --git a/aeolis/advection.py b/aeolis/advection.py new file mode 100644 index 00000000..7185d8b4 --- /dev/null +++ b/aeolis/advection.py @@ -0,0 +1,2243 @@ +'''This file is part of AeoLiS. + +AeoLiS is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +AeoLiS is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with AeoLiS. If not, see . + +AeoLiS Copyright (C) 2015 Bas Hoonhout + +bas.hoonhout@deltares.nl b.m.hoonhout@tudelft.nl +Deltares Delft University of Technology +Unit of Hydraulic Engineering Faculty of Civil Engineering and Geosciences +Boussinesqweg 1 Stevinweg 1 +2629 HVDelft 2628CN Delft +The Netherlands The Netherlands + +''' + +from __future__ import absolute_import, division + +import logging +import numpy as np +from matplotlib import pyplot as plt +import scipy.sparse.linalg +from numba import njit + +# import AeoLiS modules +import aeolis.transport +from aeolis.utils import prevent_tiny_negatives, format_log, rotate + +# initialize logger +logger = logging.getLogger(__name__) + +def solve_steadystate(self) -> dict: + '''Implements the steady state solution + ''' + # upwind scheme: + beta = 1. + + l = self.l + s = self.s + p = self.p + + Ct = s['Ct'].copy() + pickup = s['pickup'].copy() + + # compute transport weights for all sediment fractions + w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) + + if self.t == 0.: + # use initial guess for first time step + if p['grain_dist'] != None: + w = p['grain_dist'].reshape((1,1,-1)) + w = w.repeat(p['ny']+1, axis=0) + w = w.repeat(p['nx']+1, axis=1) + else: + w = w_init.copy() + else: + w = w_init.copy() + + # set model state properties that are added to warnings and errors + logprops = dict(minwind=s['uw'].min(), + maxdrop=(l['uw']-s['uw']).max(), + time=self.t, + dt=self.dt) + + nf = p['nfractions'] + + us = np.zeros((p['ny']+1,p['nx']+1)) + un = np.zeros((p['ny']+1,p['nx']+1)) + + us_plus = np.zeros((p['ny']+1,p['nx']+1)) + un_plus = np.zeros((p['ny']+1,p['nx']+1)) + + us_min = np.zeros((p['ny']+1,p['nx']+1)) + un_min = np.zeros((p['ny']+1,p['nx']+1)) + + Cs = np.zeros(us.shape) + Cn = np.zeros(un.shape) + + Cs_plus = np.zeros(us.shape) + Cn_plus = np.zeros(un.shape) + + Cs_min = np.zeros(us.shape) + Cn_min = np.zeros(un.shape) + + for i in range(nf): + us[:,:] = s['us'][:,:,i] + un[:,:] = s['un'][:,:,i] + + us_plus[:,1:] = s['us'][:,:-1,i] + un_plus[1:,:] = s['un'][:-1,:,i] + + us_min[:,:-1] = s['us'][:,1:,i] + un_min[:-1,:] = s['un'][1:,:,i] + + #boundary values + us[:,0] = s['us'][:,0,i] + un[0,:] = s['un'][0,:,i] + + us_plus[:,0] = s['us'][:,0,i] + un_plus[0,:] = s['un'][0,:,i] + + us_min[:,-1] = s['us'][:,-1,i] + un_min[-1,:] = s['un'][-1,:,i] + + + # define matrix coefficients to solve linear system of equations + Cs = s['dn'] * s['dsdni'] * us[:,:] + Cn = s['ds'] * s['dsdni'] * un[:,:] + + Cs_plus = s['dn'] * s['dsdni'] * us_plus[:,:] + Cn_plus = s['ds'] * s['dsdni'] * un_plus[:,:] + + Cs_min = s['dn'] * s['dsdni'] * us_min[:,:] + Cn_min = s['ds'] * s['dsdni'] * un_min[:,:] + + + Ti = 1 / p['T'] + + beta = abs(beta) + if beta >= 1.: + # define upwind direction + ixs = np.asarray(us[:,:] >= 0., dtype=float) + ixn = np.asarray(un[:,:] >= 0., dtype=float) + sgs = 2. * ixs - 1. + sgn = 2. * ixn - 1. + + else: + # or centralizing weights + ixs = beta + np.zeros(us) + ixn = beta + np.zeros(un) + sgs = np.zeros(us) + sgn = np.zeros(un) + + # initialize matrix diagonals + A0 = np.zeros(s['zb'].shape) + Apx = np.zeros(s['zb'].shape) + Ap1 = np.zeros(s['zb'].shape) + Ap2 = np.zeros(s['zb'].shape) + Amx = np.zeros(s['zb'].shape) + Am1 = np.zeros(s['zb'].shape) + Am2 = np.zeros(s['zb'].shape) + + # populate matrix diagonals + A0 = sgs * Cs + sgn * Cn + Ti + Apx = Cn_min * (1. - ixn) + Ap1 = Cs_min * (1. - ixs) + Amx = -Cn_plus * ixn + Am1 = -Cs_plus * ixs + + # add boundaries + A0[:,0] = 1. + Apx[:,0] = 0. + Amx[:,0] = 0. + Am2[:,0] = 0. + Am1[:,0] = 0. + + A0[:,-1] = 1. + Apx[:,-1] = 0. + Ap1[:,-1] = 0. + Ap2[:,-1] = 0. + Amx[:,-1] = 0. + + if p['boundary_offshore'] == 'flux': + Ap2[:,0] = 0. + Ap1[:,0] = 0. + elif p['boundary_offshore'] == 'constant': + Ap2[:,0] = 0. + Ap1[:,0] = 0. + elif p['boundary_offshore'] == 'uniform': + Ap2[:,0] = 0. + Ap1[:,0] = -1. + elif p['boundary_offshore'] == 'gradient': + Ap2[:,0] = s['ds'][:,1] / s['ds'][:,2] + Ap1[:,0] = -1. - s['ds'][:,1] / s['ds'][:,2] + elif p['boundary_offshore'] == 'circular': + logger.log_and_raise('Cross-shore cricular boundary condition not yet implemented', exc=NotImplementedError) + else: + logger.log_and_raise('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore'], exc=ValueError) + + if p['boundary_onshore'] == 'flux': + Am2[:,-1] = 0. + Am1[:,-1] = 0. + elif p['boundary_onshore'] == 'constant': + Am2[:,-1] = 0. + Am1[:,-1] = 0. + elif p['boundary_onshore'] == 'uniform': + Am2[:,-1] = 0. + Am1[:,-1] = -1. + elif p['boundary_onshore'] == 'gradient': + Am2[:,-1] = s['ds'][:,-2] / s['ds'][:,-3] + Am1[:,-1] = -1. - s['ds'][:,-2] / s['ds'][:,-3] + elif p['boundary_offshore'] == 'circular': + logger.log_and_raise('Cross-shore cricular boundary condition not yet implemented', exc=NotImplementedError) + else: + logger.log_and_raise('Unknown onshore boundary condition [%s]' % self.p['boundary_onshore'], exc=ValueError) + + if p['boundary_lateral'] == 'constant': + A0[0,:] = 1. + Apx[0,:] = 0. + Ap1[0,:] = 0. + Amx[0,:] = 0. + Am1[0,:] = 0. + + A0[-1,:] = 1. + Apx[-1,:] = 0. + Ap1[-1,:] = 0. + Amx[-1,:] = 0. + Am1[-1,:] = 0. + + #logger.log_and_raise('Lateral constant boundary condition not yet implemented', exc=NotImplementedError) + elif p['boundary_lateral'] == 'uniform': + logger.log_and_raise('Lateral uniform boundary condition not yet implemented', exc=NotImplementedError) + elif p['boundary_lateral'] == 'gradient': + logger.log_and_raise('Lateral gradient boundary condition not yet implemented', exc=NotImplementedError) + elif p['boundary_lateral'] == 'circular': + pass + else: + logger.log_and_raise('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral'], exc=ValueError) + + # construct sparse matrix + if p['ny'] > 0: + j = p['nx']+1 + A = scipy.sparse.diags((Apx.flatten()[:j], + Amx.flatten()[j:], + Am2.flatten()[2:], + Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1], + Ap2.flatten()[:-2], + Apx.flatten()[j:], + Amx.flatten()[:j]), + (-j*p['ny'],-j,-2,-1,0,1,2,j,j*p['ny']), format='csr') + else: + A = scipy.sparse.diags((Am2.flatten()[2:], + Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1], + Ap2.flatten()[:-2]), + (-2,-1,0,1,2), format='csr') + + # solve transport for each fraction separately using latest + # available weights + + # renormalize weights for all fractions equal or larger + # than the current one such that the sum of all weights is + # unity + w = aeolis.transport.renormalize_weights(w, i) + + # iteratively find a solution of the linear system that + # does not violate the availability of sediment in the bed + for n in range(p['max_iter']): + self._count('matrixsolve') + + # compute saturation levels + ix = s['Cu'] > 0. + S_i = np.zeros(s['Cu'].shape) + S_i[ix] = s['Ct'][ix] / s['Cu'][ix] + s['S'] = S_i.sum(axis=-1) + + # create the right hand side of the linear system + y_i = np.zeros(s['zb'].shape) + + y_i[:,1:-1] = ( + (w[:,1:-1,i] * s['Cuf'][:,1:-1,i] * Ti) * (1. - s['S'][:,1:-1]) + + (w[:,1:-1,i] * s['Cu'][:,1:-1,i] * Ti) * s['S'][:,1:-1] + ) + + # add boundaries + if p['boundary_offshore'] == 'flux': + y_i[:,0] = p['offshore_flux'] * s['Cu0'][:,0,i] + if p['boundary_onshore'] == 'flux': + y_i[:,-1] = p['onshore_flux'] * s['Cu0'][:,-1,i] + + if p['boundary_offshore'] == 'constant': + y_i[:,0] = p['constant_offshore_flux'] / s['u'][:,0,i] + if p['boundary_onshore'] == 'constant': + y_i[:,-1] = p['constant_onshore_flux'] / s['u'][:,-1,i] + + # solve system with current weights + Ct_i = scipy.sparse.linalg.spsolve(A, y_i.flatten()) + Ct_i = prevent_tiny_negatives(Ct_i, p['max_error']) + + # check for negative values + if Ct_i.min() < 0.: + ix = Ct_i < 0. + + logger.warning(format_log('Removing negative concentrations', + nrcells=np.sum(ix), + fraction=i, + iteration=n, + minvalue=Ct_i.min(), + coords=np.argwhere(ix.reshape(y_i.shape)), + **logprops)) + + Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() + Ct_i[ix] = 0. + + # determine pickup and deficit for current fraction + Cu_i = s['Cu'][:,:,i].flatten() + mass_i = s['mass'][:,:,0,i].flatten() + w_i = w[:,:,i].flatten() + pickup_i = (w_i * Cu_i - Ct_i) / p['T'] * self.dt + deficit_i = pickup_i - mass_i + ix = (deficit_i > p['max_error']) \ + & (w_i * Cu_i > 0.) + + # quit the iteration if there is no deficit, otherwise + # back-compute the maximum weight allowed to get zero + # deficit for the current fraction and progress to + # the next iteration step + if not np.any(ix): + logger.debug(format_log('Iteration converged', + steps=n, + fraction=i, + **logprops)) + pickup_i = np.minimum(pickup_i, mass_i) + break + else: + w_i[ix] = (mass_i[ix] * p['T'] / self.dt \ + + Ct_i[ix]) / Cu_i[ix] + w[:,:,i] = w_i.reshape(y_i.shape) + + # throw warning if the maximum number of iterations was reached + if np.any(ix): + logger.warning(format_log('Iteration not converged', + nrcells=np.sum(ix), + fraction=i, + **logprops)) + + # check for unexpected negative values + if Ct_i.min() < 0: + logger.warning(format_log('Negative concentrations', + nrcells=np.sum(Ct_i<0.), + fraction=i, + minvalue=Ct_i.min(), + **logprops)) + if w_i.min() < 0: + logger.warning(format_log('Negative weights', + nrcells=np.sum(w_i<0), + fraction=i, + minvalue=w_i.min(), + **logprops)) + + Ct[:,:,i] = Ct_i.reshape(y_i.shape) + pickup[:,:,i] = pickup_i.reshape(y_i.shape) + + # check if there are any cells where the sum of all weights is + # smaller than unity. these cells are supply-limited for all + # fractions. Log these events. + ix = 1. - np.sum(w, axis=2) > p['max_error'] + if np.any(ix): + self._count('supplylim') + logger.warning(format_log('Ran out of sediment', + nrcells=np.sum(ix), + minweight=np.sum(w, axis=-1).min(), + **logprops)) + + + qs = Ct * s['us'] + qn = Ct * s['un'] + q = np.hypot(qs, qn) + + + return dict(Ct=Ct, + qs=qs, + qn=qn, + pickup=pickup, + w=w, + w_init=w_init, + w_air=w_air, + w_bed=w_bed, + q=q) + + +def solve(self, alpha:float=.5, beta:float=1.) -> dict: + '''Implements the explicit Euler forward, implicit Euler backward and semi-implicit Crank-Nicolson numerical schemes + + Determines weights of sediment fractions, sediment pickup and + instantaneous sediment concentration. Returns a partial + spatial grid dictionary that can be used to update the global + spatial grid dictionary. + + Parameters + ---------- + alpha : + Implicitness coefficient (0.0 for Euler forward, 1.0 for Euler backward or 0.5 for Crank-Nicolson, default=0.5) + beta : + Centralization coefficient (1.0 for upwind or 0.5 for centralized, default=1.0) + + Returns + ------- + Partial spatial grid dictionary + + Examples + -------- + >>> model.s.update(model.solve(alpha=1., beta=1.) # euler backward + + >>> model.s.update(model.solve(alpha=.5, beta=1.) # crank-nicolson + + See Also + -------- + model.AeoLiS.euler_forward + model.AeoLiS.euler_backward + model.AeoLiS.crank_nicolson + transport.compute_weights + transport.renormalize_weights + + ''' + + l = self.l + s = self.s + p = self.p + + Ct = s['Ct'].copy() + pickup = s['pickup'].copy() + + # compute transport weights for all sediment fractions + w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) + + if self.t == 0.: + if type(p['bedcomp_file']) == np.ndarray: + w = w_init.copy() + else: + # use initial guess for first time step + w = p['grain_dist'].reshape((1,1,-1)) + w = w.repeat(p['ny']+1, axis=0) + w = w.repeat(p['nx']+1, axis=1) + else: + w = w_init.copy() + + # set model state properties that are added to warnings and errors + logprops = dict(minwind=s['uw'].min(), + maxdrop=(l['uw']-s['uw']).max(), + time=self.t, + dt=self.dt) + + nf = p['nfractions'] + + us = np.zeros((p['ny']+1,p['nx']+1)) + un = np.zeros((p['ny']+1,p['nx']+1)) + + us_plus = np.zeros((p['ny']+1,p['nx']+1)) + un_plus = np.zeros((p['ny']+1,p['nx']+1)) + + us_min = np.zeros((p['ny']+1,p['nx']+1)) + un_min = np.zeros((p['ny']+1,p['nx']+1)) + + Cs = np.zeros(us.shape) + Cn = np.zeros(un.shape) + + Cs_plus = np.zeros(us.shape) + Cn_plus = np.zeros(un.shape) + + Cs_min = np.zeros(us.shape) + Cn_min = np.zeros(un.shape) + + + for i in range(nf): + + us[:,:] = s['us'][:,:,i] + un[:,:] = s['un'][:,:,i] + + us_plus[:,1:] = s['us'][:,:-1,i] + un_plus[1:,:] = s['un'][:-1,:,i] + + us_min[:,:-1] = s['us'][:,1:,i] + un_min[:-1,:] = s['un'][1:,:,i] + + #boundary values + us_plus[:,0] = s['us'][:,0,i] + un_plus[0,:] = s['un'][0,:,i] + + us_min[:,-1] = s['us'][:,-1,i] + un_min[-1,:] = s['un'][-1,:,i] + + + # define matrix coefficients to solve linear system of equations + Cs = self.dt * s['dn'] * s['dsdni'] * us[:,:] + Cn = self.dt * s['ds'] * s['dsdni'] * un[:,:] + + Cs_plus = self.dt * s['dn'] * s['dsdni'] * us_plus[:,:] + Cn_plus = self.dt * s['ds'] * s['dsdni'] * un_plus[:,:] + + Cs_min = self.dt * s['dn'] * s['dsdni'] * us_min[:,:] + Cn_min = self.dt * s['ds'] * s['dsdni'] * un_min[:,:] + + Ti = self.dt / p['T'] + + + beta = abs(beta) + if beta >= 1.: + # define upwind direction + ixs = np.asarray(s['us'][:,:,i] >= 0., dtype=float) + ixn = np.asarray(s['un'][:,:,i] >= 0., dtype=float) + sgs = 2. * ixs - 1. + sgn = 2. * ixn - 1. + + else: + # or centralizing weights + ixs = beta + np.zeros(Cs.shape) + ixn = beta + np.zeros(Cn.shape) + sgs = np.zeros(Cs.shape) + sgn = np.zeros(Cn.shape) + + # initialize matrix diagonals + A0 = np.zeros(s['zb'].shape) + Apx = np.zeros(s['zb'].shape) + Ap1 = np.zeros(s['zb'].shape) + Ap2 = np.zeros(s['zb'].shape) + Amx = np.zeros(s['zb'].shape) + Am1 = np.zeros(s['zb'].shape) + Am2 = np.zeros(s['zb'].shape) + + # populate matrix diagonals + A0 = 1. + (sgs * Cs + sgn * Cn + Ti) * alpha + Apx = Cn_min * alpha * (1. - ixn) + Ap1 = Cs_min * alpha * (1. - ixs) + Amx = -Cn_plus * alpha * ixn + Am1 = -Cs_plus * alpha * ixs + + # add boundaries + A0[:,0] = 1. + Apx[:,0] = 0. + Amx[:,0] = 0. + Am2[:,0] = 0. + Am1[:,0] = 0. + + A0[:,-1] = 1. + Apx[:,-1] = 0. + Ap1[:,-1] = 0. + Ap2[:,-1] = 0. + Amx[:,-1] = 0. + + if (p['boundary_offshore'] == 'flux') | (p['boundary_offshore'] == 'noflux'): + Ap2[:,0] = 0. + Ap1[:,0] = 0. + elif p['boundary_offshore'] == 'constant': + Ap2[:,0] = 0. + Ap1[:,0] = 0. + elif p['boundary_offshore'] == 'uniform': + Ap2[:,0] = 0. + Ap1[:,0] = -1. + elif p['boundary_offshore'] == 'gradient': + Ap2[:,0] = s['ds'][:,1] / s['ds'][:,2] + Ap1[:,0] = -1. - s['ds'][:,1] / s['ds'][:,2] + elif p['boundary_offshore'] == 'circular': + logger.log_and_raise('Cross-shore cricular boundary condition not yet implemented', exc=NotImplementedError) + else: + logger.log_and_raise('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore'], exc=ValueError) + + if (p['boundary_onshore'] == 'flux') | (p['boundary_offshore'] == 'noflux'): + Am2[:,-1] = 0. + Am1[:,-1] = 0. + elif p['boundary_onshore'] == 'constant': + Am2[:,-1] = 0. + Am1[:,-1] = 0. + elif p['boundary_onshore'] == 'uniform': + Am2[:,-1] = 0. + Am1[:,-1] = -1. + elif p['boundary_onshore'] == 'gradient': + Am2[:,-1] = s['ds'][:,-2] / s['ds'][:,-3] + Am1[:,-1] = -1. - s['ds'][:,-2] / s['ds'][:,-3] + elif p['boundary_offshore'] == 'circular': + logger.log_and_raise('Cross-shore cricular boundary condition not yet implemented', exc=NotImplementedError) + else: + logger.log_and_raise('Unknown onshore boundary condition [%s]' % self.p['boundary_onshore'], exc=ValueError) + + if p['boundary_lateral'] == 'constant': + A0[0,:] = 1. + Apx[0,:] = 0. + Ap1[0,:] = 0. + Amx[0,:] = 0. + Am1[0,:] = 0. + + A0[-1,:] = 1. + Apx[-1,:] = 0. + Ap1[-1,:] = 0. + Amx[-1,:] = 0. + Am1[-1,:] = 0. + + #logger.log_and_raise('Lateral constant boundary condition not yet implemented', exc=NotImplementedError) + elif p['boundary_lateral'] == 'uniform': + logger.log_and_raise('Lateral uniform boundary condition not yet implemented', exc=NotImplementedError) + elif p['boundary_lateral'] == 'gradient': + logger.log_and_raise('Lateral gradient boundary condition not yet implemented', exc=NotImplementedError) + elif p['boundary_lateral'] == 'circular': + pass + else: + logger.log_and_raise('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral'], exc=ValueError) + + # construct sparse matrix + if p['ny'] > 0: + j = p['nx']+1 + A = scipy.sparse.diags((Apx.flatten()[:j], + Amx.flatten()[j:], + Am2.flatten()[2:], + Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1], + Ap2.flatten()[:-2], + Apx.flatten()[j:], + Amx.flatten()[:j]), + (-j*p['ny'],-j,-2,-1,0,1,2,j,j*p['ny']), format='csr') + else: + A = scipy.sparse.diags((Am2.flatten()[2:], + Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1], + Ap2.flatten()[:-2]), + (-2,-1,0,1,2), format='csr') + + # solve transport for each fraction separately using latest + # available weights + + # renormalize weights for all fractions equal or larger + # than the current one such that the sum of all weights is + # unity + # Christa: seems to have no significant effect on weights, + # numerical check to prevent any deviation from unity + w = aeolis.transport.renormalize_weights(w, i) + + # iteratively find a solution of the linear system that + # does not violate the availability of sediment in the bed + for n in range(p['max_iter']): + self._count('matrixsolve') + + # compute saturation levels + ix = s['Cu'] > 0. + S_i = np.zeros(s['Cu'].shape) + S_i[ix] = s['Ct'][ix] / s['Cu'][ix] + s['S'] = S_i.sum(axis=-1) + + # create the right hand side of the linear system + y_i = np.zeros(s['zb'].shape) + y_im = np.zeros(s['zb'].shape) # implicit terms + y_ex = np.zeros(s['zb'].shape) # explicit terms + + y_im[:,1:-1] = ( + (w[:,1:-1,i] * s['Cuf'][:,1:-1,i] * Ti) * (1. - s['S'][:,1:-1]) + + (w[:,1:-1,i] * s['Cu'][:,1:-1,i] * Ti) * s['S'][:,1:-1] + ) + + y_ex[:,1:-1] = ( + (l['w'][:,1:-1,i] * l['Cuf'][:,1:-1,i] * Ti) * (1. - s['S'][:,1:-1]) \ + + (l['w'][:,1:-1,i] * l['Cu'][:,1:-1,i] * Ti) * s['S'][:,1:-1] \ + - ( + sgs[:,1:-1] * Cs[:,1:-1] +\ + sgn[:,1:-1] * Cn[:,1:-1] + Ti + ) * l['Ct'][:,1:-1,i] \ + + ixs[:,1:-1] * Cs_plus[:,1:-1] * l['Ct'][:,:-2,i] \ + - (1. - ixs[:,1:-1]) * Cs_min[:,1:-1] * l['Ct'][:,2:,i] \ + + ixn[:,1:-1] * Cn_plus[:,1:-1] * np.roll(l['Ct'][:,1:-1,i], 1, axis=0) \ + - (1. - ixn[:,1:-1]) * Cn_min[:,1:-1] * np.roll(l['Ct'][:,1:-1,i], -1, axis=0) \ + ) + + y_i[:,1:-1] = l['Ct'][:,1:-1,i] + alpha * y_im[:,1:-1] + (1. - alpha) * y_ex[:,1:-1] + + # add boundaries + if p['boundary_offshore'] == 'flux': + y_i[:,0] = p['offshore_flux'] * s['Cu0'][:,0,i] + if p['boundary_onshore'] == 'flux': + y_i[:,-1] = p['onshore_flux'] * s['Cu0'][:,-1,i] + + if p['boundary_offshore'] == 'constant': + y_i[:,0] = p['constant_offshore_flux'] / s['u'][:,0,i] + if p['boundary_onshore'] == 'constant': + y_i[:,-1] = p['constant_onshore_flux'] / s['u'][:,-1,i] + + # solve system with current weights + Ct_i = scipy.sparse.linalg.spsolve(A, y_i.flatten()) + Ct_i = prevent_tiny_negatives(Ct_i, p['max_error']) + + # check for negative values + if Ct_i.min() < 0.: + ix = Ct_i < 0. + + logger.warning(format_log('Removing negative concentrations', + nrcells=np.sum(ix), + fraction=i, + iteration=n, + minvalue=Ct_i.min(), + coords=np.argwhere(ix.reshape(y_i.shape)), + **logprops)) + + if Ct_i[~ix].sum() != 0: + Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() + else: + Ct_i[~ix] = 0 + + #Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() + Ct_i[ix] = 0. + + # determine pickup and deficit for current fraction + Cu_i = s['Cu'][:,:,i].flatten() + mass_i = s['mass'][:,:,0,i].flatten() + w_i = w[:,:,i].flatten() + pickup_i = (w_i * Cu_i - Ct_i) / p['T'] * self.dt + deficit_i = pickup_i - mass_i + ix = (deficit_i > p['max_error']) \ + & (w_i * Cu_i > 0.) + + # quit the iteration if there is no deficit, otherwise + # back-compute the maximum weight allowed to get zero + # deficit for the current fraction and progress to + # the next iteration step + if not np.any(ix): + logger.debug(format_log('Iteration converged', + steps=n, + fraction=i, + **logprops)) + pickup_i = np.minimum(pickup_i, mass_i) + break + else: + w_i[ix] = (mass_i[ix] * p['T'] / self.dt \ + + Ct_i[ix]) / Cu_i[ix] + w[:,:,i] = w_i.reshape(y_i.shape) + + # throw warning if the maximum number of iterations was reached + if np.any(ix): + logger.warning(format_log('Iteration not converged', + nrcells=np.sum(ix), + fraction=i, + **logprops)) + + # check for unexpected negative values + if Ct_i.min() < 0: + logger.warning(format_log('Negative concentrations', + nrcells=np.sum(Ct_i<0.), + fraction=i, + minvalue=Ct_i.min(), + **logprops)) + if w_i.min() < 0: + logger.warning(format_log('Negative weights', + nrcells=np.sum(w_i<0), + fraction=i, + minvalue=w_i.min(), + **logprops)) + + Ct[:,:,i] = Ct_i.reshape(y_i.shape) + pickup[:,:,i] = pickup_i.reshape(y_i.shape) + + # check if there are any cells where the sum of all weights is + # smaller than unity. these cells are supply-limited for all + # fractions. Log these events. + ix = 1. - np.sum(w, axis=2) > p['max_error'] + if np.any(ix): + self._count('supplylim') + # logger.warning(format_log('Ran out of sediment', + # nrcells=np.sum(ix), + # minweight=np.sum(w, axis=-1).min(), + # **logprops)) + + qs = Ct * s['us'] + qn = Ct * s['un'] + + return dict(Ct=Ct, + qs=qs, + qn=qn, + pickup=pickup, + w=w, + w_init=w_init, + w_air=w_air, + w_bed=w_bed) + +#@njit +def solve_EF(self, alpha:float=0., beta:float=1.) -> dict: + '''Implements the explicit Euler forward, implicit Euler backward and semi-implicit Crank-Nicolson numerical schemes + + Determines weights of sediment fractions, sediment pickup and + instantaneous sediment concentration. Returns a partial + spatial grid dictionary that can be used to update the global + spatial grid dictionary. + + Parameters + ---------- + alpha : + Implicitness coefficient (0.0 for Euler forward, 1.0 for Euler backward or 0.5 for Crank-Nicolson, default=0.5) + beta : + Centralization coefficient (1.0 for upwind or 0.5 for centralized, default=1.0) + + Returns + ------- + Partial spatial grid dictionary + + Examples + -------- + >>> model.s.update(model.solve(alpha=1., beta=1.) # euler backward + + >>> model.s.update(model.solve(alpha=.5, beta=1.) # crank-nicolson + + See Also + -------- + model.AeoLiS.euler_forward + model.AeoLiS.euler_backward + model.AeoLiS.crank_nicolson + transport.compute_weights + transport.renormalize_weights + + ''' + + l = self.l + s = self.s + p = self.p + + Ct = s['Ct'].copy() + pickup = s['pickup'].copy() + Ts = p['T'] + + # compute transport weights for all sediment fractions + w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) + + if self.t == 0.: + if type(p['bedcomp_file']) == np.ndarray: + w = w_init.copy() + else: + # use initial guess for first time step + w = p['grain_dist'].reshape((1,1,-1)) + w = w.repeat(p['ny']+1, axis=0) + w = w.repeat(p['nx']+1, axis=1) + else: + w = w_init.copy() + + # set model state properties that are added to warnings and errors + logprops = dict(minwind=s['uw'].min(), + maxdrop=(l['uw']-s['uw']).max(), + time=self.t, + dt=self.dt) + + nf = p['nfractions'] + + + for i in range(nf): + + if 1: + #define 4 quadrants based on wind directions + ix1 = ((s['us'][:,:,0]>=0) & (s['un'][:,:,0]>=0)) + ix2 = ((s['us'][:,:,0]<0) & (s['un'][:,:,0]>=0)) + ix3 = ((s['us'][:,:,0]<0) & (s['un'][:,:,0]<0)) + ix4 = ((s['us'][:,:,0]>0) & (s['un'][:,:,0]<0)) + + # initiate solution matrix including ghost cells to accomodate boundaries + Ct_s = np.zeros((Ct.shape[0]+2,Ct.shape[1]+2)) + # populate solution matrix with previous concentration results + Ct_s[1:-1,1:-1] = Ct[:,:,i] + + #set upwind boundary condition + Ct_s[:,0:2]=0 + #circular boundary condition in lateral directions + Ct_s[0,:]=Ct_s[-2,:] + Ct_s[-1,:]=Ct_s[1,:] + # using the Euler forward scheme we can calculate pickup first based on the previous timestep + # there is no need for iteration + pickup[:,:,i] = self.dt*(np.minimum(s['Cu'][:,:,i],s['mass'][:,:,0,i]+Ct[:,:,i])-Ct[:,:,i])/Ts + + #solve for all 4 quadrants in one step using logical indexing + Ct_s[1:-1,1:-1] = Ct_s[1:-1,1:-1] + \ + ix1*(-self.dt*s['us'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[1:-1,:-2])/s['ds'] \ + -self.dt*s['un'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[:-2,1:-1])/s['dn']) +\ + ix2*(+self.dt*s['us'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[1:-1,2:])/s['ds'] \ + -self.dt*s['un'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[:-2,1:-1])/s['dn']) +\ + ix3*(+self.dt*s['us'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[1:-1,2:])/s['ds'] \ + +self.dt*s['un'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[2:,1:-1])/s['dn']) +\ + ix4*(-self.dt*s['us'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[1:-1,:-2])/s['ds'] \ + +self.dt*s['un'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[2:,1:-1])/s['dn']) \ + + pickup[:,:,i] + + # define Ct as a subset of Ct_s (eliminating the boundaries) + Ct[:,:,i] = Ct_s[1:-1,1:-1] + + qs = Ct * s['us'] + qn = Ct * s['un'] + + return dict(Ct=Ct, + qs=qs, + qn=qn, + pickup=pickup, + w=w, + w_init=w_init, + w_air=w_air, + w_bed=w_bed) + +#@njit +def solve_SS(self, alpha:float=0., beta:float=1.) -> dict: + '''Implements the explicit Euler forward, implicit Euler backward and semi-implicit Crank-Nicolson numerical schemes + + Determines weights of sediment fractions, sediment pickup and + instantaneous sediment concentration. Returns a partial + spatial grid dictionary that can be used to update the global + spatial grid dictionary. + + Parameters + ---------- + alpha : + Implicitness coefficient (0.0 for Euler forward, 1.0 for Euler backward or 0.5 for Crank-Nicolson, default=0.5) + beta : + Centralization coefficient (1.0 for upwind or 0.5 for centralized, default=1.0) + + Returns + ------- + Partial spatial grid dictionary + + Examples + -------- + >>> model.s.update(model.solve(alpha=1., beta=1.) # euler backward + + >>> model.s.update(model.solve(alpha=.5, beta=1.) # crank-nicolson + + See Also + -------- + model.AeoLiS.euler_forward + model.AeoLiS.euler_backward + model.AeoLiS.crank_nicolson + transport.compute_weights + transport.renormalize_weights + + ''' + + l = self.l + s = self.s + p = self.p + + Ct = s['Ct'].copy() + pickup = s['pickup'].copy() + Ts = p['T'] + + # compute transport weights for all sediment fractions + w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) + + if self.t == 0.: + if type(p['bedcomp_file']) == np.ndarray: + w = w_init.copy() + else: + # use initial guess for first time step + # when p['grain_dist'] has 2 dimensions take the first row otherwise take the only row + if len(p['grain_dist'].shape) == 2: + w = p['grain_dist'][0,:].reshape((1,1,-1)) + else: + w = p['grain_dist'].reshape((1,1,-1)) + + w = w.repeat(p['ny']+1, axis=0) + w = w.repeat(p['nx']+1, axis=1) + else: + w = w_init.copy() + + # set model state properties that are added to warnings and errors + logprops = dict(minwind=s['uw'].min(), + maxdrop=(l['uw']-s['uw']).max(), + time=self.t, + dt=self.dt) + + nf = p['nfractions'] + + + for i in range(nf): + + + if 1: + #print('sweep') + + # initiate emmpty solution matrix, this will effectively kill time dependence and create steady state. + Ct = np.zeros(Ct.shape) + + if p['boundary_offshore'] == 'flux': + Ct[:,0,0] = s['Cu0'][:,0,0] + + if p['boundary_onshore'] == 'flux': + Ct[:,-1,0] = s['Cu0'][:,-1,0] + + if p['boundary_offshore'] == 'circular': + Ct[:,0,0] = -1 + Ct[:,-1,0] = -1 + + if p['boundary_offshore'] == 're_circular': + Ct[:,0,0] = -2 + Ct[:,-1,0] = -2 + + if p['boundary_lateral'] == 'circular': + Ct[0,:,0] = -1 + Ct[-1,:,0] = -1 + + if p['boundary_lateral'] == 're_circular': + Ct[0,:,0] = -2 + Ct[-1,:,0] = -2 + + Ct, pickup = sweep(Ct, s['Cu'].copy(), s['mass'].copy(), self.dt, p['T'], s['ds'], s['dn'], s['us'], s['un'],w) + + qs = Ct * s['us'] + qn = Ct * s['un'] + q = np.hypot(qs, qn) + + + return dict(Ct=Ct, + qs=qs, + qn=qn, + pickup=pickup, + w=w, + w_init=w_init, + w_air=w_air, + w_bed=w_bed, + q=q) + + +def solve_steadystatepieter(self) -> dict: + + beta = 1. + + l = self.l + s = self.s + p = self.p + + Ct = s['Ct'].copy() + qs = s['qs'].copy() + qn = s['qn'].copy() + pickup = s['pickup'].copy() + + Ts = p['T'] + + # compute transport weights for all sediment fractions + w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) + + if self.t == 0.: + # use initial guess for first time step + w = p['grain_dist'].reshape((1,1,-1)) + w = w.repeat(p['ny']+1, axis=0) + w = w.repeat(p['nx']+1, axis=1) + return dict(w=w) + else: + w = w_init.copy() + + # set model state properties that are added to warnings and errors + logprops = dict(minwind=s['uw'].min(), + maxdrop=(l['uw']-s['uw']).max(), + time=self.t, + dt=self.dt) + + nf = p['nfractions'] + + ufs = np.zeros((p['ny']+1,p['nx']+2)) + ufn = np.zeros((p['ny']+2,p['nx']+1)) + + for i in range(nf): #loop over fractions + + #define velocity fluxes + + ufs[:,1:-1] = 0.5*s['us'][:,:-1,i] + 0.5*s['us'][:,1:,i] + ufn[1:-1,:] = 0.5*s['un'][:-1,:,i] + 0.5*s['un'][1:,:,i] + + #boundary values + ufs[:,0] = s['us'][:,0,i] + ufs[:,-1] = s['us'][:,-1,i] + + if p['boundary_lateral'] == 'circular': + ufn[0,:] = 0.5*s['un'][0,:,i] + 0.5*s['un'][-1,:,i] + ufn[-1,:] = ufn[0,:] + else: + ufn[0,:] = s['un'][0,:,i] + ufn[-1,:] = s['un'][-1,:,i] + + beta = abs(beta) + if beta >= 1.: + # define upwind direction + ixfs = np.asarray(ufs >= 0., dtype=float) + ixfn = np.asarray(ufn >= 0., dtype=float) + else: + # or centralizing weights + ixfs = beta + np.zeros(ufs) + ixfn = beta + np.zeros(ufn) + + # initialize matrix diagonals + A0 = np.zeros(s['zb'].shape) + Apx = np.zeros(s['zb'].shape) + Ap1 = np.zeros(s['zb'].shape) + Amx = np.zeros(s['zb'].shape) + Am1 = np.zeros(s['zb'].shape) + + # populate matrix diagonals + #A0 += s['dsdn'] / self.dt #time derivative + A0 += s['dsdn'] / Ts #source term + A0[:,1:] -= s['dn'][:,1:] * ufs[:,1:-1] * (1. - ixfs[:,1:-1]) #lower x-face + Am1[:,1:] -= s['dn'][:,1:] * ufs[:,1:-1] * ixfs[:,1:-1] #lower x-face + A0[:,:-1] += s['dn'][:,:-1] * ufs[:,1:-1] * ixfs[:,1:-1] #upper x-face + Ap1[:,:-1] += s['dn'][:,:-1] * ufs[:,1:-1] * (1. - ixfs[:,1:-1]) #upper x-face + A0[1:,:] -= s['ds'][1:,:] * ufn[1:-1,:] * (1. - ixfn[1:-1,:]) #lower y-face + Amx[1:,:] -= s['ds'][1:,:] * ufn[1:-1,:] * ixfn[1:-1,:] #lower y-face + A0[:-1,:] += s['ds'][:-1,:] * ufn[1:-1,:] * ixfn[1:-1,:] #upper y-face + Apx[:-1,:] += s['ds'][:-1,:] * ufn[1:-1,:] * (1. - ixfn[1:-1,:]) #upper y-face + + # add boundaries + # offshore boundary (i=0) + + if p['boundary_offshore'] == 'flux': + #nothing to be done + pass + elif p['boundary_offshore'] == 'constant': + #constant sediment concentration (Ct) in the air + A0[:,0] = 1. + Apx[:,0] = 0. + Amx[:,0] = 0. + Ap1[:,0] = 0. + Am1[:,0] = 0. + elif p['boundary_offshore'] == 'gradient': + #remove the flux at the inner face of the cell + A0[:,0] -= s['dn'][:,0] * ufs[:,1] * ixfs[:,1] #upper x-face + Ap1[:,0] -= s['dn'][:,0] * ufs[:,1] * (1. - ixfs[:,1]) #upper x-face + elif p['boundary_offshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore']) + + #onshore boundary (i=nx) + + if p['boundary_onshore'] == 'flux': + #nothing to be done + pass + elif p['boundary_onshore'] == 'constant': + #constant sediment concentration (hC) in the air + A0[:,-1] = 1. + Apx[:,-1] = 0. + Amx[:,-1] = 0. + Ap1[:,-1] = 0. + Am1[:,-1] = 0. + elif p['boundary_onshore'] == 'gradient': + #remove the flux at the inner face of the cell + A0[:,-1] += s['dn'][:,-1] * ufs[:,-2] * (1. - ixfs[:,-2]) #lower x-face + Am1[:,-1] += s['dn'][:,-1] * ufs[:,-2] * ixfs[:,-2] #lower x-face + elif p['boundary_onshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_onshore']) + + #lateral boundaries (j=0; j=ny) + + if p['boundary_lateral'] == 'flux': + #nothing to be done + pass + elif p['boundary_lateral'] == 'constant': + #constant sediment concentration (hC) in the air + A0[0,:] = 1. + Apx[0,:] = 0. + Amx[0,:] = 0. + Ap1[0,:] = 0. + Am1[0,:] = 0. + A0[-1,:] = 1. + Apx[-1,:] = 0. + Amx[-1,:] = 0. + Ap1[-1,:] = 0. + Am1[-1,:] = 0. + elif p['boundary_lateral'] == 'gradient': + #remove the flux at the inner face of the cell + A0[0,:] -= s['ds'][0,:] * ufn[1,:] * ixfn[1,:] #upper y-face + Apx[0,:] -= s['ds'][0,:] * ufn[1,:] * (1. - ixfn[1,:]) #upper y-face + A0[-1,:] += s['ds'][-1,:] * ufn[-2,:] * (1. - ixfn[-2,:]) #lower y-face + Amx[-1,:] += s['ds'][-1,:] * ufn[-2,:] * ixfn[-2,:] #lower y-face + elif p['boundary_lateral'] == 'circular': + A0[0,:] -= s['ds'][0,:] * ufn[0,:] * (1. - ixfn[0,:]) #lower y-face + Amx[0,:] -= s['ds'][0,:] * ufn[0,:] * ixfn[0,:] #lower y-face + A0[-1,:] += s['ds'][-1,:] * ufn[-1,:] * ixfn[-1,:] #upper y-face + Apx[-1,:] += s['ds'][-1,:] * ufn[-1,:] * (1. - ixfn[-1,:]) #upper y-face + else: + raise ValueError('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral']) + + # construct sparse matrix + if p['ny'] > 0: + j = p['nx']+1 + A = scipy.sparse.diags((Apx.flatten()[:j], + Amx.flatten()[j:], + Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1], + Apx.flatten()[j:], + Amx.flatten()[:j]), + (-j*p['ny'],-j,-1,0,1,j,j*p['ny']), format='csr') + else: + j = p['nx']+1 + ny = 0 + A = scipy.sparse.diags((Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1]), + (-1, 0, 1), format='csr') + + # solve transport for each fraction separately using latest + # available weights + + + # renormalize weights for all fractions equal or larger + # than the current one such that the sum of all weights is + # unity + w = aeolis.transport.renormalize_weights(w, i) + + # iteratively find a solution of the linear system that + # does not violate the availability of sediment in the bed + for n in range(p['max_iter']): + self._count('matrixsolve') + + # define upwind face value + # sediment concentration + Ctxfs_i = np.zeros(ufs.shape) + Ctxfn_i = np.zeros(ufn.shape) + + Ctxfs_i[:,1:-1] = ixfs[:,1:-1] * Ct[:,:-1,i] \ + + (1. - ixfs[:,1:-1]) * Ct[:,1:,i] + Ctxfn_i[1:-1,:] = ixfn[1:-1,:] * Ct[:-1,:,i] \ + + (1. - ixfn[1:-1,:]) * Ct[1:,:,i] + + if p['boundary_lateral'] == 'circular': + Ctxfn_i[0,:] = ixfn[0,:] * Ct[-1,:,i] \ + + (1. - ixfn[0,:]) * Ct[0,:,i] + + # calculate pickup + D_i = s['dsdn'] / Ts * Ct[:,:,i] + A_i = s['dsdn'] / Ts * s['mass'][:,:,0,i] + D_i # Availability + U_i = s['dsdn'] / Ts * w[:,:,i] * s['Cu'][:,:,i] + + #deficit_i = E_i - A_i + E_i= np.minimum(U_i, A_i) + #pickup_i = E_i - D_i + + # create the right hand side of the linear system + # sediment concentration + yCt_i = np.zeros(s['zb'].shape) + + yCt_i += E_i - D_i #source term + yCt_i[:,1:] += s['dn'][:,1:] * ufs[:,1:-1] * Ctxfs_i[:,1:-1] #lower x-face + yCt_i[:,:-1] -= s['dn'][:,:-1] * ufs[:,1:-1] * Ctxfs_i[:,1:-1] #upper x-face + yCt_i[1:,:] += s['ds'][1:,:] * ufn[1:-1,:] * Ctxfn_i[1:-1,:] #lower y-face + yCt_i[:-1,:] -= s['ds'][:-1,:] * ufn[1:-1,:] * Ctxfn_i[1:-1,:] #upper y-face + + # boundary conditions + # offshore boundary (i=0) + + if p['boundary_offshore'] == 'flux': + yCt_i[:,0] += s['dn'][:,0] * ufs[:,0] * s['Cu0'][:,0,i] * p['offshore_flux'] + elif p['boundary_offshore'] == 'constant': + #constant sediment concentration (Ct) in the air + yCt_i[:,0] = p['constant_offshore_flux'] + + elif p['boundary_offshore'] == 'gradient': + #remove the flux at the inner face of the cell + yCt_i[:,0] += s['dn'][:,1] * ufs[:,1] * Ctxfs_i[:,1] + + elif p['boundary_offshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore']) + + # onshore boundary (i=nx) + + if p['boundary_onshore'] == 'flux': + yCt_i[:,-1] += s['dn'][:,-1] * ufs[:,-1] * s['Cu0'][:,-1,i] * p['onshore_flux'] + + elif p['boundary_onshore'] == 'constant': + #constant sediment concentration (Ct) in the air + yCt_i[:,-1] = p['constant_onshore_flux'] + + elif p['boundary_onshore'] == 'gradient': + #remove the flux at the inner face of the cell + yCt_i[:,-1] -= s['dn'][:,-2] * ufs[:,-2] * Ctxfs_i[:,-2] + + elif p['boundary_onshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown onshore boundary condition [%s]' % self.p['boundary_onshore']) + + #lateral boundaries (j=0; j=ny) + + if p['boundary_lateral'] == 'flux': + + yCt_i[0,:] += s['ds'][0,:] * ufn[0,:] * s['Cu0'][0,:,i] * p['lateral_flux'] #lower y-face + yCt_i[-1,:] -= s['ds'][-1,:] * ufn[-1,:] * s['Cu0'][-1,:,i] * p['lateral_flux'] #upper y-face + elif p['boundary_lateral'] == 'constant': + #constant sediment concentration (hC) in the air + yCt_i[0,:] = 0. + yCt_i[-1,:] = 0. + elif p['boundary_lateral'] == 'gradient': + #remove the flux at the inner face of the cell + yCt_i[-1,:] -= s['ds'][-2,:] * ufn[-2,:] * Ctxfn_i[-2,:] #lower y-face + yCt_i[0,:] += s['ds'][1,:] * ufn[1,:] * Ctxfn_i[1,:] #upper y-face + elif p['boundary_lateral'] == 'circular': + yCt_i[0,:] += s['ds'][0,:] * ufn[0,:] * Ctxfn_i[0,:] #lower y-face + yCt_i[-1,:] -= s['ds'][-1,:] * ufn[-1,:] * Ctxfn_i[-1,:] #upper y-face + else: + raise ValueError('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral']) + + # print("ugs = %.*g" % (3,s['ugs'][10,10])) + # print("ugn = %.*g" % (3,s['ugn'][10,10])) + # print("%.*g" % (3,np.amax(np.absolute(y_i)))) + + # solve system with current weights + Ct_i = Ct[:,:,i].flatten() + Ct_i += scipy.sparse.linalg.spsolve(A, yCt_i.flatten()) + Ct_i = prevent_tiny_negatives(Ct_i, p['max_error']) + + # check for negative values + if Ct_i.min() < 0.: + ix = Ct_i < 0. + +# logger.warn(format_log('Removing negative concentrations', +# nrcells=np.sum(ix), +# fraction=i, +# iteration=n, +# minvalue=Ct_i.min(), +# **logprops)) + + Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() + Ct_i[ix] = 0. + + # determine pickup and deficit for current fraction + Cu_i = s['Cu'][:,:,i].flatten() + mass_i = s['mass'][:,:,0,i].flatten() + w_i = w[:,:,i].flatten() + Ts_i = Ts + + pickup_i = (w_i * Cu_i - Ct_i) / Ts_i * self.dt # Dit klopt niet! enkel geldig bij backward euler + deficit_i = pickup_i - mass_i + ix = (deficit_i > p['max_error']) \ + & (w_i * Cu_i > 0.) + + pickup[:,:,i] = pickup_i.reshape(yCt_i.shape) + Ct[:,:,i] = Ct_i.reshape(yCt_i.shape) + + # quit the iteration if there is no deficit, otherwise + # back-compute the maximum weight allowed to get zero + # deficit for the current fraction and progress to + # the next iteration step + if not np.any(ix): + logger.debug(format_log('Iteration converged', + steps=n, + fraction=i, + **logprops)) + pickup_i = np.minimum(pickup_i, mass_i) + break + else: + w_i[ix] = (mass_i[ix] * Ts_i / self.dt \ + + Ct_i[ix]) / Cu_i[ix] + w[:,:,i] = w_i.reshape(yCt_i.shape) + + # throw warning if the maximum number of iterations was + # reached + if np.any(ix): + logger.warn(format_log('Iteration not converged', + nrcells=np.sum(ix), + fraction=i, + **logprops)) + + # check for unexpected negative values + if Ct_i.min() < 0: + logger.warn(format_log('Negative concentrations', + nrcells=np.sum(Ct_i<0.), + fraction=i, + minvalue=Ct_i.min(), + **logprops)) + if w_i.min() < 0: + logger.warn(format_log('Negative weights', + nrcells=np.sum(w_i<0), + fraction=i, + minvalue=w_i.min(), + **logprops)) + # end loop over frations + + # check if there are any cells where the sum of all weights is + # smaller than unity. these cells are supply-limited for all + # fractions. Log these events. + ix = 1. - np.sum(w, axis=2) > p['max_error'] + if np.any(ix): + self._count('supplylim') +# logger.warn(format_log('Ran out of sediment', +# nrcells=np.sum(ix), +# minweight=np.sum(w, axis=-1).min(), +# **logprops)) + qs = Ct * s['us'] + qn = Ct * s['un'] + + return dict(Ct=Ct, + qs=qs, + qn=qn, + pickup=pickup, + w=w, + w_init=w_init, + w_air=w_air, + w_bed=w_bed) + + +def solve_pieter(self, alpha:float=.5, beta:float=1.) -> dict: + '''Implements the explicit Euler forward, implicit Euler backward and semi-implicit Crank-Nicolson numerical schemes + + Determines weights of sediment fractions, sediment pickup and + instantaneous sediment concentration. Returns a partial + spatial grid dictionary that can be used to update the global + spatial grid dictionary. + + Parameters + ---------- + alpha : + Implicitness coefficient (0.0 for Euler forward, 1.0 for Euler backward or 0.5 for Crank-Nicolson, default=0.5) + beta : float, optional + Centralization coefficient (1.0 for upwind or 0.5 for centralized, default=1.0) + + Returns + ------- + Partial spatial grid dictionary + + Examples + -------- + >>> model.s.update(model.solve(alpha=1., beta=1.) # euler backward + + >>> model.s.update(model.solve(alpha=.5, beta=1.) # crank-nicolson + + See Also + -------- + model.AeoLiS.euler_forward + model.AeoLiS.euler_backward + model.AeoLiS.crank_nicolson + transport.compute_weights + transport.renormalize_weights + ''' + + l = self.l + s = self.s + p = self.p + + Ct = s['Ct'].copy() + qs = s['qs'].copy() + qn = s['qn'].copy() + pickup = s['pickup'].copy() + + Ts = p['T'] + + # compute transport weights for all sediment fractions + w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) + + if self.t == 0.: + # use initial guess for first time step + w = p['grain_dist'].reshape((1,1,-1)) + w = w.repeat(p['ny']+1, axis=0) + w = w.repeat(p['nx']+1, axis=1) + return dict(w=w) + else: + w = w_init.copy() + + # set model state properties that are added to warnings and errors + logprops = dict(minwind=s['uw'].min(), + maxdrop=(l['uw']-s['uw']).max(), + time=self.t, + dt=self.dt) + + nf = p['nfractions'] + + ufs = np.zeros((p['ny']+1,p['nx']+2)) + ufn = np.zeros((p['ny']+2,p['nx']+1)) + + for i in range(nf): #loop over fractions + + #define velocity fluxes + ufs[:,1:-1] = 0.5*s['us'][:,:-1,i] + 0.5*s['us'][:,1:,i] + ufn[1:-1,:] = 0.5*s['un'][:-1,:,i] + 0.5*s['un'][1:,:,i] + + #boundary values + ufs[:,0] = s['us'][:,0,i] + ufs[:,-1] = s['us'][:,-1,i] + + if p['boundary_lateral'] == 'circular': + ufn[0,:] = 0.5*s['un'][0,:,i] + 0.5*s['un'][-1,:,i] + ufn[-1,:] = ufn[0,:] + else: + ufn[0,:] = s['un'][0,:,i] + ufn[-1,:] = s['un'][-1,:,i] + + beta = abs(beta) + if beta >= 1.: + # define upwind direction + ixfs = np.asarray(ufs >= 0., dtype=float) + ixfn = np.asarray(ufn >= 0., dtype=float) + else: + # or centralizing weights + ixfs = beta + np.zeros(ufs) + ixfn = beta + np.zeros(ufn) + + # initialize matrix diagonals + A0 = np.zeros(s['zb'].shape) + Apx = np.zeros(s['zb'].shape) + Ap1 = np.zeros(s['zb'].shape) + Amx = np.zeros(s['zb'].shape) + Am1 = np.zeros(s['zb'].shape) + + # populate matrix diagonals + A0 += s['dsdn'] / self.dt #time derivative + A0 += s['dsdn'] / Ts * alpha #source term + A0[:,1:] -= s['dn'][:,1:] * ufs[:,1:-1] * (1. - ixfs[:,1:-1]) * alpha #lower x-face + Am1[:,1:] -= s['dn'][:,1:] * ufs[:,1:-1] * ixfs[:,1:-1] * alpha #lower x-face + A0[:,:-1] += s['dn'][:,:-1] * ufs[:,1:-1] * ixfs[:,1:-1] * alpha #upper x-face + Ap1[:,:-1] += s['dn'][:,:-1] * ufs[:,1:-1] * (1. - ixfs[:,1:-1]) * alpha #upper x-face + A0[1:,:] -= s['ds'][1:,:] * ufn[1:-1,:] * (1. - ixfn[1:-1,:]) * alpha #lower y-face + Amx[1:,:] -= s['ds'][1:,:] * ufn[1:-1,:] * ixfn[1:-1,:] * alpha #lower y-face + A0[:-1,:] += s['ds'][:-1,:] * ufn[1:-1,:] * ixfn[1:-1,:] * alpha #upper y-face + Apx[:-1,:] += s['ds'][:-1,:] * ufn[1:-1,:] * (1. - ixfn[1:-1,:]) * alpha #upper y-face + + # add boundaries + # offshore boundary (i=0) + + if p['boundary_offshore'] == 'flux': + #nothing to be done + pass + elif p['boundary_offshore'] == 'constant': + #constant sediment concentration (Ct) in the air + A0[:,0] = 1. + Apx[:,0] = 0. + Amx[:,0] = 0. + Ap1[:,0] = 0. + Am1[:,0] = 0. + elif p['boundary_offshore'] == 'gradient': + #remove the flux at the inner face of the cell + A0[:,0] -= s['dn'][:,0] * ufs[:,1] * ixfs[:,1] * alpha #upper x-face + Ap1[:,0] -= s['dn'][:,0] * ufs[:,1] * (1. - ixfs[:,1]) * alpha #upper x-face + elif p['boundary_offshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore']) + + #onshore boundary (i=nx) + + if p['boundary_onshore'] == 'flux': + #nothing to be done + pass + elif p['boundary_onshore'] == 'constant': + #constant sediment concentration (hC) in the air + A0[:,-1] = 1. + Apx[:,-1] = 0. + Amx[:,-1] = 0. + Ap1[:,-1] = 0. + Am1[:,-1] = 0. + elif p['boundary_onshore'] == 'gradient': + #remove the flux at the inner face of the cell + A0[:,-1] += s['dn'][:,-1] * ufs[:,-2] * (1. - ixfs[:,-2]) * alpha #lower x-face + Am1[:,-1] += s['dn'][:,-1] * ufs[:,-2] * ixfs[:,-2] * alpha #lower x-face + elif p['boundary_onshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_onshore']) + + #lateral boundaries (j=0; j=ny) + + if p['boundary_lateral'] == 'flux': + #nothing to be done + pass + elif p['boundary_lateral'] == 'constant': + #constant sediment concentration (hC) in the air + A0[0,:] = 1. + Apx[0,:] = 0. + Amx[0,:] = 0. + Ap1[0,:] = 0. + Am1[0,:] = 0. + A0[-1,:] = 1. + Apx[-1,:] = 0. + Amx[-1,:] = 0. + Ap1[-1,:] = 0. + Am1[-1,:] = 0. + elif p['boundary_lateral'] == 'gradient': + #remove the flux at the inner face of the cell + A0[0,:] -= s['ds'][0,:] * ufn[1,:] * ixfn[1,:] * alpha #upper y-face + Apx[0,:] -= s['ds'][0,:] * ufn[1,:] * (1. - ixfn[1,:]) * alpha #upper y-face + A0[-1,:] += s['ds'][-1,:] * ufn[-2,:] * (1. - ixfn[-2,:]) * alpha #lower y-face + Amx[-1,:] += s['ds'][-1,:] * ufn[-2,:] * ixfn[-2,:] * alpha #lower y-face + elif p['boundary_lateral'] == 'circular': + A0[0,:] -= s['ds'][0,:] * ufn[0,:] * (1. - ixfn[0,:]) * alpha #lower y-face + Amx[0,:] -= s['ds'][0,:] * ufn[0,:] * ixfn[0,:] * alpha #lower y-face + A0[-1,:] += s['ds'][-1,:] * ufn[-1,:] * ixfn[-1,:] * alpha #upper y-face + Apx[-1,:] += s['ds'][-1,:] * ufn[-1,:] * (1. - ixfn[-1,:]) * alpha #upper y-face + else: + raise ValueError('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral']) + + # construct sparse matrix + if p['ny'] > 0: + j = p['nx']+1 + A = scipy.sparse.diags((Apx.flatten()[:j], + Amx.flatten()[j:], + Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1], + Apx.flatten()[j:], + Amx.flatten()[:j]), + (-j*p['ny'],-j,-1,0,1,j,j*p['ny']), format='csr') + else: + A = scipy.sparse.diags((Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1]), + (-1,0,1), format='csr') + + # solve transport for each fraction separately using latest + # available weights + + # renormalize weights for all fractions equal or larger + # than the current one such that the sum of all weights is + # unity + w = aeolis.transport.renormalize_weights(w, i) + + # iteratively find a solution of the linear system that + # does not violate the availability of sediment in the bed + for n in range(p['max_iter']): + self._count('matrixsolve') +# print("iteration nr = %d" % n) + # define upwind face value + # sediment concentration + Ctxfs_i = np.zeros(ufs.shape) + Ctxfn_i = np.zeros(ufn.shape) + + Ctxfs_i[:,1:-1] = ixfs[:,1:-1] * ( alpha * Ct[:,:-1,i] \ + + (1. - alpha ) * l['Ct'][:,:-1,i] ) \ + + (1. - ixfs[:,1:-1]) * ( alpha * Ct[:,1:,i] \ + + (1. - alpha ) * l['Ct'][:,1:,i] ) + Ctxfn_i[1:-1,:] = ixfn[1:-1,:] * (alpha * Ct[:-1,:,i] \ + + (1. - alpha ) * l['Ct'][:-1,:,i] ) \ + + (1. - ixfn[1:-1,:]) * ( alpha * Ct[1:,:,i] \ + + (1. - alpha ) * l['Ct'][1:,:,i] ) + + if p['boundary_lateral'] == 'circular': + Ctxfn_i[0,:] = ixfn[0,:] * (alpha * Ct[-1,:,i] \ + + (1. - alpha ) * l['Ct'][-1,:,i] ) \ + + (1. - ixfn[0,:]) * ( alpha * Ct[0,:,i] \ + + (1. - alpha ) * l['Ct'][0,:,i] ) + Ctxfn_i[-1,:] = Ctxfn_i[0,:] + + # calculate pickup + D_i = s['dsdn'] / Ts * ( alpha * Ct[:,:,i] \ + + (1. - alpha ) * l['Ct'][:,:,i] ) + A_i = s['dsdn'] / Ts * s['mass'][:,:,0,i] + D_i # Availability + U_i = s['dsdn'] / Ts * ( w[:,:,i] * alpha * s['Cu'][:,:,i] \ + + (1. - alpha ) * l['w'][:,:,i] * l['Cu'][:,:,i] ) + #deficit_i = E_i - A_i + E_i= np.minimum(U_i, A_i) + #pickup_i = E_i - D_i + + # create the right hand side of the linear system + # sediment concentration + yCt_i = np.zeros(s['zb'].shape) + yCt_i -= s['dsdn'] / self.dt * ( Ct[:,:,i] \ + - l['Ct'][:,:,i] ) #time derivative + yCt_i += E_i - D_i #source term + yCt_i[:,1:] += s['dn'][:,1:] * ufs[:,1:-1] * Ctxfs_i[:,1:-1] #lower x-face + yCt_i[:,:-1] -= s['dn'][:,:-1] * ufs[:,1:-1] * Ctxfs_i[:,1:-1] #upper x-face + yCt_i[1:,:] += s['ds'][1:,:] * ufn[1:-1,:] * Ctxfn_i[1:-1,:] #lower y-face + yCt_i[:-1,:] -= s['ds'][:-1,:] * ufn[1:-1,:] * Ctxfn_i[1:-1,:] #upper y-face + + # boundary conditions + # offshore boundary (i=0) + + if p['boundary_offshore'] == 'flux': + yCt_i[:,0] += s['dn'][:,0] * ufs[:,0] * s['Cu0'][:,0,i] * p['offshore_flux'] + + elif p['boundary_offshore'] == 'constant': + #constant sediment concentration (Ct) in the air (for now = 0) + yCt_i[:,0] = 0. + + elif p['boundary_offshore'] == 'gradient': + #remove the flux at the inner face of the cell + yCt_i[:,0] += s['dn'][:,1] * ufs[:,1] * Ctxfs_i[:,1] #upper x-face + + elif p['boundary_offshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore']) + + # onshore boundary (i=nx) + + if p['boundary_onshore'] == 'flux': + yCt_i[:,-1] += s['dn'][:,-1] * ufs[:,-1] * s['Cu0'][:,-1,i] * p['onshore_flux'] + + elif p['boundary_onshore'] == 'constant': + #constant sediment concentration (Ct) in the air (for now = 0) + yCt_i[:,-1] = 0. + + elif p['boundary_onshore'] == 'gradient': + #remove the flux at the inner face of the cell + yCt_i[:,-1] -= s['dn'][:,-2] * ufs[:,-2] * Ctxfs_i[:,-2] #lower x-face + + elif p['boundary_onshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown onshore boundary condition [%s]' % self.p['boundary_onshore']) + + #lateral boundaries (j=0; j=ny) + + if p['boundary_lateral'] == 'flux': + + yCt_i[0,:] += s['ds'][0,:] * ufn[0,:] * s['Cu0'][0,:,i] * p['lateral_flux'] #lower y-face + yCt_i[-1,:] -= s['ds'][-1,:] * ufn[-1,:] * s['Cu0'][-1,:,i] * p['lateral_flux'] #upper y-face + + elif p['boundary_lateral'] == 'constant': + #constant sediment concentration (hC) in the air + yCt_i[0,:] = 0. + yCt_i[-1,:] = 0. + elif p['boundary_lateral'] == 'gradient': + #remove the flux at the inner face of the cell + yCt_i[-1,:] -= s['ds'][-2,:] * ufn[-2,:] * Ctxfn_i[-2,:] #lower y-face + yCt_i[0,:] += s['ds'][1,:] * ufn[1,:] * Ctxfn_i[1,:] #upper y-face + elif p['boundary_lateral'] == 'circular': + yCt_i[0,:] += s['ds'][0,:] * ufn[0,:] * Ctxfn_i[0,:] #lower y-face + yCt_i[-1,:] -= s['ds'][-1,:] * ufn[-1,:] * Ctxfn_i[-1,:] #upper y-face + else: + raise ValueError('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral']) + + # print("ugs = %.*g" % (3,s['ugs'][10,10])) + # print("ugn = %.*g" % (3,s['ugn'][10,10])) + # print("%.*g" % (3,np.amax(np.absolute(y_i)))) + + # solve system with current weights + Ct_i = Ct[:,:,i].flatten() + Ct_i += scipy.sparse.linalg.spsolve(A, yCt_i.flatten()) + Ct_i = prevent_tiny_negatives(Ct_i, p['max_error']) + + # check for negative values + if Ct_i.min() < 0.: + ix = Ct_i < 0. + +# logger.warn(format_log('Removing negative concentrations', +# nrcells=np.sum(ix), +# fraction=i, +# iteration=n, +# minvalue=Ct_i.min(), +# **logprops)) + + if 0: #Ct_i[~ix].sum()>0.: + # compensate the negative concentrations by distributing them over the positives. + # I guess the idea is to conserve mass but it is not sure if this is needed, + # mass continuity in the system is guaranteed by exchange with bed. + Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() + Ct_i[ix] = 0. + + # determine pickup and deficit for current fraction + Cu_i = s['Cu'][:,:,i].flatten() + mass_i = s['mass'][:,:,0,i].flatten() + w_i = w[:,:,i].flatten() + Ts_i = Ts + + pickup_i = (w_i * Cu_i - Ct_i) / Ts_i * self.dt # Dit klopt niet! enkel geldig bij backward euler + deficit_i = pickup_i - mass_i + ix = (deficit_i > p['max_error']) \ + & (w_i * Cu_i > 0.) + + pickup[:,:,i] = pickup_i.reshape(yCt_i.shape) + Ct[:,:,i] = Ct_i.reshape(yCt_i.shape) + + # quit the iteration if there is no deficit, otherwise + # back-compute the maximum weight allowed to get zero + # deficit for the current fraction and progress to + # the next iteration step + if not np.any(ix): + logger.debug(format_log('Iteration converged', + steps=n, + fraction=i, + **logprops)) + pickup_i = np.minimum(pickup_i, mass_i) + break + else: + w_i[ix] = (mass_i[ix] * Ts_i / self.dt \ + + Ct_i[ix]) / Cu_i[ix] + w[:,:,i] = w_i.reshape(yCt_i.shape) + + # throw warning if the maximum number of iterations was + # reached + + if np.any(ix): + logger.warn(format_log('Iteration not converged', + nrcells=np.sum(ix), + fraction=i, + **logprops)) + + if 0: #let's disable these warnings + # check for unexpected negative values + if Ct_i.min() < 0: + logger.warn(format_log('Negative concentrations', + nrcells=np.sum(Ct_i<0.), + fraction=i, + minvalue=Ct_i.min(), + **logprops)) + if w_i.min() < 0: + logger.warn(format_log('Negative weights', + nrcells=np.sum(w_i<0), + fraction=i, + minvalue=w_i.min(), + **logprops)) + # end loop over frations + + # check if there are any cells where the sum of all weights is + # smaller than unity. these cells are supply-limited for all + # fractions. Log these events. + ix = 1. - np.sum(w, axis=2) > p['max_error'] + if np.any(ix): + self._count('supplylim') +# logger.warn(format_log('Ran out of sediment', +# nrcells=np.sum(ix), +# minweight=np.sum(w, axis=-1).min(), +# **logprops)) + + qs = Ct * s['us'] + qn = Ct * s['un'] + qs = Ct * s['us'] + qn = Ct * s['un'] + q = np.hypot(qs, qn) + + + return dict(Ct=Ct, + qs=qs, + qn=qn, + pickup=pickup, + w=w, + w_init=w_init, + w_air=w_air, + w_bed=w_bed, + q=q) + + +# Note: @njit(cache=True) is intentionally not used here. +# This function acts as an orchestrator, delegating work to Numba-compiled helper functions. +# Decorating the orchestrator itself with njit provides no performance benefit, +# since most of the computation is already handled by optimized Numba functions. +def sweep(Ct, Cu, mass, dt, Ts, ds, dn, us, un, w): + + + pickup = np.zeros(Cu.shape) + i=0 + k=0 + + nf = np.shape(Ct)[2] + + # Are the lateral boundary conditions circular? + circ_lateral = False + if Ct[0,1,0]==-1: + circ_lateral = True + Ct[0,:,0] = 0 + Ct[-1,:,0] = 0 + + circ_offshore = False + if Ct[1,0,0]==-1: + circ_offshore = True + Ct[:,0,0] = 0 + Ct[:,-1,0] = 0 + + recirc_offshore = False + if Ct[1,0,0]==-2: + recirc_offshore = True + Ct[:,0,0] = 0 + Ct[:,-1,0] = 0 + + + ufs = np.zeros((np.shape(us)[0], np.shape(us)[1]+1, np.shape(us)[2])) + ufn = np.zeros((np.shape(un)[0]+1, np.shape(un)[1], np.shape(un)[2])) + + # define velocity at cell faces + ufs[:,1:-1, :] = 0.5*us[:,:-1, :] + 0.5*us[:,1:, :] + ufn[1:-1,:, :] = 0.5*un[:-1,:, :] + 0.5*un[1:,:, :] + + # print(ufs[5,:,0]) + + # set empty boundary values, extending the velocities at the boundaries + ufs[:,0, :] = ufs[:,1, :] + ufs[:,-1, :] = ufs[:,-2, :] + + ufn[0,:, :] = ufn[1,:, :] + ufn[-1,:, :] = ufn[-2,:, :] + + # Lets take the average of the top and bottom and left/right boundary cells + # apply the average to the boundary cells + # this ensures that the inflow at one side is equal to the outflow at the other side + + ufs[:,0,:] = (ufs[:,0,:]+ufs[:,-1,:])/2 + ufs[:,-1,:] = ufs[:,0,:] + ufs[0,:,:] = (ufs[0,:,:]+ufs[-1,:,:])/2 + ufs[-1,:,:] = ufs[0,:,:] + + ufn[:,0,:] = (ufn[:,0,:]+ufn[:,-1,:])/2 + ufn[:,-1,:] = ufn[:,0,:] + ufn[0,:,:] = (ufn[0,:,:]+ufn[-1,:,:])/2 + ufn[-1,:,:] = ufn[0,:,:] + + # now make sure that there is no gradients at the boundaries + # ufs[:,1,:] = ufs[:,0,:] + # ufs[:,-2,:] = ufs[:,-1,:] + # ufs[1,:,:] = ufs[0,:,:] + # ufs[-2,:,:] = ufs[-1,:,:] + + # ufn[:,1,:] = ufn[:,0,:] + # ufn[:,-2,:] = ufn[:,-1,:] + # ufn[1,:,:] = ufn[0,:,:] + # ufn[-2,:,:] = ufn[-1,:,:] + + # ufn[:,:,:] = ufn[-2,:,:] + + # also correct for the potential gradients at the boundary cells in the equilibrium concentrations + Cu[:,0,:] = Cu[:,1,:] + Cu[:,-1,:] = Cu[:,-2,:] + Cu[0,:,:] = Cu[1,:,:] + Cu[-1,:,:] = Cu[-2,:,:] + + # #boundary values + # ufs[:,0, :] = us[:,0, :] + # ufs[:,-1, :] = us[:,-1, :] + + # ufn[0,:, :] = un[0,:, :] + # ufn[-1,:, :] = un[-1,:, :] + + Ct_last = Ct.copy() + while k==0 or np.any(np.abs(Ct[:,:,i]-Ct_last[:,:,i])>1e-10): + # while k==0 or np.any(np.abs(Ct[:,:,i]-Ct_last[:,:,i])!=0): + Ct_last = Ct.copy() + + # lateral boundaries circular + if circ_lateral: + Ct[0,:,0],Ct[-1,:,0] = Ct[-1,:,0].copy(),Ct[0,:,0].copy() + # pickup[0,:,0],pickup[-1,:,0] = pickup[-1,:,0].copy(),pickup[0,:,0].copy() + if circ_offshore: + Ct[:,0,0],Ct[:,-1,0] = Ct[:,-1,0].copy(),Ct[:,0,0].copy() + # pickup[:,0,0],pickup[:,-1,0] = pickup[:,-1,0].copy(),pickup[:,0,0].copy() + + if recirc_offshore: + Ct[:,0,0],Ct[:,-1,0] = np.mean(Ct[:,-2,0]), np.mean(Ct[:,1,0]) + + # Track visited cells and quadrant classification + visited = np.zeros(Cu.shape[:2], dtype=bool) + quad = np.zeros(Cu.shape[:2], dtype=np.uint8) + + ######################################################################################## + # in this sweeping algorithm we sweep over the 4 quadrants + # assuming that most cells have no converging/divering charactersitics. + # In the last quadrant we take converging and diverging cells into account. + + # The First quadrant (Numba-optimized) + _solve_quadrant1(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + # The second quadrant (Numba-optimized) + _solve_quadrant2(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + # The third quadrant (Numba-optimized) + _solve_quadrant3(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + # The fourth quadrant (Numba-optimized) + _solve_quadrant4(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + # Generic stencil for remaining cells including boundaries (Numba-optimized) + _solve_generic_stencil(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + # check the boundaries of the pickup matrix for unvisited cells + # print(np.shape(visited[0,:]==False)) + pickup[0,:,0] = pickup[1,:,0].copy() + pickup[-1,:,0] = pickup[-2,:,0].copy() + + k+=1 + + # # plot Ct + # import matplotlib.pyplot as plt + # plt.imshow(quad[:10,:10], origin='lower') + # # plt.colorbar() + # plt.title('Concentration after %d sweeps' % k) + # plt.show() + # plt.imshow(Ct[:50,:50], origin='lower') + # # plt.colorbar() + # plt.title('Concentration after %d sweeps' % k) + # plt.show() + # plt.plot(pickup[0,:,0]) + # plt.plot(pickup[-1,:,0]) + # plt.show() + + # print(k) + + + # print("q1 = " + str(np.sum(q==1)) + " q2 = " + str(np.sum(q==2)) \ + # + " q3 = " + str(np.sum(q==3)) + " q4 = " + str(np.sum(q==4)) \ + # + " q5 = " + str(np.sum(q==5))) + # print("pickup deviation percentage = " + str(pickup.sum()/pickup[pickup>0].sum()*100) + " %") + # print("pickup deviation percentage = " + str(pickup[1,:,0].sum()/pickup[1,pickup[1,:,0]>0,0].sum()*100) + " %") + # print("pickup maximum = " + str(pickup.max()) + " mass max = " + str(mass.max())) + # print("pickup minimum = " + str(pickup.min())) + # print("pickup average = " + str(pickup.mean())) + # print("number of cells for pickup maximum = " + str((pickup == mass.max()).sum())) + # pickup[1,:,0].sum()/pickup[1,pickup[1,:,0]<0,0].sum() + + return Ct, pickup + + +@njit(cache=True) +def _solve_quadrant1(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + """Solve first quadrant (positive flow in both directions) with Numba optimization.""" + for n in range(1, Ct.shape[0]): + for s in range(1, Ct.shape[1]): + if ( + (not visited[n, s]) + and (ufn[n, s, 0] >= 0) + and (ufs[n, s, 0] >= 0) + and (ufn[n + 1, s, 0] >= 0) + and (ufs[n, s + 1, 0] >= 0) + ): + + # Compute concentration for all fractions + for f in range(nf): + num = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + + Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + + w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) + + den = (ufn[n + 1, s, f] * ds[n, s] + + ufs[n, s + 1, f] * dn[n, s] + + ds[n, s] * dn[n, s] / Ts) + + Ct[n, s, f] = num / den + + # Calculate pickup + pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts + + # Check for supply limitations and re-iterate + if pickup[n, s, f] > mass[n, s, 0, f]: + pickup[n, s, f] = mass[n, s, 0, f] + + num_limited = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + + Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + + pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) + + den_limited = (ufn[n + 1, s, f] * ds[n, s] + + ufs[n, s + 1, f] * dn[n, s]) + + Ct[n, s, f] = num_limited / den_limited + + visited[n, s] = True + quad[n, s] = 1 + + +@njit(cache=True) +def _solve_quadrant2(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + """Solve second quadrant (positive n-flow, negative s-flow) with Numba optimization.""" + for n in range(1, Ct.shape[0]): + for s in range(Ct.shape[1] - 2, -1, -1): + if ( + (not visited[n, s]) + and (ufn[n, s, 0] >= 0) + and (ufs[n, s, 0] <= 0) + and (ufn[n + 1, s, 0] >= 0) + and (ufs[n, s + 1, 0] <= 0) + ): + + # Compute concentration for all fractions + for f in range(nf): + num = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + + -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + + w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) + + den = (ufn[n + 1, s, f] * ds[n, s] + + -ufs[n, s, f] * dn[n, s] + + ds[n, s] * dn[n, s] / Ts) + + Ct[n, s, f] = num / den + + # Calculate pickup + pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts + + # Check for supply limitations and re-iterate + if pickup[n, s, f] > mass[n, s, 0, f]: + pickup[n, s, f] = mass[n, s, 0, f] + + num_limited = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + + -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + + pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) + + den_limited = (ufn[n + 1, s, f] * ds[n, s] + + -ufs[n, s, f] * dn[n, s]) + + Ct[n, s, f] = num_limited / den_limited + + visited[n, s] = True + quad[n, s] = 2 + + +@njit(cache=True) +def _solve_quadrant3(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + """Solve third quadrant (negative flow in both directions) with Numba optimization.""" + for n in range(Ct.shape[0] - 2, -1, -1): + for s in range(Ct.shape[1] - 2, -1, -1): + if ( + (not visited[n, s]) + and (ufn[n, s, 0] <= 0) + and (ufs[n, s, 0] <= 0) + and (ufn[n + 1, s, 0] <= 0) + and (ufs[n, s + 1, 0] <= 0) + ): + + # Compute concentration for all fractions + for f in range(nf): + num = (-Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + + -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + + w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) + + den = (-ufn[n, s, f] * dn[n, s] + + -ufs[n, s, f] * dn[n, s] + + ds[n, s] * dn[n, s] / Ts) + + Ct[n, s, f] = num / den + + # Calculate pickup + pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts + + # Check for supply limitations and re-iterate + if pickup[n, s, f] > mass[n, s, 0, f]: + pickup[n, s, f] = mass[n, s, 0, f] + + num_limited = (-Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + + -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + + pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) + + den_limited = (-ufn[n, s, f] * dn[n, s] + + -ufs[n, s, f] * dn[n, s]) + + Ct[n, s, f] = num_limited / den_limited + + visited[n, s] = True + quad[n, s] = 3 + + +@njit(cache=True) +def _solve_quadrant4(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + """Solve fourth quadrant (negative n-flow, positive s-flow) with Numba optimization.""" + for n in range(Ct.shape[0] - 2, -1, -1): + for s in range(1, Ct.shape[1]): + if ( + (not visited[n, s]) + and (ufn[n, s, 0] <= 0) + and (ufs[n, s, 0] >= 0) + and (ufn[n + 1, s, 0] <= 0) + and (ufs[n, s + 1, 0] >= 0) + ): + + # Compute concentration for all fractions + for f in range(nf): + num = (Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + + -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + + w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) + + den = (ufs[n, s + 1, f] * dn[n, s] + + -ufn[n, s, f] * dn[n, s] + + ds[n, s] * dn[n, s] / Ts) + + Ct[n, s, f] = num / den + + # Calculate pickup + pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts + + # Check for supply limitations and re-iterate + if pickup[n, s, f] > mass[n, s, 0, f]: + pickup[n, s, f] = mass[n, s, 0, f] + + num_limited = (Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + + -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + + pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) + + den_limited = (ufs[n, s + 1, f] * dn[n, s] + + -ufn[n, s, f] * dn[n, s]) + + Ct[n, s, f] = num_limited / den_limited + + visited[n, s] = True + quad[n, s] = 4 + + +@njit(cache=True) +def _solve_generic_stencil(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + """Solve remaining cells with generic stencil using conditionals (Numba-optimized).""" + for n in range(Ct.shape[0] - 2, -1, -1): + for s in range(1, Ct.shape[1]): + if (not visited[n, s]) and (n != 0) and (s != Ct.shape[1] - 1): + # Apply generic stencil with conditionals instead of boolean multiplication + for f in range(nf): + # Initialize with source term + num = w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts + den = ds[n, s] * dn[n, s] / Ts + + # Add flux contributions conditionally + if ufn[n, s, 0] > 0: + num += Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + + if ufs[n, s, 0] > 0: + num += Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + + if ufn[n + 1, s, 0] < 0: + num += -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + elif ufn[n + 1, s, 0] > 0: + den += ufn[n + 1, s, f] * ds[n, s] + + if ufs[n, s + 1, 0] < 0: + num += -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + elif ufs[n, s + 1, 0] > 0: + den += ufs[n, s + 1, f] * dn[n, s] + + if ufn[n, s, 0] < 0: + den += -ufn[n, s, f] * dn[n, s] + + if ufs[n, s, 0] < 0: + den += -ufs[n, s, f] * dn[n, s] + + Ct[n, s, f] = num / den + + # Calculate pickup + pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts + + # Check for supply limitations and re-iterate + if pickup[n, s, f] > mass[n, s, 0, f]: + pickup[n, s, f] = mass[n, s, 0, f] + + # Recompute with limited pickup + num_lim = pickup[n, s, f] * ds[n, s] * dn[n, s] / dt + den_lim = 0.0 + + if ufn[n, s, 0] > 0: + num_lim += Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + + if ufs[n, s, 0] > 0: + num_lim += Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + + if ufn[n + 1, s, 0] < 0: + num_lim += -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + elif ufn[n + 1, s, 0] > 0: + den_lim += ufn[n + 1, s, f] * ds[n, s] + + if ufs[n, s + 1, 0] < 0: + num_lim += -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + elif ufs[n, s + 1, 0] > 0: + den_lim += ufs[n, s + 1, f] * dn[n, s] + + if ufn[n, s, 0] < 0: + den_lim += -ufn[n, s, f] * dn[n, s] + + if ufs[n, s, 0] < 0: + den_lim += -ufs[n, s, f] * dn[n, s] + + Ct[n, s, f] = num_lim / den_lim + + visited[n, s] = True + quad[n, s] = 5 + diff --git a/aeolis/model.py b/aeolis/model.py index c7215aaa..dc3c5edd 100644 --- a/aeolis/model.py +++ b/aeolis/model.py @@ -61,12 +61,22 @@ import aeolis.fences import aeolis.gridparams +from aeolis.advection import ( + solve, + solve_EF, + solve_SS, + solve_pieter, + solve_steadystate, + solve_steadystatepieter, +) + # type hints from typing import Any, Union, Tuple from aeolis.utils import * + class StreamFormatter(logging.Formatter): """A formater for log messages""" @@ -148,6 +158,14 @@ class AeoLiS(IBmi): >>> model.finalize() """ + # Bind imported functions as methods + solve = solve + solve_EF = solve_EF + solve_SS = solve_SS + solve_pieter = solve_pieter + solve_steadystate = solve_steadystate + solve_steadystatepieter = solve_steadystatepieter + def __init__(self, configfile: str) -> None: '''Initialize class @@ -751,1786 +769,7 @@ def crank_nicolson(self) -> Any: solve = self.solve_steadystatepieter() return solve - - - def solve_steadystate(self) -> dict: - '''Implements the steady state solution - ''' - # upwind scheme: - beta = 1. - - l = self.l - s = self.s - p = self.p - - Ct = s['Ct'].copy() - pickup = s['pickup'].copy() - - # compute transport weights for all sediment fractions - w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) - - if self.t == 0.: - # use initial guess for first time step - if p['grain_dist'] != None: - w = p['grain_dist'].reshape((1,1,-1)) - w = w.repeat(p['ny']+1, axis=0) - w = w.repeat(p['nx']+1, axis=1) - else: - w = w_init.copy() - else: - w = w_init.copy() - - # set model state properties that are added to warnings and errors - logprops = dict(minwind=s['uw'].min(), - maxdrop=(l['uw']-s['uw']).max(), - time=self.t, - dt=self.dt) - - nf = p['nfractions'] - - us = np.zeros((p['ny']+1,p['nx']+1)) - un = np.zeros((p['ny']+1,p['nx']+1)) - - us_plus = np.zeros((p['ny']+1,p['nx']+1)) - un_plus = np.zeros((p['ny']+1,p['nx']+1)) - - us_min = np.zeros((p['ny']+1,p['nx']+1)) - un_min = np.zeros((p['ny']+1,p['nx']+1)) - - Cs = np.zeros(us.shape) - Cn = np.zeros(un.shape) - - Cs_plus = np.zeros(us.shape) - Cn_plus = np.zeros(un.shape) - - Cs_min = np.zeros(us.shape) - Cn_min = np.zeros(un.shape) - - for i in range(nf): - us[:,:] = s['us'][:,:,i] - un[:,:] = s['un'][:,:,i] - - us_plus[:,1:] = s['us'][:,:-1,i] - un_plus[1:,:] = s['un'][:-1,:,i] - - us_min[:,:-1] = s['us'][:,1:,i] - un_min[:-1,:] = s['un'][1:,:,i] - - #boundary values - us[:,0] = s['us'][:,0,i] - un[0,:] = s['un'][0,:,i] - - us_plus[:,0] = s['us'][:,0,i] - un_plus[0,:] = s['un'][0,:,i] - - us_min[:,-1] = s['us'][:,-1,i] - un_min[-1,:] = s['un'][-1,:,i] - - - # define matrix coefficients to solve linear system of equations - Cs = s['dn'] * s['dsdni'] * us[:,:] - Cn = s['ds'] * s['dsdni'] * un[:,:] - - Cs_plus = s['dn'] * s['dsdni'] * us_plus[:,:] - Cn_plus = s['ds'] * s['dsdni'] * un_plus[:,:] - - Cs_min = s['dn'] * s['dsdni'] * us_min[:,:] - Cn_min = s['ds'] * s['dsdni'] * un_min[:,:] - - - Ti = 1 / p['T'] - - beta = abs(beta) - if beta >= 1.: - # define upwind direction - ixs = np.asarray(us[:,:] >= 0., dtype=float) - ixn = np.asarray(un[:,:] >= 0., dtype=float) - sgs = 2. * ixs - 1. - sgn = 2. * ixn - 1. - - else: - # or centralizing weights - ixs = beta + np.zeros(us) - ixn = beta + np.zeros(un) - sgs = np.zeros(us) - sgn = np.zeros(un) - - # initialize matrix diagonals - A0 = np.zeros(s['zb'].shape) - Apx = np.zeros(s['zb'].shape) - Ap1 = np.zeros(s['zb'].shape) - Ap2 = np.zeros(s['zb'].shape) - Amx = np.zeros(s['zb'].shape) - Am1 = np.zeros(s['zb'].shape) - Am2 = np.zeros(s['zb'].shape) - - # populate matrix diagonals - A0 = sgs * Cs + sgn * Cn + Ti - Apx = Cn_min * (1. - ixn) - Ap1 = Cs_min * (1. - ixs) - Amx = -Cn_plus * ixn - Am1 = -Cs_plus * ixs - - # add boundaries - A0[:,0] = 1. - Apx[:,0] = 0. - Amx[:,0] = 0. - Am2[:,0] = 0. - Am1[:,0] = 0. - - A0[:,-1] = 1. - Apx[:,-1] = 0. - Ap1[:,-1] = 0. - Ap2[:,-1] = 0. - Amx[:,-1] = 0. - - if p['boundary_offshore'] == 'flux': - Ap2[:,0] = 0. - Ap1[:,0] = 0. - elif p['boundary_offshore'] == 'constant': - Ap2[:,0] = 0. - Ap1[:,0] = 0. - elif p['boundary_offshore'] == 'uniform': - Ap2[:,0] = 0. - Ap1[:,0] = -1. - elif p['boundary_offshore'] == 'gradient': - Ap2[:,0] = s['ds'][:,1] / s['ds'][:,2] - Ap1[:,0] = -1. - s['ds'][:,1] / s['ds'][:,2] - elif p['boundary_offshore'] == 'circular': - logger.log_and_raise('Cross-shore cricular boundary condition not yet implemented', exc=NotImplementedError) - else: - logger.log_and_raise('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore'], exc=ValueError) - - if p['boundary_onshore'] == 'flux': - Am2[:,-1] = 0. - Am1[:,-1] = 0. - elif p['boundary_onshore'] == 'constant': - Am2[:,-1] = 0. - Am1[:,-1] = 0. - elif p['boundary_onshore'] == 'uniform': - Am2[:,-1] = 0. - Am1[:,-1] = -1. - elif p['boundary_onshore'] == 'gradient': - Am2[:,-1] = s['ds'][:,-2] / s['ds'][:,-3] - Am1[:,-1] = -1. - s['ds'][:,-2] / s['ds'][:,-3] - elif p['boundary_offshore'] == 'circular': - logger.log_and_raise('Cross-shore cricular boundary condition not yet implemented', exc=NotImplementedError) - else: - logger.log_and_raise('Unknown onshore boundary condition [%s]' % self.p['boundary_onshore'], exc=ValueError) - - if p['boundary_lateral'] == 'constant': - A0[0,:] = 1. - Apx[0,:] = 0. - Ap1[0,:] = 0. - Amx[0,:] = 0. - Am1[0,:] = 0. - - A0[-1,:] = 1. - Apx[-1,:] = 0. - Ap1[-1,:] = 0. - Amx[-1,:] = 0. - Am1[-1,:] = 0. - - #logger.log_and_raise('Lateral constant boundary condition not yet implemented', exc=NotImplementedError) - elif p['boundary_lateral'] == 'uniform': - logger.log_and_raise('Lateral uniform boundary condition not yet implemented', exc=NotImplementedError) - elif p['boundary_lateral'] == 'gradient': - logger.log_and_raise('Lateral gradient boundary condition not yet implemented', exc=NotImplementedError) - elif p['boundary_lateral'] == 'circular': - pass - else: - logger.log_and_raise('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral'], exc=ValueError) - - # construct sparse matrix - if p['ny'] > 0: - j = p['nx']+1 - A = scipy.sparse.diags((Apx.flatten()[:j], - Amx.flatten()[j:], - Am2.flatten()[2:], - Am1.flatten()[1:], - A0.flatten(), - Ap1.flatten()[:-1], - Ap2.flatten()[:-2], - Apx.flatten()[j:], - Amx.flatten()[:j]), - (-j*p['ny'],-j,-2,-1,0,1,2,j,j*p['ny']), format='csr') - else: - A = scipy.sparse.diags((Am2.flatten()[2:], - Am1.flatten()[1:], - A0.flatten(), - Ap1.flatten()[:-1], - Ap2.flatten()[:-2]), - (-2,-1,0,1,2), format='csr') - - # solve transport for each fraction separately using latest - # available weights - - # renormalize weights for all fractions equal or larger - # than the current one such that the sum of all weights is - # unity - w = aeolis.transport.renormalize_weights(w, i) - - # iteratively find a solution of the linear system that - # does not violate the availability of sediment in the bed - for n in range(p['max_iter']): - self._count('matrixsolve') - - # compute saturation levels - ix = s['Cu'] > 0. - S_i = np.zeros(s['Cu'].shape) - S_i[ix] = s['Ct'][ix] / s['Cu'][ix] - s['S'] = S_i.sum(axis=-1) - - # create the right hand side of the linear system - y_i = np.zeros(s['zb'].shape) - - y_i[:,1:-1] = ( - (w[:,1:-1,i] * s['Cuf'][:,1:-1,i] * Ti) * (1. - s['S'][:,1:-1]) + - (w[:,1:-1,i] * s['Cu'][:,1:-1,i] * Ti) * s['S'][:,1:-1] - ) - - # add boundaries - if p['boundary_offshore'] == 'flux': - y_i[:,0] = p['offshore_flux'] * s['Cu0'][:,0,i] - if p['boundary_onshore'] == 'flux': - y_i[:,-1] = p['onshore_flux'] * s['Cu0'][:,-1,i] - - if p['boundary_offshore'] == 'constant': - y_i[:,0] = p['constant_offshore_flux'] / s['u'][:,0,i] - if p['boundary_onshore'] == 'constant': - y_i[:,-1] = p['constant_onshore_flux'] / s['u'][:,-1,i] - - # solve system with current weights - Ct_i = scipy.sparse.linalg.spsolve(A, y_i.flatten()) - Ct_i = prevent_tiny_negatives(Ct_i, p['max_error']) - - # check for negative values - if Ct_i.min() < 0.: - ix = Ct_i < 0. - - logger.warning(format_log('Removing negative concentrations', - nrcells=np.sum(ix), - fraction=i, - iteration=n, - minvalue=Ct_i.min(), - coords=np.argwhere(ix.reshape(y_i.shape)), - **logprops)) - - Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() - Ct_i[ix] = 0. - - # determine pickup and deficit for current fraction - Cu_i = s['Cu'][:,:,i].flatten() - mass_i = s['mass'][:,:,0,i].flatten() - w_i = w[:,:,i].flatten() - pickup_i = (w_i * Cu_i - Ct_i) / p['T'] * self.dt - deficit_i = pickup_i - mass_i - ix = (deficit_i > p['max_error']) \ - & (w_i * Cu_i > 0.) - - # quit the iteration if there is no deficit, otherwise - # back-compute the maximum weight allowed to get zero - # deficit for the current fraction and progress to - # the next iteration step - if not np.any(ix): - logger.debug(format_log('Iteration converged', - steps=n, - fraction=i, - **logprops)) - pickup_i = np.minimum(pickup_i, mass_i) - break - else: - w_i[ix] = (mass_i[ix] * p['T'] / self.dt \ - + Ct_i[ix]) / Cu_i[ix] - w[:,:,i] = w_i.reshape(y_i.shape) - - # throw warning if the maximum number of iterations was reached - if np.any(ix): - logger.warning(format_log('Iteration not converged', - nrcells=np.sum(ix), - fraction=i, - **logprops)) - - # check for unexpected negative values - if Ct_i.min() < 0: - logger.warning(format_log('Negative concentrations', - nrcells=np.sum(Ct_i<0.), - fraction=i, - minvalue=Ct_i.min(), - **logprops)) - if w_i.min() < 0: - logger.warning(format_log('Negative weights', - nrcells=np.sum(w_i<0), - fraction=i, - minvalue=w_i.min(), - **logprops)) - - Ct[:,:,i] = Ct_i.reshape(y_i.shape) - pickup[:,:,i] = pickup_i.reshape(y_i.shape) - - # check if there are any cells where the sum of all weights is - # smaller than unity. these cells are supply-limited for all - # fractions. Log these events. - ix = 1. - np.sum(w, axis=2) > p['max_error'] - if np.any(ix): - self._count('supplylim') - logger.warning(format_log('Ran out of sediment', - nrcells=np.sum(ix), - minweight=np.sum(w, axis=-1).min(), - **logprops)) - - - qs = Ct * s['us'] - qn = Ct * s['un'] - q = np.hypot(qs, qn) - - - return dict(Ct=Ct, - qs=qs, - qn=qn, - pickup=pickup, - w=w, - w_init=w_init, - w_air=w_air, - w_bed=w_bed, - q=q) - - - def solve(self, alpha:float=.5, beta:float=1.) -> dict: - '''Implements the explicit Euler forward, implicit Euler backward and semi-implicit Crank-Nicolson numerical schemes - - Determines weights of sediment fractions, sediment pickup and - instantaneous sediment concentration. Returns a partial - spatial grid dictionary that can be used to update the global - spatial grid dictionary. - - Parameters - ---------- - alpha : - Implicitness coefficient (0.0 for Euler forward, 1.0 for Euler backward or 0.5 for Crank-Nicolson, default=0.5) - beta : - Centralization coefficient (1.0 for upwind or 0.5 for centralized, default=1.0) - - Returns - ------- - Partial spatial grid dictionary - - Examples - -------- - >>> model.s.update(model.solve(alpha=1., beta=1.) # euler backward - - >>> model.s.update(model.solve(alpha=.5, beta=1.) # crank-nicolson - - See Also - -------- - model.AeoLiS.euler_forward - model.AeoLiS.euler_backward - model.AeoLiS.crank_nicolson - transport.compute_weights - transport.renormalize_weights - - ''' - - l = self.l - s = self.s - p = self.p - - Ct = s['Ct'].copy() - pickup = s['pickup'].copy() - - # compute transport weights for all sediment fractions - w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) - - if self.t == 0.: - if type(p['bedcomp_file']) == np.ndarray: - w = w_init.copy() - else: - # use initial guess for first time step - w = p['grain_dist'].reshape((1,1,-1)) - w = w.repeat(p['ny']+1, axis=0) - w = w.repeat(p['nx']+1, axis=1) - else: - w = w_init.copy() - - # set model state properties that are added to warnings and errors - logprops = dict(minwind=s['uw'].min(), - maxdrop=(l['uw']-s['uw']).max(), - time=self.t, - dt=self.dt) - - nf = p['nfractions'] - - us = np.zeros((p['ny']+1,p['nx']+1)) - un = np.zeros((p['ny']+1,p['nx']+1)) - - us_plus = np.zeros((p['ny']+1,p['nx']+1)) - un_plus = np.zeros((p['ny']+1,p['nx']+1)) - - us_min = np.zeros((p['ny']+1,p['nx']+1)) - un_min = np.zeros((p['ny']+1,p['nx']+1)) - - Cs = np.zeros(us.shape) - Cn = np.zeros(un.shape) - - Cs_plus = np.zeros(us.shape) - Cn_plus = np.zeros(un.shape) - - Cs_min = np.zeros(us.shape) - Cn_min = np.zeros(un.shape) - - - for i in range(nf): - - us[:,:] = s['us'][:,:,i] - un[:,:] = s['un'][:,:,i] - - us_plus[:,1:] = s['us'][:,:-1,i] - un_plus[1:,:] = s['un'][:-1,:,i] - - us_min[:,:-1] = s['us'][:,1:,i] - un_min[:-1,:] = s['un'][1:,:,i] - - #boundary values - us_plus[:,0] = s['us'][:,0,i] - un_plus[0,:] = s['un'][0,:,i] - - us_min[:,-1] = s['us'][:,-1,i] - un_min[-1,:] = s['un'][-1,:,i] - - - # define matrix coefficients to solve linear system of equations - Cs = self.dt * s['dn'] * s['dsdni'] * us[:,:] - Cn = self.dt * s['ds'] * s['dsdni'] * un[:,:] - - Cs_plus = self.dt * s['dn'] * s['dsdni'] * us_plus[:,:] - Cn_plus = self.dt * s['ds'] * s['dsdni'] * un_plus[:,:] - - Cs_min = self.dt * s['dn'] * s['dsdni'] * us_min[:,:] - Cn_min = self.dt * s['ds'] * s['dsdni'] * un_min[:,:] - - Ti = self.dt / p['T'] - - - beta = abs(beta) - if beta >= 1.: - # define upwind direction - ixs = np.asarray(s['us'][:,:,i] >= 0., dtype=float) - ixn = np.asarray(s['un'][:,:,i] >= 0., dtype=float) - sgs = 2. * ixs - 1. - sgn = 2. * ixn - 1. - - else: - # or centralizing weights - ixs = beta + np.zeros(Cs.shape) - ixn = beta + np.zeros(Cn.shape) - sgs = np.zeros(Cs.shape) - sgn = np.zeros(Cn.shape) - - # initialize matrix diagonals - A0 = np.zeros(s['zb'].shape) - Apx = np.zeros(s['zb'].shape) - Ap1 = np.zeros(s['zb'].shape) - Ap2 = np.zeros(s['zb'].shape) - Amx = np.zeros(s['zb'].shape) - Am1 = np.zeros(s['zb'].shape) - Am2 = np.zeros(s['zb'].shape) - - # populate matrix diagonals - A0 = 1. + (sgs * Cs + sgn * Cn + Ti) * alpha - Apx = Cn_min * alpha * (1. - ixn) - Ap1 = Cs_min * alpha * (1. - ixs) - Amx = -Cn_plus * alpha * ixn - Am1 = -Cs_plus * alpha * ixs - - # add boundaries - A0[:,0] = 1. - Apx[:,0] = 0. - Amx[:,0] = 0. - Am2[:,0] = 0. - Am1[:,0] = 0. - - A0[:,-1] = 1. - Apx[:,-1] = 0. - Ap1[:,-1] = 0. - Ap2[:,-1] = 0. - Amx[:,-1] = 0. - - if (p['boundary_offshore'] == 'flux') | (p['boundary_offshore'] == 'noflux'): - Ap2[:,0] = 0. - Ap1[:,0] = 0. - elif p['boundary_offshore'] == 'constant': - Ap2[:,0] = 0. - Ap1[:,0] = 0. - elif p['boundary_offshore'] == 'uniform': - Ap2[:,0] = 0. - Ap1[:,0] = -1. - elif p['boundary_offshore'] == 'gradient': - Ap2[:,0] = s['ds'][:,1] / s['ds'][:,2] - Ap1[:,0] = -1. - s['ds'][:,1] / s['ds'][:,2] - elif p['boundary_offshore'] == 'circular': - logger.log_and_raise('Cross-shore cricular boundary condition not yet implemented', exc=NotImplementedError) - else: - logger.log_and_raise('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore'], exc=ValueError) - - if (p['boundary_onshore'] == 'flux') | (p['boundary_offshore'] == 'noflux'): - Am2[:,-1] = 0. - Am1[:,-1] = 0. - elif p['boundary_onshore'] == 'constant': - Am2[:,-1] = 0. - Am1[:,-1] = 0. - elif p['boundary_onshore'] == 'uniform': - Am2[:,-1] = 0. - Am1[:,-1] = -1. - elif p['boundary_onshore'] == 'gradient': - Am2[:,-1] = s['ds'][:,-2] / s['ds'][:,-3] - Am1[:,-1] = -1. - s['ds'][:,-2] / s['ds'][:,-3] - elif p['boundary_offshore'] == 'circular': - logger.log_and_raise('Cross-shore cricular boundary condition not yet implemented', exc=NotImplementedError) - else: - logger.log_and_raise('Unknown onshore boundary condition [%s]' % self.p['boundary_onshore'], exc=ValueError) - - if p['boundary_lateral'] == 'constant': - A0[0,:] = 1. - Apx[0,:] = 0. - Ap1[0,:] = 0. - Amx[0,:] = 0. - Am1[0,:] = 0. - - A0[-1,:] = 1. - Apx[-1,:] = 0. - Ap1[-1,:] = 0. - Amx[-1,:] = 0. - Am1[-1,:] = 0. - - #logger.log_and_raise('Lateral constant boundary condition not yet implemented', exc=NotImplementedError) - elif p['boundary_lateral'] == 'uniform': - logger.log_and_raise('Lateral uniform boundary condition not yet implemented', exc=NotImplementedError) - elif p['boundary_lateral'] == 'gradient': - logger.log_and_raise('Lateral gradient boundary condition not yet implemented', exc=NotImplementedError) - elif p['boundary_lateral'] == 'circular': - pass - else: - logger.log_and_raise('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral'], exc=ValueError) - - # construct sparse matrix - if p['ny'] > 0: - j = p['nx']+1 - A = scipy.sparse.diags((Apx.flatten()[:j], - Amx.flatten()[j:], - Am2.flatten()[2:], - Am1.flatten()[1:], - A0.flatten(), - Ap1.flatten()[:-1], - Ap2.flatten()[:-2], - Apx.flatten()[j:], - Amx.flatten()[:j]), - (-j*p['ny'],-j,-2,-1,0,1,2,j,j*p['ny']), format='csr') - else: - A = scipy.sparse.diags((Am2.flatten()[2:], - Am1.flatten()[1:], - A0.flatten(), - Ap1.flatten()[:-1], - Ap2.flatten()[:-2]), - (-2,-1,0,1,2), format='csr') - - # solve transport for each fraction separately using latest - # available weights - - # renormalize weights for all fractions equal or larger - # than the current one such that the sum of all weights is - # unity - # Christa: seems to have no significant effect on weights, - # numerical check to prevent any deviation from unity - w = aeolis.transport.renormalize_weights(w, i) - - # iteratively find a solution of the linear system that - # does not violate the availability of sediment in the bed - for n in range(p['max_iter']): - self._count('matrixsolve') - - # compute saturation levels - ix = s['Cu'] > 0. - S_i = np.zeros(s['Cu'].shape) - S_i[ix] = s['Ct'][ix] / s['Cu'][ix] - s['S'] = S_i.sum(axis=-1) - - # create the right hand side of the linear system - y_i = np.zeros(s['zb'].shape) - y_im = np.zeros(s['zb'].shape) # implicit terms - y_ex = np.zeros(s['zb'].shape) # explicit terms - - y_im[:,1:-1] = ( - (w[:,1:-1,i] * s['Cuf'][:,1:-1,i] * Ti) * (1. - s['S'][:,1:-1]) + - (w[:,1:-1,i] * s['Cu'][:,1:-1,i] * Ti) * s['S'][:,1:-1] - ) - - y_ex[:,1:-1] = ( - (l['w'][:,1:-1,i] * l['Cuf'][:,1:-1,i] * Ti) * (1. - s['S'][:,1:-1]) \ - + (l['w'][:,1:-1,i] * l['Cu'][:,1:-1,i] * Ti) * s['S'][:,1:-1] \ - - ( - sgs[:,1:-1] * Cs[:,1:-1] +\ - sgn[:,1:-1] * Cn[:,1:-1] + Ti - ) * l['Ct'][:,1:-1,i] \ - + ixs[:,1:-1] * Cs_plus[:,1:-1] * l['Ct'][:,:-2,i] \ - - (1. - ixs[:,1:-1]) * Cs_min[:,1:-1] * l['Ct'][:,2:,i] \ - + ixn[:,1:-1] * Cn_plus[:,1:-1] * np.roll(l['Ct'][:,1:-1,i], 1, axis=0) \ - - (1. - ixn[:,1:-1]) * Cn_min[:,1:-1] * np.roll(l['Ct'][:,1:-1,i], -1, axis=0) \ - ) - - y_i[:,1:-1] = l['Ct'][:,1:-1,i] + alpha * y_im[:,1:-1] + (1. - alpha) * y_ex[:,1:-1] - - # add boundaries - if p['boundary_offshore'] == 'flux': - y_i[:,0] = p['offshore_flux'] * s['Cu0'][:,0,i] - if p['boundary_onshore'] == 'flux': - y_i[:,-1] = p['onshore_flux'] * s['Cu0'][:,-1,i] - - if p['boundary_offshore'] == 'constant': - y_i[:,0] = p['constant_offshore_flux'] / s['u'][:,0,i] - if p['boundary_onshore'] == 'constant': - y_i[:,-1] = p['constant_onshore_flux'] / s['u'][:,-1,i] - - # solve system with current weights - Ct_i = scipy.sparse.linalg.spsolve(A, y_i.flatten()) - Ct_i = prevent_tiny_negatives(Ct_i, p['max_error']) - - # check for negative values - if Ct_i.min() < 0.: - ix = Ct_i < 0. - - logger.warning(format_log('Removing negative concentrations', - nrcells=np.sum(ix), - fraction=i, - iteration=n, - minvalue=Ct_i.min(), - coords=np.argwhere(ix.reshape(y_i.shape)), - **logprops)) - - if Ct_i[~ix].sum() != 0: - Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() - else: - Ct_i[~ix] = 0 - - #Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() - Ct_i[ix] = 0. - - # determine pickup and deficit for current fraction - Cu_i = s['Cu'][:,:,i].flatten() - mass_i = s['mass'][:,:,0,i].flatten() - w_i = w[:,:,i].flatten() - pickup_i = (w_i * Cu_i - Ct_i) / p['T'] * self.dt - deficit_i = pickup_i - mass_i - ix = (deficit_i > p['max_error']) \ - & (w_i * Cu_i > 0.) - - # quit the iteration if there is no deficit, otherwise - # back-compute the maximum weight allowed to get zero - # deficit for the current fraction and progress to - # the next iteration step - if not np.any(ix): - logger.debug(format_log('Iteration converged', - steps=n, - fraction=i, - **logprops)) - pickup_i = np.minimum(pickup_i, mass_i) - break - else: - w_i[ix] = (mass_i[ix] * p['T'] / self.dt \ - + Ct_i[ix]) / Cu_i[ix] - w[:,:,i] = w_i.reshape(y_i.shape) - - # throw warning if the maximum number of iterations was reached - if np.any(ix): - logger.warning(format_log('Iteration not converged', - nrcells=np.sum(ix), - fraction=i, - **logprops)) - - # check for unexpected negative values - if Ct_i.min() < 0: - logger.warning(format_log('Negative concentrations', - nrcells=np.sum(Ct_i<0.), - fraction=i, - minvalue=Ct_i.min(), - **logprops)) - if w_i.min() < 0: - logger.warning(format_log('Negative weights', - nrcells=np.sum(w_i<0), - fraction=i, - minvalue=w_i.min(), - **logprops)) - - Ct[:,:,i] = Ct_i.reshape(y_i.shape) - pickup[:,:,i] = pickup_i.reshape(y_i.shape) - - # check if there are any cells where the sum of all weights is - # smaller than unity. these cells are supply-limited for all - # fractions. Log these events. - ix = 1. - np.sum(w, axis=2) > p['max_error'] - if np.any(ix): - self._count('supplylim') - # logger.warning(format_log('Ran out of sediment', - # nrcells=np.sum(ix), - # minweight=np.sum(w, axis=-1).min(), - # **logprops)) - - qs = Ct * s['us'] - qn = Ct * s['un'] - - return dict(Ct=Ct, - qs=qs, - qn=qn, - pickup=pickup, - w=w, - w_init=w_init, - w_air=w_air, - w_bed=w_bed) - - #@njit - def solve_EF(self, alpha:float=0., beta:float=1.) -> dict: - '''Implements the explicit Euler forward, implicit Euler backward and semi-implicit Crank-Nicolson numerical schemes - - Determines weights of sediment fractions, sediment pickup and - instantaneous sediment concentration. Returns a partial - spatial grid dictionary that can be used to update the global - spatial grid dictionary. - - Parameters - ---------- - alpha : - Implicitness coefficient (0.0 for Euler forward, 1.0 for Euler backward or 0.5 for Crank-Nicolson, default=0.5) - beta : - Centralization coefficient (1.0 for upwind or 0.5 for centralized, default=1.0) - - Returns - ------- - Partial spatial grid dictionary - - Examples - -------- - >>> model.s.update(model.solve(alpha=1., beta=1.) # euler backward - - >>> model.s.update(model.solve(alpha=.5, beta=1.) # crank-nicolson - - See Also - -------- - model.AeoLiS.euler_forward - model.AeoLiS.euler_backward - model.AeoLiS.crank_nicolson - transport.compute_weights - transport.renormalize_weights - - ''' - - l = self.l - s = self.s - p = self.p - - Ct = s['Ct'].copy() - pickup = s['pickup'].copy() - Ts = p['T'] - - # compute transport weights for all sediment fractions - w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) - - if self.t == 0.: - if type(p['bedcomp_file']) == np.ndarray: - w = w_init.copy() - else: - # use initial guess for first time step - w = p['grain_dist'].reshape((1,1,-1)) - w = w.repeat(p['ny']+1, axis=0) - w = w.repeat(p['nx']+1, axis=1) - else: - w = w_init.copy() - - # set model state properties that are added to warnings and errors - logprops = dict(minwind=s['uw'].min(), - maxdrop=(l['uw']-s['uw']).max(), - time=self.t, - dt=self.dt) - - nf = p['nfractions'] - - - for i in range(nf): - - if 1: - #define 4 quadrants based on wind directions - ix1 = ((s['us'][:,:,0]>=0) & (s['un'][:,:,0]>=0)) - ix2 = ((s['us'][:,:,0]<0) & (s['un'][:,:,0]>=0)) - ix3 = ((s['us'][:,:,0]<0) & (s['un'][:,:,0]<0)) - ix4 = ((s['us'][:,:,0]>0) & (s['un'][:,:,0]<0)) - - # initiate solution matrix including ghost cells to accomodate boundaries - Ct_s = np.zeros((Ct.shape[0]+2,Ct.shape[1]+2)) - # populate solution matrix with previous concentration results - Ct_s[1:-1,1:-1] = Ct[:,:,i] - - #set upwind boundary condition - Ct_s[:,0:2]=0 - #circular boundary condition in lateral directions - Ct_s[0,:]=Ct_s[-2,:] - Ct_s[-1,:]=Ct_s[1,:] - # using the Euler forward scheme we can calculate pickup first based on the previous timestep - # there is no need for iteration - pickup[:,:,i] = self.dt*(np.minimum(s['Cu'][:,:,i],s['mass'][:,:,0,i]+Ct[:,:,i])-Ct[:,:,i])/Ts - - #solve for all 4 quadrants in one step using logical indexing - Ct_s[1:-1,1:-1] = Ct_s[1:-1,1:-1] + \ - ix1*(-self.dt*s['us'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[1:-1,:-2])/s['ds'] \ - -self.dt*s['un'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[:-2,1:-1])/s['dn']) +\ - ix2*(+self.dt*s['us'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[1:-1,2:])/s['ds'] \ - -self.dt*s['un'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[:-2,1:-1])/s['dn']) +\ - ix3*(+self.dt*s['us'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[1:-1,2:])/s['ds'] \ - +self.dt*s['un'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[2:,1:-1])/s['dn']) +\ - ix4*(-self.dt*s['us'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[1:-1,:-2])/s['ds'] \ - +self.dt*s['un'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[2:,1:-1])/s['dn']) \ - + pickup[:,:,i] - - # define Ct as a subset of Ct_s (eliminating the boundaries) - Ct[:,:,i] = Ct_s[1:-1,1:-1] - - qs = Ct * s['us'] - qn = Ct * s['un'] - - return dict(Ct=Ct, - qs=qs, - qn=qn, - pickup=pickup, - w=w, - w_init=w_init, - w_air=w_air, - w_bed=w_bed) - - #@njit - def solve_SS(self, alpha:float=0., beta:float=1.) -> dict: - '''Implements the explicit Euler forward, implicit Euler backward and semi-implicit Crank-Nicolson numerical schemes - - Determines weights of sediment fractions, sediment pickup and - instantaneous sediment concentration. Returns a partial - spatial grid dictionary that can be used to update the global - spatial grid dictionary. - - Parameters - ---------- - alpha : - Implicitness coefficient (0.0 for Euler forward, 1.0 for Euler backward or 0.5 for Crank-Nicolson, default=0.5) - beta : - Centralization coefficient (1.0 for upwind or 0.5 for centralized, default=1.0) - - Returns - ------- - Partial spatial grid dictionary - - Examples - -------- - >>> model.s.update(model.solve(alpha=1., beta=1.) # euler backward - - >>> model.s.update(model.solve(alpha=.5, beta=1.) # crank-nicolson - - See Also - -------- - model.AeoLiS.euler_forward - model.AeoLiS.euler_backward - model.AeoLiS.crank_nicolson - transport.compute_weights - transport.renormalize_weights - - ''' - - l = self.l - s = self.s - p = self.p - - Ct = s['Ct'].copy() - pickup = s['pickup'].copy() - Ts = p['T'] - - # compute transport weights for all sediment fractions - w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) - - if self.t == 0.: - if type(p['bedcomp_file']) == np.ndarray: - w = w_init.copy() - else: - # use initial guess for first time step - # when p['grain_dist'] has 2 dimensions take the first row otherwise take the only row - if len(p['grain_dist'].shape) == 2: - w = p['grain_dist'][0,:].reshape((1,1,-1)) - else: - w = p['grain_dist'].reshape((1,1,-1)) - - w = w.repeat(p['ny']+1, axis=0) - w = w.repeat(p['nx']+1, axis=1) - else: - w = w_init.copy() - - # set model state properties that are added to warnings and errors - logprops = dict(minwind=s['uw'].min(), - maxdrop=(l['uw']-s['uw']).max(), - time=self.t, - dt=self.dt) - - nf = p['nfractions'] - - - for i in range(nf): - - - if 1: - #print('sweep') - - # initiate emmpty solution matrix, this will effectively kill time dependence and create steady state. - Ct = np.zeros(Ct.shape) - - if p['boundary_offshore'] == 'flux': - Ct[:,0,0] = s['Cu0'][:,0,0] - - if p['boundary_onshore'] == 'flux': - Ct[:,-1,0] = s['Cu0'][:,-1,0] - - if p['boundary_offshore'] == 'circular': - Ct[:,0,0] = -1 - Ct[:,-1,0] = -1 - - if p['boundary_offshore'] == 're_circular': - Ct[:,0,0] = -2 - Ct[:,-1,0] = -2 - - if p['boundary_lateral'] == 'circular': - Ct[0,:,0] = -1 - Ct[-1,:,0] = -1 - - if p['boundary_lateral'] == 're_circular': - Ct[0,:,0] = -2 - Ct[-1,:,0] = -2 - - Ct, pickup = sweep(Ct, s['Cu'].copy(), s['mass'].copy(), self.dt, p['T'], s['ds'], s['dn'], s['us'], s['un'],w) - - qs = Ct * s['us'] - qn = Ct * s['un'] - q = np.hypot(qs, qn) - - - return dict(Ct=Ct, - qs=qs, - qn=qn, - pickup=pickup, - w=w, - w_init=w_init, - w_air=w_air, - w_bed=w_bed, - q=q) - - - def solve_steadystatepieter(self) -> dict: - - beta = 1. - - l = self.l - s = self.s - p = self.p - - Ct = s['Ct'].copy() - qs = s['qs'].copy() - qn = s['qn'].copy() - pickup = s['pickup'].copy() - - Ts = p['T'] - - # compute transport weights for all sediment fractions - w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) - - if self.t == 0.: - # use initial guess for first time step - w = p['grain_dist'].reshape((1,1,-1)) - w = w.repeat(p['ny']+1, axis=0) - w = w.repeat(p['nx']+1, axis=1) - return dict(w=w) - else: - w = w_init.copy() - - # set model state properties that are added to warnings and errors - logprops = dict(minwind=s['uw'].min(), - maxdrop=(l['uw']-s['uw']).max(), - time=self.t, - dt=self.dt) - - nf = p['nfractions'] - - ufs = np.zeros((p['ny']+1,p['nx']+2)) - ufn = np.zeros((p['ny']+2,p['nx']+1)) - - for i in range(nf): #loop over fractions - - #define velocity fluxes - - ufs[:,1:-1] = 0.5*s['us'][:,:-1,i] + 0.5*s['us'][:,1:,i] - ufn[1:-1,:] = 0.5*s['un'][:-1,:,i] + 0.5*s['un'][1:,:,i] - - #boundary values - ufs[:,0] = s['us'][:,0,i] - ufs[:,-1] = s['us'][:,-1,i] - - if p['boundary_lateral'] == 'circular': - ufn[0,:] = 0.5*s['un'][0,:,i] + 0.5*s['un'][-1,:,i] - ufn[-1,:] = ufn[0,:] - else: - ufn[0,:] = s['un'][0,:,i] - ufn[-1,:] = s['un'][-1,:,i] - - beta = abs(beta) - if beta >= 1.: - # define upwind direction - ixfs = np.asarray(ufs >= 0., dtype=float) - ixfn = np.asarray(ufn >= 0., dtype=float) - else: - # or centralizing weights - ixfs = beta + np.zeros(ufs) - ixfn = beta + np.zeros(ufn) - - # initialize matrix diagonals - A0 = np.zeros(s['zb'].shape) - Apx = np.zeros(s['zb'].shape) - Ap1 = np.zeros(s['zb'].shape) - Amx = np.zeros(s['zb'].shape) - Am1 = np.zeros(s['zb'].shape) - - # populate matrix diagonals - #A0 += s['dsdn'] / self.dt #time derivative - A0 += s['dsdn'] / Ts #source term - A0[:,1:] -= s['dn'][:,1:] * ufs[:,1:-1] * (1. - ixfs[:,1:-1]) #lower x-face - Am1[:,1:] -= s['dn'][:,1:] * ufs[:,1:-1] * ixfs[:,1:-1] #lower x-face - A0[:,:-1] += s['dn'][:,:-1] * ufs[:,1:-1] * ixfs[:,1:-1] #upper x-face - Ap1[:,:-1] += s['dn'][:,:-1] * ufs[:,1:-1] * (1. - ixfs[:,1:-1]) #upper x-face - A0[1:,:] -= s['ds'][1:,:] * ufn[1:-1,:] * (1. - ixfn[1:-1,:]) #lower y-face - Amx[1:,:] -= s['ds'][1:,:] * ufn[1:-1,:] * ixfn[1:-1,:] #lower y-face - A0[:-1,:] += s['ds'][:-1,:] * ufn[1:-1,:] * ixfn[1:-1,:] #upper y-face - Apx[:-1,:] += s['ds'][:-1,:] * ufn[1:-1,:] * (1. - ixfn[1:-1,:]) #upper y-face - - # add boundaries - # offshore boundary (i=0) - - if p['boundary_offshore'] == 'flux': - #nothing to be done - pass - elif p['boundary_offshore'] == 'constant': - #constant sediment concentration (Ct) in the air - A0[:,0] = 1. - Apx[:,0] = 0. - Amx[:,0] = 0. - Ap1[:,0] = 0. - Am1[:,0] = 0. - elif p['boundary_offshore'] == 'gradient': - #remove the flux at the inner face of the cell - A0[:,0] -= s['dn'][:,0] * ufs[:,1] * ixfs[:,1] #upper x-face - Ap1[:,0] -= s['dn'][:,0] * ufs[:,1] * (1. - ixfs[:,1]) #upper x-face - elif p['boundary_offshore'] == 'circular': - raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') - else: - raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore']) - - #onshore boundary (i=nx) - - if p['boundary_onshore'] == 'flux': - #nothing to be done - pass - elif p['boundary_onshore'] == 'constant': - #constant sediment concentration (hC) in the air - A0[:,-1] = 1. - Apx[:,-1] = 0. - Amx[:,-1] = 0. - Ap1[:,-1] = 0. - Am1[:,-1] = 0. - elif p['boundary_onshore'] == 'gradient': - #remove the flux at the inner face of the cell - A0[:,-1] += s['dn'][:,-1] * ufs[:,-2] * (1. - ixfs[:,-2]) #lower x-face - Am1[:,-1] += s['dn'][:,-1] * ufs[:,-2] * ixfs[:,-2] #lower x-face - elif p['boundary_onshore'] == 'circular': - raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') - else: - raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_onshore']) - - #lateral boundaries (j=0; j=ny) - - if p['boundary_lateral'] == 'flux': - #nothing to be done - pass - elif p['boundary_lateral'] == 'constant': - #constant sediment concentration (hC) in the air - A0[0,:] = 1. - Apx[0,:] = 0. - Amx[0,:] = 0. - Ap1[0,:] = 0. - Am1[0,:] = 0. - A0[-1,:] = 1. - Apx[-1,:] = 0. - Amx[-1,:] = 0. - Ap1[-1,:] = 0. - Am1[-1,:] = 0. - elif p['boundary_lateral'] == 'gradient': - #remove the flux at the inner face of the cell - A0[0,:] -= s['ds'][0,:] * ufn[1,:] * ixfn[1,:] #upper y-face - Apx[0,:] -= s['ds'][0,:] * ufn[1,:] * (1. - ixfn[1,:]) #upper y-face - A0[-1,:] += s['ds'][-1,:] * ufn[-2,:] * (1. - ixfn[-2,:]) #lower y-face - Amx[-1,:] += s['ds'][-1,:] * ufn[-2,:] * ixfn[-2,:] #lower y-face - elif p['boundary_lateral'] == 'circular': - A0[0,:] -= s['ds'][0,:] * ufn[0,:] * (1. - ixfn[0,:]) #lower y-face - Amx[0,:] -= s['ds'][0,:] * ufn[0,:] * ixfn[0,:] #lower y-face - A0[-1,:] += s['ds'][-1,:] * ufn[-1,:] * ixfn[-1,:] #upper y-face - Apx[-1,:] += s['ds'][-1,:] * ufn[-1,:] * (1. - ixfn[-1,:]) #upper y-face - else: - raise ValueError('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral']) - - # construct sparse matrix - if p['ny'] > 0: - j = p['nx']+1 - A = scipy.sparse.diags((Apx.flatten()[:j], - Amx.flatten()[j:], - Am1.flatten()[1:], - A0.flatten(), - Ap1.flatten()[:-1], - Apx.flatten()[j:], - Amx.flatten()[:j]), - (-j*p['ny'],-j,-1,0,1,j,j*p['ny']), format='csr') - else: - j = p['nx']+1 - ny = 0 - A = scipy.sparse.diags((Am1.flatten()[1:], - A0.flatten(), - Ap1.flatten()[:-1]), - (-1, 0, 1), format='csr') - - # solve transport for each fraction separately using latest - # available weights - - - # renormalize weights for all fractions equal or larger - # than the current one such that the sum of all weights is - # unity - w = aeolis.transport.renormalize_weights(w, i) - - # iteratively find a solution of the linear system that - # does not violate the availability of sediment in the bed - for n in range(p['max_iter']): - self._count('matrixsolve') - - # define upwind face value - # sediment concentration - Ctxfs_i = np.zeros(ufs.shape) - Ctxfn_i = np.zeros(ufn.shape) - - Ctxfs_i[:,1:-1] = ixfs[:,1:-1] * Ct[:,:-1,i] \ - + (1. - ixfs[:,1:-1]) * Ct[:,1:,i] - Ctxfn_i[1:-1,:] = ixfn[1:-1,:] * Ct[:-1,:,i] \ - + (1. - ixfn[1:-1,:]) * Ct[1:,:,i] - - if p['boundary_lateral'] == 'circular': - Ctxfn_i[0,:] = ixfn[0,:] * Ct[-1,:,i] \ - + (1. - ixfn[0,:]) * Ct[0,:,i] - - # calculate pickup - D_i = s['dsdn'] / Ts * Ct[:,:,i] - A_i = s['dsdn'] / Ts * s['mass'][:,:,0,i] + D_i # Availability - U_i = s['dsdn'] / Ts * w[:,:,i] * s['Cu'][:,:,i] - - #deficit_i = E_i - A_i - E_i= np.minimum(U_i, A_i) - #pickup_i = E_i - D_i - - # create the right hand side of the linear system - # sediment concentration - yCt_i = np.zeros(s['zb'].shape) - - yCt_i += E_i - D_i #source term - yCt_i[:,1:] += s['dn'][:,1:] * ufs[:,1:-1] * Ctxfs_i[:,1:-1] #lower x-face - yCt_i[:,:-1] -= s['dn'][:,:-1] * ufs[:,1:-1] * Ctxfs_i[:,1:-1] #upper x-face - yCt_i[1:,:] += s['ds'][1:,:] * ufn[1:-1,:] * Ctxfn_i[1:-1,:] #lower y-face - yCt_i[:-1,:] -= s['ds'][:-1,:] * ufn[1:-1,:] * Ctxfn_i[1:-1,:] #upper y-face - - # boundary conditions - # offshore boundary (i=0) - - if p['boundary_offshore'] == 'flux': - yCt_i[:,0] += s['dn'][:,0] * ufs[:,0] * s['Cu0'][:,0,i] * p['offshore_flux'] - elif p['boundary_offshore'] == 'constant': - #constant sediment concentration (Ct) in the air - yCt_i[:,0] = p['constant_offshore_flux'] - - elif p['boundary_offshore'] == 'gradient': - #remove the flux at the inner face of the cell - yCt_i[:,0] += s['dn'][:,1] * ufs[:,1] * Ctxfs_i[:,1] - - elif p['boundary_offshore'] == 'circular': - raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') - else: - raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore']) - - # onshore boundary (i=nx) - - if p['boundary_onshore'] == 'flux': - yCt_i[:,-1] += s['dn'][:,-1] * ufs[:,-1] * s['Cu0'][:,-1,i] * p['onshore_flux'] - - elif p['boundary_onshore'] == 'constant': - #constant sediment concentration (Ct) in the air - yCt_i[:,-1] = p['constant_onshore_flux'] - - elif p['boundary_onshore'] == 'gradient': - #remove the flux at the inner face of the cell - yCt_i[:,-1] -= s['dn'][:,-2] * ufs[:,-2] * Ctxfs_i[:,-2] - - elif p['boundary_onshore'] == 'circular': - raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') - else: - raise ValueError('Unknown onshore boundary condition [%s]' % self.p['boundary_onshore']) - - #lateral boundaries (j=0; j=ny) - - if p['boundary_lateral'] == 'flux': - - yCt_i[0,:] += s['ds'][0,:] * ufn[0,:] * s['Cu0'][0,:,i] * p['lateral_flux'] #lower y-face - yCt_i[-1,:] -= s['ds'][-1,:] * ufn[-1,:] * s['Cu0'][-1,:,i] * p['lateral_flux'] #upper y-face - elif p['boundary_lateral'] == 'constant': - #constant sediment concentration (hC) in the air - yCt_i[0,:] = 0. - yCt_i[-1,:] = 0. - elif p['boundary_lateral'] == 'gradient': - #remove the flux at the inner face of the cell - yCt_i[-1,:] -= s['ds'][-2,:] * ufn[-2,:] * Ctxfn_i[-2,:] #lower y-face - yCt_i[0,:] += s['ds'][1,:] * ufn[1,:] * Ctxfn_i[1,:] #upper y-face - elif p['boundary_lateral'] == 'circular': - yCt_i[0,:] += s['ds'][0,:] * ufn[0,:] * Ctxfn_i[0,:] #lower y-face - yCt_i[-1,:] -= s['ds'][-1,:] * ufn[-1,:] * Ctxfn_i[-1,:] #upper y-face - else: - raise ValueError('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral']) - - # print("ugs = %.*g" % (3,s['ugs'][10,10])) - # print("ugn = %.*g" % (3,s['ugn'][10,10])) - # print("%.*g" % (3,np.amax(np.absolute(y_i)))) - - # solve system with current weights - Ct_i = Ct[:,:,i].flatten() - Ct_i += scipy.sparse.linalg.spsolve(A, yCt_i.flatten()) - Ct_i = prevent_tiny_negatives(Ct_i, p['max_error']) - - # check for negative values - if Ct_i.min() < 0.: - ix = Ct_i < 0. - -# logger.warn(format_log('Removing negative concentrations', -# nrcells=np.sum(ix), -# fraction=i, -# iteration=n, -# minvalue=Ct_i.min(), -# **logprops)) - - Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() - Ct_i[ix] = 0. - - # determine pickup and deficit for current fraction - Cu_i = s['Cu'][:,:,i].flatten() - mass_i = s['mass'][:,:,0,i].flatten() - w_i = w[:,:,i].flatten() - Ts_i = Ts - - pickup_i = (w_i * Cu_i - Ct_i) / Ts_i * self.dt # Dit klopt niet! enkel geldig bij backward euler - deficit_i = pickup_i - mass_i - ix = (deficit_i > p['max_error']) \ - & (w_i * Cu_i > 0.) - - pickup[:,:,i] = pickup_i.reshape(yCt_i.shape) - Ct[:,:,i] = Ct_i.reshape(yCt_i.shape) - - # quit the iteration if there is no deficit, otherwise - # back-compute the maximum weight allowed to get zero - # deficit for the current fraction and progress to - # the next iteration step - if not np.any(ix): - logger.debug(format_log('Iteration converged', - steps=n, - fraction=i, - **logprops)) - pickup_i = np.minimum(pickup_i, mass_i) - break - else: - w_i[ix] = (mass_i[ix] * Ts_i / self.dt \ - + Ct_i[ix]) / Cu_i[ix] - w[:,:,i] = w_i.reshape(yCt_i.shape) - - # throw warning if the maximum number of iterations was - # reached - if np.any(ix): - logger.warn(format_log('Iteration not converged', - nrcells=np.sum(ix), - fraction=i, - **logprops)) - - # check for unexpected negative values - if Ct_i.min() < 0: - logger.warn(format_log('Negative concentrations', - nrcells=np.sum(Ct_i<0.), - fraction=i, - minvalue=Ct_i.min(), - **logprops)) - if w_i.min() < 0: - logger.warn(format_log('Negative weights', - nrcells=np.sum(w_i<0), - fraction=i, - minvalue=w_i.min(), - **logprops)) - # end loop over frations - - # check if there are any cells where the sum of all weights is - # smaller than unity. these cells are supply-limited for all - # fractions. Log these events. - ix = 1. - np.sum(w, axis=2) > p['max_error'] - if np.any(ix): - self._count('supplylim') -# logger.warn(format_log('Ran out of sediment', -# nrcells=np.sum(ix), -# minweight=np.sum(w, axis=-1).min(), -# **logprops)) - qs = Ct * s['us'] - qn = Ct * s['un'] - - return dict(Ct=Ct, - qs=qs, - qn=qn, - pickup=pickup, - w=w, - w_init=w_init, - w_air=w_air, - w_bed=w_bed) - - - def solve_pieter(self, alpha:float=.5, beta:float=1.) -> dict: - '''Implements the explicit Euler forward, implicit Euler backward and semi-implicit Crank-Nicolson numerical schemes - - Determines weights of sediment fractions, sediment pickup and - instantaneous sediment concentration. Returns a partial - spatial grid dictionary that can be used to update the global - spatial grid dictionary. - - Parameters - ---------- - alpha : - Implicitness coefficient (0.0 for Euler forward, 1.0 for Euler backward or 0.5 for Crank-Nicolson, default=0.5) - beta : float, optional - Centralization coefficient (1.0 for upwind or 0.5 for centralized, default=1.0) - - Returns - ------- - Partial spatial grid dictionary - - Examples - -------- - >>> model.s.update(model.solve(alpha=1., beta=1.) # euler backward - - >>> model.s.update(model.solve(alpha=.5, beta=1.) # crank-nicolson - - See Also - -------- - model.AeoLiS.euler_forward - model.AeoLiS.euler_backward - model.AeoLiS.crank_nicolson - transport.compute_weights - transport.renormalize_weights - ''' - - l = self.l - s = self.s - p = self.p - - Ct = s['Ct'].copy() - qs = s['qs'].copy() - qn = s['qn'].copy() - pickup = s['pickup'].copy() - - Ts = p['T'] - - # compute transport weights for all sediment fractions - w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) - - if self.t == 0.: - # use initial guess for first time step - w = p['grain_dist'].reshape((1,1,-1)) - w = w.repeat(p['ny']+1, axis=0) - w = w.repeat(p['nx']+1, axis=1) - return dict(w=w) - else: - w = w_init.copy() - - # set model state properties that are added to warnings and errors - logprops = dict(minwind=s['uw'].min(), - maxdrop=(l['uw']-s['uw']).max(), - time=self.t, - dt=self.dt) - - nf = p['nfractions'] - - ufs = np.zeros((p['ny']+1,p['nx']+2)) - ufn = np.zeros((p['ny']+2,p['nx']+1)) - - for i in range(nf): #loop over fractions - - #define velocity fluxes - ufs[:,1:-1] = 0.5*s['us'][:,:-1,i] + 0.5*s['us'][:,1:,i] - ufn[1:-1,:] = 0.5*s['un'][:-1,:,i] + 0.5*s['un'][1:,:,i] - - #boundary values - ufs[:,0] = s['us'][:,0,i] - ufs[:,-1] = s['us'][:,-1,i] - - if p['boundary_lateral'] == 'circular': - ufn[0,:] = 0.5*s['un'][0,:,i] + 0.5*s['un'][-1,:,i] - ufn[-1,:] = ufn[0,:] - else: - ufn[0,:] = s['un'][0,:,i] - ufn[-1,:] = s['un'][-1,:,i] - - beta = abs(beta) - if beta >= 1.: - # define upwind direction - ixfs = np.asarray(ufs >= 0., dtype=float) - ixfn = np.asarray(ufn >= 0., dtype=float) - else: - # or centralizing weights - ixfs = beta + np.zeros(ufs) - ixfn = beta + np.zeros(ufn) - - # initialize matrix diagonals - A0 = np.zeros(s['zb'].shape) - Apx = np.zeros(s['zb'].shape) - Ap1 = np.zeros(s['zb'].shape) - Amx = np.zeros(s['zb'].shape) - Am1 = np.zeros(s['zb'].shape) - - # populate matrix diagonals - A0 += s['dsdn'] / self.dt #time derivative - A0 += s['dsdn'] / Ts * alpha #source term - A0[:,1:] -= s['dn'][:,1:] * ufs[:,1:-1] * (1. - ixfs[:,1:-1]) * alpha #lower x-face - Am1[:,1:] -= s['dn'][:,1:] * ufs[:,1:-1] * ixfs[:,1:-1] * alpha #lower x-face - A0[:,:-1] += s['dn'][:,:-1] * ufs[:,1:-1] * ixfs[:,1:-1] * alpha #upper x-face - Ap1[:,:-1] += s['dn'][:,:-1] * ufs[:,1:-1] * (1. - ixfs[:,1:-1]) * alpha #upper x-face - A0[1:,:] -= s['ds'][1:,:] * ufn[1:-1,:] * (1. - ixfn[1:-1,:]) * alpha #lower y-face - Amx[1:,:] -= s['ds'][1:,:] * ufn[1:-1,:] * ixfn[1:-1,:] * alpha #lower y-face - A0[:-1,:] += s['ds'][:-1,:] * ufn[1:-1,:] * ixfn[1:-1,:] * alpha #upper y-face - Apx[:-1,:] += s['ds'][:-1,:] * ufn[1:-1,:] * (1. - ixfn[1:-1,:]) * alpha #upper y-face - - # add boundaries - # offshore boundary (i=0) - - if p['boundary_offshore'] == 'flux': - #nothing to be done - pass - elif p['boundary_offshore'] == 'constant': - #constant sediment concentration (Ct) in the air - A0[:,0] = 1. - Apx[:,0] = 0. - Amx[:,0] = 0. - Ap1[:,0] = 0. - Am1[:,0] = 0. - elif p['boundary_offshore'] == 'gradient': - #remove the flux at the inner face of the cell - A0[:,0] -= s['dn'][:,0] * ufs[:,1] * ixfs[:,1] * alpha #upper x-face - Ap1[:,0] -= s['dn'][:,0] * ufs[:,1] * (1. - ixfs[:,1]) * alpha #upper x-face - elif p['boundary_offshore'] == 'circular': - raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') - else: - raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore']) - - #onshore boundary (i=nx) - - if p['boundary_onshore'] == 'flux': - #nothing to be done - pass - elif p['boundary_onshore'] == 'constant': - #constant sediment concentration (hC) in the air - A0[:,-1] = 1. - Apx[:,-1] = 0. - Amx[:,-1] = 0. - Ap1[:,-1] = 0. - Am1[:,-1] = 0. - elif p['boundary_onshore'] == 'gradient': - #remove the flux at the inner face of the cell - A0[:,-1] += s['dn'][:,-1] * ufs[:,-2] * (1. - ixfs[:,-2]) * alpha #lower x-face - Am1[:,-1] += s['dn'][:,-1] * ufs[:,-2] * ixfs[:,-2] * alpha #lower x-face - elif p['boundary_onshore'] == 'circular': - raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') - else: - raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_onshore']) - - #lateral boundaries (j=0; j=ny) - - if p['boundary_lateral'] == 'flux': - #nothing to be done - pass - elif p['boundary_lateral'] == 'constant': - #constant sediment concentration (hC) in the air - A0[0,:] = 1. - Apx[0,:] = 0. - Amx[0,:] = 0. - Ap1[0,:] = 0. - Am1[0,:] = 0. - A0[-1,:] = 1. - Apx[-1,:] = 0. - Amx[-1,:] = 0. - Ap1[-1,:] = 0. - Am1[-1,:] = 0. - elif p['boundary_lateral'] == 'gradient': - #remove the flux at the inner face of the cell - A0[0,:] -= s['ds'][0,:] * ufn[1,:] * ixfn[1,:] * alpha #upper y-face - Apx[0,:] -= s['ds'][0,:] * ufn[1,:] * (1. - ixfn[1,:]) * alpha #upper y-face - A0[-1,:] += s['ds'][-1,:] * ufn[-2,:] * (1. - ixfn[-2,:]) * alpha #lower y-face - Amx[-1,:] += s['ds'][-1,:] * ufn[-2,:] * ixfn[-2,:] * alpha #lower y-face - elif p['boundary_lateral'] == 'circular': - A0[0,:] -= s['ds'][0,:] * ufn[0,:] * (1. - ixfn[0,:]) * alpha #lower y-face - Amx[0,:] -= s['ds'][0,:] * ufn[0,:] * ixfn[0,:] * alpha #lower y-face - A0[-1,:] += s['ds'][-1,:] * ufn[-1,:] * ixfn[-1,:] * alpha #upper y-face - Apx[-1,:] += s['ds'][-1,:] * ufn[-1,:] * (1. - ixfn[-1,:]) * alpha #upper y-face - else: - raise ValueError('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral']) - - # construct sparse matrix - if p['ny'] > 0: - j = p['nx']+1 - A = scipy.sparse.diags((Apx.flatten()[:j], - Amx.flatten()[j:], - Am1.flatten()[1:], - A0.flatten(), - Ap1.flatten()[:-1], - Apx.flatten()[j:], - Amx.flatten()[:j]), - (-j*p['ny'],-j,-1,0,1,j,j*p['ny']), format='csr') - else: - A = scipy.sparse.diags((Am1.flatten()[1:], - A0.flatten(), - Ap1.flatten()[:-1]), - (-1,0,1), format='csr') - - # solve transport for each fraction separately using latest - # available weights - - # renormalize weights for all fractions equal or larger - # than the current one such that the sum of all weights is - # unity - w = aeolis.transport.renormalize_weights(w, i) - - # iteratively find a solution of the linear system that - # does not violate the availability of sediment in the bed - for n in range(p['max_iter']): - self._count('matrixsolve') -# print("iteration nr = %d" % n) - # define upwind face value - # sediment concentration - Ctxfs_i = np.zeros(ufs.shape) - Ctxfn_i = np.zeros(ufn.shape) - - Ctxfs_i[:,1:-1] = ixfs[:,1:-1] * ( alpha * Ct[:,:-1,i] \ - + (1. - alpha ) * l['Ct'][:,:-1,i] ) \ - + (1. - ixfs[:,1:-1]) * ( alpha * Ct[:,1:,i] \ - + (1. - alpha ) * l['Ct'][:,1:,i] ) - Ctxfn_i[1:-1,:] = ixfn[1:-1,:] * (alpha * Ct[:-1,:,i] \ - + (1. - alpha ) * l['Ct'][:-1,:,i] ) \ - + (1. - ixfn[1:-1,:]) * ( alpha * Ct[1:,:,i] \ - + (1. - alpha ) * l['Ct'][1:,:,i] ) - - if p['boundary_lateral'] == 'circular': - Ctxfn_i[0,:] = ixfn[0,:] * (alpha * Ct[-1,:,i] \ - + (1. - alpha ) * l['Ct'][-1,:,i] ) \ - + (1. - ixfn[0,:]) * ( alpha * Ct[0,:,i] \ - + (1. - alpha ) * l['Ct'][0,:,i] ) - Ctxfn_i[-1,:] = Ctxfn_i[0,:] - - # calculate pickup - D_i = s['dsdn'] / Ts * ( alpha * Ct[:,:,i] \ - + (1. - alpha ) * l['Ct'][:,:,i] ) - A_i = s['dsdn'] / Ts * s['mass'][:,:,0,i] + D_i # Availability - U_i = s['dsdn'] / Ts * ( w[:,:,i] * alpha * s['Cu'][:,:,i] \ - + (1. - alpha ) * l['w'][:,:,i] * l['Cu'][:,:,i] ) - #deficit_i = E_i - A_i - E_i= np.minimum(U_i, A_i) - #pickup_i = E_i - D_i - - # create the right hand side of the linear system - # sediment concentration - yCt_i = np.zeros(s['zb'].shape) - yCt_i -= s['dsdn'] / self.dt * ( Ct[:,:,i] \ - - l['Ct'][:,:,i] ) #time derivative - yCt_i += E_i - D_i #source term - yCt_i[:,1:] += s['dn'][:,1:] * ufs[:,1:-1] * Ctxfs_i[:,1:-1] #lower x-face - yCt_i[:,:-1] -= s['dn'][:,:-1] * ufs[:,1:-1] * Ctxfs_i[:,1:-1] #upper x-face - yCt_i[1:,:] += s['ds'][1:,:] * ufn[1:-1,:] * Ctxfn_i[1:-1,:] #lower y-face - yCt_i[:-1,:] -= s['ds'][:-1,:] * ufn[1:-1,:] * Ctxfn_i[1:-1,:] #upper y-face - - # boundary conditions - # offshore boundary (i=0) - - if p['boundary_offshore'] == 'flux': - yCt_i[:,0] += s['dn'][:,0] * ufs[:,0] * s['Cu0'][:,0,i] * p['offshore_flux'] - - elif p['boundary_offshore'] == 'constant': - #constant sediment concentration (Ct) in the air (for now = 0) - yCt_i[:,0] = 0. - - elif p['boundary_offshore'] == 'gradient': - #remove the flux at the inner face of the cell - yCt_i[:,0] += s['dn'][:,1] * ufs[:,1] * Ctxfs_i[:,1] #upper x-face - - elif p['boundary_offshore'] == 'circular': - raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') - else: - raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore']) - - # onshore boundary (i=nx) - - if p['boundary_onshore'] == 'flux': - yCt_i[:,-1] += s['dn'][:,-1] * ufs[:,-1] * s['Cu0'][:,-1,i] * p['onshore_flux'] - - elif p['boundary_onshore'] == 'constant': - #constant sediment concentration (Ct) in the air (for now = 0) - yCt_i[:,-1] = 0. - - elif p['boundary_onshore'] == 'gradient': - #remove the flux at the inner face of the cell - yCt_i[:,-1] -= s['dn'][:,-2] * ufs[:,-2] * Ctxfs_i[:,-2] #lower x-face - - elif p['boundary_onshore'] == 'circular': - raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') - else: - raise ValueError('Unknown onshore boundary condition [%s]' % self.p['boundary_onshore']) - - #lateral boundaries (j=0; j=ny) - - if p['boundary_lateral'] == 'flux': - - yCt_i[0,:] += s['ds'][0,:] * ufn[0,:] * s['Cu0'][0,:,i] * p['lateral_flux'] #lower y-face - yCt_i[-1,:] -= s['ds'][-1,:] * ufn[-1,:] * s['Cu0'][-1,:,i] * p['lateral_flux'] #upper y-face - - elif p['boundary_lateral'] == 'constant': - #constant sediment concentration (hC) in the air - yCt_i[0,:] = 0. - yCt_i[-1,:] = 0. - elif p['boundary_lateral'] == 'gradient': - #remove the flux at the inner face of the cell - yCt_i[-1,:] -= s['ds'][-2,:] * ufn[-2,:] * Ctxfn_i[-2,:] #lower y-face - yCt_i[0,:] += s['ds'][1,:] * ufn[1,:] * Ctxfn_i[1,:] #upper y-face - elif p['boundary_lateral'] == 'circular': - yCt_i[0,:] += s['ds'][0,:] * ufn[0,:] * Ctxfn_i[0,:] #lower y-face - yCt_i[-1,:] -= s['ds'][-1,:] * ufn[-1,:] * Ctxfn_i[-1,:] #upper y-face - else: - raise ValueError('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral']) - - # print("ugs = %.*g" % (3,s['ugs'][10,10])) - # print("ugn = %.*g" % (3,s['ugn'][10,10])) - # print("%.*g" % (3,np.amax(np.absolute(y_i)))) - - # solve system with current weights - Ct_i = Ct[:,:,i].flatten() - Ct_i += scipy.sparse.linalg.spsolve(A, yCt_i.flatten()) - Ct_i = prevent_tiny_negatives(Ct_i, p['max_error']) - - # check for negative values - if Ct_i.min() < 0.: - ix = Ct_i < 0. - -# logger.warn(format_log('Removing negative concentrations', -# nrcells=np.sum(ix), -# fraction=i, -# iteration=n, -# minvalue=Ct_i.min(), -# **logprops)) - - if 0: #Ct_i[~ix].sum()>0.: - # compensate the negative concentrations by distributing them over the positives. - # I guess the idea is to conserve mass but it is not sure if this is needed, - # mass continuity in the system is guaranteed by exchange with bed. - Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() - Ct_i[ix] = 0. - - # determine pickup and deficit for current fraction - Cu_i = s['Cu'][:,:,i].flatten() - mass_i = s['mass'][:,:,0,i].flatten() - w_i = w[:,:,i].flatten() - Ts_i = Ts - - pickup_i = (w_i * Cu_i - Ct_i) / Ts_i * self.dt # Dit klopt niet! enkel geldig bij backward euler - deficit_i = pickup_i - mass_i - ix = (deficit_i > p['max_error']) \ - & (w_i * Cu_i > 0.) - - pickup[:,:,i] = pickup_i.reshape(yCt_i.shape) - Ct[:,:,i] = Ct_i.reshape(yCt_i.shape) - - # quit the iteration if there is no deficit, otherwise - # back-compute the maximum weight allowed to get zero - # deficit for the current fraction and progress to - # the next iteration step - if not np.any(ix): - logger.debug(format_log('Iteration converged', - steps=n, - fraction=i, - **logprops)) - pickup_i = np.minimum(pickup_i, mass_i) - break - else: - w_i[ix] = (mass_i[ix] * Ts_i / self.dt \ - + Ct_i[ix]) / Cu_i[ix] - w[:,:,i] = w_i.reshape(yCt_i.shape) - - # throw warning if the maximum number of iterations was - # reached - - if np.any(ix): - logger.warn(format_log('Iteration not converged', - nrcells=np.sum(ix), - fraction=i, - **logprops)) - - if 0: #let's disable these warnings - # check for unexpected negative values - if Ct_i.min() < 0: - logger.warn(format_log('Negative concentrations', - nrcells=np.sum(Ct_i<0.), - fraction=i, - minvalue=Ct_i.min(), - **logprops)) - if w_i.min() < 0: - logger.warn(format_log('Negative weights', - nrcells=np.sum(w_i<0), - fraction=i, - minvalue=w_i.min(), - **logprops)) - # end loop over frations - - # check if there are any cells where the sum of all weights is - # smaller than unity. these cells are supply-limited for all - # fractions. Log these events. - ix = 1. - np.sum(w, axis=2) > p['max_error'] - if np.any(ix): - self._count('supplylim') -# logger.warn(format_log('Ran out of sediment', -# nrcells=np.sum(ix), -# minweight=np.sum(w, axis=-1).min(), -# **logprops)) - - qs = Ct * s['us'] - qn = Ct * s['un'] - qs = Ct * s['us'] - qn = Ct * s['un'] - q = np.hypot(qs, qn) - - - return dict(Ct=Ct, - qs=qs, - qn=qn, - pickup=pickup, - w=w, - w_init=w_init, - w_air=w_air, - w_bed=w_bed, - q=q) + def get_count(self, name:str) -> int: '''Get counter value diff --git a/aeolis/utils.py b/aeolis/utils.py index 9001abfb..bb662d35 100644 --- a/aeolis/utils.py +++ b/aeolis/utils.py @@ -447,427 +447,4 @@ def calc_mean_grain_size(p, s): # Retrieve mean grain size based on weight of mass D_mean[yi, xi] = np.sum(diameters*weights) - return D_mean - -# Note: @njit(cache=True) is intentionally not used here. -# This function acts as an orchestrator, delegating work to Numba-compiled helper functions. -# Decorating the orchestrator itself with njit provides no performance benefit, -# since most of the computation is already handled by optimized Numba functions. -def sweep(Ct, Cu, mass, dt, Ts, ds, dn, us, un, w): - - - pickup = np.zeros(Cu.shape) - i=0 - k=0 - - nf = np.shape(Ct)[2] - - # Are the lateral boundary conditions circular? - circ_lateral = False - if Ct[0,1,0]==-1: - circ_lateral = True - Ct[0,:,0] = 0 - Ct[-1,:,0] = 0 - - circ_offshore = False - if Ct[1,0,0]==-1: - circ_offshore = True - Ct[:,0,0] = 0 - Ct[:,-1,0] = 0 - - recirc_offshore = False - if Ct[1,0,0]==-2: - recirc_offshore = True - Ct[:,0,0] = 0 - Ct[:,-1,0] = 0 - - - ufs = np.zeros((np.shape(us)[0], np.shape(us)[1]+1, np.shape(us)[2])) - ufn = np.zeros((np.shape(un)[0]+1, np.shape(un)[1], np.shape(un)[2])) - - # define velocity at cell faces - ufs[:,1:-1, :] = 0.5*us[:,:-1, :] + 0.5*us[:,1:, :] - ufn[1:-1,:, :] = 0.5*un[:-1,:, :] + 0.5*un[1:,:, :] - - # print(ufs[5,:,0]) - - # set empty boundary values, extending the velocities at the boundaries - ufs[:,0, :] = ufs[:,1, :] - ufs[:,-1, :] = ufs[:,-2, :] - - ufn[0,:, :] = ufn[1,:, :] - ufn[-1,:, :] = ufn[-2,:, :] - - # Lets take the average of the top and bottom and left/right boundary cells - # apply the average to the boundary cells - # this ensures that the inflow at one side is equal to the outflow at the other side - - ufs[:,0,:] = (ufs[:,0,:]+ufs[:,-1,:])/2 - ufs[:,-1,:] = ufs[:,0,:] - ufs[0,:,:] = (ufs[0,:,:]+ufs[-1,:,:])/2 - ufs[-1,:,:] = ufs[0,:,:] - - ufn[:,0,:] = (ufn[:,0,:]+ufn[:,-1,:])/2 - ufn[:,-1,:] = ufn[:,0,:] - ufn[0,:,:] = (ufn[0,:,:]+ufn[-1,:,:])/2 - ufn[-1,:,:] = ufn[0,:,:] - - # now make sure that there is no gradients at the boundaries - # ufs[:,1,:] = ufs[:,0,:] - # ufs[:,-2,:] = ufs[:,-1,:] - # ufs[1,:,:] = ufs[0,:,:] - # ufs[-2,:,:] = ufs[-1,:,:] - - # ufn[:,1,:] = ufn[:,0,:] - # ufn[:,-2,:] = ufn[:,-1,:] - # ufn[1,:,:] = ufn[0,:,:] - # ufn[-2,:,:] = ufn[-1,:,:] - - # ufn[:,:,:] = ufn[-2,:,:] - - # also correct for the potential gradients at the boundary cells in the equilibrium concentrations - Cu[:,0,:] = Cu[:,1,:] - Cu[:,-1,:] = Cu[:,-2,:] - Cu[0,:,:] = Cu[1,:,:] - Cu[-1,:,:] = Cu[-2,:,:] - - # #boundary values - # ufs[:,0, :] = us[:,0, :] - # ufs[:,-1, :] = us[:,-1, :] - - # ufn[0,:, :] = un[0,:, :] - # ufn[-1,:, :] = un[-1,:, :] - - Ct_last = Ct.copy() - while k==0 or np.any(np.abs(Ct[:,:,i]-Ct_last[:,:,i])>1e-10): - # while k==0 or np.any(np.abs(Ct[:,:,i]-Ct_last[:,:,i])!=0): - Ct_last = Ct.copy() - - # lateral boundaries circular - if circ_lateral: - Ct[0,:,0],Ct[-1,:,0] = Ct[-1,:,0].copy(),Ct[0,:,0].copy() - # pickup[0,:,0],pickup[-1,:,0] = pickup[-1,:,0].copy(),pickup[0,:,0].copy() - if circ_offshore: - Ct[:,0,0],Ct[:,-1,0] = Ct[:,-1,0].copy(),Ct[:,0,0].copy() - # pickup[:,0,0],pickup[:,-1,0] = pickup[:,-1,0].copy(),pickup[:,0,0].copy() - - if recirc_offshore: - Ct[:,0,0],Ct[:,-1,0] = np.mean(Ct[:,-2,0]), np.mean(Ct[:,1,0]) - - # Track visited cells and quadrant classification - visited = np.zeros(Cu.shape[:2], dtype=bool) - quad = np.zeros(Cu.shape[:2], dtype=np.uint8) - - ######################################################################################## - # in this sweeping algorithm we sweep over the 4 quadrants - # assuming that most cells have no converging/divering charactersitics. - # In the last quadrant we take converging and diverging cells into account. - - # The First quadrant (Numba-optimized) - _solve_quadrant1(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) - - # The second quadrant (Numba-optimized) - _solve_quadrant2(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) - - # The third quadrant (Numba-optimized) - _solve_quadrant3(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) - - # The fourth quadrant (Numba-optimized) - _solve_quadrant4(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) - - # Generic stencil for remaining cells including boundaries (Numba-optimized) - _solve_generic_stencil(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) - - # check the boundaries of the pickup matrix for unvisited cells - # print(np.shape(visited[0,:]==False)) - pickup[0,:,0] = pickup[1,:,0].copy() - pickup[-1,:,0] = pickup[-2,:,0].copy() - - k+=1 - - # # plot Ct - # import matplotlib.pyplot as plt - # plt.imshow(quad[:10,:10], origin='lower') - # # plt.colorbar() - # plt.title('Concentration after %d sweeps' % k) - # plt.show() - # plt.imshow(Ct[:50,:50], origin='lower') - # # plt.colorbar() - # plt.title('Concentration after %d sweeps' % k) - # plt.show() - # plt.plot(pickup[0,:,0]) - # plt.plot(pickup[-1,:,0]) - # plt.show() - - # print(k) - - - # print("q1 = " + str(np.sum(q==1)) + " q2 = " + str(np.sum(q==2)) \ - # + " q3 = " + str(np.sum(q==3)) + " q4 = " + str(np.sum(q==4)) \ - # + " q5 = " + str(np.sum(q==5))) - # print("pickup deviation percentage = " + str(pickup.sum()/pickup[pickup>0].sum()*100) + " %") - # print("pickup deviation percentage = " + str(pickup[1,:,0].sum()/pickup[1,pickup[1,:,0]>0,0].sum()*100) + " %") - # print("pickup maximum = " + str(pickup.max()) + " mass max = " + str(mass.max())) - # print("pickup minimum = " + str(pickup.min())) - # print("pickup average = " + str(pickup.mean())) - # print("number of cells for pickup maximum = " + str((pickup == mass.max()).sum())) - # pickup[1,:,0].sum()/pickup[1,pickup[1,:,0]<0,0].sum() - - return Ct, pickup - - -@njit(cache=True) -def _solve_quadrant1(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): - """Solve first quadrant (positive flow in both directions) with Numba optimization.""" - for n in range(1, Ct.shape[0]): - for s in range(1, Ct.shape[1]): - if ( - (not visited[n, s]) - and (ufn[n, s, 0] >= 0) - and (ufs[n, s, 0] >= 0) - and (ufn[n + 1, s, 0] >= 0) - and (ufs[n, s + 1, 0] >= 0) - ): - - # Compute concentration for all fractions - for f in range(nf): - num = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + - Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + - w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) - - den = (ufn[n + 1, s, f] * ds[n, s] + - ufs[n, s + 1, f] * dn[n, s] + - ds[n, s] * dn[n, s] / Ts) - - Ct[n, s, f] = num / den - - # Calculate pickup - pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts - - # Check for supply limitations and re-iterate - if pickup[n, s, f] > mass[n, s, 0, f]: - pickup[n, s, f] = mass[n, s, 0, f] - - num_limited = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + - Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + - pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) - - den_limited = (ufn[n + 1, s, f] * ds[n, s] + - ufs[n, s + 1, f] * dn[n, s]) - - Ct[n, s, f] = num_limited / den_limited - - visited[n, s] = True - quad[n, s] = 1 - - -@njit(cache=True) -def _solve_quadrant2(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): - """Solve second quadrant (positive n-flow, negative s-flow) with Numba optimization.""" - for n in range(1, Ct.shape[0]): - for s in range(Ct.shape[1] - 2, -1, -1): - if ( - (not visited[n, s]) - and (ufn[n, s, 0] >= 0) - and (ufs[n, s, 0] <= 0) - and (ufn[n + 1, s, 0] >= 0) - and (ufs[n, s + 1, 0] <= 0) - ): - - # Compute concentration for all fractions - for f in range(nf): - num = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + - -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + - w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) - - den = (ufn[n + 1, s, f] * ds[n, s] + - -ufs[n, s, f] * dn[n, s] + - ds[n, s] * dn[n, s] / Ts) - - Ct[n, s, f] = num / den - - # Calculate pickup - pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts - - # Check for supply limitations and re-iterate - if pickup[n, s, f] > mass[n, s, 0, f]: - pickup[n, s, f] = mass[n, s, 0, f] - - num_limited = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + - -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + - pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) - - den_limited = (ufn[n + 1, s, f] * ds[n, s] + - -ufs[n, s, f] * dn[n, s]) - - Ct[n, s, f] = num_limited / den_limited - - visited[n, s] = True - quad[n, s] = 2 - - -@njit(cache=True) -def _solve_quadrant3(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): - """Solve third quadrant (negative flow in both directions) with Numba optimization.""" - for n in range(Ct.shape[0] - 2, -1, -1): - for s in range(Ct.shape[1] - 2, -1, -1): - if ( - (not visited[n, s]) - and (ufn[n, s, 0] <= 0) - and (ufs[n, s, 0] <= 0) - and (ufn[n + 1, s, 0] <= 0) - and (ufs[n, s + 1, 0] <= 0) - ): - - # Compute concentration for all fractions - for f in range(nf): - num = (-Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + - -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + - w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) - - den = (-ufn[n, s, f] * dn[n, s] + - -ufs[n, s, f] * dn[n, s] + - ds[n, s] * dn[n, s] / Ts) - - Ct[n, s, f] = num / den - - # Calculate pickup - pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts - - # Check for supply limitations and re-iterate - if pickup[n, s, f] > mass[n, s, 0, f]: - pickup[n, s, f] = mass[n, s, 0, f] - - num_limited = (-Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + - -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + - pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) - - den_limited = (-ufn[n, s, f] * dn[n, s] + - -ufs[n, s, f] * dn[n, s]) - - Ct[n, s, f] = num_limited / den_limited - - visited[n, s] = True - quad[n, s] = 3 - - -@njit(cache=True) -def _solve_quadrant4(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): - """Solve fourth quadrant (negative n-flow, positive s-flow) with Numba optimization.""" - for n in range(Ct.shape[0] - 2, -1, -1): - for s in range(1, Ct.shape[1]): - if ( - (not visited[n, s]) - and (ufn[n, s, 0] <= 0) - and (ufs[n, s, 0] >= 0) - and (ufn[n + 1, s, 0] <= 0) - and (ufs[n, s + 1, 0] >= 0) - ): - - # Compute concentration for all fractions - for f in range(nf): - num = (Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + - -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + - w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) - - den = (ufs[n, s + 1, f] * dn[n, s] + - -ufn[n, s, f] * dn[n, s] + - ds[n, s] * dn[n, s] / Ts) - - Ct[n, s, f] = num / den - - # Calculate pickup - pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts - - # Check for supply limitations and re-iterate - if pickup[n, s, f] > mass[n, s, 0, f]: - pickup[n, s, f] = mass[n, s, 0, f] - - num_limited = (Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + - -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + - pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) - - den_limited = (ufs[n, s + 1, f] * dn[n, s] + - -ufn[n, s, f] * dn[n, s]) - - Ct[n, s, f] = num_limited / den_limited - - visited[n, s] = True - quad[n, s] = 4 - - -@njit(cache=True) -def _solve_generic_stencil(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): - """Solve remaining cells with generic stencil using conditionals (Numba-optimized).""" - for n in range(Ct.shape[0] - 2, -1, -1): - for s in range(1, Ct.shape[1]): - if (not visited[n, s]) and (n != 0) and (s != Ct.shape[1] - 1): - # Apply generic stencil with conditionals instead of boolean multiplication - for f in range(nf): - # Initialize with source term - num = w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts - den = ds[n, s] * dn[n, s] / Ts - - # Add flux contributions conditionally - if ufn[n, s, 0] > 0: - num += Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] - - if ufs[n, s, 0] > 0: - num += Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] - - if ufn[n + 1, s, 0] < 0: - num += -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] - elif ufn[n + 1, s, 0] > 0: - den += ufn[n + 1, s, f] * ds[n, s] - - if ufs[n, s + 1, 0] < 0: - num += -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] - elif ufs[n, s + 1, 0] > 0: - den += ufs[n, s + 1, f] * dn[n, s] - - if ufn[n, s, 0] < 0: - den += -ufn[n, s, f] * dn[n, s] - - if ufs[n, s, 0] < 0: - den += -ufs[n, s, f] * dn[n, s] - - Ct[n, s, f] = num / den - - # Calculate pickup - pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts - - # Check for supply limitations and re-iterate - if pickup[n, s, f] > mass[n, s, 0, f]: - pickup[n, s, f] = mass[n, s, 0, f] - - # Recompute with limited pickup - num_lim = pickup[n, s, f] * ds[n, s] * dn[n, s] / dt - den_lim = 0.0 - - if ufn[n, s, 0] > 0: - num_lim += Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] - - if ufs[n, s, 0] > 0: - num_lim += Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] - - if ufn[n + 1, s, 0] < 0: - num_lim += -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] - elif ufn[n + 1, s, 0] > 0: - den_lim += ufn[n + 1, s, f] * ds[n, s] - - if ufs[n, s + 1, 0] < 0: - num_lim += -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] - elif ufs[n, s + 1, 0] > 0: - den_lim += ufs[n, s + 1, f] * dn[n, s] - - if ufn[n, s, 0] < 0: - den_lim += -ufn[n, s, f] * dn[n, s] - - if ufs[n, s, 0] < 0: - den_lim += -ufs[n, s, f] * dn[n, s] - - Ct[n, s, f] = num_lim / den_lim - - visited[n, s] = True - quad[n, s] = 5 - + return D_mean \ No newline at end of file From 2f38e9ebed52888f1927dc2847dd118e71059f8b Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Sun, 23 Nov 2025 20:23:06 -0800 Subject: [PATCH 05/23] Add bed interaction parameter 'zeta' and related configuration options --- aeolis/bed.py | 6 ++++++ aeolis/constants.py | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/aeolis/bed.py b/aeolis/bed.py index 224b2861..9e6dc137 100644 --- a/aeolis/bed.py +++ b/aeolis/bed.py @@ -125,6 +125,12 @@ def initialize(s, p): # initialize threshold if p['threshold_file'] is not None: s['uth'] = p['threshold_file'][:,:,np.newaxis].repeat(nf, axis=-1) + + # initialize bed interaction parameter zeta + if p['process_bedinteraction']: + s['zeta'][:,:] = p['zeta_base'] + else: + s['zeta'][:,:] = 1.0 # Similar to the old advection version without zeta return s diff --git a/aeolis/constants.py b/aeolis/constants.py index eae691bc..9c13e596 100644 --- a/aeolis/constants.py +++ b/aeolis/constants.py @@ -117,6 +117,8 @@ 'SWL', # [m] Still Water Level above reference 'DSWL', # [m] Dynamic Still water level above reference (SWL + Set-up) 'Rti', # [-] Factor taking into account sheltering by roughness elements + + 'zeta', # [-] [NEW] Bed interaction parameter for in advection equation ), ('ny','nx','nfractions') : ( 'Cu', # [kg/m^2] Equilibrium sediment concentration integrated over saltation height @@ -185,9 +187,13 @@ 'process_fences' : False, # Enable the process of sand fencing 'process_dune_erosion' : False, # Enable the process of wave-driven dune erosion 'process_seepage_face' : False, # Enable the process of groundwater seepage (NB. only applicable to positive beach slopes) + 'process_bedinteraction' : False, # Enable the process of bed interaction in the advection equation + 'visualization' : False, # Boolean for visualization of model interpretation before and just after initialization + 'output_sedtrails' : False, # NEW! [T/F] Boolean to see whether additional output for SedTRAILS should be generated 'nfraction_sedtrails' : 0, # [-] Index of selected fraction for SedTRAILS (0 if only one fraction) + 'xgrid_file' : None, # Filename of ASCII file with x-coordinates of grid cells 'ygrid_file' : None, # Filename of ASCII file with y-coordinates of grid cells 'bed_file' : None, # Filename of ASCII file with bed level heights of grid cells @@ -340,6 +346,8 @@ 'rhoveg_max' : 0.5, #maximum vegetation density, only used in duran and moore 14 formulation 't_veg' : 3, #time scale of vegetation growth (days), only used in duran and moore 14 formulation 'v_gam' : 1, # only used in duran and moore 14 formulation + + 'zeta_base' : 0.6, # [m] Base value for bed interaction (0: air-dominated, 1: bed-dominated) } REQUIRED_CONFIG = ['nx', 'ny'] From fcb1e324ba9e82c598066f349605cc77bc5f9728 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Sun, 23 Nov 2025 20:24:39 -0800 Subject: [PATCH 06/23] Add bed interaction parameter 'zeta' handling for non-erodible layers --- aeolis/threshold.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/aeolis/threshold.py b/aeolis/threshold.py index f2fc0f87..48f3658f 100644 --- a/aeolis/threshold.py +++ b/aeolis/threshold.py @@ -432,7 +432,11 @@ def non_erodible(s,p): # Hard method for i in range(nf): - s['uth'][ix,i] = np.inf + s['uth'][ix,i] = np.inf + + # Influence of non-erodible layer on bed interaction parameter zeta + if p['process_bedinteraction']: + s['zeta'][ix] = 0.0 # Air-dominated interaction when non-erodible layer is exposed return s From 6bed708665e5187f9c06e2db7d57d75c633efcc3 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Sun, 23 Nov 2025 20:34:52 -0800 Subject: [PATCH 07/23] Add handling for airborne and bed sediment concentrations in transport model --- aeolis/advection.py | 5 +++-- aeolis/constants.py | 2 ++ aeolis/transport.py | 7 +++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/aeolis/advection.py b/aeolis/advection.py index 7185d8b4..71647313 100644 --- a/aeolis/advection.py +++ b/aeolis/advection.py @@ -992,7 +992,7 @@ def solve_SS(self, alpha:float=0., beta:float=1.) -> dict: Ct[0,:,0] = -2 Ct[-1,:,0] = -2 - Ct, pickup = sweep(Ct, s['Cu'].copy(), s['mass'].copy(), self.dt, p['T'], s['ds'], s['dn'], s['us'], s['un'],w) + Ct, pickup = sweep(Ct, s['Cu_bed'].copy(), s['Cu_air'].copy(), s['mass'].copy(), self.dt, p['T'], s['ds'], s['dn'], s['us'], s['un'],w) qs = Ct * s['us'] qn = Ct * s['un'] @@ -1823,8 +1823,9 @@ def solve_pieter(self, alpha:float=.5, beta:float=1.) -> dict: # This function acts as an orchestrator, delegating work to Numba-compiled helper functions. # Decorating the orchestrator itself with njit provides no performance benefit, # since most of the computation is already handled by optimized Numba functions. -def sweep(Ct, Cu, mass, dt, Ts, ds, dn, us, un, w): +def sweep(Ct, Cu_bed, Cu_air, mass, dt, Ts, ds, dn, us, un, w): + Cu = Cu_bed pickup = np.zeros(Cu.shape) i=0 diff --git a/aeolis/constants.py b/aeolis/constants.py index 9c13e596..5560d775 100644 --- a/aeolis/constants.py +++ b/aeolis/constants.py @@ -124,6 +124,8 @@ 'Cu', # [kg/m^2] Equilibrium sediment concentration integrated over saltation height 'Cuf', # [kg/m^2] Equilibrium sediment concentration integrated over saltation height, assuming the fluid shear velocity threshold 'Cu0', # [kg/m^2] Flat bad equilibrium sediment concentration integrated over saltation height + 'Cu_air', # [kg/m^2] [NEW] Equilibrium sediment concentration for airborne sediment + 'Cu_bed', # [kg/m^2] [NEW] Equilibrium sediment concentration for bed sediment 'Ct', # [kg/m^2] Instantaneous sediment concentration integrated over saltation height 'q', # [kg/m/s] Instantaneous sediment flux 'qs', # [kg/m/s] Instantaneous sediment flux in x-direction diff --git a/aeolis/transport.py b/aeolis/transport.py index f0e799bf..87fc90af 100644 --- a/aeolis/transport.py +++ b/aeolis/transport.py @@ -345,6 +345,8 @@ def equilibrium(s, p): s['Cu'] = np.zeros(uth.shape) s['Cuf'] = np.zeros(uth.shape) + s['Cu_air'] = np.zeros(uth.shape) + s['Cu_bed'] = np.zeros(uth.shape) ix = (ustar != 0.)*(u != 0.) @@ -354,6 +356,11 @@ def equilibrium(s, p): s['Cuf'][ix] = np.maximum(0., p['Cb'] * rhoa / g * (ustar[ix] - uthf[ix])**3 / u[ix]) s['Cu0'][ix] = np.maximum(0., p['Cb'] * rhoa / g * (ustar0[ix] - uth0[ix])**3 / u[ix]) + + # [NEW] Two transport components divided into air and bed interaction + s['Cu_air'][ix] = np.maximum(0., p['Cb'] * rhoa / g * (ustar[ix] - uth0[ix])**3 / u[ix]) + s['Cu_bed'][ix] = s['Cu'][ix].copy() # Temporary solution + elif p['method_transport'].lower() == 'bagnold_gs': Dref = 0.000250 From c3ae8be0e2e856bf1f02ca6071a64d7626b3c74c Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Mon, 24 Nov 2025 17:40:08 -0800 Subject: [PATCH 08/23] Add support for airborne sediment shear stress and concentration - Introduced new constants for airborne sediment shear stress and velocity in `constants.py`. - Updated `WindShear` class in `shear.py` to compute and rotate airborne shear stresses. - Modified `threshold.py` to handle non-erodible layer interactions with airborne sediment. - Enhanced `transport.py` to calculate equilibrium sediment concentrations for airborne and bed sediment. - Updated `wind.py` to compute airborne shear stress and adjust bed interaction parameters accordingly. --- aeolis/advection.py | 675 ++++++------ aeolis/advection_old.py | 2236 +++++++++++++++++++++++++++++++++++++++ aeolis/constants.py | 14 +- aeolis/shear.py | 9 +- aeolis/threshold.py | 2 +- aeolis/transport.py | 9 +- aeolis/wind.py | 37 +- 7 files changed, 2638 insertions(+), 344 deletions(-) create mode 100644 aeolis/advection_old.py diff --git a/aeolis/advection.py b/aeolis/advection.py index 71647313..3c19f587 100644 --- a/aeolis/advection.py +++ b/aeolis/advection.py @@ -971,10 +971,10 @@ def solve_SS(self, alpha:float=0., beta:float=1.) -> dict: Ct = np.zeros(Ct.shape) if p['boundary_offshore'] == 'flux': - Ct[:,0,0] = s['Cu0'][:,0,0] + Ct[:,0,0] = p['offshore_flux'] * s['Cu0'][:,0,0] if p['boundary_onshore'] == 'flux': - Ct[:,-1,0] = s['Cu0'][:,-1,0] + Ct[:,-1,0] = p['onshore_flux'] * s['Cu0'][:,-1,0] if p['boundary_offshore'] == 'circular': Ct[:,0,0] = -1 @@ -992,7 +992,15 @@ def solve_SS(self, alpha:float=0., beta:float=1.) -> dict: Ct[0,:,0] = -2 Ct[-1,:,0] = -2 - Ct, pickup = sweep(Ct, s['Cu_bed'].copy(), s['Cu_air'].copy(), s['mass'].copy(), self.dt, p['T'], s['ds'], s['dn'], s['us'], s['un'],w) + if p['boundary_lateral'] == 'flux': + Ct[0,:,0] = p['lateral_flux'] * s['Cu0'][0,:,0] + Ct[-1,:,0] = p['lateral_flux'] * s['Cu0'][-1,:,0] + + + Ct, pickup = sweep(Ct, s['CuBed'].copy(), s['CuAir'].copy(), + s['zeta'].copy(), s['mass'].copy(), + self.dt, p['T'], + s['ds'], s['dn'], s['us'], s['un'], w) qs = Ct * s['us'] qn = Ct * s['un'] @@ -1823,9 +1831,9 @@ def solve_pieter(self, alpha:float=.5, beta:float=1.) -> dict: # This function acts as an orchestrator, delegating work to Numba-compiled helper functions. # Decorating the orchestrator itself with njit provides no performance benefit, # since most of the computation is already handled by optimized Numba functions. -def sweep(Ct, Cu_bed, Cu_air, mass, dt, Ts, ds, dn, us, un, w): +def sweep(Ct, Cu_bed, Cu_air, zeta, mass, dt, Ts, ds, dn, us, un, w): - Cu = Cu_bed + Cu = Cu_bed.copy() pickup = np.zeros(Cu.shape) i=0 @@ -1833,24 +1841,24 @@ def sweep(Ct, Cu_bed, Cu_air, mass, dt, Ts, ds, dn, us, un, w): nf = np.shape(Ct)[2] - # Are the lateral boundary conditions circular? - circ_lateral = False - if Ct[0,1,0]==-1: - circ_lateral = True - Ct[0,:,0] = 0 - Ct[-1,:,0] = 0 - - circ_offshore = False - if Ct[1,0,0]==-1: - circ_offshore = True - Ct[:,0,0] = 0 - Ct[:,-1,0] = 0 - - recirc_offshore = False - if Ct[1,0,0]==-2: - recirc_offshore = True - Ct[:,0,0] = 0 - Ct[:,-1,0] = 0 + # # Are the lateral boundary conditions circular? + # circ_lateral = False + # if Ct[0,1,0]==-1: + # circ_lateral = True + # Ct[0,:,0] = 0 + # Ct[-1,:,0] = 0 + + # circ_offshore = False + # if Ct[1,0,0]==-1: + # circ_offshore = True + # Ct[:,0,0] = 0 + # Ct[:,-1,0] = 0 + + # recirc_offshore = False + # if Ct[1,0,0]==-2: + # recirc_offshore = True + # Ct[:,0,0] = 0 + # Ct[:,-1,0] = 0 ufs = np.zeros((np.shape(us)[0], np.shape(us)[1]+1, np.shape(us)[2])) @@ -1883,362 +1891,365 @@ def sweep(Ct, Cu_bed, Cu_air, mass, dt, Ts, ds, dn, us, un, w): ufn[0,:,:] = (ufn[0,:,:]+ufn[-1,:,:])/2 ufn[-1,:,:] = ufn[0,:,:] - # now make sure that there is no gradients at the boundaries - # ufs[:,1,:] = ufs[:,0,:] - # ufs[:,-2,:] = ufs[:,-1,:] - # ufs[1,:,:] = ufs[0,:,:] - # ufs[-2,:,:] = ufs[-1,:,:] - - # ufn[:,1,:] = ufn[:,0,:] - # ufn[:,-2,:] = ufn[:,-1,:] - # ufn[1,:,:] = ufn[0,:,:] - # ufn[-2,:,:] = ufn[-1,:,:] - - # ufn[:,:,:] = ufn[-2,:,:] - # also correct for the potential gradients at the boundary cells in the equilibrium concentrations Cu[:,0,:] = Cu[:,1,:] Cu[:,-1,:] = Cu[:,-2,:] Cu[0,:,:] = Cu[1,:,:] Cu[-1,:,:] = Cu[-2,:,:] - - # #boundary values - # ufs[:,0, :] = us[:,0, :] - # ufs[:,-1, :] = us[:,-1, :] - - # ufn[0,:, :] = un[0,:, :] - # ufn[-1,:, :] = un[-1,:, :] Ct_last = Ct.copy() + while k==0 or np.any(np.abs(Ct[:,:,i]-Ct_last[:,:,i])>1e-10): # while k==0 or np.any(np.abs(Ct[:,:,i]-Ct_last[:,:,i])!=0): Ct_last = Ct.copy() - # lateral boundaries circular - if circ_lateral: - Ct[0,:,0],Ct[-1,:,0] = Ct[-1,:,0].copy(),Ct[0,:,0].copy() - # pickup[0,:,0],pickup[-1,:,0] = pickup[-1,:,0].copy(),pickup[0,:,0].copy() - if circ_offshore: - Ct[:,0,0],Ct[:,-1,0] = Ct[:,-1,0].copy(),Ct[:,0,0].copy() - # pickup[:,0,0],pickup[:,-1,0] = pickup[:,-1,0].copy(),pickup[:,0,0].copy() + # # lateral boundaries circular + # if circ_lateral: + # Ct[0,:,0],Ct[-1,:,0] = Ct[-1,:,0].copy(),Ct[0,:,0].copy() + # # pickup[0,:,0],pickup[-1,:,0] = pickup[-1,:,0].copy(),pickup[0,:,0].copy() + # if circ_offshore: + # Ct[:,0,0],Ct[:,-1,0] = Ct[:,-1,0].copy(),Ct[:,0,0].copy() + # # pickup[:,0,0],pickup[:,-1,0] = pickup[:,-1,0].copy(),pickup[:,0,0].copy() - if recirc_offshore: - Ct[:,0,0],Ct[:,-1,0] = np.mean(Ct[:,-2,0]), np.mean(Ct[:,1,0]) + # if recirc_offshore: + # Ct[:,0,0],Ct[:,-1,0] = np.mean(Ct[:,-2,0]), np.mean(Ct[:,1,0]) - # Track visited cells and quadrant classification - visited = np.zeros(Cu.shape[:2], dtype=bool) - quad = np.zeros(Cu.shape[:2], dtype=np.uint8) + visited = np.zeros(Ct.shape[:2], dtype=np.bool_) + quad = np.zeros(Ct.shape[:2], dtype=np.uint8) - ######################################################################################## - # in this sweeping algorithm we sweep over the 4 quadrants - # assuming that most cells have no converging/divering charactersitics. - # In the last quadrant we take converging and diverging cells into account. - # The First quadrant (Numba-optimized) - _solve_quadrant1(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) - - # The second quadrant (Numba-optimized) - _solve_quadrant2(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) - - # The third quadrant (Numba-optimized) - _solve_quadrant3(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) - - # The fourth quadrant (Numba-optimized) - _solve_quadrant4(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) - - # Generic stencil for remaining cells including boundaries (Numba-optimized) - _solve_generic_stencil(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + _solve_quadrant1(Ct, Cu_air, Cu_bed, zeta, mass, pickup, + dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + _solve_quadrant2(Ct, Cu_air, Cu_bed, zeta, mass, pickup, + dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + _solve_quadrant3(Ct, Cu_air, Cu_bed, zeta, mass, pickup, + dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + _solve_quadrant4(Ct, Cu_air, Cu_bed, zeta, mass, pickup, + dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + _solve_generic_stencil(Ct, Cu_air, Cu_bed, zeta, mass, pickup, + dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + # check the boundaries of the pickup matrix for unvisited cells # print(np.shape(visited[0,:]==False)) pickup[0,:,0] = pickup[1,:,0].copy() pickup[-1,:,0] = pickup[-2,:,0].copy() - - k+=1 - - # # plot Ct - # import matplotlib.pyplot as plt - # plt.imshow(quad[:10,:10], origin='lower') - # # plt.colorbar() - # plt.title('Concentration after %d sweeps' % k) - # plt.show() - # plt.imshow(Ct[:50,:50], origin='lower') - # # plt.colorbar() - # plt.title('Concentration after %d sweeps' % k) - # plt.show() - # plt.plot(pickup[0,:,0]) - # plt.plot(pickup[-1,:,0]) - # plt.show() - # print(k) + omega = 0.99 + Ct[:] = Ct_last + omega*(Ct - Ct_last) + k+=1 - # print("q1 = " + str(np.sum(q==1)) + " q2 = " + str(np.sum(q==2)) \ - # + " q3 = " + str(np.sum(q==3)) + " q4 = " + str(np.sum(q==4)) \ - # + " q5 = " + str(np.sum(q==5))) - # print("pickup deviation percentage = " + str(pickup.sum()/pickup[pickup>0].sum()*100) + " %") - # print("pickup deviation percentage = " + str(pickup[1,:,0].sum()/pickup[1,pickup[1,:,0]>0,0].sum()*100) + " %") - # print("pickup maximum = " + str(pickup.max()) + " mass max = " + str(mass.max())) - # print("pickup minimum = " + str(pickup.min())) - # print("pickup average = " + str(pickup.mean())) - # print("number of cells for pickup maximum = " + str((pickup == mass.max()).sum())) - # pickup[1,:,0].sum()/pickup[1,pickup[1,:,0]<0,0].sum() + print(f"Number of sweeps: {k}") + # # Plotting + # import matplotlib.pyplot as plt + # plt.figure(figsize=(12, 6)) + # plt.subplot(1, 2, 1) + # plt.title('Sediment Concentration (Ct)') + # plt.imshow(Ct[:,:,0], origin='lower', cmap='viridis', vmin=0, vmax=0.05) + # plt.colorbar(label='Ct') + # plt.subplot(1, 2, 2) + # plt.title('zeta') + # plt.imshow(zeta, origin='lower', cmap='viridis') + # plt.colorbar(label='zeta') + # plt.tight_layout() + # plt.show() + + return Ct, pickup @njit(cache=True) -def _solve_quadrant1(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): - """Solve first quadrant (positive flow in both directions) with Numba optimization.""" +def _solve_quadrant1(Ct, Cu_air, Cu_bed, zeta, mass, pickup, + dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + for n in range(1, Ct.shape[0]): for s in range(1, Ct.shape[1]): - if ( - (not visited[n, s]) - and (ufn[n, s, 0] >= 0) - and (ufs[n, s, 0] >= 0) - and (ufn[n + 1, s, 0] >= 0) - and (ufs[n, s + 1, 0] >= 0) - ): - - # Compute concentration for all fractions + + if ((not visited[n, s]) and + (ufn[n,s,0] >= 0) and (ufs[n,s,0] >= 0) and + (ufn[n+1,s,0] >= 0) and (ufs[n,s+1,0] >= 0)): + + A = ds[n,s] * dn[n,s] + for f in range(nf): - num = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + - Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + - w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) - - den = (ufn[n + 1, s, f] * ds[n, s] + - ufs[n, s + 1, f] * dn[n, s] + - ds[n, s] * dn[n, s] / Ts) - - Ct[n, s, f] = num / den - - # Calculate pickup - pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts - - # Check for supply limitations and re-iterate - if pickup[n, s, f] > mass[n, s, 0, f]: - pickup[n, s, f] = mass[n, s, 0, f] - - num_limited = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + - Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + - pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) - - den_limited = (ufn[n + 1, s, f] * ds[n, s] + - ufs[n, s + 1, f] * dn[n, s]) - - Ct[n, s, f] = num_limited / den_limited - - visited[n, s] = True - quad[n, s] = 1 + + # compute a,b + if Cu_air[n,s,f] > 0 and Ct[n,s,f] > 0: + a = (1 - zeta[n,s]) * (Cu_air[n,s,f] - Cu_bed[n,s,f]) / Cu_air[n,s,f] + b = Cu_bed[n,s,f] + else: + a = 0.0 + b = Cu_bed[n,s,f] + + # inflow term + N = ( + Ct[n-1,s,f] * ufn[n,s,f] * ds[n,s] + + Ct[n, s-1,f] * ufs[n,s,f] * dn[n,s] + ) + + # denominator term + D = ( + ufn[n+1,s,f] * ds[n,s] + + ufs[n, s+1,f] * dn[n,s] + + A / Ts + ) + + Ct_new = (N + w[n,s,f] * b * A/Ts) / (D - w[n,s,f] * a * A/Ts) + Ct[n,s,f] = Ct_new + + Cu_local = b + a * Ct_new + p = (w[n,s,f] * Cu_local - Ct_new) * dt/Ts + + if p > mass[n,s,0,f]: + p = mass[n,s,0,f] + N_lim = N + p * A/dt + D_lim = ufn[n+1,s,f] * ds[n,s] + ufs[n,s+1,f] * dn[n,s] + Ct[n,s,f] = N_lim / D_lim + + pickup[n,s,f] = p + + visited[n,s] = True + quad[n,s] = 1 @njit(cache=True) -def _solve_quadrant2(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): - """Solve second quadrant (positive n-flow, negative s-flow) with Numba optimization.""" +def _solve_quadrant2(Ct, Cu_air, Cu_bed, zeta, mass, pickup, + dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + for n in range(1, Ct.shape[0]): - for s in range(Ct.shape[1] - 2, -1, -1): - if ( - (not visited[n, s]) - and (ufn[n, s, 0] >= 0) - and (ufs[n, s, 0] <= 0) - and (ufn[n + 1, s, 0] >= 0) - and (ufs[n, s + 1, 0] <= 0) - ): - - # Compute concentration for all fractions + for s in range(Ct.shape[1]-2, -1, -1): + + if ((not visited[n,s]) and + (ufn[n,s,0] >= 0) and (ufs[n,s,0] <= 0) and + (ufn[n+1,s,0] >= 0) and (ufs[n,s+1,0] <= 0)): + + A = ds[n,s] * dn[n,s] + for f in range(nf): - num = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + - -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + - w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) - - den = (ufn[n + 1, s, f] * ds[n, s] + - -ufs[n, s, f] * dn[n, s] + - ds[n, s] * dn[n, s] / Ts) - - Ct[n, s, f] = num / den - - # Calculate pickup - pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts - - # Check for supply limitations and re-iterate - if pickup[n, s, f] > mass[n, s, 0, f]: - pickup[n, s, f] = mass[n, s, 0, f] - - num_limited = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + - -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + - pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) - - den_limited = (ufn[n + 1, s, f] * ds[n, s] + - -ufs[n, s, f] * dn[n, s]) - - Ct[n, s, f] = num_limited / den_limited - - visited[n, s] = True - quad[n, s] = 2 + + if Cu_air[n,s,f] > 0 and Ct[n,s,f] > 0: + a = (1 - zeta[n,s]) * (Cu_air[n,s,f] - Cu_bed[n,s,f]) / Cu_air[n,s,f] + b = Cu_bed[n,s,f] + else: + a = 0.0 + b = Cu_bed[n,s,f] + + N = ( + Ct[n-1,s,f] * ufn[n,s,f] * ds[n,s] + + -Ct[n,s+1,f] * ufs[n,s+1,f] * dn[n,s] + ) + + D = ( + ufn[n+1,s,f] * ds[n,s] + + -ufs[n,s,f] * dn[n,s] + + A/Ts + ) + + Ct_new = (N + w[n,s,f] * b * A/Ts) / (D - w[n,s,f] * a * A/Ts) + Ct[n,s,f] = Ct_new + + Cu_local = b + a * Ct_new + p = (w[n,s,f] * Cu_local - Ct_new) * dt/Ts + + if p > mass[n,s,0,f]: + p = mass[n,s,0,f] + N_lim = N + p*A/dt + D_lim = ufn[n+1,s,f]*ds[n,s] + -ufs[n,s,f]*dn[n,s] + Ct[n,s,f] = N_lim / D_lim + + pickup[n,s,f] = p + + visited[n,s] = True + quad[n,s] = 2 @njit(cache=True) -def _solve_quadrant3(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): - """Solve third quadrant (negative flow in both directions) with Numba optimization.""" - for n in range(Ct.shape[0] - 2, -1, -1): - for s in range(Ct.shape[1] - 2, -1, -1): - if ( - (not visited[n, s]) - and (ufn[n, s, 0] <= 0) - and (ufs[n, s, 0] <= 0) - and (ufn[n + 1, s, 0] <= 0) - and (ufs[n, s + 1, 0] <= 0) - ): - - # Compute concentration for all fractions +def _solve_quadrant3(Ct, Cu_air, Cu_bed, zeta, mass, pickup, + dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + + for n in range(Ct.shape[0]-2, -1, -1): + for s in range(Ct.shape[1]-2, -1, -1): + + if ((not visited[n,s]) and + (ufn[n,s,0] <= 0) and (ufs[n,s,0] <= 0) and + (ufn[n+1,s,0] <= 0) and (ufs[n,s+1,0] <= 0)): + + A = ds[n,s] * dn[n,s] + for f in range(nf): - num = (-Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + - -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + - w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) - - den = (-ufn[n, s, f] * dn[n, s] + - -ufs[n, s, f] * dn[n, s] + - ds[n, s] * dn[n, s] / Ts) - - Ct[n, s, f] = num / den - - # Calculate pickup - pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts - - # Check for supply limitations and re-iterate - if pickup[n, s, f] > mass[n, s, 0, f]: - pickup[n, s, f] = mass[n, s, 0, f] - - num_limited = (-Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + - -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + - pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) - - den_limited = (-ufn[n, s, f] * dn[n, s] + - -ufs[n, s, f] * dn[n, s]) - - Ct[n, s, f] = num_limited / den_limited - - visited[n, s] = True - quad[n, s] = 3 + + if Cu_air[n,s,f] > 0 and Ct[n,s,f] > 0: + a = (1 - zeta[n,s]) * (Cu_air[n,s,f] - Cu_bed[n,s,f]) / Cu_air[n,s,f] + b = Cu_bed[n,s,f] + else: + a = 0.0 + b = Cu_bed[n,s,f] + + N = ( + -Ct[n+1,s,f] * ufn[n+1,s,f] * dn[n,s] + + -Ct[n,s+1,f] * ufs[n,s+1,f] * dn[n,s] + ) + + D = ( + -ufn[n,s,f] * dn[n,s] + + -ufs[n,s,f] * dn[n,s] + + A/Ts + ) + + Ct_new = (N + w[n,s,f]*b*A/Ts) / (D - w[n,s,f]*a*A/Ts) + Ct[n,s,f] = Ct_new + + Cu_local = b + a * Ct_new + p = (w[n,s,f]*Cu_local - Ct_new)*dt/Ts + + if p > mass[n,s,0,f]: + p = mass[n,s,0,f] + N_lim = N + p*A/dt + D_lim = -ufn[n,s,f]*dn[n,s] + -ufs[n,s,f]*dn[n,s] + Ct[n,s,f] = N_lim / D_lim + + pickup[n,s,f] = p + + visited[n,s] = True + quad[n,s] = 3 @njit(cache=True) -def _solve_quadrant4(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): - """Solve fourth quadrant (negative n-flow, positive s-flow) with Numba optimization.""" - for n in range(Ct.shape[0] - 2, -1, -1): +def _solve_quadrant4(Ct, Cu_air, Cu_bed, zeta, mass, pickup, + dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + + for n in range(Ct.shape[0]-2, -1, -1): for s in range(1, Ct.shape[1]): - if ( - (not visited[n, s]) - and (ufn[n, s, 0] <= 0) - and (ufs[n, s, 0] >= 0) - and (ufn[n + 1, s, 0] <= 0) - and (ufs[n, s + 1, 0] >= 0) - ): - - # Compute concentration for all fractions + + if ((not visited[n,s]) and + (ufn[n,s,0] <= 0) and (ufs[n,s,0] >= 0) and + (ufn[n+1,s,0] <= 0) and (ufs[n,s+1,0] >= 0)): + + A = ds[n,s] * dn[n,s] + for f in range(nf): - num = (Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + - -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + - w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) - - den = (ufs[n, s + 1, f] * dn[n, s] + - -ufn[n, s, f] * dn[n, s] + - ds[n, s] * dn[n, s] / Ts) - - Ct[n, s, f] = num / den - - # Calculate pickup - pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts - - # Check for supply limitations and re-iterate - if pickup[n, s, f] > mass[n, s, 0, f]: - pickup[n, s, f] = mass[n, s, 0, f] - - num_limited = (Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + - -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + - pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) - - den_limited = (ufs[n, s + 1, f] * dn[n, s] + - -ufn[n, s, f] * dn[n, s]) - - Ct[n, s, f] = num_limited / den_limited - - visited[n, s] = True - quad[n, s] = 4 + + if Cu_air[n,s,f] > 0 and Ct[n,s,f] > 0: + a = (1 - zeta[n,s])*(Cu_air[n,s,f] - Cu_bed[n,s,f]) / Cu_air[n,s,f] + b = Cu_bed[n,s,f] + else: + a = 0.0 + b = Cu_bed[n,s,f] + + N = ( + Ct[n,s-1,f] * ufs[n,s,f] * dn[n,s] + + -Ct[n+1,s,f] * ufn[n+1,s,f] * dn[n,s] + ) + + D = ( + ufs[n,s+1,f] * dn[n,s] + + -ufn[n,s,f] * dn[n,s] + + A/Ts + ) + + Ct_new = (N + w[n,s,f]*b*A/Ts) / (D - w[n,s,f]*a*A/Ts) + Ct[n,s,f] = Ct_new + + Cu_local = b + a * Ct_new + p = (w[n,s,f]*Cu_local - Ct_new)*dt/Ts + + if p > mass[n,s,0,f]: + p = mass[n,s,0,f] + N_lim = N + p*A/dt + D_lim = ufs[n,s+1,f]*dn[n,s] + -ufn[n,s,f]*dn[n,s] + Ct[n,s,f] = N_lim / D_lim + + pickup[n,s,f] = p + + visited[n,s] = True + quad[n,s] = 4 @njit(cache=True) -def _solve_generic_stencil(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): - """Solve remaining cells with generic stencil using conditionals (Numba-optimized).""" - for n in range(Ct.shape[0] - 2, -1, -1): - for s in range(1, Ct.shape[1]): - if (not visited[n, s]) and (n != 0) and (s != Ct.shape[1] - 1): - # Apply generic stencil with conditionals instead of boolean multiplication +def _solve_generic_stencil(Ct, Cu_air, Cu_bed, zeta, mass, pickup, + dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + + Nx, Ny = Ct.shape[0], Ct.shape[1] + + for n in range(1, Nx-1): + for s in range(1, Ny-1): + + if not visited[n,s]: + + A = ds[n,s] * dn[n,s] + for f in range(nf): - # Initialize with source term - num = w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts - den = ds[n, s] * dn[n, s] / Ts - - # Add flux contributions conditionally - if ufn[n, s, 0] > 0: - num += Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] - - if ufs[n, s, 0] > 0: - num += Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] - - if ufn[n + 1, s, 0] < 0: - num += -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] - elif ufn[n + 1, s, 0] > 0: - den += ufn[n + 1, s, f] * ds[n, s] - - if ufs[n, s + 1, 0] < 0: - num += -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] - elif ufs[n, s + 1, 0] > 0: - den += ufs[n, s + 1, f] * dn[n, s] - - if ufn[n, s, 0] < 0: - den += -ufn[n, s, f] * dn[n, s] - - if ufs[n, s, 0] < 0: - den += -ufs[n, s, f] * dn[n, s] - - Ct[n, s, f] = num / den - - # Calculate pickup - pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts - - # Check for supply limitations and re-iterate - if pickup[n, s, f] > mass[n, s, 0, f]: - pickup[n, s, f] = mass[n, s, 0, f] - - # Recompute with limited pickup - num_lim = pickup[n, s, f] * ds[n, s] * dn[n, s] / dt - den_lim = 0.0 - - if ufn[n, s, 0] > 0: - num_lim += Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] - - if ufs[n, s, 0] > 0: - num_lim += Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] - - if ufn[n + 1, s, 0] < 0: - num_lim += -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] - elif ufn[n + 1, s, 0] > 0: - den_lim += ufn[n + 1, s, f] * ds[n, s] - - if ufs[n, s + 1, 0] < 0: - num_lim += -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] - elif ufs[n, s + 1, 0] > 0: - den_lim += ufs[n, s + 1, f] * dn[n, s] - - if ufn[n, s, 0] < 0: - den_lim += -ufn[n, s, f] * dn[n, s] - - if ufs[n, s, 0] < 0: - den_lim += -ufs[n, s, f] * dn[n, s] - - Ct[n, s, f] = num_lim / den_lim - - visited[n, s] = True - quad[n, s] = 5 + + # Cu = b + a * Ct + if Cu_air[n,s,f] > 0 and Ct[n,s,f] > 0: + a = (1 - zeta[n,s])*(Cu_air[n,s,f] - Cu_bed[n,s,f]) / Cu_air[n,s,f] + b = Cu_bed[n,s,f] + else: + a = 0.0 + b = Cu_bed[n,s,f] + + # start with source term + N = w[n,s,f] * b * A/Ts + D = A/Ts + + # inflow contributions + if ufn[n,s,0] > 0: + N += Ct[n-1,s,f] * ufn[n,s,f] * ds[n,s] + else: + D += -ufn[n,s,f] * dn[n,s] + + if ufs[n,s,0] > 0: + N += Ct[n,s-1,f] * ufs[n,s,f] * dn[n,s] + else: + D += -ufs[n,s,f] * dn[n,s] + + # outflow contributions + if ufn[n+1,s,0] > 0: + D += ufn[n+1,s,f] * ds[n,s] + else: + N += -Ct[n+1,s,f] * ufn[n+1,s,f] * dn[n,s] + + if ufs[n,s+1,0] > 0: + D += ufs[n,s+1,f] * dn[n,s] + else: + N += -Ct[n,s+1,f] * ufs[n,s+1,f] * dn[n,s] + + # ---- DENOMINATOR PROTECTION ---- + wa = w[n,s,f] * a + if wa > 0.999: + wa = 0.999 + + den = D - wa * A / Ts + + # In extremely pathological cases D can be tiny; just in case: + if den == 0.0: + den = 1e-12 + + Ct_new = N / den + Ct[n,s,f] = Ct_new + + Cu_local = b + a * Ct_new + p = (w[n,s,f]*Cu_local - Ct_new)*dt/Ts + + if p > mass[n,s,0,f]: + p = mass[n,s,0,f] + # recompute limited: + N_lim = N + p*A/dt + # approximate denominator (no source term) + den_lim = D - A/Ts + if abs(den_lim) > 1e-12: + Ct[n,s,f] = N_lim / den_lim + # else: leave Ct[n,s,f] as is + + pickup[n,s,f] = p + + visited[n,s] = True + quad[n,s] = 5 + diff --git a/aeolis/advection_old.py b/aeolis/advection_old.py new file mode 100644 index 00000000..78842320 --- /dev/null +++ b/aeolis/advection_old.py @@ -0,0 +1,2236 @@ +'''This file is part of AeoLiS. + +AeoLiS is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +AeoLiS is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with AeoLiS. If not, see . + +AeoLiS Copyright (C) 2015 Bas Hoonhout + +bas.hoonhout@deltares.nl b.m.hoonhout@tudelft.nl +Deltares Delft University of Technology +Unit of Hydraulic Engineering Faculty of Civil Engineering and Geosciences +Boussinesqweg 1 Stevinweg 1 +2629 HVDelft 2628CN Delft +The Netherlands The Netherlands + +''' + +from __future__ import absolute_import, division + +import logging +import numpy as np +from matplotlib import pyplot as plt +import scipy.sparse.linalg +from numba import njit + +# import AeoLiS modules +import aeolis.transport +from aeolis.utils import prevent_tiny_negatives, format_log, rotate + +# initialize logger +logger = logging.getLogger(__name__) + +def solve_steadystate(self) -> dict: + '''Implements the steady state solution + ''' + # upwind scheme: + beta = 1. + + l = self.l + s = self.s + p = self.p + + Ct = s['Ct'].copy() + pickup = s['pickup'].copy() + + # compute transport weights for all sediment fractions + w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) + + if self.t == 0.: + # use initial guess for first time step + if p['grain_dist'] != None: + w = p['grain_dist'].reshape((1,1,-1)) + w = w.repeat(p['ny']+1, axis=0) + w = w.repeat(p['nx']+1, axis=1) + else: + w = w_init.copy() + else: + w = w_init.copy() + + # set model state properties that are added to warnings and errors + logprops = dict(minwind=s['uw'].min(), + maxdrop=(l['uw']-s['uw']).max(), + time=self.t, + dt=self.dt) + + nf = p['nfractions'] + + us = np.zeros((p['ny']+1,p['nx']+1)) + un = np.zeros((p['ny']+1,p['nx']+1)) + + us_plus = np.zeros((p['ny']+1,p['nx']+1)) + un_plus = np.zeros((p['ny']+1,p['nx']+1)) + + us_min = np.zeros((p['ny']+1,p['nx']+1)) + un_min = np.zeros((p['ny']+1,p['nx']+1)) + + Cs = np.zeros(us.shape) + Cn = np.zeros(un.shape) + + Cs_plus = np.zeros(us.shape) + Cn_plus = np.zeros(un.shape) + + Cs_min = np.zeros(us.shape) + Cn_min = np.zeros(un.shape) + + for i in range(nf): + us[:,:] = s['us'][:,:,i] + un[:,:] = s['un'][:,:,i] + + us_plus[:,1:] = s['us'][:,:-1,i] + un_plus[1:,:] = s['un'][:-1,:,i] + + us_min[:,:-1] = s['us'][:,1:,i] + un_min[:-1,:] = s['un'][1:,:,i] + + #boundary values + us[:,0] = s['us'][:,0,i] + un[0,:] = s['un'][0,:,i] + + us_plus[:,0] = s['us'][:,0,i] + un_plus[0,:] = s['un'][0,:,i] + + us_min[:,-1] = s['us'][:,-1,i] + un_min[-1,:] = s['un'][-1,:,i] + + + # define matrix coefficients to solve linear system of equations + Cs = s['dn'] * s['dsdni'] * us[:,:] + Cn = s['ds'] * s['dsdni'] * un[:,:] + + Cs_plus = s['dn'] * s['dsdni'] * us_plus[:,:] + Cn_plus = s['ds'] * s['dsdni'] * un_plus[:,:] + + Cs_min = s['dn'] * s['dsdni'] * us_min[:,:] + Cn_min = s['ds'] * s['dsdni'] * un_min[:,:] + + + Ti = 1 / p['T'] + + beta = abs(beta) + if beta >= 1.: + # define upwind direction + ixs = np.asarray(us[:,:] >= 0., dtype=float) + ixn = np.asarray(un[:,:] >= 0., dtype=float) + sgs = 2. * ixs - 1. + sgn = 2. * ixn - 1. + + else: + # or centralizing weights + ixs = beta + np.zeros(us) + ixn = beta + np.zeros(un) + sgs = np.zeros(us) + sgn = np.zeros(un) + + # initialize matrix diagonals + A0 = np.zeros(s['zb'].shape) + Apx = np.zeros(s['zb'].shape) + Ap1 = np.zeros(s['zb'].shape) + Ap2 = np.zeros(s['zb'].shape) + Amx = np.zeros(s['zb'].shape) + Am1 = np.zeros(s['zb'].shape) + Am2 = np.zeros(s['zb'].shape) + + # populate matrix diagonals + A0 = sgs * Cs + sgn * Cn + Ti + Apx = Cn_min * (1. - ixn) + Ap1 = Cs_min * (1. - ixs) + Amx = -Cn_plus * ixn + Am1 = -Cs_plus * ixs + + # add boundaries + A0[:,0] = 1. + Apx[:,0] = 0. + Amx[:,0] = 0. + Am2[:,0] = 0. + Am1[:,0] = 0. + + A0[:,-1] = 1. + Apx[:,-1] = 0. + Ap1[:,-1] = 0. + Ap2[:,-1] = 0. + Amx[:,-1] = 0. + + if p['boundary_offshore'] == 'flux': + Ap2[:,0] = 0. + Ap1[:,0] = 0. + elif p['boundary_offshore'] == 'constant': + Ap2[:,0] = 0. + Ap1[:,0] = 0. + elif p['boundary_offshore'] == 'uniform': + Ap2[:,0] = 0. + Ap1[:,0] = -1. + elif p['boundary_offshore'] == 'gradient': + Ap2[:,0] = s['ds'][:,1] / s['ds'][:,2] + Ap1[:,0] = -1. - s['ds'][:,1] / s['ds'][:,2] + elif p['boundary_offshore'] == 'circular': + logger.log_and_raise('Cross-shore cricular boundary condition not yet implemented', exc=NotImplementedError) + else: + logger.log_and_raise('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore'], exc=ValueError) + + if p['boundary_onshore'] == 'flux': + Am2[:,-1] = 0. + Am1[:,-1] = 0. + elif p['boundary_onshore'] == 'constant': + Am2[:,-1] = 0. + Am1[:,-1] = 0. + elif p['boundary_onshore'] == 'uniform': + Am2[:,-1] = 0. + Am1[:,-1] = -1. + elif p['boundary_onshore'] == 'gradient': + Am2[:,-1] = s['ds'][:,-2] / s['ds'][:,-3] + Am1[:,-1] = -1. - s['ds'][:,-2] / s['ds'][:,-3] + elif p['boundary_offshore'] == 'circular': + logger.log_and_raise('Cross-shore cricular boundary condition not yet implemented', exc=NotImplementedError) + else: + logger.log_and_raise('Unknown onshore boundary condition [%s]' % self.p['boundary_onshore'], exc=ValueError) + + if p['boundary_lateral'] == 'constant': + A0[0,:] = 1. + Apx[0,:] = 0. + Ap1[0,:] = 0. + Amx[0,:] = 0. + Am1[0,:] = 0. + + A0[-1,:] = 1. + Apx[-1,:] = 0. + Ap1[-1,:] = 0. + Amx[-1,:] = 0. + Am1[-1,:] = 0. + + #logger.log_and_raise('Lateral constant boundary condition not yet implemented', exc=NotImplementedError) + elif p['boundary_lateral'] == 'uniform': + logger.log_and_raise('Lateral uniform boundary condition not yet implemented', exc=NotImplementedError) + elif p['boundary_lateral'] == 'gradient': + logger.log_and_raise('Lateral gradient boundary condition not yet implemented', exc=NotImplementedError) + elif p['boundary_lateral'] == 'circular': + pass + else: + logger.log_and_raise('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral'], exc=ValueError) + + # construct sparse matrix + if p['ny'] > 0: + j = p['nx']+1 + A = scipy.sparse.diags((Apx.flatten()[:j], + Amx.flatten()[j:], + Am2.flatten()[2:], + Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1], + Ap2.flatten()[:-2], + Apx.flatten()[j:], + Amx.flatten()[:j]), + (-j*p['ny'],-j,-2,-1,0,1,2,j,j*p['ny']), format='csr') + else: + A = scipy.sparse.diags((Am2.flatten()[2:], + Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1], + Ap2.flatten()[:-2]), + (-2,-1,0,1,2), format='csr') + + # solve transport for each fraction separately using latest + # available weights + + # renormalize weights for all fractions equal or larger + # than the current one such that the sum of all weights is + # unity + w = aeolis.transport.renormalize_weights(w, i) + + # iteratively find a solution of the linear system that + # does not violate the availability of sediment in the bed + for n in range(p['max_iter']): + self._count('matrixsolve') + + # compute saturation levels + ix = s['Cu'] > 0. + S_i = np.zeros(s['Cu'].shape) + S_i[ix] = s['Ct'][ix] / s['Cu'][ix] + s['S'] = S_i.sum(axis=-1) + + # create the right hand side of the linear system + y_i = np.zeros(s['zb'].shape) + + y_i[:,1:-1] = ( + (w[:,1:-1,i] * s['Cuf'][:,1:-1,i] * Ti) * (1. - s['S'][:,1:-1]) + + (w[:,1:-1,i] * s['Cu'][:,1:-1,i] * Ti) * s['S'][:,1:-1] + ) + + # add boundaries + if p['boundary_offshore'] == 'flux': + y_i[:,0] = p['offshore_flux'] * s['Cu0'][:,0,i] + if p['boundary_onshore'] == 'flux': + y_i[:,-1] = p['onshore_flux'] * s['Cu0'][:,-1,i] + + if p['boundary_offshore'] == 'constant': + y_i[:,0] = p['constant_offshore_flux'] / s['u'][:,0,i] + if p['boundary_onshore'] == 'constant': + y_i[:,-1] = p['constant_onshore_flux'] / s['u'][:,-1,i] + + # solve system with current weights + Ct_i = scipy.sparse.linalg.spsolve(A, y_i.flatten()) + Ct_i = prevent_tiny_negatives(Ct_i, p['max_error']) + + # check for negative values + if Ct_i.min() < 0.: + ix = Ct_i < 0. + + logger.warning(format_log('Removing negative concentrations', + nrcells=np.sum(ix), + fraction=i, + iteration=n, + minvalue=Ct_i.min(), + coords=np.argwhere(ix.reshape(y_i.shape)), + **logprops)) + + Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() + Ct_i[ix] = 0. + + # determine pickup and deficit for current fraction + Cu_i = s['Cu'][:,:,i].flatten() + mass_i = s['mass'][:,:,0,i].flatten() + w_i = w[:,:,i].flatten() + pickup_i = (w_i * Cu_i - Ct_i) / p['T'] * self.dt + deficit_i = pickup_i - mass_i + ix = (deficit_i > p['max_error']) \ + & (w_i * Cu_i > 0.) + + # quit the iteration if there is no deficit, otherwise + # back-compute the maximum weight allowed to get zero + # deficit for the current fraction and progress to + # the next iteration step + if not np.any(ix): + logger.debug(format_log('Iteration converged', + steps=n, + fraction=i, + **logprops)) + pickup_i = np.minimum(pickup_i, mass_i) + break + else: + w_i[ix] = (mass_i[ix] * p['T'] / self.dt \ + + Ct_i[ix]) / Cu_i[ix] + w[:,:,i] = w_i.reshape(y_i.shape) + + # throw warning if the maximum number of iterations was reached + if np.any(ix): + logger.warning(format_log('Iteration not converged', + nrcells=np.sum(ix), + fraction=i, + **logprops)) + + # check for unexpected negative values + if Ct_i.min() < 0: + logger.warning(format_log('Negative concentrations', + nrcells=np.sum(Ct_i<0.), + fraction=i, + minvalue=Ct_i.min(), + **logprops)) + if w_i.min() < 0: + logger.warning(format_log('Negative weights', + nrcells=np.sum(w_i<0), + fraction=i, + minvalue=w_i.min(), + **logprops)) + + Ct[:,:,i] = Ct_i.reshape(y_i.shape) + pickup[:,:,i] = pickup_i.reshape(y_i.shape) + + # check if there are any cells where the sum of all weights is + # smaller than unity. these cells are supply-limited for all + # fractions. Log these events. + ix = 1. - np.sum(w, axis=2) > p['max_error'] + if np.any(ix): + self._count('supplylim') + logger.warning(format_log('Ran out of sediment', + nrcells=np.sum(ix), + minweight=np.sum(w, axis=-1).min(), + **logprops)) + + + qs = Ct * s['us'] + qn = Ct * s['un'] + q = np.hypot(qs, qn) + + + return dict(Ct=Ct, + qs=qs, + qn=qn, + pickup=pickup, + w=w, + w_init=w_init, + w_air=w_air, + w_bed=w_bed, + q=q) + + +def solve(self, alpha:float=.5, beta:float=1.) -> dict: + '''Implements the explicit Euler forward, implicit Euler backward and semi-implicit Crank-Nicolson numerical schemes + + Determines weights of sediment fractions, sediment pickup and + instantaneous sediment concentration. Returns a partial + spatial grid dictionary that can be used to update the global + spatial grid dictionary. + + Parameters + ---------- + alpha : + Implicitness coefficient (0.0 for Euler forward, 1.0 for Euler backward or 0.5 for Crank-Nicolson, default=0.5) + beta : + Centralization coefficient (1.0 for upwind or 0.5 for centralized, default=1.0) + + Returns + ------- + Partial spatial grid dictionary + + Examples + -------- + >>> model.s.update(model.solve(alpha=1., beta=1.) # euler backward + + >>> model.s.update(model.solve(alpha=.5, beta=1.) # crank-nicolson + + See Also + -------- + model.AeoLiS.euler_forward + model.AeoLiS.euler_backward + model.AeoLiS.crank_nicolson + transport.compute_weights + transport.renormalize_weights + + ''' + + l = self.l + s = self.s + p = self.p + + Ct = s['Ct'].copy() + pickup = s['pickup'].copy() + + # compute transport weights for all sediment fractions + w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) + + if self.t == 0.: + if type(p['bedcomp_file']) == np.ndarray: + w = w_init.copy() + else: + # use initial guess for first time step + w = p['grain_dist'].reshape((1,1,-1)) + w = w.repeat(p['ny']+1, axis=0) + w = w.repeat(p['nx']+1, axis=1) + else: + w = w_init.copy() + + # set model state properties that are added to warnings and errors + logprops = dict(minwind=s['uw'].min(), + maxdrop=(l['uw']-s['uw']).max(), + time=self.t, + dt=self.dt) + + nf = p['nfractions'] + + us = np.zeros((p['ny']+1,p['nx']+1)) + un = np.zeros((p['ny']+1,p['nx']+1)) + + us_plus = np.zeros((p['ny']+1,p['nx']+1)) + un_plus = np.zeros((p['ny']+1,p['nx']+1)) + + us_min = np.zeros((p['ny']+1,p['nx']+1)) + un_min = np.zeros((p['ny']+1,p['nx']+1)) + + Cs = np.zeros(us.shape) + Cn = np.zeros(un.shape) + + Cs_plus = np.zeros(us.shape) + Cn_plus = np.zeros(un.shape) + + Cs_min = np.zeros(us.shape) + Cn_min = np.zeros(un.shape) + + + for i in range(nf): + + us[:,:] = s['us'][:,:,i] + un[:,:] = s['un'][:,:,i] + + us_plus[:,1:] = s['us'][:,:-1,i] + un_plus[1:,:] = s['un'][:-1,:,i] + + us_min[:,:-1] = s['us'][:,1:,i] + un_min[:-1,:] = s['un'][1:,:,i] + + #boundary values + us_plus[:,0] = s['us'][:,0,i] + un_plus[0,:] = s['un'][0,:,i] + + us_min[:,-1] = s['us'][:,-1,i] + un_min[-1,:] = s['un'][-1,:,i] + + + # define matrix coefficients to solve linear system of equations + Cs = self.dt * s['dn'] * s['dsdni'] * us[:,:] + Cn = self.dt * s['ds'] * s['dsdni'] * un[:,:] + + Cs_plus = self.dt * s['dn'] * s['dsdni'] * us_plus[:,:] + Cn_plus = self.dt * s['ds'] * s['dsdni'] * un_plus[:,:] + + Cs_min = self.dt * s['dn'] * s['dsdni'] * us_min[:,:] + Cn_min = self.dt * s['ds'] * s['dsdni'] * un_min[:,:] + + Ti = self.dt / p['T'] + + + beta = abs(beta) + if beta >= 1.: + # define upwind direction + ixs = np.asarray(s['us'][:,:,i] >= 0., dtype=float) + ixn = np.asarray(s['un'][:,:,i] >= 0., dtype=float) + sgs = 2. * ixs - 1. + sgn = 2. * ixn - 1. + + else: + # or centralizing weights + ixs = beta + np.zeros(Cs.shape) + ixn = beta + np.zeros(Cn.shape) + sgs = np.zeros(Cs.shape) + sgn = np.zeros(Cn.shape) + + # initialize matrix diagonals + A0 = np.zeros(s['zb'].shape) + Apx = np.zeros(s['zb'].shape) + Ap1 = np.zeros(s['zb'].shape) + Ap2 = np.zeros(s['zb'].shape) + Amx = np.zeros(s['zb'].shape) + Am1 = np.zeros(s['zb'].shape) + Am2 = np.zeros(s['zb'].shape) + + # populate matrix diagonals + A0 = 1. + (sgs * Cs + sgn * Cn + Ti) * alpha + Apx = Cn_min * alpha * (1. - ixn) + Ap1 = Cs_min * alpha * (1. - ixs) + Amx = -Cn_plus * alpha * ixn + Am1 = -Cs_plus * alpha * ixs + + # add boundaries + A0[:,0] = 1. + Apx[:,0] = 0. + Amx[:,0] = 0. + Am2[:,0] = 0. + Am1[:,0] = 0. + + A0[:,-1] = 1. + Apx[:,-1] = 0. + Ap1[:,-1] = 0. + Ap2[:,-1] = 0. + Amx[:,-1] = 0. + + if (p['boundary_offshore'] == 'flux') | (p['boundary_offshore'] == 'noflux'): + Ap2[:,0] = 0. + Ap1[:,0] = 0. + elif p['boundary_offshore'] == 'constant': + Ap2[:,0] = 0. + Ap1[:,0] = 0. + elif p['boundary_offshore'] == 'uniform': + Ap2[:,0] = 0. + Ap1[:,0] = -1. + elif p['boundary_offshore'] == 'gradient': + Ap2[:,0] = s['ds'][:,1] / s['ds'][:,2] + Ap1[:,0] = -1. - s['ds'][:,1] / s['ds'][:,2] + elif p['boundary_offshore'] == 'circular': + logger.log_and_raise('Cross-shore cricular boundary condition not yet implemented', exc=NotImplementedError) + else: + logger.log_and_raise('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore'], exc=ValueError) + + if (p['boundary_onshore'] == 'flux') | (p['boundary_offshore'] == 'noflux'): + Am2[:,-1] = 0. + Am1[:,-1] = 0. + elif p['boundary_onshore'] == 'constant': + Am2[:,-1] = 0. + Am1[:,-1] = 0. + elif p['boundary_onshore'] == 'uniform': + Am2[:,-1] = 0. + Am1[:,-1] = -1. + elif p['boundary_onshore'] == 'gradient': + Am2[:,-1] = s['ds'][:,-2] / s['ds'][:,-3] + Am1[:,-1] = -1. - s['ds'][:,-2] / s['ds'][:,-3] + elif p['boundary_offshore'] == 'circular': + logger.log_and_raise('Cross-shore cricular boundary condition not yet implemented', exc=NotImplementedError) + else: + logger.log_and_raise('Unknown onshore boundary condition [%s]' % self.p['boundary_onshore'], exc=ValueError) + + if p['boundary_lateral'] == 'constant': + A0[0,:] = 1. + Apx[0,:] = 0. + Ap1[0,:] = 0. + Amx[0,:] = 0. + Am1[0,:] = 0. + + A0[-1,:] = 1. + Apx[-1,:] = 0. + Ap1[-1,:] = 0. + Amx[-1,:] = 0. + Am1[-1,:] = 0. + + #logger.log_and_raise('Lateral constant boundary condition not yet implemented', exc=NotImplementedError) + elif p['boundary_lateral'] == 'uniform': + logger.log_and_raise('Lateral uniform boundary condition not yet implemented', exc=NotImplementedError) + elif p['boundary_lateral'] == 'gradient': + logger.log_and_raise('Lateral gradient boundary condition not yet implemented', exc=NotImplementedError) + elif p['boundary_lateral'] == 'circular': + pass + else: + logger.log_and_raise('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral'], exc=ValueError) + + # construct sparse matrix + if p['ny'] > 0: + j = p['nx']+1 + A = scipy.sparse.diags((Apx.flatten()[:j], + Amx.flatten()[j:], + Am2.flatten()[2:], + Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1], + Ap2.flatten()[:-2], + Apx.flatten()[j:], + Amx.flatten()[:j]), + (-j*p['ny'],-j,-2,-1,0,1,2,j,j*p['ny']), format='csr') + else: + A = scipy.sparse.diags((Am2.flatten()[2:], + Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1], + Ap2.flatten()[:-2]), + (-2,-1,0,1,2), format='csr') + + # solve transport for each fraction separately using latest + # available weights + + # renormalize weights for all fractions equal or larger + # than the current one such that the sum of all weights is + # unity + # Christa: seems to have no significant effect on weights, + # numerical check to prevent any deviation from unity + w = aeolis.transport.renormalize_weights(w, i) + + # iteratively find a solution of the linear system that + # does not violate the availability of sediment in the bed + for n in range(p['max_iter']): + self._count('matrixsolve') + + # compute saturation levels + ix = s['Cu'] > 0. + S_i = np.zeros(s['Cu'].shape) + S_i[ix] = s['Ct'][ix] / s['Cu'][ix] + s['S'] = S_i.sum(axis=-1) + + # create the right hand side of the linear system + y_i = np.zeros(s['zb'].shape) + y_im = np.zeros(s['zb'].shape) # implicit terms + y_ex = np.zeros(s['zb'].shape) # explicit terms + + y_im[:,1:-1] = ( + (w[:,1:-1,i] * s['Cuf'][:,1:-1,i] * Ti) * (1. - s['S'][:,1:-1]) + + (w[:,1:-1,i] * s['Cu'][:,1:-1,i] * Ti) * s['S'][:,1:-1] + ) + + y_ex[:,1:-1] = ( + (l['w'][:,1:-1,i] * l['Cuf'][:,1:-1,i] * Ti) * (1. - s['S'][:,1:-1]) \ + + (l['w'][:,1:-1,i] * l['Cu'][:,1:-1,i] * Ti) * s['S'][:,1:-1] \ + - ( + sgs[:,1:-1] * Cs[:,1:-1] +\ + sgn[:,1:-1] * Cn[:,1:-1] + Ti + ) * l['Ct'][:,1:-1,i] \ + + ixs[:,1:-1] * Cs_plus[:,1:-1] * l['Ct'][:,:-2,i] \ + - (1. - ixs[:,1:-1]) * Cs_min[:,1:-1] * l['Ct'][:,2:,i] \ + + ixn[:,1:-1] * Cn_plus[:,1:-1] * np.roll(l['Ct'][:,1:-1,i], 1, axis=0) \ + - (1. - ixn[:,1:-1]) * Cn_min[:,1:-1] * np.roll(l['Ct'][:,1:-1,i], -1, axis=0) \ + ) + + y_i[:,1:-1] = l['Ct'][:,1:-1,i] + alpha * y_im[:,1:-1] + (1. - alpha) * y_ex[:,1:-1] + + # add boundaries + if p['boundary_offshore'] == 'flux': + y_i[:,0] = p['offshore_flux'] * s['Cu0'][:,0,i] + if p['boundary_onshore'] == 'flux': + y_i[:,-1] = p['onshore_flux'] * s['Cu0'][:,-1,i] + + if p['boundary_offshore'] == 'constant': + y_i[:,0] = p['constant_offshore_flux'] / s['u'][:,0,i] + if p['boundary_onshore'] == 'constant': + y_i[:,-1] = p['constant_onshore_flux'] / s['u'][:,-1,i] + + # solve system with current weights + Ct_i = scipy.sparse.linalg.spsolve(A, y_i.flatten()) + Ct_i = prevent_tiny_negatives(Ct_i, p['max_error']) + + # check for negative values + if Ct_i.min() < 0.: + ix = Ct_i < 0. + + logger.warning(format_log('Removing negative concentrations', + nrcells=np.sum(ix), + fraction=i, + iteration=n, + minvalue=Ct_i.min(), + coords=np.argwhere(ix.reshape(y_i.shape)), + **logprops)) + + if Ct_i[~ix].sum() != 0: + Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() + else: + Ct_i[~ix] = 0 + + #Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() + Ct_i[ix] = 0. + + # determine pickup and deficit for current fraction + Cu_i = s['Cu'][:,:,i].flatten() + mass_i = s['mass'][:,:,0,i].flatten() + w_i = w[:,:,i].flatten() + pickup_i = (w_i * Cu_i - Ct_i) / p['T'] * self.dt + deficit_i = pickup_i - mass_i + ix = (deficit_i > p['max_error']) \ + & (w_i * Cu_i > 0.) + + # quit the iteration if there is no deficit, otherwise + # back-compute the maximum weight allowed to get zero + # deficit for the current fraction and progress to + # the next iteration step + if not np.any(ix): + logger.debug(format_log('Iteration converged', + steps=n, + fraction=i, + **logprops)) + pickup_i = np.minimum(pickup_i, mass_i) + break + else: + w_i[ix] = (mass_i[ix] * p['T'] / self.dt \ + + Ct_i[ix]) / Cu_i[ix] + w[:,:,i] = w_i.reshape(y_i.shape) + + # throw warning if the maximum number of iterations was reached + if np.any(ix): + logger.warning(format_log('Iteration not converged', + nrcells=np.sum(ix), + fraction=i, + **logprops)) + + # check for unexpected negative values + if Ct_i.min() < 0: + logger.warning(format_log('Negative concentrations', + nrcells=np.sum(Ct_i<0.), + fraction=i, + minvalue=Ct_i.min(), + **logprops)) + if w_i.min() < 0: + logger.warning(format_log('Negative weights', + nrcells=np.sum(w_i<0), + fraction=i, + minvalue=w_i.min(), + **logprops)) + + Ct[:,:,i] = Ct_i.reshape(y_i.shape) + pickup[:,:,i] = pickup_i.reshape(y_i.shape) + + # check if there are any cells where the sum of all weights is + # smaller than unity. these cells are supply-limited for all + # fractions. Log these events. + ix = 1. - np.sum(w, axis=2) > p['max_error'] + if np.any(ix): + self._count('supplylim') + # logger.warning(format_log('Ran out of sediment', + # nrcells=np.sum(ix), + # minweight=np.sum(w, axis=-1).min(), + # **logprops)) + + qs = Ct * s['us'] + qn = Ct * s['un'] + + return dict(Ct=Ct, + qs=qs, + qn=qn, + pickup=pickup, + w=w, + w_init=w_init, + w_air=w_air, + w_bed=w_bed) + +#@njit +def solve_EF(self, alpha:float=0., beta:float=1.) -> dict: + '''Implements the explicit Euler forward, implicit Euler backward and semi-implicit Crank-Nicolson numerical schemes + + Determines weights of sediment fractions, sediment pickup and + instantaneous sediment concentration. Returns a partial + spatial grid dictionary that can be used to update the global + spatial grid dictionary. + + Parameters + ---------- + alpha : + Implicitness coefficient (0.0 for Euler forward, 1.0 for Euler backward or 0.5 for Crank-Nicolson, default=0.5) + beta : + Centralization coefficient (1.0 for upwind or 0.5 for centralized, default=1.0) + + Returns + ------- + Partial spatial grid dictionary + + Examples + -------- + >>> model.s.update(model.solve(alpha=1., beta=1.) # euler backward + + >>> model.s.update(model.solve(alpha=.5, beta=1.) # crank-nicolson + + See Also + -------- + model.AeoLiS.euler_forward + model.AeoLiS.euler_backward + model.AeoLiS.crank_nicolson + transport.compute_weights + transport.renormalize_weights + + ''' + + l = self.l + s = self.s + p = self.p + + Ct = s['Ct'].copy() + pickup = s['pickup'].copy() + Ts = p['T'] + + # compute transport weights for all sediment fractions + w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) + + if self.t == 0.: + if type(p['bedcomp_file']) == np.ndarray: + w = w_init.copy() + else: + # use initial guess for first time step + w = p['grain_dist'].reshape((1,1,-1)) + w = w.repeat(p['ny']+1, axis=0) + w = w.repeat(p['nx']+1, axis=1) + else: + w = w_init.copy() + + # set model state properties that are added to warnings and errors + logprops = dict(minwind=s['uw'].min(), + maxdrop=(l['uw']-s['uw']).max(), + time=self.t, + dt=self.dt) + + nf = p['nfractions'] + + + for i in range(nf): + + if 1: + #define 4 quadrants based on wind directions + ix1 = ((s['us'][:,:,0]>=0) & (s['un'][:,:,0]>=0)) + ix2 = ((s['us'][:,:,0]<0) & (s['un'][:,:,0]>=0)) + ix3 = ((s['us'][:,:,0]<0) & (s['un'][:,:,0]<0)) + ix4 = ((s['us'][:,:,0]>0) & (s['un'][:,:,0]<0)) + + # initiate solution matrix including ghost cells to accomodate boundaries + Ct_s = np.zeros((Ct.shape[0]+2,Ct.shape[1]+2)) + # populate solution matrix with previous concentration results + Ct_s[1:-1,1:-1] = Ct[:,:,i] + + #set upwind boundary condition + Ct_s[:,0:2]=0 + #circular boundary condition in lateral directions + Ct_s[0,:]=Ct_s[-2,:] + Ct_s[-1,:]=Ct_s[1,:] + # using the Euler forward scheme we can calculate pickup first based on the previous timestep + # there is no need for iteration + pickup[:,:,i] = self.dt*(np.minimum(s['Cu'][:,:,i],s['mass'][:,:,0,i]+Ct[:,:,i])-Ct[:,:,i])/Ts + + #solve for all 4 quadrants in one step using logical indexing + Ct_s[1:-1,1:-1] = Ct_s[1:-1,1:-1] + \ + ix1*(-self.dt*s['us'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[1:-1,:-2])/s['ds'] \ + -self.dt*s['un'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[:-2,1:-1])/s['dn']) +\ + ix2*(+self.dt*s['us'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[1:-1,2:])/s['ds'] \ + -self.dt*s['un'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[:-2,1:-1])/s['dn']) +\ + ix3*(+self.dt*s['us'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[1:-1,2:])/s['ds'] \ + +self.dt*s['un'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[2:,1:-1])/s['dn']) +\ + ix4*(-self.dt*s['us'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[1:-1,:-2])/s['ds'] \ + +self.dt*s['un'][:,:,i]*(Ct_s[1:-1,1:-1]-Ct_s[2:,1:-1])/s['dn']) \ + + pickup[:,:,i] + + # define Ct as a subset of Ct_s (eliminating the boundaries) + Ct[:,:,i] = Ct_s[1:-1,1:-1] + + qs = Ct * s['us'] + qn = Ct * s['un'] + + return dict(Ct=Ct, + qs=qs, + qn=qn, + pickup=pickup, + w=w, + w_init=w_init, + w_air=w_air, + w_bed=w_bed) + +#@njit +def solve_SS(self, alpha:float=0., beta:float=1.) -> dict: + '''Implements the explicit Euler forward, implicit Euler backward and semi-implicit Crank-Nicolson numerical schemes + + Determines weights of sediment fractions, sediment pickup and + instantaneous sediment concentration. Returns a partial + spatial grid dictionary that can be used to update the global + spatial grid dictionary. + + Parameters + ---------- + alpha : + Implicitness coefficient (0.0 for Euler forward, 1.0 for Euler backward or 0.5 for Crank-Nicolson, default=0.5) + beta : + Centralization coefficient (1.0 for upwind or 0.5 for centralized, default=1.0) + + Returns + ------- + Partial spatial grid dictionary + + Examples + -------- + >>> model.s.update(model.solve(alpha=1., beta=1.) # euler backward + + >>> model.s.update(model.solve(alpha=.5, beta=1.) # crank-nicolson + + See Also + -------- + model.AeoLiS.euler_forward + model.AeoLiS.euler_backward + model.AeoLiS.crank_nicolson + transport.compute_weights + transport.renormalize_weights + + ''' + + l = self.l + s = self.s + p = self.p + + Ct = s['Ct'].copy() + pickup = s['pickup'].copy() + Ts = p['T'] + + # compute transport weights for all sediment fractions + w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) + + if self.t == 0.: + if type(p['bedcomp_file']) == np.ndarray: + w = w_init.copy() + else: + # use initial guess for first time step + # when p['grain_dist'] has 2 dimensions take the first row otherwise take the only row + if len(p['grain_dist'].shape) == 2: + w = p['grain_dist'][0,:].reshape((1,1,-1)) + else: + w = p['grain_dist'].reshape((1,1,-1)) + + w = w.repeat(p['ny']+1, axis=0) + w = w.repeat(p['nx']+1, axis=1) + else: + w = w_init.copy() + + # set model state properties that are added to warnings and errors + logprops = dict(minwind=s['uw'].min(), + maxdrop=(l['uw']-s['uw']).max(), + time=self.t, + dt=self.dt) + + nf = p['nfractions'] + + + for i in range(nf): + + + if 1: + #print('sweep') + + # initiate emmpty solution matrix, this will effectively kill time dependence and create steady state. + Ct = np.zeros(Ct.shape) + + if p['boundary_offshore'] == 'flux': + Ct[:,0,0] = s['Cu0'][:,0,0] + + if p['boundary_onshore'] == 'flux': + Ct[:,-1,0] = s['Cu0'][:,-1,0] + + if p['boundary_offshore'] == 'circular': + Ct[:,0,0] = -1 + Ct[:,-1,0] = -1 + + if p['boundary_offshore'] == 're_circular': + Ct[:,0,0] = -2 + Ct[:,-1,0] = -2 + + if p['boundary_lateral'] == 'circular': + Ct[0,:,0] = -1 + Ct[-1,:,0] = -1 + + if p['boundary_lateral'] == 're_circular': + Ct[0,:,0] = -2 + Ct[-1,:,0] = -2 + + Ct, pickup = sweep(Ct, s['CuBed'].copy(), s['CuAir'].copy(), + s['zeta'].copy(), s['mass'].copy(), + self.dt, p['T'], + s['ds'], s['dn'], s['us'], s['un'], w) + + qs = Ct * s['us'] + qn = Ct * s['un'] + q = np.hypot(qs, qn) + + + return dict(Ct=Ct, + qs=qs, + qn=qn, + pickup=pickup, + w=w, + w_init=w_init, + w_air=w_air, + w_bed=w_bed, + q=q) + + +def solve_steadystatepieter(self) -> dict: + + beta = 1. + + l = self.l + s = self.s + p = self.p + + Ct = s['Ct'].copy() + qs = s['qs'].copy() + qn = s['qn'].copy() + pickup = s['pickup'].copy() + + Ts = p['T'] + + # compute transport weights for all sediment fractions + w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) + + if self.t == 0.: + # use initial guess for first time step + w = p['grain_dist'].reshape((1,1,-1)) + w = w.repeat(p['ny']+1, axis=0) + w = w.repeat(p['nx']+1, axis=1) + return dict(w=w) + else: + w = w_init.copy() + + # set model state properties that are added to warnings and errors + logprops = dict(minwind=s['uw'].min(), + maxdrop=(l['uw']-s['uw']).max(), + time=self.t, + dt=self.dt) + + nf = p['nfractions'] + + ufs = np.zeros((p['ny']+1,p['nx']+2)) + ufn = np.zeros((p['ny']+2,p['nx']+1)) + + for i in range(nf): #loop over fractions + + #define velocity fluxes + + ufs[:,1:-1] = 0.5*s['us'][:,:-1,i] + 0.5*s['us'][:,1:,i] + ufn[1:-1,:] = 0.5*s['un'][:-1,:,i] + 0.5*s['un'][1:,:,i] + + #boundary values + ufs[:,0] = s['us'][:,0,i] + ufs[:,-1] = s['us'][:,-1,i] + + if p['boundary_lateral'] == 'circular': + ufn[0,:] = 0.5*s['un'][0,:,i] + 0.5*s['un'][-1,:,i] + ufn[-1,:] = ufn[0,:] + else: + ufn[0,:] = s['un'][0,:,i] + ufn[-1,:] = s['un'][-1,:,i] + + beta = abs(beta) + if beta >= 1.: + # define upwind direction + ixfs = np.asarray(ufs >= 0., dtype=float) + ixfn = np.asarray(ufn >= 0., dtype=float) + else: + # or centralizing weights + ixfs = beta + np.zeros(ufs) + ixfn = beta + np.zeros(ufn) + + # initialize matrix diagonals + A0 = np.zeros(s['zb'].shape) + Apx = np.zeros(s['zb'].shape) + Ap1 = np.zeros(s['zb'].shape) + Amx = np.zeros(s['zb'].shape) + Am1 = np.zeros(s['zb'].shape) + + # populate matrix diagonals + #A0 += s['dsdn'] / self.dt #time derivative + A0 += s['dsdn'] / Ts #source term + A0[:,1:] -= s['dn'][:,1:] * ufs[:,1:-1] * (1. - ixfs[:,1:-1]) #lower x-face + Am1[:,1:] -= s['dn'][:,1:] * ufs[:,1:-1] * ixfs[:,1:-1] #lower x-face + A0[:,:-1] += s['dn'][:,:-1] * ufs[:,1:-1] * ixfs[:,1:-1] #upper x-face + Ap1[:,:-1] += s['dn'][:,:-1] * ufs[:,1:-1] * (1. - ixfs[:,1:-1]) #upper x-face + A0[1:,:] -= s['ds'][1:,:] * ufn[1:-1,:] * (1. - ixfn[1:-1,:]) #lower y-face + Amx[1:,:] -= s['ds'][1:,:] * ufn[1:-1,:] * ixfn[1:-1,:] #lower y-face + A0[:-1,:] += s['ds'][:-1,:] * ufn[1:-1,:] * ixfn[1:-1,:] #upper y-face + Apx[:-1,:] += s['ds'][:-1,:] * ufn[1:-1,:] * (1. - ixfn[1:-1,:]) #upper y-face + + # add boundaries + # offshore boundary (i=0) + + if p['boundary_offshore'] == 'flux': + #nothing to be done + pass + elif p['boundary_offshore'] == 'constant': + #constant sediment concentration (Ct) in the air + A0[:,0] = 1. + Apx[:,0] = 0. + Amx[:,0] = 0. + Ap1[:,0] = 0. + Am1[:,0] = 0. + elif p['boundary_offshore'] == 'gradient': + #remove the flux at the inner face of the cell + A0[:,0] -= s['dn'][:,0] * ufs[:,1] * ixfs[:,1] #upper x-face + Ap1[:,0] -= s['dn'][:,0] * ufs[:,1] * (1. - ixfs[:,1]) #upper x-face + elif p['boundary_offshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore']) + + #onshore boundary (i=nx) + + if p['boundary_onshore'] == 'flux': + #nothing to be done + pass + elif p['boundary_onshore'] == 'constant': + #constant sediment concentration (hC) in the air + A0[:,-1] = 1. + Apx[:,-1] = 0. + Amx[:,-1] = 0. + Ap1[:,-1] = 0. + Am1[:,-1] = 0. + elif p['boundary_onshore'] == 'gradient': + #remove the flux at the inner face of the cell + A0[:,-1] += s['dn'][:,-1] * ufs[:,-2] * (1. - ixfs[:,-2]) #lower x-face + Am1[:,-1] += s['dn'][:,-1] * ufs[:,-2] * ixfs[:,-2] #lower x-face + elif p['boundary_onshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_onshore']) + + #lateral boundaries (j=0; j=ny) + + if p['boundary_lateral'] == 'flux': + #nothing to be done + pass + elif p['boundary_lateral'] == 'constant': + #constant sediment concentration (hC) in the air + A0[0,:] = 1. + Apx[0,:] = 0. + Amx[0,:] = 0. + Ap1[0,:] = 0. + Am1[0,:] = 0. + A0[-1,:] = 1. + Apx[-1,:] = 0. + Amx[-1,:] = 0. + Ap1[-1,:] = 0. + Am1[-1,:] = 0. + elif p['boundary_lateral'] == 'gradient': + #remove the flux at the inner face of the cell + A0[0,:] -= s['ds'][0,:] * ufn[1,:] * ixfn[1,:] #upper y-face + Apx[0,:] -= s['ds'][0,:] * ufn[1,:] * (1. - ixfn[1,:]) #upper y-face + A0[-1,:] += s['ds'][-1,:] * ufn[-2,:] * (1. - ixfn[-2,:]) #lower y-face + Amx[-1,:] += s['ds'][-1,:] * ufn[-2,:] * ixfn[-2,:] #lower y-face + elif p['boundary_lateral'] == 'circular': + A0[0,:] -= s['ds'][0,:] * ufn[0,:] * (1. - ixfn[0,:]) #lower y-face + Amx[0,:] -= s['ds'][0,:] * ufn[0,:] * ixfn[0,:] #lower y-face + A0[-1,:] += s['ds'][-1,:] * ufn[-1,:] * ixfn[-1,:] #upper y-face + Apx[-1,:] += s['ds'][-1,:] * ufn[-1,:] * (1. - ixfn[-1,:]) #upper y-face + else: + raise ValueError('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral']) + + # construct sparse matrix + if p['ny'] > 0: + j = p['nx']+1 + A = scipy.sparse.diags((Apx.flatten()[:j], + Amx.flatten()[j:], + Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1], + Apx.flatten()[j:], + Amx.flatten()[:j]), + (-j*p['ny'],-j,-1,0,1,j,j*p['ny']), format='csr') + else: + j = p['nx']+1 + ny = 0 + A = scipy.sparse.diags((Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1]), + (-1, 0, 1), format='csr') + + # solve transport for each fraction separately using latest + # available weights + + + # renormalize weights for all fractions equal or larger + # than the current one such that the sum of all weights is + # unity + w = aeolis.transport.renormalize_weights(w, i) + + # iteratively find a solution of the linear system that + # does not violate the availability of sediment in the bed + for n in range(p['max_iter']): + self._count('matrixsolve') + + # define upwind face value + # sediment concentration + Ctxfs_i = np.zeros(ufs.shape) + Ctxfn_i = np.zeros(ufn.shape) + + Ctxfs_i[:,1:-1] = ixfs[:,1:-1] * Ct[:,:-1,i] \ + + (1. - ixfs[:,1:-1]) * Ct[:,1:,i] + Ctxfn_i[1:-1,:] = ixfn[1:-1,:] * Ct[:-1,:,i] \ + + (1. - ixfn[1:-1,:]) * Ct[1:,:,i] + + if p['boundary_lateral'] == 'circular': + Ctxfn_i[0,:] = ixfn[0,:] * Ct[-1,:,i] \ + + (1. - ixfn[0,:]) * Ct[0,:,i] + + # calculate pickup + D_i = s['dsdn'] / Ts * Ct[:,:,i] + A_i = s['dsdn'] / Ts * s['mass'][:,:,0,i] + D_i # Availability + U_i = s['dsdn'] / Ts * w[:,:,i] * s['Cu'][:,:,i] + + #deficit_i = E_i - A_i + E_i= np.minimum(U_i, A_i) + #pickup_i = E_i - D_i + + # create the right hand side of the linear system + # sediment concentration + yCt_i = np.zeros(s['zb'].shape) + + yCt_i += E_i - D_i #source term + yCt_i[:,1:] += s['dn'][:,1:] * ufs[:,1:-1] * Ctxfs_i[:,1:-1] #lower x-face + yCt_i[:,:-1] -= s['dn'][:,:-1] * ufs[:,1:-1] * Ctxfs_i[:,1:-1] #upper x-face + yCt_i[1:,:] += s['ds'][1:,:] * ufn[1:-1,:] * Ctxfn_i[1:-1,:] #lower y-face + yCt_i[:-1,:] -= s['ds'][:-1,:] * ufn[1:-1,:] * Ctxfn_i[1:-1,:] #upper y-face + + # boundary conditions + # offshore boundary (i=0) + + if p['boundary_offshore'] == 'flux': + yCt_i[:,0] += s['dn'][:,0] * ufs[:,0] * s['Cu0'][:,0,i] * p['offshore_flux'] + elif p['boundary_offshore'] == 'constant': + #constant sediment concentration (Ct) in the air + yCt_i[:,0] = p['constant_offshore_flux'] + + elif p['boundary_offshore'] == 'gradient': + #remove the flux at the inner face of the cell + yCt_i[:,0] += s['dn'][:,1] * ufs[:,1] * Ctxfs_i[:,1] + + elif p['boundary_offshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore']) + + # onshore boundary (i=nx) + + if p['boundary_onshore'] == 'flux': + yCt_i[:,-1] += s['dn'][:,-1] * ufs[:,-1] * s['Cu0'][:,-1,i] * p['onshore_flux'] + + elif p['boundary_onshore'] == 'constant': + #constant sediment concentration (Ct) in the air + yCt_i[:,-1] = p['constant_onshore_flux'] + + elif p['boundary_onshore'] == 'gradient': + #remove the flux at the inner face of the cell + yCt_i[:,-1] -= s['dn'][:,-2] * ufs[:,-2] * Ctxfs_i[:,-2] + + elif p['boundary_onshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown onshore boundary condition [%s]' % self.p['boundary_onshore']) + + #lateral boundaries (j=0; j=ny) + + if p['boundary_lateral'] == 'flux': + + yCt_i[0,:] += s['ds'][0,:] * ufn[0,:] * s['Cu0'][0,:,i] * p['lateral_flux'] #lower y-face + yCt_i[-1,:] -= s['ds'][-1,:] * ufn[-1,:] * s['Cu0'][-1,:,i] * p['lateral_flux'] #upper y-face + elif p['boundary_lateral'] == 'constant': + #constant sediment concentration (hC) in the air + yCt_i[0,:] = 0. + yCt_i[-1,:] = 0. + elif p['boundary_lateral'] == 'gradient': + #remove the flux at the inner face of the cell + yCt_i[-1,:] -= s['ds'][-2,:] * ufn[-2,:] * Ctxfn_i[-2,:] #lower y-face + yCt_i[0,:] += s['ds'][1,:] * ufn[1,:] * Ctxfn_i[1,:] #upper y-face + elif p['boundary_lateral'] == 'circular': + yCt_i[0,:] += s['ds'][0,:] * ufn[0,:] * Ctxfn_i[0,:] #lower y-face + yCt_i[-1,:] -= s['ds'][-1,:] * ufn[-1,:] * Ctxfn_i[-1,:] #upper y-face + else: + raise ValueError('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral']) + + # print("ugs = %.*g" % (3,s['ugs'][10,10])) + # print("ugn = %.*g" % (3,s['ugn'][10,10])) + # print("%.*g" % (3,np.amax(np.absolute(y_i)))) + + # solve system with current weights + Ct_i = Ct[:,:,i].flatten() + Ct_i += scipy.sparse.linalg.spsolve(A, yCt_i.flatten()) + Ct_i = prevent_tiny_negatives(Ct_i, p['max_error']) + + # check for negative values + if Ct_i.min() < 0.: + ix = Ct_i < 0. + +# logger.warn(format_log('Removing negative concentrations', +# nrcells=np.sum(ix), +# fraction=i, +# iteration=n, +# minvalue=Ct_i.min(), +# **logprops)) + + Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() + Ct_i[ix] = 0. + + # determine pickup and deficit for current fraction + Cu_i = s['Cu'][:,:,i].flatten() + mass_i = s['mass'][:,:,0,i].flatten() + w_i = w[:,:,i].flatten() + Ts_i = Ts + + pickup_i = (w_i * Cu_i - Ct_i) / Ts_i * self.dt # Dit klopt niet! enkel geldig bij backward euler + deficit_i = pickup_i - mass_i + ix = (deficit_i > p['max_error']) \ + & (w_i * Cu_i > 0.) + + pickup[:,:,i] = pickup_i.reshape(yCt_i.shape) + Ct[:,:,i] = Ct_i.reshape(yCt_i.shape) + + # quit the iteration if there is no deficit, otherwise + # back-compute the maximum weight allowed to get zero + # deficit for the current fraction and progress to + # the next iteration step + if not np.any(ix): + logger.debug(format_log('Iteration converged', + steps=n, + fraction=i, + **logprops)) + pickup_i = np.minimum(pickup_i, mass_i) + break + else: + w_i[ix] = (mass_i[ix] * Ts_i / self.dt \ + + Ct_i[ix]) / Cu_i[ix] + w[:,:,i] = w_i.reshape(yCt_i.shape) + + # throw warning if the maximum number of iterations was + # reached + if np.any(ix): + logger.warn(format_log('Iteration not converged', + nrcells=np.sum(ix), + fraction=i, + **logprops)) + + # check for unexpected negative values + if Ct_i.min() < 0: + logger.warn(format_log('Negative concentrations', + nrcells=np.sum(Ct_i<0.), + fraction=i, + minvalue=Ct_i.min(), + **logprops)) + if w_i.min() < 0: + logger.warn(format_log('Negative weights', + nrcells=np.sum(w_i<0), + fraction=i, + minvalue=w_i.min(), + **logprops)) + # end loop over frations + + # check if there are any cells where the sum of all weights is + # smaller than unity. these cells are supply-limited for all + # fractions. Log these events. + ix = 1. - np.sum(w, axis=2) > p['max_error'] + if np.any(ix): + self._count('supplylim') +# logger.warn(format_log('Ran out of sediment', +# nrcells=np.sum(ix), +# minweight=np.sum(w, axis=-1).min(), +# **logprops)) + qs = Ct * s['us'] + qn = Ct * s['un'] + + return dict(Ct=Ct, + qs=qs, + qn=qn, + pickup=pickup, + w=w, + w_init=w_init, + w_air=w_air, + w_bed=w_bed) + + +def solve_pieter(self, alpha:float=.5, beta:float=1.) -> dict: + '''Implements the explicit Euler forward, implicit Euler backward and semi-implicit Crank-Nicolson numerical schemes + + Determines weights of sediment fractions, sediment pickup and + instantaneous sediment concentration. Returns a partial + spatial grid dictionary that can be used to update the global + spatial grid dictionary. + + Parameters + ---------- + alpha : + Implicitness coefficient (0.0 for Euler forward, 1.0 for Euler backward or 0.5 for Crank-Nicolson, default=0.5) + beta : float, optional + Centralization coefficient (1.0 for upwind or 0.5 for centralized, default=1.0) + + Returns + ------- + Partial spatial grid dictionary + + Examples + -------- + >>> model.s.update(model.solve(alpha=1., beta=1.) # euler backward + + >>> model.s.update(model.solve(alpha=.5, beta=1.) # crank-nicolson + + See Also + -------- + model.AeoLiS.euler_forward + model.AeoLiS.euler_backward + model.AeoLiS.crank_nicolson + transport.compute_weights + transport.renormalize_weights + ''' + + l = self.l + s = self.s + p = self.p + + Ct = s['Ct'].copy() + qs = s['qs'].copy() + qn = s['qn'].copy() + pickup = s['pickup'].copy() + + Ts = p['T'] + + # compute transport weights for all sediment fractions + w_init, w_air, w_bed = aeolis.transport.compute_weights(s, p) + + if self.t == 0.: + # use initial guess for first time step + w = p['grain_dist'].reshape((1,1,-1)) + w = w.repeat(p['ny']+1, axis=0) + w = w.repeat(p['nx']+1, axis=1) + return dict(w=w) + else: + w = w_init.copy() + + # set model state properties that are added to warnings and errors + logprops = dict(minwind=s['uw'].min(), + maxdrop=(l['uw']-s['uw']).max(), + time=self.t, + dt=self.dt) + + nf = p['nfractions'] + + ufs = np.zeros((p['ny']+1,p['nx']+2)) + ufn = np.zeros((p['ny']+2,p['nx']+1)) + + for i in range(nf): #loop over fractions + + #define velocity fluxes + ufs[:,1:-1] = 0.5*s['us'][:,:-1,i] + 0.5*s['us'][:,1:,i] + ufn[1:-1,:] = 0.5*s['un'][:-1,:,i] + 0.5*s['un'][1:,:,i] + + #boundary values + ufs[:,0] = s['us'][:,0,i] + ufs[:,-1] = s['us'][:,-1,i] + + if p['boundary_lateral'] == 'circular': + ufn[0,:] = 0.5*s['un'][0,:,i] + 0.5*s['un'][-1,:,i] + ufn[-1,:] = ufn[0,:] + else: + ufn[0,:] = s['un'][0,:,i] + ufn[-1,:] = s['un'][-1,:,i] + + beta = abs(beta) + if beta >= 1.: + # define upwind direction + ixfs = np.asarray(ufs >= 0., dtype=float) + ixfn = np.asarray(ufn >= 0., dtype=float) + else: + # or centralizing weights + ixfs = beta + np.zeros(ufs) + ixfn = beta + np.zeros(ufn) + + # initialize matrix diagonals + A0 = np.zeros(s['zb'].shape) + Apx = np.zeros(s['zb'].shape) + Ap1 = np.zeros(s['zb'].shape) + Amx = np.zeros(s['zb'].shape) + Am1 = np.zeros(s['zb'].shape) + + # populate matrix diagonals + A0 += s['dsdn'] / self.dt #time derivative + A0 += s['dsdn'] / Ts * alpha #source term + A0[:,1:] -= s['dn'][:,1:] * ufs[:,1:-1] * (1. - ixfs[:,1:-1]) * alpha #lower x-face + Am1[:,1:] -= s['dn'][:,1:] * ufs[:,1:-1] * ixfs[:,1:-1] * alpha #lower x-face + A0[:,:-1] += s['dn'][:,:-1] * ufs[:,1:-1] * ixfs[:,1:-1] * alpha #upper x-face + Ap1[:,:-1] += s['dn'][:,:-1] * ufs[:,1:-1] * (1. - ixfs[:,1:-1]) * alpha #upper x-face + A0[1:,:] -= s['ds'][1:,:] * ufn[1:-1,:] * (1. - ixfn[1:-1,:]) * alpha #lower y-face + Amx[1:,:] -= s['ds'][1:,:] * ufn[1:-1,:] * ixfn[1:-1,:] * alpha #lower y-face + A0[:-1,:] += s['ds'][:-1,:] * ufn[1:-1,:] * ixfn[1:-1,:] * alpha #upper y-face + Apx[:-1,:] += s['ds'][:-1,:] * ufn[1:-1,:] * (1. - ixfn[1:-1,:]) * alpha #upper y-face + + # add boundaries + # offshore boundary (i=0) + + if p['boundary_offshore'] == 'flux': + #nothing to be done + pass + elif p['boundary_offshore'] == 'constant': + #constant sediment concentration (Ct) in the air + A0[:,0] = 1. + Apx[:,0] = 0. + Amx[:,0] = 0. + Ap1[:,0] = 0. + Am1[:,0] = 0. + elif p['boundary_offshore'] == 'gradient': + #remove the flux at the inner face of the cell + A0[:,0] -= s['dn'][:,0] * ufs[:,1] * ixfs[:,1] * alpha #upper x-face + Ap1[:,0] -= s['dn'][:,0] * ufs[:,1] * (1. - ixfs[:,1]) * alpha #upper x-face + elif p['boundary_offshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore']) + + #onshore boundary (i=nx) + + if p['boundary_onshore'] == 'flux': + #nothing to be done + pass + elif p['boundary_onshore'] == 'constant': + #constant sediment concentration (hC) in the air + A0[:,-1] = 1. + Apx[:,-1] = 0. + Amx[:,-1] = 0. + Ap1[:,-1] = 0. + Am1[:,-1] = 0. + elif p['boundary_onshore'] == 'gradient': + #remove the flux at the inner face of the cell + A0[:,-1] += s['dn'][:,-1] * ufs[:,-2] * (1. - ixfs[:,-2]) * alpha #lower x-face + Am1[:,-1] += s['dn'][:,-1] * ufs[:,-2] * ixfs[:,-2] * alpha #lower x-face + elif p['boundary_onshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_onshore']) + + #lateral boundaries (j=0; j=ny) + + if p['boundary_lateral'] == 'flux': + #nothing to be done + pass + elif p['boundary_lateral'] == 'constant': + #constant sediment concentration (hC) in the air + A0[0,:] = 1. + Apx[0,:] = 0. + Amx[0,:] = 0. + Ap1[0,:] = 0. + Am1[0,:] = 0. + A0[-1,:] = 1. + Apx[-1,:] = 0. + Amx[-1,:] = 0. + Ap1[-1,:] = 0. + Am1[-1,:] = 0. + elif p['boundary_lateral'] == 'gradient': + #remove the flux at the inner face of the cell + A0[0,:] -= s['ds'][0,:] * ufn[1,:] * ixfn[1,:] * alpha #upper y-face + Apx[0,:] -= s['ds'][0,:] * ufn[1,:] * (1. - ixfn[1,:]) * alpha #upper y-face + A0[-1,:] += s['ds'][-1,:] * ufn[-2,:] * (1. - ixfn[-2,:]) * alpha #lower y-face + Amx[-1,:] += s['ds'][-1,:] * ufn[-2,:] * ixfn[-2,:] * alpha #lower y-face + elif p['boundary_lateral'] == 'circular': + A0[0,:] -= s['ds'][0,:] * ufn[0,:] * (1. - ixfn[0,:]) * alpha #lower y-face + Amx[0,:] -= s['ds'][0,:] * ufn[0,:] * ixfn[0,:] * alpha #lower y-face + A0[-1,:] += s['ds'][-1,:] * ufn[-1,:] * ixfn[-1,:] * alpha #upper y-face + Apx[-1,:] += s['ds'][-1,:] * ufn[-1,:] * (1. - ixfn[-1,:]) * alpha #upper y-face + else: + raise ValueError('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral']) + + # construct sparse matrix + if p['ny'] > 0: + j = p['nx']+1 + A = scipy.sparse.diags((Apx.flatten()[:j], + Amx.flatten()[j:], + Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1], + Apx.flatten()[j:], + Amx.flatten()[:j]), + (-j*p['ny'],-j,-1,0,1,j,j*p['ny']), format='csr') + else: + A = scipy.sparse.diags((Am1.flatten()[1:], + A0.flatten(), + Ap1.flatten()[:-1]), + (-1,0,1), format='csr') + + # solve transport for each fraction separately using latest + # available weights + + # renormalize weights for all fractions equal or larger + # than the current one such that the sum of all weights is + # unity + w = aeolis.transport.renormalize_weights(w, i) + + # iteratively find a solution of the linear system that + # does not violate the availability of sediment in the bed + for n in range(p['max_iter']): + self._count('matrixsolve') +# print("iteration nr = %d" % n) + # define upwind face value + # sediment concentration + Ctxfs_i = np.zeros(ufs.shape) + Ctxfn_i = np.zeros(ufn.shape) + + Ctxfs_i[:,1:-1] = ixfs[:,1:-1] * ( alpha * Ct[:,:-1,i] \ + + (1. - alpha ) * l['Ct'][:,:-1,i] ) \ + + (1. - ixfs[:,1:-1]) * ( alpha * Ct[:,1:,i] \ + + (1. - alpha ) * l['Ct'][:,1:,i] ) + Ctxfn_i[1:-1,:] = ixfn[1:-1,:] * (alpha * Ct[:-1,:,i] \ + + (1. - alpha ) * l['Ct'][:-1,:,i] ) \ + + (1. - ixfn[1:-1,:]) * ( alpha * Ct[1:,:,i] \ + + (1. - alpha ) * l['Ct'][1:,:,i] ) + + if p['boundary_lateral'] == 'circular': + Ctxfn_i[0,:] = ixfn[0,:] * (alpha * Ct[-1,:,i] \ + + (1. - alpha ) * l['Ct'][-1,:,i] ) \ + + (1. - ixfn[0,:]) * ( alpha * Ct[0,:,i] \ + + (1. - alpha ) * l['Ct'][0,:,i] ) + Ctxfn_i[-1,:] = Ctxfn_i[0,:] + + # calculate pickup + D_i = s['dsdn'] / Ts * ( alpha * Ct[:,:,i] \ + + (1. - alpha ) * l['Ct'][:,:,i] ) + A_i = s['dsdn'] / Ts * s['mass'][:,:,0,i] + D_i # Availability + U_i = s['dsdn'] / Ts * ( w[:,:,i] * alpha * s['Cu'][:,:,i] \ + + (1. - alpha ) * l['w'][:,:,i] * l['Cu'][:,:,i] ) + #deficit_i = E_i - A_i + E_i= np.minimum(U_i, A_i) + #pickup_i = E_i - D_i + + # create the right hand side of the linear system + # sediment concentration + yCt_i = np.zeros(s['zb'].shape) + yCt_i -= s['dsdn'] / self.dt * ( Ct[:,:,i] \ + - l['Ct'][:,:,i] ) #time derivative + yCt_i += E_i - D_i #source term + yCt_i[:,1:] += s['dn'][:,1:] * ufs[:,1:-1] * Ctxfs_i[:,1:-1] #lower x-face + yCt_i[:,:-1] -= s['dn'][:,:-1] * ufs[:,1:-1] * Ctxfs_i[:,1:-1] #upper x-face + yCt_i[1:,:] += s['ds'][1:,:] * ufn[1:-1,:] * Ctxfn_i[1:-1,:] #lower y-face + yCt_i[:-1,:] -= s['ds'][:-1,:] * ufn[1:-1,:] * Ctxfn_i[1:-1,:] #upper y-face + + # boundary conditions + # offshore boundary (i=0) + + if p['boundary_offshore'] == 'flux': + yCt_i[:,0] += s['dn'][:,0] * ufs[:,0] * s['Cu0'][:,0,i] * p['offshore_flux'] + + elif p['boundary_offshore'] == 'constant': + #constant sediment concentration (Ct) in the air (for now = 0) + yCt_i[:,0] = 0. + + elif p['boundary_offshore'] == 'gradient': + #remove the flux at the inner face of the cell + yCt_i[:,0] += s['dn'][:,1] * ufs[:,1] * Ctxfs_i[:,1] #upper x-face + + elif p['boundary_offshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown offshore boundary condition [%s]' % self.p['boundary_offshore']) + + # onshore boundary (i=nx) + + if p['boundary_onshore'] == 'flux': + yCt_i[:,-1] += s['dn'][:,-1] * ufs[:,-1] * s['Cu0'][:,-1,i] * p['onshore_flux'] + + elif p['boundary_onshore'] == 'constant': + #constant sediment concentration (Ct) in the air (for now = 0) + yCt_i[:,-1] = 0. + + elif p['boundary_onshore'] == 'gradient': + #remove the flux at the inner face of the cell + yCt_i[:,-1] -= s['dn'][:,-2] * ufs[:,-2] * Ctxfs_i[:,-2] #lower x-face + + elif p['boundary_onshore'] == 'circular': + raise NotImplementedError('Cross-shore cricular boundary condition not yet implemented') + else: + raise ValueError('Unknown onshore boundary condition [%s]' % self.p['boundary_onshore']) + + #lateral boundaries (j=0; j=ny) + + if p['boundary_lateral'] == 'flux': + + yCt_i[0,:] += s['ds'][0,:] * ufn[0,:] * s['Cu0'][0,:,i] * p['lateral_flux'] #lower y-face + yCt_i[-1,:] -= s['ds'][-1,:] * ufn[-1,:] * s['Cu0'][-1,:,i] * p['lateral_flux'] #upper y-face + + elif p['boundary_lateral'] == 'constant': + #constant sediment concentration (hC) in the air + yCt_i[0,:] = 0. + yCt_i[-1,:] = 0. + elif p['boundary_lateral'] == 'gradient': + #remove the flux at the inner face of the cell + yCt_i[-1,:] -= s['ds'][-2,:] * ufn[-2,:] * Ctxfn_i[-2,:] #lower y-face + yCt_i[0,:] += s['ds'][1,:] * ufn[1,:] * Ctxfn_i[1,:] #upper y-face + elif p['boundary_lateral'] == 'circular': + yCt_i[0,:] += s['ds'][0,:] * ufn[0,:] * Ctxfn_i[0,:] #lower y-face + yCt_i[-1,:] -= s['ds'][-1,:] * ufn[-1,:] * Ctxfn_i[-1,:] #upper y-face + else: + raise ValueError('Unknown lateral boundary condition [%s]' % self.p['boundary_lateral']) + + # print("ugs = %.*g" % (3,s['ugs'][10,10])) + # print("ugn = %.*g" % (3,s['ugn'][10,10])) + # print("%.*g" % (3,np.amax(np.absolute(y_i)))) + + # solve system with current weights + Ct_i = Ct[:,:,i].flatten() + Ct_i += scipy.sparse.linalg.spsolve(A, yCt_i.flatten()) + Ct_i = prevent_tiny_negatives(Ct_i, p['max_error']) + + # check for negative values + if Ct_i.min() < 0.: + ix = Ct_i < 0. + +# logger.warn(format_log('Removing negative concentrations', +# nrcells=np.sum(ix), +# fraction=i, +# iteration=n, +# minvalue=Ct_i.min(), +# **logprops)) + + if 0: #Ct_i[~ix].sum()>0.: + # compensate the negative concentrations by distributing them over the positives. + # I guess the idea is to conserve mass but it is not sure if this is needed, + # mass continuity in the system is guaranteed by exchange with bed. + Ct_i[~ix] *= 1. + Ct_i[ix].sum() / Ct_i[~ix].sum() + Ct_i[ix] = 0. + + # determine pickup and deficit for current fraction + Cu_i = s['Cu'][:,:,i].flatten() + mass_i = s['mass'][:,:,0,i].flatten() + w_i = w[:,:,i].flatten() + Ts_i = Ts + + pickup_i = (w_i * Cu_i - Ct_i) / Ts_i * self.dt # Dit klopt niet! enkel geldig bij backward euler + deficit_i = pickup_i - mass_i + ix = (deficit_i > p['max_error']) \ + & (w_i * Cu_i > 0.) + + pickup[:,:,i] = pickup_i.reshape(yCt_i.shape) + Ct[:,:,i] = Ct_i.reshape(yCt_i.shape) + + # quit the iteration if there is no deficit, otherwise + # back-compute the maximum weight allowed to get zero + # deficit for the current fraction and progress to + # the next iteration step + if not np.any(ix): + logger.debug(format_log('Iteration converged', + steps=n, + fraction=i, + **logprops)) + pickup_i = np.minimum(pickup_i, mass_i) + break + else: + w_i[ix] = (mass_i[ix] * Ts_i / self.dt \ + + Ct_i[ix]) / Cu_i[ix] + w[:,:,i] = w_i.reshape(yCt_i.shape) + + # throw warning if the maximum number of iterations was + # reached + + if np.any(ix): + logger.warn(format_log('Iteration not converged', + nrcells=np.sum(ix), + fraction=i, + **logprops)) + + if 0: #let's disable these warnings + # check for unexpected negative values + if Ct_i.min() < 0: + logger.warn(format_log('Negative concentrations', + nrcells=np.sum(Ct_i<0.), + fraction=i, + minvalue=Ct_i.min(), + **logprops)) + if w_i.min() < 0: + logger.warn(format_log('Negative weights', + nrcells=np.sum(w_i<0), + fraction=i, + minvalue=w_i.min(), + **logprops)) + # end loop over frations + + # check if there are any cells where the sum of all weights is + # smaller than unity. these cells are supply-limited for all + # fractions. Log these events. + ix = 1. - np.sum(w, axis=2) > p['max_error'] + if np.any(ix): + self._count('supplylim') +# logger.warn(format_log('Ran out of sediment', +# nrcells=np.sum(ix), +# minweight=np.sum(w, axis=-1).min(), +# **logprops)) + + qs = Ct * s['us'] + qn = Ct * s['un'] + qs = Ct * s['us'] + qn = Ct * s['un'] + q = np.hypot(qs, qn) + + + return dict(Ct=Ct, + qs=qs, + qn=qn, + pickup=pickup, + w=w, + w_init=w_init, + w_air=w_air, + w_bed=w_bed, + q=q) + + +# Note: @njit(cache=True) is intentionally not used here. +# This function acts as an orchestrator, delegating work to Numba-compiled helper functions. +# Decorating the orchestrator itself with njit provides no performance benefit, +# since most of the computation is already handled by optimized Numba functions. +def sweep(Ct, Cu_bed, Cu_air, zeta, mass, dt, Ts, ds, dn, us, un, w): + + Cu = Cu_bed.copy() + + pickup = np.zeros(Cu.shape) + i=0 + k=0 + + nf = np.shape(Ct)[2] + + # Are the lateral boundary conditions circular? + circ_lateral = False + if Ct[0,1,0]==-1: + circ_lateral = True + Ct[0,:,0] = 0 + Ct[-1,:,0] = 0 + + circ_offshore = False + if Ct[1,0,0]==-1: + circ_offshore = True + Ct[:,0,0] = 0 + Ct[:,-1,0] = 0 + + recirc_offshore = False + if Ct[1,0,0]==-2: + recirc_offshore = True + Ct[:,0,0] = 0 + Ct[:,-1,0] = 0 + + + ufs = np.zeros((np.shape(us)[0], np.shape(us)[1]+1, np.shape(us)[2])) + ufn = np.zeros((np.shape(un)[0]+1, np.shape(un)[1], np.shape(un)[2])) + + # define velocity at cell faces + ufs[:,1:-1, :] = 0.5*us[:,:-1, :] + 0.5*us[:,1:, :] + ufn[1:-1,:, :] = 0.5*un[:-1,:, :] + 0.5*un[1:,:, :] + + # print(ufs[5,:,0]) + + # set empty boundary values, extending the velocities at the boundaries + ufs[:,0, :] = ufs[:,1, :] + ufs[:,-1, :] = ufs[:,-2, :] + + ufn[0,:, :] = ufn[1,:, :] + ufn[-1,:, :] = ufn[-2,:, :] + + # Lets take the average of the top and bottom and left/right boundary cells + # apply the average to the boundary cells + # this ensures that the inflow at one side is equal to the outflow at the other side + + ufs[:,0,:] = (ufs[:,0,:]+ufs[:,-1,:])/2 + ufs[:,-1,:] = ufs[:,0,:] + ufs[0,:,:] = (ufs[0,:,:]+ufs[-1,:,:])/2 + ufs[-1,:,:] = ufs[0,:,:] + + ufn[:,0,:] = (ufn[:,0,:]+ufn[:,-1,:])/2 + ufn[:,-1,:] = ufn[:,0,:] + ufn[0,:,:] = (ufn[0,:,:]+ufn[-1,:,:])/2 + ufn[-1,:,:] = ufn[0,:,:] + + # also correct for the potential gradients at the boundary cells in the equilibrium concentrations + Cu[:,0,:] = Cu[:,1,:] + Cu[:,-1,:] = Cu[:,-2,:] + Cu[0,:,:] = Cu[1,:,:] + Cu[-1,:,:] = Cu[-2,:,:] + + Ct_last = Ct.copy() + + while k==0 or np.any(np.abs(Ct[:,:,i]-Ct_last[:,:,i])>1e-6): + # while k==0 or np.any(np.abs(Ct[:,:,i]-Ct_last[:,:,i])!=0): + Ct_last = Ct.copy() + + # Compute Cu based on air and bed contributions (Cu_air > 0 and Ct_air > 0) + w_air = np.zeros(Ct.shape) + w_bed = np.zeros(Ct.shape) + for i in range(nf): + ix = (Cu_air[:,:,i] > 0) & (Ct[:,:,i] > 0) + w_air[ix,i] = (1 - zeta[ix]) * Ct[ix,i] / Cu_air[ix,i] + w_bed[ix,i] = 1 - w_air[ix,i] + + Cu[ix,i] = w_air[ix,i] * Cu_air[ix,i] + w_bed[ix,i] * Cu_bed[ix,i] + Cu[~ix,i] = Cu_bed[~ix,i] + + + # lateral boundaries circular + if circ_lateral: + Ct[0,:,0],Ct[-1,:,0] = Ct[-1,:,0].copy(),Ct[0,:,0].copy() + # pickup[0,:,0],pickup[-1,:,0] = pickup[-1,:,0].copy(),pickup[0,:,0].copy() + if circ_offshore: + Ct[:,0,0],Ct[:,-1,0] = Ct[:,-1,0].copy(),Ct[:,0,0].copy() + # pickup[:,0,0],pickup[:,-1,0] = pickup[:,-1,0].copy(),pickup[:,0,0].copy() + + if recirc_offshore: + Ct[:,0,0],Ct[:,-1,0] = np.mean(Ct[:,-2,0]), np.mean(Ct[:,1,0]) + + # Track visited cells and quadrant classification + visited = np.zeros(Cu.shape[:2], dtype=bool) + quad = np.zeros(Cu.shape[:2], dtype=np.uint8) + + ######################################################################################## + # in this sweeping algorithm we sweep over the 4 quadrants + # assuming that most cells have no converging/divering charactersitics. + # In the last quadrant we take converging and diverging cells into account. + + # The First quadrant (Numba-optimized) + _solve_quadrant1(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + # The second quadrant (Numba-optimized) + _solve_quadrant2(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + # The third quadrant (Numba-optimized) + _solve_quadrant3(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + # The fourth quadrant (Numba-optimized) + _solve_quadrant4(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + # Generic stencil for remaining cells including boundaries (Numba-optimized) + _solve_generic_stencil(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf) + + # check the boundaries of the pickup matrix for unvisited cells + # print(np.shape(visited[0,:]==False)) + pickup[0,:,0] = pickup[1,:,0].copy() + pickup[-1,:,0] = pickup[-2,:,0].copy() + + k+=1 + + # diff_Ct = np.abs(Ct[:,:,0] - Ct_last[:,:,0]) + # print(f"Sweep {k} completed. Max difference in Ct: {diff_Ct.max()}") + + # Plot + # import matplotlib.pyplot as plt + # plt.imshow(Ct, cmap='viridis') + # plt.colorbar(label='Ct') + # plt.show() + + # print(k) + + + # print("q1 = " + str(np.sum(q==1)) + " q2 = " + str(np.sum(q==2)) \ + # + " q3 = " + str(np.sum(q==3)) + " q4 = " + str(np.sum(q==4)) \ + # + " q5 = " + str(np.sum(q==5))) + # print("pickup deviation percentage = " + str(pickup.sum()/pickup[pickup>0].sum()*100) + " %") + # print("pickup deviation percentage = " + str(pickup[1,:,0].sum()/pickup[1,pickup[1,:,0]>0,0].sum()*100) + " %") + # print("pickup maximum = " + str(pickup.max()) + " mass max = " + str(mass.max())) + # print("pickup minimum = " + str(pickup.min())) + # print("pickup average = " + str(pickup.mean())) + # print("number of cells for pickup maximum = " + str((pickup == mass.max()).sum())) + # pickup[1,:,0].sum()/pickup[1,pickup[1,:,0]<0,0].sum() + + print(f"Number of sweeps: {k}") + + return Ct, pickup + + +@njit(cache=True) +def _solve_quadrant1(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + """Solve first quadrant (positive flow in both directions) with Numba optimization.""" + for n in range(1, Ct.shape[0]): + for s in range(1, Ct.shape[1]): + if ( + (not visited[n, s]) + and (ufn[n, s, 0] >= 0) + and (ufs[n, s, 0] >= 0) + and (ufn[n + 1, s, 0] >= 0) + and (ufs[n, s + 1, 0] >= 0) + ): + + # Compute concentration for all fractions + for f in range(nf): + num = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + + Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + + w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) + + den = (ufn[n + 1, s, f] * ds[n, s] + + ufs[n, s + 1, f] * dn[n, s] + + ds[n, s] * dn[n, s] / Ts) + + Ct[n, s, f] = num / den + + # Calculate pickup + pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts + + # Check for supply limitations and re-iterate + if pickup[n, s, f] > mass[n, s, 0, f]: + pickup[n, s, f] = mass[n, s, 0, f] + + num_limited = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + + Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + + pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) + + den_limited = (ufn[n + 1, s, f] * ds[n, s] + + ufs[n, s + 1, f] * dn[n, s]) + + Ct[n, s, f] = num_limited / den_limited + + visited[n, s] = True + quad[n, s] = 1 + + +@njit(cache=True) +def _solve_quadrant2(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + """Solve second quadrant (positive n-flow, negative s-flow) with Numba optimization.""" + for n in range(1, Ct.shape[0]): + for s in range(Ct.shape[1] - 2, -1, -1): + if ( + (not visited[n, s]) + and (ufn[n, s, 0] >= 0) + and (ufs[n, s, 0] <= 0) + and (ufn[n + 1, s, 0] >= 0) + and (ufs[n, s + 1, 0] <= 0) + ): + + # Compute concentration for all fractions + for f in range(nf): + num = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + + -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + + w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) + + den = (ufn[n + 1, s, f] * ds[n, s] + + -ufs[n, s, f] * dn[n, s] + + ds[n, s] * dn[n, s] / Ts) + + Ct[n, s, f] = num / den + + # Calculate pickup + pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts + + # Check for supply limitations and re-iterate + if pickup[n, s, f] > mass[n, s, 0, f]: + pickup[n, s, f] = mass[n, s, 0, f] + + num_limited = (Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + + -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + + pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) + + den_limited = (ufn[n + 1, s, f] * ds[n, s] + + -ufs[n, s, f] * dn[n, s]) + + Ct[n, s, f] = num_limited / den_limited + + visited[n, s] = True + quad[n, s] = 2 + + +@njit(cache=True) +def _solve_quadrant3(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + """Solve third quadrant (negative flow in both directions) with Numba optimization.""" + for n in range(Ct.shape[0] - 2, -1, -1): + for s in range(Ct.shape[1] - 2, -1, -1): + if ( + (not visited[n, s]) + and (ufn[n, s, 0] <= 0) + and (ufs[n, s, 0] <= 0) + and (ufn[n + 1, s, 0] <= 0) + and (ufs[n, s + 1, 0] <= 0) + ): + + # Compute concentration for all fractions + for f in range(nf): + num = (-Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + + -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + + w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) + + den = (-ufn[n, s, f] * dn[n, s] + + -ufs[n, s, f] * dn[n, s] + + ds[n, s] * dn[n, s] / Ts) + + Ct[n, s, f] = num / den + + # Calculate pickup + pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts + + # Check for supply limitations and re-iterate + if pickup[n, s, f] > mass[n, s, 0, f]: + pickup[n, s, f] = mass[n, s, 0, f] + + num_limited = (-Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + + -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + + pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) + + den_limited = (-ufn[n, s, f] * dn[n, s] + + -ufs[n, s, f] * dn[n, s]) + + Ct[n, s, f] = num_limited / den_limited + + visited[n, s] = True + quad[n, s] = 3 + + +@njit(cache=True) +def _solve_quadrant4(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + """Solve fourth quadrant (negative n-flow, positive s-flow) with Numba optimization.""" + for n in range(Ct.shape[0] - 2, -1, -1): + for s in range(1, Ct.shape[1]): + if ( + (not visited[n, s]) + and (ufn[n, s, 0] <= 0) + and (ufs[n, s, 0] >= 0) + and (ufn[n + 1, s, 0] <= 0) + and (ufs[n, s + 1, 0] >= 0) + ): + + # Compute concentration for all fractions + for f in range(nf): + num = (Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + + -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + + w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts) + + den = (ufs[n, s + 1, f] * dn[n, s] + + -ufn[n, s, f] * dn[n, s] + + ds[n, s] * dn[n, s] / Ts) + + Ct[n, s, f] = num / den + + # Calculate pickup + pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts + + # Check for supply limitations and re-iterate + if pickup[n, s, f] > mass[n, s, 0, f]: + pickup[n, s, f] = mass[n, s, 0, f] + + num_limited = (Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + + -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + + pickup[n, s, f] * ds[n, s] * dn[n, s] / dt) + + den_limited = (ufs[n, s + 1, f] * dn[n, s] + + -ufn[n, s, f] * dn[n, s]) + + Ct[n, s, f] = num_limited / den_limited + + visited[n, s] = True + quad[n, s] = 4 + + +@njit(cache=True) +def _solve_generic_stencil(Ct, Cu, mass, pickup, dt, Ts, ds, dn, ufs, ufn, w, visited, quad, nf): + """Solve remaining cells with generic stencil using conditionals (Numba-optimized).""" + for n in range(Ct.shape[0] - 2, -1, -1): + for s in range(1, Ct.shape[1]): + if (not visited[n, s]) and (n != 0) and (s != Ct.shape[1] - 1): + # Apply generic stencil with conditionals instead of boolean multiplication + for f in range(nf): + # Initialize with source term + num = w[n, s, f] * Cu[n, s, f] * ds[n, s] * dn[n, s] / Ts + den = ds[n, s] * dn[n, s] / Ts + + # Add flux contributions conditionally + if ufn[n, s, 0] > 0: + num += Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + + if ufs[n, s, 0] > 0: + num += Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + + if ufn[n + 1, s, 0] < 0: + num += -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + elif ufn[n + 1, s, 0] > 0: + den += ufn[n + 1, s, f] * ds[n, s] + + if ufs[n, s + 1, 0] < 0: + num += -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + elif ufs[n, s + 1, 0] > 0: + den += ufs[n, s + 1, f] * dn[n, s] + + if ufn[n, s, 0] < 0: + den += -ufn[n, s, f] * dn[n, s] + + if ufs[n, s, 0] < 0: + den += -ufs[n, s, f] * dn[n, s] + + Ct[n, s, f] = num / den + + # Calculate pickup + pickup[n, s, f] = (w[n, s, f] * Cu[n, s, f] - Ct[n, s, f]) * dt / Ts + + # Check for supply limitations and re-iterate + if pickup[n, s, f] > mass[n, s, 0, f]: + pickup[n, s, f] = mass[n, s, 0, f] + + # Recompute with limited pickup + num_lim = pickup[n, s, f] * ds[n, s] * dn[n, s] / dt + den_lim = 0.0 + + if ufn[n, s, 0] > 0: + num_lim += Ct[n - 1, s, f] * ufn[n, s, f] * ds[n, s] + + if ufs[n, s, 0] > 0: + num_lim += Ct[n, s - 1, f] * ufs[n, s, f] * dn[n, s] + + if ufn[n + 1, s, 0] < 0: + num_lim += -Ct[n + 1, s, f] * ufn[n + 1, s, f] * dn[n, s] + elif ufn[n + 1, s, 0] > 0: + den_lim += ufn[n + 1, s, f] * ds[n, s] + + if ufs[n, s + 1, 0] < 0: + num_lim += -Ct[n, s + 1, f] * ufs[n, s + 1, f] * dn[n, s] + elif ufs[n, s + 1, 0] > 0: + den_lim += ufs[n, s + 1, f] * dn[n, s] + + if ufn[n, s, 0] < 0: + den_lim += -ufn[n, s, f] * dn[n, s] + + if ufs[n, s, 0] < 0: + den_lim += -ufs[n, s, f] * dn[n, s] + + Ct[n, s, f] = num_lim / den_lim + + visited[n, s] = True + quad[n, s] = 5 diff --git a/aeolis/constants.py b/aeolis/constants.py index 5560d775..e8f5d74b 100644 --- a/aeolis/constants.py +++ b/aeolis/constants.py @@ -43,6 +43,10 @@ 'dtaus', # [-] Component of the wind shear perturbation in x-direction 'dtaun', # [-] Component of the wind shear perturbation in y-direction + 'tauAir', # [N/m^2] Wind shear stress for airborne sediment + 'tausAir', # [N/m^2] Component of wind shear stress for airborne sediment in x-direction + 'taunAir', # [N/m^2] Component of wind shear stress for airborne sediment in y-direction + 'ustar', # [m/s] Wind shear velocity 'ustars', # [m/s] Component of wind shear velocity in x-direction 'ustarn', # [m/s] Component of wind shear velocity in y-direction @@ -50,6 +54,10 @@ 'ustars0', # [m/s] Component of wind shear velocity in x-direction over a flat bed 'ustarn0', # [m/s] Component of wind shear velocity in y-direction over a flat bed + 'ustarAir', # [m/s] Wind shear velocity for airborne sediment + 'ustarsAir', # [m/s] Component of wind shear velocity for airborne sediment in x-direction + 'ustarnAir', # [m/s] Component of wind shear velocity for airborne sediment in y-direction + 'udir', # [rad] Wind direction 'zs', # [m] Water level above reference (or equal to zb if zb > zs) 'SWL', # [m] Still water level above reference @@ -124,8 +132,8 @@ 'Cu', # [kg/m^2] Equilibrium sediment concentration integrated over saltation height 'Cuf', # [kg/m^2] Equilibrium sediment concentration integrated over saltation height, assuming the fluid shear velocity threshold 'Cu0', # [kg/m^2] Flat bad equilibrium sediment concentration integrated over saltation height - 'Cu_air', # [kg/m^2] [NEW] Equilibrium sediment concentration for airborne sediment - 'Cu_bed', # [kg/m^2] [NEW] Equilibrium sediment concentration for bed sediment + 'CuAir', # [kg/m^2] [NEW] Equilibrium sediment concentration for airborne sediment + 'CuBed', # [kg/m^2] [NEW] Equilibrium sediment concentration for bed sediment 'Ct', # [kg/m^2] Instantaneous sediment concentration integrated over saltation height 'q', # [kg/m/s] Instantaneous sediment flux 'qs', # [kg/m/s] Instantaneous sediment flux in x-direction @@ -253,7 +261,7 @@ 'L' : 100., # [m] Typical length scale of dune feature (perturbation) 'l' : 10., # [m] Inner layer height (perturbation) 'c_b' : 0.2, # [-] Slope at the leeside of the separation bubble # c = 0.2 according to Durán 2010 (Sauermann 2001: c = 0.25 for 14 degrees) - 'mu_b' : 30, # [deg] Minimum required slope for the start of flow separation + 'mu_b' : 10., # [deg] Minimum required slope for the start of flow separation 'buffer_width' : 10, # [m] Width of the bufferzone around the rotational grid for wind perturbation 'sep_filter_iterations' : 0, # [-] Number of filtering iterations on the sep-bubble (0 = no filtering) 'zsep_y_filter' : False, # [-] Boolean for turning on/off the filtering of the separation bubble in y-direction diff --git a/aeolis/shear.py b/aeolis/shear.py index 5b2ae4e0..1121ca99 100644 --- a/aeolis/shear.py +++ b/aeolis/shear.py @@ -240,6 +240,8 @@ def __call__(self, x, y, z, taux, tauy, u0, udir, # Compute the influence of the separation on the shear stress if process_separation: gc['hsep'] = gc['z'] - z_origin + gc['taux_air'] = gc['taux'].copy() + gc['tauy_air'] = gc['tauy'].copy() self.separation_shear(gc['hsep']) if plot: @@ -255,6 +257,7 @@ def __call__(self, x, y, z, taux, tauy, u0, udir, gc['x'], gc['y'] = self.rotate(gc['x'], gc['y'], -u_angle, origin=(self.x0, self.y0)) gi['x'], gi['y'] = self.rotate(gi['x'], gi['y'], -u_angle, origin=(self.x0, self.y0)) gc['taux'], gc['tauy'] = self.rotate(gc['taux'], gc['tauy'], -u_angle) + gc['taux_air'], gc['tauy_air'] = self.rotate(gc['taux_air'], gc['tauy_air'], -u_angle) # ===================================================================== # Interpolation from the computational grid back to the original @@ -263,6 +266,8 @@ def __call__(self, x, y, z, taux, tauy, u0, udir, # Interpolate wind shear results to real grid gi['taux'] = self.interpolate(gc['x'], gc['y'], gc['taux'], gi['x'], gi['y'], taus0) gi['tauy'] = self.interpolate(gc['x'], gc['y'], gc['tauy'], gi['x'], gi['y'], taun0) + gi['taux_air'] = self.interpolate(gc['x'], gc['y'], gc['taux_air'], gi['x'], gi['y'], taus0) + gi['tauy_air'] = self.interpolate(gc['x'], gc['y'], gc['tauy_air'], gi['x'], gi['y'], taun0) if process_separation: gi['hsep'] = self.interpolate(gc['x'], gc['y'], gc['hsep'], gi['x'], gi['y'], 0. ) @@ -729,8 +734,10 @@ def get_shear(self): taux = self.igrid['taux'] tauy = self.igrid['tauy'] + taux_air = self.igrid['taux_air'] + tauy_air = self.igrid['tauy_air'] - return taux, tauy + return taux, tauy, taux_air, tauy_air def add_shear(self): diff --git a/aeolis/threshold.py b/aeolis/threshold.py index 48f3658f..08cc1720 100644 --- a/aeolis/threshold.py +++ b/aeolis/threshold.py @@ -436,7 +436,7 @@ def non_erodible(s,p): # Influence of non-erodible layer on bed interaction parameter zeta if p['process_bedinteraction']: - s['zeta'][ix] = 0.0 # Air-dominated interaction when non-erodible layer is exposed + s['zeta'][ix] = 0. # Air-dominated interaction when non-erodible layer is exposed return s diff --git a/aeolis/transport.py b/aeolis/transport.py index 87fc90af..a44208e3 100644 --- a/aeolis/transport.py +++ b/aeolis/transport.py @@ -335,6 +335,7 @@ def equilibrium(s, p): ustar = s['ustar'][:,:,np.newaxis].repeat(nf, axis=2) ustar0 = s['ustar0'][:,:,np.newaxis].repeat(nf, axis=2) + ustar_air = s['ustarAir'][:,:,np.newaxis].repeat(nf, axis=2) uth = s['uth'] uthf = s['uthf'] @@ -345,8 +346,8 @@ def equilibrium(s, p): s['Cu'] = np.zeros(uth.shape) s['Cuf'] = np.zeros(uth.shape) - s['Cu_air'] = np.zeros(uth.shape) - s['Cu_bed'] = np.zeros(uth.shape) + s['CuAir'] = np.zeros(uth.shape) + s['CuBed'] = np.zeros(uth.shape) ix = (ustar != 0.)*(u != 0.) @@ -358,8 +359,8 @@ def equilibrium(s, p): s['Cu0'][ix] = np.maximum(0., p['Cb'] * rhoa / g * (ustar0[ix] - uth0[ix])**3 / u[ix]) # [NEW] Two transport components divided into air and bed interaction - s['Cu_air'][ix] = np.maximum(0., p['Cb'] * rhoa / g * (ustar[ix] - uth0[ix])**3 / u[ix]) - s['Cu_bed'][ix] = s['Cu'][ix].copy() # Temporary solution + s['CuAir'][ix] = np.maximum(0., p['Cb'] * rhoa / g * (ustar_air[ix] - uth0[ix])**3 / u[ix]) + s['CuBed'][ix] = s['Cu'][ix].copy() # Temporary solution elif p['method_transport'].lower() == 'bagnold_gs': diff --git a/aeolis/wind.py b/aeolis/wind.py index db82c030..926c0b49 100644 --- a/aeolis/wind.py +++ b/aeolis/wind.py @@ -259,16 +259,34 @@ def shear(s,p): sep_filter_iterations=p['sep_filter_iterations'], zsep_y_filter=p['zsep_y_filter']) - s['taus'], s['taun'] = s['shear'].get_shear() + s['taus'], s['taun'], s['tausAir'], s['taunAir'] = s['shear'].get_shear() s['tau'] = np.hypot(s['taus'], s['taun']) - - s = stress_velocity(s,p) # Returns separation surface if p['process_separation']: s['hsep'] = s['shear'].get_separation() s['zsep'] = s['hsep'] + s['zb'] + + # Set airborne shear stress and zeta in case of bed interaction + if p['process_bedinteraction']: + + # Airborne shear stress (not affected by separation) + s['tauAir'] = np.hypot(s['tausAir'], s['taunAir']) + + # Bed interaction parameter zeta (towards 1.0 in case of separation) + tau_sep = 0.5 + slope = 0.2 + delta = 1./(slope*tau_sep) + zsepdelta = np.maximum(np.minimum(delta * s['hsep'], 1.), 0.) + s['zeta'] = zsepdelta * 1.0 + (1. - zsepdelta) * s['zeta'] + + + else: + s['tauAir'] = None + + s = stress_velocity(s,p) + elif p['process_shear'] and p['ny'] == 0: #NTC - Added in 1D only capabilities s = compute_shear1d(s, p) @@ -334,6 +352,19 @@ def stress_velocity(s, p): s['ustars'][ix] = 0. s['ustarn'][ix] = 0. + if s['tauAir'] is not None: + + s['ustarAir'] = np.sqrt(s['tauAir'] / p['rhoa']) + + ix = s['tauAir'] > 0. + s['ustarsAir'][ix] = s['ustarAir'][ix] * s['tausAir'][ix] / s['tauAir'][ix] + s['ustarnAir'][ix] = s['ustarAir'][ix] * s['taunAir'][ix] / s['tauAir'][ix] + + ix = s['tauAir'] == 0. + s['ustarAir'][ix] = 0. + s['ustarsAir'][ix] = 0. + s['ustarnAir'][ix] = 0. + return s From bf2c0ea2f97697f5e97a4607c33a2aea7f2943ca Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Wed, 17 Dec 2025 09:43:45 -0800 Subject: [PATCH 09/23] Refactor bed interaction parameter handling and clean up code comments --- aeolis/advection.py | 4 ++-- aeolis/bed.py | 6 ------ aeolis/hydro.py | 7 +++++++ aeolis/model.py | 2 -- aeolis/threshold.py | 7 ++++++- aeolis/wind.py | 1 - 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/aeolis/advection.py b/aeolis/advection.py index 3c19f587..18cb296f 100644 --- a/aeolis/advection.py +++ b/aeolis/advection.py @@ -1944,9 +1944,9 @@ def sweep(Ct, Cu_bed, Cu_air, zeta, mass, dt, Ts, ds, dn, us, un, w): k+=1 - print(f"Number of sweeps: {k}") + # print(f"Number of sweeps: {k}") - # # Plotting + # Plotting # import matplotlib.pyplot as plt # plt.figure(figsize=(12, 6)) # plt.subplot(1, 2, 1) diff --git a/aeolis/bed.py b/aeolis/bed.py index 9e6dc137..224b2861 100644 --- a/aeolis/bed.py +++ b/aeolis/bed.py @@ -125,12 +125,6 @@ def initialize(s, p): # initialize threshold if p['threshold_file'] is not None: s['uth'] = p['threshold_file'][:,:,np.newaxis].repeat(nf, axis=-1) - - # initialize bed interaction parameter zeta - if p['process_bedinteraction']: - s['zeta'][:,:] = p['zeta_base'] - else: - s['zeta'][:,:] = 1.0 # Similar to the old advection version without zeta return s diff --git a/aeolis/hydro.py b/aeolis/hydro.py index 6ac42541..d2a87205 100644 --- a/aeolis/hydro.py +++ b/aeolis/hydro.py @@ -234,6 +234,13 @@ def update(s, p, dt,t): Spatial grids ''' + + # Reset bed interaction parameter zeta + if p['process_bedinteraction']: + s['zeta'][:,:] = p['zeta_base'] + else: + s['zeta'][:,:] = 1.0 # Similar to the old advection version without zeta + # Groundwater level Boussinesq (1D CS-transects) if p['process_groundwater']: diff --git a/aeolis/model.py b/aeolis/model.py index dc3c5edd..f529a536 100644 --- a/aeolis/model.py +++ b/aeolis/model.py @@ -310,8 +310,6 @@ def update(self, dt:float=-1) -> None: ''' self.p['_time'] = self.t - - # store previous state self.l = self.s.copy() diff --git a/aeolis/threshold.py b/aeolis/threshold.py index 08cc1720..737fcf01 100644 --- a/aeolis/threshold.py +++ b/aeolis/threshold.py @@ -421,7 +421,7 @@ def non_erodible(s,p): s['zne'][:,:] = p['ne_file'] # Determine where ne-layer is "exposed" - thuthlyr = 0.01 + thuthlyr = 0.05 ix = (s['zb'] <= s['zne'] + thuthlyr) # Smooth method @@ -435,8 +435,13 @@ def non_erodible(s,p): s['uth'][ix,i] = np.inf # Influence of non-erodible layer on bed interaction parameter zeta + thuthlyr = 0.05 + ix = (s['zb'] <= s['zne'] + thuthlyr) if p['process_bedinteraction']: s['zeta'][ix] = 0. # Air-dominated interaction when non-erodible layer is exposed + + + return s diff --git a/aeolis/wind.py b/aeolis/wind.py index 5ddee8fb..c4628357 100644 --- a/aeolis/wind.py +++ b/aeolis/wind.py @@ -138,7 +138,6 @@ def interpolate(s, p, t): s['uws'] = - s['uw'] * np.sin((-p['alfa'] + s['udir']) / 180. * np.pi) # alfa [deg] is real world grid cell orientation (clockwise) s['uwn'] = - s['uw'] * np.cos((-p['alfa'] + s['udir']) / 180. * np.pi) - s['uw'] = np.abs(s['uw']) # Compute wind shear velocity From 6f384ebb5b2c22cfbb5c09d2c3961b5199a8ff40 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Wed, 17 Dec 2025 17:11:06 -0800 Subject: [PATCH 10/23] Add vegetation model for dune grasses and integrate into AeoLiS framework - Introduced new grass vegetation module with initialization and update functions. - Added utility functions for grass model parameter handling and subgrid generation. - Updated AeoLiS model to support grass vegetation dynamics and shear reduction. - Enhanced NetCDF output to include vegetation species data. --- aeolis/constants.py | 69 ++++++++++- aeolis/grass.py | 196 ++++++++++++++++++++++++++++++ aeolis/grass_utils.py | 268 ++++++++++++++++++++++++++++++++++++++++++ aeolis/model.py | 52 ++++++-- aeolis/netcdf.py | 11 ++ aeolis/shear.py | 4 +- 6 files changed, 585 insertions(+), 15 deletions(-) create mode 100644 aeolis/grass.py create mode 100644 aeolis/grass_utils.py diff --git a/aeolis/constants.py b/aeolis/constants.py index 6b75e8ee..b3cc3fb2 100644 --- a/aeolis/constants.py +++ b/aeolis/constants.py @@ -108,15 +108,17 @@ 'zsep', # [m] Z level of polynomial that defines the separation bubble 'hsep', # [m] Height of separation bubble = difference between z-level of zsep and of the bed level zb 'theta_dyn', # [degrees] spatially varying dynamic angle of repose for avalanching - 'rhoveg', # [-] Vegetation cover + # 'rhoveg', # [-] Vegetation cover 'drhoveg', # Change in vegetation cover - 'hveg', # [m] height of vegetation + # 'hveg', # [m] height of vegetation 'dhveg', # [m] Difference in vegetation height per time step 'dzbveg', # [m] Bed level change used for calculation of vegetation growth 'germinate', # [bool] Newly vegetated due to germination (or establishment) 'lateral', # [bool] Newly vegetated due to lateral propagation 'vegetated', # [bool] Vegetated, determines if vegetation growth or burial is allowed 'vegfac', # Vegetation factor to modify shear stress by according to Raupach 1993 + 'Rveg', # [-] NEW Vegetation shear reduction factor including Okin effect + 'R0veg', # [-] NEW Local vegetation shear reduction factor (replaces vegfac) 'fence_height', # Fence height 'R', # [m] wave runup 'eta', # [m] wave setup @@ -161,8 +163,26 @@ ('ny','nx','nlayers','nfractions') : ( 'mass', # [kg/m^2] Sediment mass in bed ), -} + # Vegetation variables for grass model (original grid) + ('ny','nx','nspecies') : ( + 'Nt', # [1/m^2] Density of grass tillers + 'hveg', # [m] Average height of the grass tillers + 'hveg_eff', # [m] Effective vegetation height + 'lamveg', # [-] Frontal area density + 'rhoveg', # [-] Cover area density + ), + + # Vegetation variables for grass model (refined vegetation grid) + ('ny_vsub','nx_vsub') : ( + 'x_vsub', # [m] x-coordinates of vegetation subgrid + 'y_vsub', # [m] y-coordinates of vegetation subgrid + ), + ('ny_vsub','nx_vsub','nspecies') : ( + 'Nt_vsub', # [1/m^2] Density of tillers + 'hveg_vsub', # [m] Height of individual tillers + ) +} #: AeoLiS model default configuration DEFAULT_CONFIG = { @@ -318,6 +338,7 @@ 'theta_dyn' : 33., # [degrees] Initial Dynamic angle of repose, critical dynamic slope for avalanching 'theta_stat' : 34., # [degrees] Initial Static angle of repose, critical static slope for avalanching 'avg_time' : 86400., # [s] Indication of the time period over which the bed level change is averaged for vegetation growth + 'T_burial' : 86400.*30., # [s] NEW! Time scale for sediment burial effect on vegetation growth (replaces avg_time) 'gamma_vegshear' : 16., # [-] Roughness factor for the shear stress reduction by vegetation 'hveg_max' : 1., # [m] Max height of vegetation 'dzb_opt' : 0., # [m/year] Sediment burial for optimal growth @@ -344,6 +365,7 @@ 'method_roughness' : 'constant', # Name of method to compute the roughness height z0, note that here the z0 = k, which does not follow the definition of Nikuradse where z0 = k/30. 'method_grainspeed' : 'windspeed', # Name of method to assume/compute grainspeed (windspeed, duran, constant) 'method_shear' : 'fft', # Name of method to compute topographic effects on wind shear stress (fft, quasi2d, duna2d (experimental)) + 'method_vegetation' : 'duran', # Name of method to compute vegetation: duran (original) or grass (new framework) 'max_error' : 1e-6, # [-] Maximum error at which to quit iterative solution in implicit numerical schemes 'max_iter' : 1000, # [-] Maximum number of iterations at which to quit iterative solution in implicit numerical schemes 'max_iter_ava' : 1000, # [-] Maximum number of iterations at which to quit iterative solution in avalanching calculation @@ -364,7 +386,46 @@ 't_veg' : 3, #time scale of vegetation growth (days), only used in duran and moore 14 formulation 'v_gam' : 1, # only used in duran and moore 14 formulation - 'zeta_base' : 0.6, # [m] Base value for bed interaction (0: air-dominated, 1: bed-dominated) + # --- Grass vegetation model (new framework) ------------------------------ + 'method_vegetation' : 'duran', # ['duran' | 'grass'] Vegetation formulation + 'veg_res_factor' : 5, # [-] Vegetation subgrid refinement factor (dx_veg = dx / factor) + 'dt_veg' : 86400., # [s] Time step for vegetation growth calculations + 'species_names' : ['marram'], # [-] Name(s) of vegetation species + 'hveg_file' : None, # Filename of ASCII file with initial vegetation height (shape: ny * nx * nspecies) + 'Nt_file' : None, # Filename of ASCII file with initial tiller density (shape: ny * nx * nspecies) + + 'd_tiller' : [0.006], # [m] Mean tiller diameter + 'r_stem' : [0.2], # [-] Fraction of rigid (non-bending) stem height + 'alpha_uw' : [-0.0412], # [s/m] Wind-speed sensitivity of vegetation bending + 'alpha_Nt' : [1.95e-4], # [m^2] Tiller-density sensitivity of vegetation bending + 'alpha_0' : [0.9445], # [-] Baseline bending factor (no wind, sparse vegetation) + + 'G_h' : [1.0], # [m/yr] Intrinsic vertical vegetation growth rate + 'G_c' : [1.5], # [1/yr] Intrinsic clonal tiller production rate + 'G_s' : [0.01], # [1/yr] Intrinsic seedling establishment rate + 'Hveg' : [0.8], # [m] Maximum attainable vegetation height + 'phi_h' : [1.0], # [-] Saturation exponent for height growth + + 'Nt_max' : [900.0], # [1/m^2] Maximum attainable tiller density + 'R_cov' : [1.2], # [m] Radius for neighbourhood density averaging + + 'lmax_c' : [0.9], # [m] Maximum clonal dispersal distance + 'mu_c' : [2.5], # [-] Shape parameter of clonal dispersal kernel + 'alpha_s' : [4.0], # [m^2] Scale parameter of seed dispersal kernel + 'nu_s' : [2.5], # [-] Tail-heaviness of seed dispersal kernel + + 'gamma_h' : [1.0], # [-] Burial sensitivity of vertical growth + 'gamma_c' : [1.0], # [-] Burial sensitivity of clonal expansion + 'gamma_s' : [2.0], # [-] Burial sensitivity of seed establishment + 'dzb_opt_h' : [0.02], # [m/yr] Optimal burial rate for vertical growth + 'dzb_opt_c' : [0.01], # [m/yr] Optimal burial rate for clonal expansion + 'dzb_opt_s' : [0.0], # [m/yr] Optimal burial rate for seed establishment + + 'beta_veg' : [120.0], # [-] Vegetation momentum-extraction efficiency (Raupach) + 'm_veg' : [0.4], # [-] Shear non-uniformity correction factor + 'c1_okin' : [0.32], # [-] Downwind decay coefficient in Okin shear reduction + 'bounce' : [0.5], # [-] Fraction of sediment skimming over vegetation canopy + } REQUIRED_CONFIG = ['nx', 'ny'] diff --git a/aeolis/grass.py b/aeolis/grass.py new file mode 100644 index 00000000..70b039c1 --- /dev/null +++ b/aeolis/grass.py @@ -0,0 +1,196 @@ +""" +Vegetation module for dune grasses (new framework). + +This module is an alternative to aeolis.vegetation without overwriting it. +It describes the local growth and spreading of dune grasses, +including their effects on shear stress and sediment transport. +""" + +import numpy as np +import matplotlib.pyplot as plt +from aeolis import grass_utils as gutils + +def initialize(s, p): + """ + Initialize vegetation state variables. + Vegetation subgrid is prognostic; main grid is diagnostic. + """ + + f = p['veg_res_factor'] + + # --- Make parameters iterable over species and check length ------------- + p = gutils.ensure_grass_parameters(p) + + # --- Convert yearly to secondly rates ------------------------------------ + for param in ['G_h', 'G_c', 'G_s', 'dzb_opt_h', 'dzb_opt_c', 'dzb_opt_s']: + p[param] /= (365.25 * 24.0 * 3600.0) + + # --- Read main-grid vegetation state variables -------------------------- + # s['hveg']: vegetation height [m] + # s['Nt']: tiller density [tillers/m2] + + # --- Initialize vegetation subgrid geometry ----------------------------- + s['x_vsub'], s['y_vsub'] = gutils.generate_grass_subgrid( + s['x'], s['y'], f) + + # --- Compute resolution of vegetation subgrid (assumed uniform) --------- + p['dx_veg'] = np.sqrt((s['x_vsub'][0, 1] - s['x_vsub'][0, 0])**2 + + (s['y_vsub'][1, 0] - s['y_vsub'][0, 0])**2) + print(f"Vegetation subgrid resolution dx_veg = {p['dx_veg']:.3f} m") + + # --- One-time lift: main grid → vegetation subgrid ---------------------- + s['Nt_vsub'] = gutils.expand_to_subgrid(s['Nt'], f) + s['hveg_vsub'] = gutils.expand_to_subgrid(s['hveg'], f) + + # --- Build kernel functions for spreading -------------------------------- + s['kernel_c'] = [None] * p['nspecies'] + s['radius_c'] = np.zeros(p['nspecies'], dtype=int) + for k in range(p['nspecies']): + s['kernel_c'][k], s['radius_c'][k] = gutils.build_clonal_kernel( + 0.001, p['lmax_c'][k], p['mu_c'][k], p['dx_veg']) + + return s, p + + +def update(s, p): + + """ + Main vegetation update. + All dynamics occur on the vegetation subgrid. + """ + + # --- Time step and resolution factor -------------------------------------- + dt = p['dt_veg'] + f = p['veg_res_factor'] + + # --- Burial smoothing (main grid → subgrid, diagnostic → prognostic) ----- + dzb_main = gutils.smooth_burial(s, p) + dzb_vsub = gutils.expand_to_subgrid(dzb_main[None, ...], f)[0] + + bend = np.zeros((p['nspecies'], p['ny'], p['nx'])) + + # --- Loop over species (subgrid physics) -------------------------------- + for ns in range(p['nspecies']): + + Nt = s['Nt_vsub'][ns] + hveg = s['hveg_vsub'][ns] + + # --- Burial responses ---------------------------------------------- + B_h = p['gamma_h'][ns] * (dzb_vsub - p['dzb_opt_h'][ns]) + B_c = np.maximum(p['gamma_c'][ns] * (dzb_vsub - p['dzb_opt_c'][ns]), 0.0) + B_s = np.maximum(p['gamma_s'][ns] * (dzb_vsub - p['dzb_opt_s'][ns]), 0.0) + + # --- Spreading ------------------------------------------------------ + dNt = spreading(Nt, hveg, p, s) # [tillers/s] + + # --- Local growth --------------------------------------------------- + dhveg = p['G_h'][ns] * (1.0 - hveg / p['Hveg'][ns])**p['phi_h'][ns] + B_h # [m/s] + dhveg = dhveg * dt - hveg / np.maximum(Nt, 1e-6) * dNt # [m/dt] + + # --- Update prognostic subgrid state -------------------------------- + s['Nt_vsub'][ns] = np.maximum(Nt + dNt, 0.0) + s['hveg_vsub'][ns] = np.clip(hveg + dhveg, 0.0, p['Hveg'][ns]) + + # --- Vegetation bending (main grid) --------------------------------- + bend[ns, :, :] = (p['r_stem'][ns] + (1.0 - p['r_stem'][ns]) + * (p['alpha_uw'][ns] * s['uw'] + + p['alpha_Nt'][ns] * s['Nt'][ns] + + p['alpha_0'][ns])) + + # --- Aggregate back to main grid (diagnostic only) ---------------------- + s['Nt'] = gutils.aggregate_from_subgrid(s['Nt_vsub'], f) + s['hveg'] = gutils.aggregate_from_subgrid(s['hveg_vsub'], f) + + # --- Main-grid vegetation metrics -------------------------------------- + s['hveg_eff'] = np.clip(s['hveg'] * bend, 0.0, s['hveg']) + s['lamveg'] = s['Nt'] * s['hveg_eff'] * p['d_tiller'] + s['rhoveg'] = s['Nt'] * np.pi * (p['d_tiller'] / 2.0)**2 + + +def spreading(Nt, hveg, p, s): + """ + Spatial redistribution of vegetation: + clonal expansion and seed dispersal. + """ + + # --- Neighbourhood average density -------------------------------------- + Nt_avg = gutils.neighbourhood_average(Nt, p['R_cov'], p['dx_veg']) + saturation = np.maximum(1.0 - Nt_avg / p['Nt_max'], 0.0) + maturity = np.clip(hveg / p['Hveg'], 0.0, 1.0) + + # --- Tiller production rates -------------------------------------------- + S_c = p['G_c'] * Nt * maturity * saturation # [tillers/s] clonal rate + S_s = p['G_s'] * Nt * maturity # [tillers/s] seed rate + + # --- Clonal expansion --------------------------------------------------- + dNt_clonal = gutils.apply_clonal_kernel(S_c, s['kernel_c']) + Nt_clonal_new = np.random.poisson(dNt_clonal * p['dt_veg']) + + # --- Seed dispersal ----------------------------------------------------- + Nt_seed_new = gutils.sample_seed_germination(S_s, p['alpha_s'], + p['nu_s'], p['dx_veg']) + + # --- Sum contributions -------------------------------------------------- + dNt = Nt_clonal_new + Nt_seed_new + + return dNt + + +def compute_shear_reduction(s, p): + """ + Compute vegetation-induced shear reduction. + """ + + lamveg = np.zeros((p['ny'], p['nx'])) + weight_sum = np.zeros_like(lamveg) + + # --- Species-weighted frontal density ---------------------------------- + for ns in range(p['nspecies']): + maturity = s['hveg'][ns] / p['Hveg'][ns] + density = s['Nt'][ns] / p['Nt_max'][ns] + w = maturity * density + + lamveg += w * s['lamveg'][ns] + weight_sum += w + + # Normalize weighted sum + lamveg /= np.maximum(weight_sum, 1.0) + + # --- Local shear reduction --------------------------------------------- + s['R0veg'] = 1.0 / np.sqrt(1.0 + p['m_veg'] * p['beta_veg'] * lamveg) + + return s + + +def apply_shear_reduction(s, p): + """ + Apply vegetation-induced shear reduction to wind shear. + """ + + ets = np.zeros(s['zb'].shape) + etn = np.zeros(s['zb'].shape) + + ix = s['ustar'] != 0 + + ets[ix] = s['ustars'][ix] / s['ustar'][ix] + etn[ix] = s['ustarn'][ix] / s['ustar'][ix] + + s['ustar'] *= s['Rveg'] + s['ustars'] = s['ustar'] * ets + s['ustarn'] = s['ustar'] * etn + + return s + + +def compute_zeta(s, p): + """ + Compute bed–interaction factor zeta. + """ + + # Compute k_str and lambda_str here.... + lam = 1 + k = 1 + + # --- Weibull function for zeta ------------------------------------------ + s['zeta'] = 1.0 - np.exp(-(s['hveg_eff'] / lam)**k) + s['zeta'] = s['zeta'] * (1.0 - p['bounce']) diff --git a/aeolis/grass_utils.py b/aeolis/grass_utils.py new file mode 100644 index 00000000..b6951ca7 --- /dev/null +++ b/aeolis/grass_utils.py @@ -0,0 +1,268 @@ +""" +Utility functions for vegetation_grass. +Contains long, technical, or reusable logic only. +""" + +import numpy as np +import logging +from numba import njit + +from aeolis.utils import * + +logger = logging.getLogger(__name__) + +def ensure_grass_parameters(p): + """ + Check the length of grass parameters and make iterable over species. + """ + + ns = p['nspecies'] + + param_lists = [ + 'd_tiller', 'r_stem', 'alpha_uw', 'alpha_Nt', 'alpha_0', + 'G_h', 'G_c', 'G_s', 'Hveg', 'phi_h', + 'Nt_max', 'R_cov', + 'lmax_c', 'mu_c', 'alpha_s', 'nu_s', + 'gamma_h', 'gamma_c', 'gamma_s', + 'dzb_opt_h', 'dzb_opt_c', 'dzb_opt_s', + 'beta_veg', 'm_veg', 'c1_okin', 'bounce' + ] + + for param in param_lists: + if param in p: + p[param] = makeiterable(p[param]) + if len(p[param]) != ns: + logger.error( + f"Parameter '{param}' length {len(p[param])} " + f"does not match nspecies {ns}." + ) + raise ValueError( + f"Parameter '{param}' length {len(p[param])} " + f"does not match nspecies {ns}." + ) + else: + logger.error(f"Parameter '{param}' is missing for grass model.") + raise KeyError(f"Parameter '{param}' is missing for grass model.") + + return p + + +def generate_grass_subgrid(x, y, veg_res_factor): + """ + Generate a refined vegetation subgrid inside each main grid cell. + """ + + f = veg_res_factor + + # Local grid vectors (rotation-safe) + ex_x, ex_y = np.gradient(x, axis=1), np.gradient(y, axis=1) + ey_x, ey_y = np.gradient(x, axis=0), np.gradient(y, axis=0) + + # Subcell offsets inside parent cell + offs = (np.arange(f) + 0.5) / f - 0.5 + + # Expand base grid + x_veg = np.repeat(np.repeat(x, f, axis=1), f, axis=0) + y_veg = np.repeat(np.repeat(y, f, axis=1), f, axis=0) + + for j in range(f): + for i in range(f): + x_veg[j::f, i::f] += offs[i] * ex_x + offs[j] * ey_x + y_veg[j::f, i::f] += offs[i] * ex_y + offs[j] * ey_y + + return x_veg, y_veg + + +def expand_to_subgrid(A, f): + """Expand (ns, ny, nx) → (ns, ny*f, nx*f).""" + return np.repeat(np.repeat(A, f, axis=1), f, axis=2) + + +def aggregate_from_subgrid(A, f): + """Aggregate (ns, ny*f, nx*f) → (ns, ny, nx) by averaging.""" + ns, nyf, nxf = A.shape + ny, nx = nyf // f, nxf // f + return A.reshape(ns, ny, f, nx, f).mean(axis=(2,4)) + + +def smooth_burial(s, p): + """ + Compute smoothed burial rate over a trailing window T_burial. + Stores bed levels over time and computes: + dzb_veg = (zb(t) - zb(t - T_burial)) / T_burial [m/s] + """ + + t = p['_time'] + T = p['T_burial'] + + # Initialize history on first call + if 'zb_hist' not in s: + s['zb_hist'] = [(t, s['zb'].copy())] + return np.zeros_like(s['zb']) + + # Append current state + s['zb_hist'].append((t, s['zb'].copy())) + + # Drop states older than window + t_min = t - T + while s['zb_hist'][0][0] < t_min: + s['zb_hist'].pop(0) + + t0, zb0 = s['zb_hist'][0] # Oldest retained bed level + + # Burial rate [m/s] + return (s['zb'] - zb0) / max(t - t0, 1e-12) + + +@njit +def neighbourhood_average(Nt, R_cov, dx): + """ + Compute neighbourhood average of Nt within radius R_cov. + """ + + # Determine radius in grid cells + r = int(np.ceil(R_cov / dx)) + + # Loop over grid and compute average + Nt_avg = np.zeros_like(Nt) + for i in range(Nt.shape[0]): + for j in range(Nt.shape[1]): + ssum = 0.0 + cnt = 0 + + # Define neighbourhood bounds + i0 = max(i - r, 0) + i1 = min(i + r + 1, Nt.shape[0]) + j0 = max(j - r, 0) + j1 = min(j + r + 1, Nt.shape[1]) + + # Loop over neighbourhood + for ii in range(i0, i1): + for jj in range(j0, j1): + if ((ii - i)**2 + (jj - j)**2) * dx**2 <= R_cov**2: + ssum += Nt[ii, jj] + cnt += 1 + + # Compute average + if cnt > 0: + Nt_avg[i, j] = ssum / cnt + + return Nt_avg + + +def build_clonal_kernel(s_min, s_max, mu, dx): + """ + Construct clonal dispersal kernel using a truncated Pareto distribution. + """ + + # Determine kernel radius in grid cells + r = int(s_max / dx) + + # Create distance grid in physical space + offsets = np.arange(-r, r + 1) * dx + KX, KY = np.meshgrid(offsets, offsets) + dist = np.sqrt(KX**2 + KY**2) + + # Set centre cell distance for internal recruitment + dist[r, r] = dx / np.sqrt(6) + + # Initialise kernel + kernel = np.zeros_like(dist) + + # Apply truncated Pareto distribution + mask = (dist >= s_min) & (dist <= s_max) + kernel[mask] = ( + mu + / (s_min**(1 - mu) - s_max**(1 - mu)) + * dist[mask] ** (-mu) + ) + + # Normalize kernel + kernel /= kernel.sum() + + return kernel, r + + +@njit +def apply_clonal_kernel(S_c, kernel): + """ + Apply clonal dispersal kernel to clonal production rate S_c. + """ + + # Kernel radius + r = kernel.shape[0] // 2 + + # Initialize output + dNt_clonal = np.zeros_like(S_c) + + # Loop over grid cells + for i in range(S_c.shape[0]): + for j in range(S_c.shape[1]): + ssum = 0.0 + + # Loop over kernel + for ki in range(kernel.shape[0]): + for kj in range(kernel.shape[1]): + ii = i + ki - r + jj = j + kj - r + + if 0 <= ii < S_c.shape[0] and 0 <= jj < S_c.shape[1]: + ssum += kernel[ki, kj] * S_c[ii, jj] + + # Assign spreaded value + dNt_clonal[i, j] = ssum + + return dNt_clonal + + +@njit +def sample_seed_germination(S_s, a_s, nu_s, dx): + """ + Sample stochastic seed germination events from seed production rate S_s. + """ + + ny, nx = S_s.shape + + # Total seed production rate + lambda_total = S_s.sum() + dNt_seed = np.zeros_like(S_s) + + if lambda_total <= 0.0: + return dNt_seed + + # Number of seeds this timestep + n_seeds = np.random.poisson(lambda_total) + + if n_seeds == 0: + return dNt_seed + + # Flatten for weighted source selection + flat = S_s.ravel() + probs = flat / lambda_total + + for _ in range(n_seeds): + + # Choose source cell + j = np.random.choice(flat.size, p=probs) + iy = j // nx + ix = j - iy * nx + + # Sample jump distance from 2Dt distribution + u = np.random.rand() + r = np.sqrt(a_s * (u ** (-1.0 / (nu_s - 1.0)) - 1.0)) + + # Sample direction + theta = 2.0 * np.pi * np.random.rand() + + # Convert to grid offsets + dy = int(round((np.sin(theta) * r) / dx)) + dx_ = int(round((np.cos(theta) * r) / dx)) + + yy = iy + dy + xx = ix + dx_ + + # 5. Check bounds + if 0 <= yy < ny and 0 <= xx < nx: + dNt_seed[yy, xx] += 1 + + return dNt_seed diff --git a/aeolis/model.py b/aeolis/model.py index f529a536..c3ebac8f 100644 --- a/aeolis/model.py +++ b/aeolis/model.py @@ -58,6 +58,7 @@ import aeolis.constants import aeolis.erosion import aeolis.vegetation +import aeolis.grass import aeolis.fences import aeolis.gridparams @@ -245,8 +246,24 @@ def initialize(self)-> None: self.p['nx'] -= 1 self.p['ny'] -= 1 - #self.p['nfractions'] = len(self.p['grain_dist']) + # Determine number of grain size fractions self.p['nfractions'] = len(self.p['grain_size']) + + # Initilize number of species and size of subgrid for grass model + if self.p['method_vegetation'] == 'grass': + + # Determine number of grass species + if isinstance(self.p['G_h'], list): + self.p['nspecies'] = len(self.p['G_h']) # number of grass species + else: + self.p['nspecies'] = 1 + + # Determine size of vegetation subgrid + self.p['nx_vsub'] = self.p['nx'] * self.p['veg_res_factor'] + self.p['ny_vsub'] = self.p['ny'] * self.p['veg_res_factor'] + + else: + self.p['nspecies'] = 1 # default to one species # initialize time self.t = self.p['tstart'] @@ -272,7 +289,12 @@ def initialize(self)-> None: self.s = aeolis.wind.initialize(self.s, self.p) #initialize vegetation model - self.s = aeolis.vegetation.initialize(self.s, self.p) + if self.p['method_vegetation'] == 'duran': + self.s = aeolis.vegetation.initialize(self.s, self.p) + elif self.p['method_vegetation'] == 'grass': + self.s, self.p = aeolis.grass.initialize(self.s, self.p) + else: + logger.log_and_raise('Unknown vegetation method [%s]' % self.p['method_vegetation'], exc=ValueError) #initialize fence model self.s = aeolis.fences.initialize(self.s, self.p) @@ -321,18 +343,27 @@ def update(self, dt:float=-1) -> None: # Rotate gridparams, such that the grids is alligned horizontally self.s = self.grid_rotate(self.p['alpha']) - - if np.sum(self.s['uw']) != 0: - self.s = aeolis.wind.shear(self.s, self.p) #compute sand fence shear if self.p['process_fences']: self.s = aeolis.fences.update_fences(self.s, self.p) - # compute vegetation shear + # compute local vegetation shear reduction if self.p['process_vegetation']: - self.s = aeolis.vegetation.vegshear(self.s, self.p) + if self.p['method_vegetation'] == 'grass': + self.s = aeolis.grass.compute_shear_reduction(self.s, self.p) + # topographic steering (including Okin on rotating grid) + if np.sum(self.s['uw']) != 0: + self.s = aeolis.wind.shear(self.s, self.p) + + # apply vegetation shear + if self.p['process_vegetation']: + if self.p['method_vegetation'] == 'duran': + self.s = aeolis.vegetation.vegshear(self.s, self.p) + if self.p['method_vegetation'] == 'grass': + self.s = aeolis.grass.apply_shear_reduction(self.s, self.p) + # determine optimal time step self.dt_prev = self.dt if not self.set_timestep(dt): @@ -384,8 +415,11 @@ def update(self, dt:float=-1) -> None: # grow vegetation if self.p['process_vegetation']: - self.s = aeolis.vegetation.germinate(self.s, self.p) - self.s = aeolis.vegetation.grow(self.s, self.p) + if self.p['method_vegetation'] == 'duran': + self.s = aeolis.vegetation.germinate(self.s, self.p) + self.s = aeolis.vegetation.grow(self.s, self.p) + elif self.p['method_vegetation'] == 'grass': + self.s = aeolis.grass.update(self.s, self.p) # increment time self.t += self.dt * self.p['accfac'] diff --git a/aeolis/netcdf.py b/aeolis/netcdf.py index 558e39af..df709cf9 100644 --- a/aeolis/netcdf.py +++ b/aeolis/netcdf.py @@ -97,6 +97,7 @@ def initialize(outputfile, outputvars, s, p, dimensions): nc.createDimension('nv2', 4) nc.createDimension('layers', p['nlayers']) nc.createDimension('fractions', p['nfractions']) + nc.createDimension('species', p['nspecies']) # add global attributes # see http://www.unidata.ucar.edu/software/thredds/current/netcdf-java/formats/DataDiscoveryAttConvention.html @@ -194,6 +195,12 @@ def initialize(outputfile, outputvars, s, p, dimensions): nc.variables['fractions'].valid_min = 0 nc.variables['fractions'].valid_max = np.inf + nc.createVariable('species', 'float32', (u'species',)) + nc.variables['species'].long_name = 'vegetation species' + nc.variables['species'].units = '1' + nc.variables['species'].valid_min = 0 + nc.variables['species'].valid_max = np.inf + nc.createVariable('lat', 'float32', (u'n', u's')) nc.variables['lat'].long_name = 'latitude' nc.variables['lat'].standard_name = 'latitude' @@ -304,8 +311,12 @@ def initialize(outputfile, outputvars, s, p, dimensions): nc.variables['x'][:,:] = s['x'] nc.variables['y'][:,:] = s['y'] + nc.variables['x_vsub'] = s['x_vsub'] + nc.variables['y_vsub'] = s['y_vsub'] + nc.variables['layers'][:] = np.arange(p['nlayers']) nc.variables['fractions'][:] = p['grain_size'] + nc.variables['species'][:] = p['G_h'] nc.variables['lat'][:,:] = 0. nc.variables['lon'][:,:] = 0. diff --git a/aeolis/shear.py b/aeolis/shear.py index 1b4816b1..583bd65a 100644 --- a/aeolis/shear.py +++ b/aeolis/shear.py @@ -235,10 +235,10 @@ def __call__(self, p, x, y, z, u0, udir, gc['taux'] = np.maximum(gc['taux'], 0.) # Compute the influence of the separation on the shear stress + gc['taux_air'] = gc['taux'].copy() + gc['tauy_air'] = gc['tauy'].copy() if p['process_separation']: gc['hsep'] = gc['z'] - z_origin - gc['taux_air'] = gc['taux'].copy() - gc['tauy_air'] = gc['tauy'].copy() self.separation_shear(gc['hsep']) if plot: From 738ade3da6f7778347c36c9ad4b5f6d7e057bb0e Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Thu, 18 Dec 2025 09:45:41 -0800 Subject: [PATCH 11/23] Enhance vegetation update function with flooding and mortality handling --- aeolis/grass.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/aeolis/grass.py b/aeolis/grass.py index 70b039c1..18c4eee9 100644 --- a/aeolis/grass.py +++ b/aeolis/grass.py @@ -59,14 +59,18 @@ def update(s, p): All dynamics occur on the vegetation subgrid. """ - # --- Time step and resolution factor -------------------------------------- + # --- Time step and resolution factor ------------------------------------ dt = p['dt_veg'] f = p['veg_res_factor'] - # --- Burial smoothing (main grid → subgrid, diagnostic → prognostic) ----- + # --- Burial smoothing (main grid → subgrid, diagnostic → prognostic) ---- dzb_main = gutils.smooth_burial(s, p) dzb_vsub = gutils.expand_to_subgrid(dzb_main[None, ...], f)[0] + # --- Expand main-grid state variables to subgrid (for flooding) --------- + zb_vsub = gutils.expand_to_subgrid(s['zb'][None, ...], f)[0] + TWL_vsub = gutils.expand_to_subgrid(s['TWL'][None, ...], f)[0] + bend = np.zeros((p['nspecies'], p['ny'], p['nx'])) # --- Loop over species (subgrid physics) -------------------------------- @@ -91,6 +95,17 @@ def update(s, p): s['Nt_vsub'][ns] = np.maximum(Nt + dNt, 0.0) s['hveg_vsub'][ns] = np.clip(hveg + dhveg, 0.0, p['Hveg'][ns]) + # --- Mortality ------------------------------------------------------ + + # Flooding + if p['process_tide']: + ix_flooded = zb_vsub < TWL_vsub + s['hveg_vsub'][ns][ix_flooded] = 0. + + # Diseased (e.g. due to burial) + ix_decayed = (s['hveg_vsub'][ns] == 0.0) + s['Nt_vsub'][ns][ix_decayed] = 0.0 + # --- Vegetation bending (main grid) --------------------------------- bend[ns, :, :] = (p['r_stem'][ns] + (1.0 - p['r_stem'][ns]) * (p['alpha_uw'][ns] * s['uw'] From 1c0ada70e1c56dd0595cf943733ce749c59c3994 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Thu, 18 Dec 2025 10:07:50 -0800 Subject: [PATCH 12/23] Ensure compatibility with existing vegetation module by solving overlapping variable names (rhoveg & hveg) and filling default values to length nspecies. --- aeolis/constants.py | 4 ++-- aeolis/grass_utils.py | 21 +++++++++++---------- aeolis/model.py | 10 +++++++--- aeolis/vegetation.py | 11 ++++++++--- 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/aeolis/constants.py b/aeolis/constants.py index b3cc3fb2..42cb33c2 100644 --- a/aeolis/constants.py +++ b/aeolis/constants.py @@ -108,9 +108,9 @@ 'zsep', # [m] Z level of polynomial that defines the separation bubble 'hsep', # [m] Height of separation bubble = difference between z-level of zsep and of the bed level zb 'theta_dyn', # [degrees] spatially varying dynamic angle of repose for avalanching - # 'rhoveg', # [-] Vegetation cover + # 'rhoveg', # [-] Vegetation cover (now defined via grass module; overlapping name) + # 'hveg', # [m] height of vegetatiion (now defined via grass module; overlapping name) 'drhoveg', # Change in vegetation cover - # 'hveg', # [m] height of vegetation 'dhveg', # [m] Difference in vegetation height per time step 'dzbveg', # [m] Bed level change used for calculation of vegetation growth 'germinate', # [bool] Newly vegetated due to germination (or establishment) diff --git a/aeolis/grass_utils.py b/aeolis/grass_utils.py index b6951ca7..2e1f4a72 100644 --- a/aeolis/grass_utils.py +++ b/aeolis/grass_utils.py @@ -16,7 +16,7 @@ def ensure_grass_parameters(p): Check the length of grass parameters and make iterable over species. """ - ns = p['nspecies'] + ns = p['nspecies'] # based on length of 'species_names' param_lists = [ 'd_tiller', 'r_stem', 'alpha_uw', 'alpha_Nt', 'alpha_0', @@ -30,16 +30,17 @@ def ensure_grass_parameters(p): for param in param_lists: if param in p: - p[param] = makeiterable(p[param]) + p[param] = makeiterable(p[param]) # Coverts to array + + # If the parameter length does not match nspecies, extend it if len(p[param]) != ns: - logger.error( - f"Parameter '{param}' length {len(p[param])} " - f"does not match nspecies {ns}." - ) - raise ValueError( - f"Parameter '{param}' length {len(p[param])} " - f"does not match nspecies {ns}." - ) + if len(p[param]) == 1: + p[param] = np.full(ns, p[param][0]) + logger.info(f"Parameter '{param}' extended to match nspecies={ns}.") + else: + logger.error(f"Parameter '{param}' length does not match nspecies={ns}.") + raise ValueError(f"Parameter '{param}' length does not match nspecies={ns}.") + else: logger.error(f"Parameter '{param}' is missing for grass model.") raise KeyError(f"Parameter '{param}' is missing for grass model.") diff --git a/aeolis/model.py b/aeolis/model.py index c3ebac8f..c1e4dd23 100644 --- a/aeolis/model.py +++ b/aeolis/model.py @@ -253,8 +253,8 @@ def initialize(self)-> None: if self.p['method_vegetation'] == 'grass': # Determine number of grass species - if isinstance(self.p['G_h'], list): - self.p['nspecies'] = len(self.p['G_h']) # number of grass species + if isinstance(self.p['species_names'], np.ndarray): + self.p['nspecies'] = len(self.p['species_names']) # number of grass species else: self.p['nspecies'] = 1 @@ -264,7 +264,11 @@ def initialize(self)-> None: else: self.p['nspecies'] = 1 # default to one species - + + # Not used if "grass" is not used, but set to avoid errors + self.p['nx_vsub'] = self.p['nx'] * 1 + self.p['ny_vsub'] = self.p['ny'] * 1 + # initialize time self.t = self.p['tstart'] diff --git a/aeolis/vegetation.py b/aeolis/vegetation.py index f8afd0f0..dcd5f966 100644 --- a/aeolis/vegetation.py +++ b/aeolis/vegetation.py @@ -57,10 +57,15 @@ def initialize (s,p): ''' if p['veg_file'] is not None: - s['rhoveg'][:, :] = p['veg_file'] + s['rhoveg'][:, :, 0] = p['veg_file'] - if np.isnan(s['rhoveg'][0, 0]): - s['rhoveg'][:,:] = 0. + if np.isnan(s['rhoveg'][0, 0, 0]): + s['rhoveg'][:] = 0. + + # Remove the nspecies-dimension from 'rhoveg' and 'hveg' + # (added for grass.py; overlapping names) + s['rhoveg'] = s['rhoveg'][:, :, 0] + s['hveg'] = s['hveg'][:, :, 0] ix = s['rhoveg'] < 0 s['rhoveg'][ix] *= 0. From fa494043fa8dd9fefdb9e11e6e59bc71eb5275c9 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Thu, 18 Dec 2025 11:31:39 -0800 Subject: [PATCH 13/23] Fixes for multiple vegetation species --- aeolis/grass.py | 95 +++++++++++++++++++++++-------------------- aeolis/grass_utils.py | 41 ++++++++++--------- 2 files changed, 72 insertions(+), 64 deletions(-) diff --git a/aeolis/grass.py b/aeolis/grass.py index 18c4eee9..291cab56 100644 --- a/aeolis/grass.py +++ b/aeolis/grass.py @@ -26,17 +26,21 @@ def initialize(s, p): p[param] /= (365.25 * 24.0 * 3600.0) # --- Read main-grid vegetation state variables -------------------------- - # s['hveg']: vegetation height [m] - # s['Nt']: tiller density [tillers/m2] - + try: + s['Nt'][:] = p['Nt_file'].reshape((p['ny']+1, p['nx']+1, p['nspecies'])) + s['hveg'][:] = p['hveg_file'].reshape((p['ny']+1, p['nx']+1, p['nspecies'])) + except Exception as e: + raise RuntimeError("Shape mismatch for vegetation files.") from e + # --- Initialize vegetation subgrid geometry ----------------------------- - s['x_vsub'], s['y_vsub'] = gutils.generate_grass_subgrid( - s['x'], s['y'], f) + s['x_vsub'], s['y_vsub'] = gutils.generate_grass_subgrid(s['x'], s['y'], f) # --- Compute resolution of vegetation subgrid (assumed uniform) --------- p['dx_veg'] = np.sqrt((s['x_vsub'][0, 1] - s['x_vsub'][0, 0])**2 - + (s['y_vsub'][1, 0] - s['y_vsub'][0, 0])**2) - print(f"Vegetation subgrid resolution dx_veg = {p['dx_veg']:.3f} m") + + (s['y_vsub'][0, 1] - s['y_vsub'][0, 0])**2) + dx_main = np.sqrt((s['x'][0, 1] - s['x'][0, 0])**2 + + (s['y'][0, 1] - s['y'][0, 0])**2) + print(f"Subgrid resolution reduced from {dx_main:.3f} m to {p['dx_veg']:.3f} m.") # --- One-time lift: main grid → vegetation subgrid ---------------------- s['Nt_vsub'] = gutils.expand_to_subgrid(s['Nt'], f) @@ -65,19 +69,17 @@ def update(s, p): # --- Burial smoothing (main grid → subgrid, diagnostic → prognostic) ---- dzb_main = gutils.smooth_burial(s, p) - dzb_vsub = gutils.expand_to_subgrid(dzb_main[None, ...], f)[0] + dzb_vsub = gutils.expand_to_subgrid(dzb_main[:,:,None], f)[:,:,0] # --- Expand main-grid state variables to subgrid (for flooding) --------- - zb_vsub = gutils.expand_to_subgrid(s['zb'][None, ...], f)[0] - TWL_vsub = gutils.expand_to_subgrid(s['TWL'][None, ...], f)[0] - - bend = np.zeros((p['nspecies'], p['ny'], p['nx'])) + zb_vsub = gutils.expand_to_subgrid(s['zb'][:,:,None], f)[:,:,0] + TWL_vsub = gutils.expand_to_subgrid(s['TWL'][:,:,None], f)[:,:,0] # --- Loop over species (subgrid physics) -------------------------------- for ns in range(p['nspecies']): - Nt = s['Nt_vsub'][ns] - hveg = s['hveg_vsub'][ns] + Nt = s['Nt_vsub'][:,:,ns] + hveg = s['hveg_vsub'][:,:,ns] # --- Burial responses ---------------------------------------------- B_h = p['gamma_h'][ns] * (dzb_vsub - p['dzb_opt_h'][ns]) @@ -85,65 +87,68 @@ def update(s, p): B_s = np.maximum(p['gamma_s'][ns] * (dzb_vsub - p['dzb_opt_s'][ns]), 0.0) # --- Spreading ------------------------------------------------------ - dNt = spreading(Nt, hveg, p, s) # [tillers/s] + dNt = spreading(ns, Nt, hveg, p, s) # [tillers/s] # --- Local growth --------------------------------------------------- dhveg = p['G_h'][ns] * (1.0 - hveg / p['Hveg'][ns])**p['phi_h'][ns] + B_h # [m/s] dhveg = dhveg * dt - hveg / np.maximum(Nt, 1e-6) * dNt # [m/dt] # --- Update prognostic subgrid state -------------------------------- - s['Nt_vsub'][ns] = np.maximum(Nt + dNt, 0.0) - s['hveg_vsub'][ns] = np.clip(hveg + dhveg, 0.0, p['Hveg'][ns]) + s['Nt_vsub'][:,:,ns] = np.maximum(Nt + dNt, 0.0) + s['hveg_vsub'][:,:,ns] = np.clip(hveg + dhveg, 0.0, p['Hveg'][ns]) # --- Mortality ------------------------------------------------------ # Flooding if p['process_tide']: ix_flooded = zb_vsub < TWL_vsub - s['hveg_vsub'][ns][ix_flooded] = 0. + s['hveg_vsub'][:,:,ns][ix_flooded] = 0. # Diseased (e.g. due to burial) - ix_decayed = (s['hveg_vsub'][ns] == 0.0) - s['Nt_vsub'][ns][ix_decayed] = 0.0 - - # --- Vegetation bending (main grid) --------------------------------- - bend[ns, :, :] = (p['r_stem'][ns] + (1.0 - p['r_stem'][ns]) - * (p['alpha_uw'][ns] * s['uw'] - + p['alpha_Nt'][ns] * s['Nt'][ns] - + p['alpha_0'][ns])) + ix_decayed = (s['hveg_vsub'][:,:,ns] == 0.0) + s['Nt_vsub'][:,:,ns][ix_decayed] = 0.0 # --- Aggregate back to main grid (diagnostic only) ---------------------- s['Nt'] = gutils.aggregate_from_subgrid(s['Nt_vsub'], f) s['hveg'] = gutils.aggregate_from_subgrid(s['hveg_vsub'], f) + # --- Vegetation bending ------------------------------------------------- + bend = np.ones_like(s['hveg']) + for ns in range(p['nspecies']): + bend[:,:,ns] = (p['r_stem'][ns] + + (1.0 - p['r_stem'][ns]) + * (p['alpha_uw'][ns] * s['uw'] + + p['alpha_Nt'][ns] * s['Nt'][:,:,ns] + + p['alpha_0'][ns])) + # --- Main-grid vegetation metrics -------------------------------------- s['hveg_eff'] = np.clip(s['hveg'] * bend, 0.0, s['hveg']) s['lamveg'] = s['Nt'] * s['hveg_eff'] * p['d_tiller'] s['rhoveg'] = s['Nt'] * np.pi * (p['d_tiller'] / 2.0)**2 -def spreading(Nt, hveg, p, s): +def spreading(ns, Nt, hveg, p, s): """ Spatial redistribution of vegetation: clonal expansion and seed dispersal. """ # --- Neighbourhood average density -------------------------------------- - Nt_avg = gutils.neighbourhood_average(Nt, p['R_cov'], p['dx_veg']) - saturation = np.maximum(1.0 - Nt_avg / p['Nt_max'], 0.0) - maturity = np.clip(hveg / p['Hveg'], 0.0, 1.0) + Nt_avg = gutils.neighbourhood_average(Nt, p['R_cov'][ns], p['dx_veg']) + saturation = np.maximum(1.0 - Nt_avg / p['Nt_max'][ns], 0.0) + maturity = np.clip(hveg / p['Hveg'][ns], 0.0, 1.0) # --- Tiller production rates -------------------------------------------- - S_c = p['G_c'] * Nt * maturity * saturation # [tillers/s] clonal rate - S_s = p['G_s'] * Nt * maturity # [tillers/s] seed rate + S_c = p['G_c'][ns] * Nt * maturity * saturation # [tillers/s] clonal rate + S_s = p['G_s'][ns] * Nt * maturity # [tillers/s] seed rate # --- Clonal expansion --------------------------------------------------- - dNt_clonal = gutils.apply_clonal_kernel(S_c, s['kernel_c']) + dNt_clonal = gutils.apply_clonal_kernel(S_c, s['kernel_c'][ns]) Nt_clonal_new = np.random.poisson(dNt_clonal * p['dt_veg']) # --- Seed dispersal ----------------------------------------------------- - Nt_seed_new = gutils.sample_seed_germination(S_s, p['alpha_s'], - p['nu_s'], p['dx_veg']) + Nt_seed_new = gutils.sample_seed_germination(S_s, p['alpha_s'][ns], + p['nu_s'][ns], p['dx_veg']) # --- Sum contributions -------------------------------------------------- dNt = Nt_clonal_new + Nt_seed_new @@ -156,23 +161,23 @@ def compute_shear_reduction(s, p): Compute vegetation-induced shear reduction. """ - lamveg = np.zeros((p['ny'], p['nx'])) - weight_sum = np.zeros_like(lamveg) + s['R0veg'] = np.ones((p['ny']+1, p['nx']+1)) + R0veg = np.zeros_like(s['R0veg']) + w_sum = np.zeros_like(s['R0veg']) # --- Species-weighted frontal density ---------------------------------- for ns in range(p['nspecies']): - maturity = s['hveg'][ns] / p['Hveg'][ns] - density = s['Nt'][ns] / p['Nt_max'][ns] + maturity = s['hveg'][:,:,ns] / p['Hveg'][ns] + density = s['Nt'][:,:,ns] / p['Nt_max'][ns] w = maturity * density - lamveg += w * s['lamveg'][ns] - weight_sum += w + # Compute shear reduction per species + R0veg += w * 1.0 / np.sqrt(1.0 + p['m_veg'][ns] * p['beta_veg'][ns] * s['lamveg'][:,:,ns]) + w_sum += w # Normalize weighted sum - lamveg /= np.maximum(weight_sum, 1.0) - - # --- Local shear reduction --------------------------------------------- - s['R0veg'] = 1.0 / np.sqrt(1.0 + p['m_veg'] * p['beta_veg'] * lamveg) + ix = w_sum != 0.0 + s['R0veg'][ix] = R0veg[ix] / w_sum[ix] return s diff --git a/aeolis/grass_utils.py b/aeolis/grass_utils.py index 2e1f4a72..ed40c7c7 100644 --- a/aeolis/grass_utils.py +++ b/aeolis/grass_utils.py @@ -75,15 +75,15 @@ def generate_grass_subgrid(x, y, veg_res_factor): def expand_to_subgrid(A, f): - """Expand (ns, ny, nx) → (ns, ny*f, nx*f).""" - return np.repeat(np.repeat(A, f, axis=1), f, axis=2) + """Expand (ny, nx, nspecies) → (ny*f, nx*f, nspecies) by replication.""" + return np.repeat(np.repeat(A, f, axis=0), f, axis=1) def aggregate_from_subgrid(A, f): - """Aggregate (ns, ny*f, nx*f) → (ns, ny, nx) by averaging.""" - ns, nyf, nxf = A.shape + """Aggregate (ny*f, nx*f, nspecies) → (ny, nx, nspecies) by averaging.""" + nyf, nxf, ns = A.shape ny, nx = nyf // f, nxf // f - return A.reshape(ns, ny, f, nx, f).mean(axis=(2,4)) + return A.reshape(ny, f, nx, f, ns).mean(axis=(1,3)) def smooth_burial(s, p): @@ -215,7 +215,6 @@ def apply_clonal_kernel(S_c, kernel): return dNt_clonal - @njit def sample_seed_germination(S_s, a_s, nu_s, dx): """ @@ -223,46 +222,50 @@ def sample_seed_germination(S_s, a_s, nu_s, dx): """ ny, nx = S_s.shape - - # Total seed production rate - lambda_total = S_s.sum() dNt_seed = np.zeros_like(S_s) + lambda_total = S_s.sum() if lambda_total <= 0.0: return dNt_seed - # Number of seeds this timestep n_seeds = np.random.poisson(lambda_total) - if n_seeds == 0: return dNt_seed - # Flatten for weighted source selection + # Flatten source strengths flat = S_s.ravel() - probs = flat / lambda_total + + # Build cumulative distribution + cdf = np.empty(flat.size) + csum = 0.0 + for i in range(flat.size): + csum += flat[i] + cdf[i] = csum for _ in range(n_seeds): - # Choose source cell - j = np.random.choice(flat.size, p=probs) + # --- Weighted source selection (CDF sampling) --- + u = np.random.rand() * cdf[-1] + j = 0 + while cdf[j] < u: + j += 1 + iy = j // nx ix = j - iy * nx - # Sample jump distance from 2Dt distribution + # --- Sample jump distance (2Dt) --- u = np.random.rand() r = np.sqrt(a_s * (u ** (-1.0 / (nu_s - 1.0)) - 1.0)) - # Sample direction + # --- Sample direction --- theta = 2.0 * np.pi * np.random.rand() - # Convert to grid offsets dy = int(round((np.sin(theta) * r) / dx)) dx_ = int(round((np.cos(theta) * r) / dx)) yy = iy + dy xx = ix + dx_ - # 5. Check bounds if 0 <= yy < ny and 0 <= xx < nx: dNt_seed[yy, xx] += 1 From bb3fed77d669ee56b411e6ea11ac665a794c0e2b Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Thu, 18 Dec 2025 14:01:40 -0800 Subject: [PATCH 14/23] Added vegetation competition, burial influence and correct timestepping for vegetation --- aeolis/constants.py | 3 ++ aeolis/grass.py | 80 +++++++++++++++++++++++++------------------ aeolis/grass_utils.py | 36 +++++++++++++++++++ aeolis/model.py | 8 ++++- 4 files changed, 92 insertions(+), 35 deletions(-) diff --git a/aeolis/constants.py b/aeolis/constants.py index 42cb33c2..7a801207 100644 --- a/aeolis/constants.py +++ b/aeolis/constants.py @@ -408,6 +408,9 @@ 'Nt_max' : [900.0], # [1/m^2] Maximum attainable tiller density 'R_cov' : [1.2], # [m] Radius for neighbourhood density averaging + 'alpha_comp' : [0.], # [-] Lotka–Volterra competition coefficients + # shape: nspecies * nspecies (flattened) + # alpha_comp[k,l] = effect of species l on species k 'lmax_c' : [0.9], # [m] Maximum clonal dispersal distance 'mu_c' : [2.5], # [-] Shape parameter of clonal dispersal kernel diff --git a/aeolis/grass.py b/aeolis/grass.py index 291cab56..6fbcf636 100644 --- a/aeolis/grass.py +++ b/aeolis/grass.py @@ -75,38 +75,44 @@ def update(s, p): zb_vsub = gutils.expand_to_subgrid(s['zb'][:,:,None], f)[:,:,0] TWL_vsub = gutils.expand_to_subgrid(s['TWL'][:,:,None], f)[:,:,0] + # --- Neighbourhood-averaged densities (all species) ------------------------ + Nt_avg = np.zeros_like(s['Nt_vsub']) + for l in range(p['nspecies']): + Nt_avg[:, :, l] = gutils.neighbourhood_average( + s['Nt_vsub'][:, :, l], p['R_cov'][l], p['dx_veg']) + # --- Loop over species (subgrid physics) -------------------------------- - for ns in range(p['nspecies']): + for k in range(p['nspecies']): - Nt = s['Nt_vsub'][:,:,ns] - hveg = s['hveg_vsub'][:,:,ns] + Nt = s['Nt_vsub'][:,:,k] + hveg = s['hveg_vsub'][:,:,k] # --- Burial responses ---------------------------------------------- - B_h = p['gamma_h'][ns] * (dzb_vsub - p['dzb_opt_h'][ns]) - B_c = np.maximum(p['gamma_c'][ns] * (dzb_vsub - p['dzb_opt_c'][ns]), 0.0) - B_s = np.maximum(p['gamma_s'][ns] * (dzb_vsub - p['dzb_opt_s'][ns]), 0.0) + B_h = p['gamma_h'][k] * np.abs(dzb_vsub - p['dzb_opt_h'][k]) # additive factor + B_c = np.maximum(1 + p['gamma_c'][k] * np.abs(dzb_vsub - p['dzb_opt_c'][k]), 0.0) # multiplicative factor + B_s = np.maximum(1 + p['gamma_s'][k] * np.abs(dzb_vsub - p['dzb_opt_s'][k]), 0.0) # multiplicative factor # --- Spreading ------------------------------------------------------ - dNt = spreading(ns, Nt, hveg, p, s) # [tillers/s] + dNt = spreading(k, Nt, hveg, Nt_avg, B_c, B_s, p, s) # [tillers/dt] # --- Local growth --------------------------------------------------- - dhveg = p['G_h'][ns] * (1.0 - hveg / p['Hveg'][ns])**p['phi_h'][ns] + B_h # [m/s] + dhveg = p['G_h'][k] * (1.0 - hveg / p['Hveg'][k])**p['phi_h'][k] + B_h # [m/s] dhveg = dhveg * dt - hveg / np.maximum(Nt, 1e-6) * dNt # [m/dt] # --- Update prognostic subgrid state -------------------------------- - s['Nt_vsub'][:,:,ns] = np.maximum(Nt + dNt, 0.0) - s['hveg_vsub'][:,:,ns] = np.clip(hveg + dhveg, 0.0, p['Hveg'][ns]) + s['Nt_vsub'][:,:,k] = np.maximum(Nt + dNt, 0.0) + s['hveg_vsub'][:,:,k] = np.clip(hveg + dhveg, 0.0, p['Hveg'][k]) # --- Mortality ------------------------------------------------------ # Flooding if p['process_tide']: ix_flooded = zb_vsub < TWL_vsub - s['hveg_vsub'][:,:,ns][ix_flooded] = 0. + s['hveg_vsub'][:,:,k][ix_flooded] = 0. # Diseased (e.g. due to burial) - ix_decayed = (s['hveg_vsub'][:,:,ns] == 0.0) - s['Nt_vsub'][:,:,ns][ix_decayed] = 0.0 + ix_decayed = (s['hveg_vsub'][:,:,k] == 0.0) + s['Nt_vsub'][:,:,k][ix_decayed] = 0.0 # --- Aggregate back to main grid (diagnostic only) ---------------------- s['Nt'] = gutils.aggregate_from_subgrid(s['Nt_vsub'], f) @@ -114,12 +120,11 @@ def update(s, p): # --- Vegetation bending ------------------------------------------------- bend = np.ones_like(s['hveg']) - for ns in range(p['nspecies']): - bend[:,:,ns] = (p['r_stem'][ns] + - (1.0 - p['r_stem'][ns]) - * (p['alpha_uw'][ns] * s['uw'] - + p['alpha_Nt'][ns] * s['Nt'][:,:,ns] - + p['alpha_0'][ns])) + for k in range(p['nspecies']): + bend[:,:,k] = (p['r_stem'][k] + (1.0 - p['r_stem'][k]) + * (p['alpha_uw'][k] * s['uw'] + + p['alpha_Nt'][k] * s['Nt'][:,:,k] + + p['alpha_0'][k])) # --- Main-grid vegetation metrics -------------------------------------- s['hveg_eff'] = np.clip(s['hveg'] * bend, 0.0, s['hveg']) @@ -127,28 +132,35 @@ def update(s, p): s['rhoveg'] = s['Nt'] * np.pi * (p['d_tiller'] / 2.0)**2 -def spreading(ns, Nt, hveg, p, s): +def spreading(k, Nt, hveg, Nt_avg, B_c, B_s, p, s): """ Spatial redistribution of vegetation: clonal expansion and seed dispersal. """ - # --- Neighbourhood average density -------------------------------------- - Nt_avg = gutils.neighbourhood_average(Nt, p['R_cov'][ns], p['dx_veg']) - saturation = np.maximum(1.0 - Nt_avg / p['Nt_max'][ns], 0.0) - maturity = np.clip(hveg / p['Hveg'][ns], 0.0, 1.0) + # --- Competition-weighted relative densities (Option B) -------------------- + comp = np.zeros_like(Nt) + for l in range(p['nspecies']): + comp += p['alpha_comp'][k, l] * (Nt_avg[:, :, l] / p['Nt_max'][l]) + + saturation = np.maximum(1.0 - comp / p['Nt_max'][k], 0.0) + + maturity = np.clip(hveg / p['Hveg'][k], 0.0, 1.0) # --- Tiller production rates -------------------------------------------- - S_c = p['G_c'][ns] * Nt * maturity * saturation # [tillers/s] clonal rate - S_s = p['G_s'][ns] * Nt * maturity # [tillers/s] seed rate + S_c = p['G_c'][k] * Nt * B_c * maturity * saturation # [tillers/s] clonal rate + S_s = p['G_s'][k] * Nt * B_s * maturity # [tillers/s] seed rate + + S_c *= p['dt_veg'] # [tillers/dt] + S_s *= p['dt_veg'] # [tillers/dt] # --- Clonal expansion --------------------------------------------------- - dNt_clonal = gutils.apply_clonal_kernel(S_c, s['kernel_c'][ns]) - Nt_clonal_new = np.random.poisson(dNt_clonal * p['dt_veg']) + dNt_clonal = gutils.apply_clonal_kernel(S_c, s['kernel_c'][k]) + Nt_clonal_new = np.random.poisson(dNt_clonal) # --- Seed dispersal ----------------------------------------------------- - Nt_seed_new = gutils.sample_seed_germination(S_s, p['alpha_s'][ns], - p['nu_s'][ns], p['dx_veg']) + Nt_seed_new = gutils.sample_seed_germination(S_s, p['alpha_s'][k], + p['nu_s'][k], p['dx_veg']) # --- Sum contributions -------------------------------------------------- dNt = Nt_clonal_new + Nt_seed_new @@ -166,13 +178,13 @@ def compute_shear_reduction(s, p): w_sum = np.zeros_like(s['R0veg']) # --- Species-weighted frontal density ---------------------------------- - for ns in range(p['nspecies']): - maturity = s['hveg'][:,:,ns] / p['Hveg'][ns] - density = s['Nt'][:,:,ns] / p['Nt_max'][ns] + for k in range(p['nspecies']): + maturity = s['hveg'][:,:,k] / p['Hveg'][k] + density = s['Nt'][:,:,k] / p['Nt_max'][k] w = maturity * density # Compute shear reduction per species - R0veg += w * 1.0 / np.sqrt(1.0 + p['m_veg'][ns] * p['beta_veg'][ns] * s['lamveg'][:,:,ns]) + R0veg += w * 1.0 / np.sqrt(1.0 + p['m_veg'][k] * p['beta_veg'][k] * s['lamveg'][:,:,k]) w_sum += w # Normalize weighted sum diff --git a/aeolis/grass_utils.py b/aeolis/grass_utils.py index ed40c7c7..859a5d48 100644 --- a/aeolis/grass_utils.py +++ b/aeolis/grass_utils.py @@ -30,6 +30,11 @@ def ensure_grass_parameters(p): for param in param_lists: if param in p: + + # Check if the parameter is converted to a string + if isinstance(p[param], str): + p[param] = [float(t) for t in p[param].replace(',', ' ').split()] + p[param] = makeiterable(p[param]) # Coverts to array # If the parameter length does not match nspecies, extend it @@ -44,6 +49,37 @@ def ensure_grass_parameters(p): else: logger.error(f"Parameter '{param}' is missing for grass model.") raise KeyError(f"Parameter '{param}' is missing for grass model.") + + # Handle alpha_comp separately as it is a matrix (nspecies x nspecies) + if 'alpha_comp' in p: + + # Check if (elements of) alpha_comp is converted to a string + if isinstance(p['alpha_comp'], str): + p['alpha_comp'] = [float(t) for t in p['alpha_comp'].replace(',', ' ').split()] + if isinstance(p['alpha_comp'], ndarray) and p['alpha_comp'].dtype.type is np.str_: + p['alpha_comp'] = [float(item) for sublist in p['alpha_comp'] for item in sublist.replace(',', ' ').split()] + + p['alpha_comp'] = makeiterable(p['alpha_comp']) + + if p['alpha_comp'].size == 1: + p['alpha_comp'] = np.zeros((ns, ns)) + np.fill_diagonal(p['alpha_comp'], 1.0) + + elif p['alpha_comp'].size == ns * ns: + p['alpha_comp'] = p['alpha_comp'].reshape((ns, ns)) + + else: + logger.error(f"Parameter 'alpha_comp' length must be {ns*ns} ({ns} x {ns}).") + raise ValueError(f"Parameter 'alpha_comp' length must be {ns*ns} ({ns} x {ns}).") + else: + p['alpha_comp'] = np.zeros((ns, ns)) + + # --- Check intraspecific competition --------------------------------------- + for k in range(ns): + if p['alpha_comp'][k, k] != 1.0: + logger.error(f"alpha_comp[{k},{k}] = {p['alpha_comp'][k,k]:.3f}. Expected value is 1.0 (equal to intraspecific competition).") + raise ValueError(f"alpha_comp[{k},{k}] = {p['alpha_comp'][k,k]:.3f}. Expected value is 1.0 (equal to intraspecific competition).") + return p diff --git a/aeolis/model.py b/aeolis/model.py index c1e4dd23..3da903c2 100644 --- a/aeolis/model.py +++ b/aeolis/model.py @@ -422,8 +422,13 @@ def update(self, dt:float=-1) -> None: if self.p['method_vegetation'] == 'duran': self.s = aeolis.vegetation.germinate(self.s, self.p) self.s = aeolis.vegetation.grow(self.s, self.p) + + # update grass when dt_veg has passed elif self.p['method_vegetation'] == 'grass': - self.s = aeolis.grass.update(self.s, self.p) + self.tveg += self.dt * self.p['accfac'] + if self.tveg >= self.p['dt_veg']: + self.s = aeolis.grass.update(self.s, self.p) + self.tveg -= self.p['dt_veg'] # increment time self.t += self.dt * self.p['accfac'] @@ -944,6 +949,7 @@ def __init__(self, configfile:str='aeolis.txt') -> None: self.t0 = None self.tout = 0. + self.tveg = 0. self.tlog = 0. self.plog = -1. self.trestart = 0. From 18dccd5ad1208cf923cb621cbada522eb6e4c48b Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Thu, 18 Dec 2025 18:24:13 -0800 Subject: [PATCH 15/23] Solving several warnings and major grass bug fixes --- aeolis/constants.py | 14 +++++++------- aeolis/grass.py | 14 +++++++------- aeolis/inout.py | 37 ++++++++++++++++++++++++------------- aeolis/model.py | 8 +++++++- 4 files changed, 45 insertions(+), 28 deletions(-) diff --git a/aeolis/constants.py b/aeolis/constants.py index 7a801207..3b009ae3 100644 --- a/aeolis/constants.py +++ b/aeolis/constants.py @@ -401,8 +401,8 @@ 'alpha_0' : [0.9445], # [-] Baseline bending factor (no wind, sparse vegetation) 'G_h' : [1.0], # [m/yr] Intrinsic vertical vegetation growth rate - 'G_c' : [1.5], # [1/yr] Intrinsic clonal tiller production rate - 'G_s' : [0.01], # [1/yr] Intrinsic seedling establishment rate + 'G_c' : [2.5], # [tillers/tiller/yr] Intrinsic clonal tiller production rate + 'G_s' : [0.01], # [tillers/tiller/yr] Intrinsic seedling establishment rate 'Hveg' : [0.8], # [m] Maximum attainable vegetation height 'phi_h' : [1.0], # [-] Saturation exponent for height growth @@ -419,14 +419,14 @@ 'gamma_h' : [1.0], # [-] Burial sensitivity of vertical growth 'gamma_c' : [1.0], # [-] Burial sensitivity of clonal expansion - 'gamma_s' : [2.0], # [-] Burial sensitivity of seed establishment - 'dzb_opt_h' : [0.02], # [m/yr] Optimal burial rate for vertical growth - 'dzb_opt_c' : [0.01], # [m/yr] Optimal burial rate for clonal expansion - 'dzb_opt_s' : [0.0], # [m/yr] Optimal burial rate for seed establishment + 'gamma_s' : [10.0], # [-] Burial sensitivity of seed establishment + 'dzb_opt_h' : [0.5], # [m/yr] Optimal burial rate for vertical growth + 'dzb_opt_c' : [0.5], # [m/yr] Optimal burial rate for clonal expansion + 'dzb_opt_s' : [0.025], # [m/yr] Optimal burial rate for seed establishment 'beta_veg' : [120.0], # [-] Vegetation momentum-extraction efficiency (Raupach) 'm_veg' : [0.4], # [-] Shear non-uniformity correction factor - 'c1_okin' : [0.32], # [-] Downwind decay coefficient in Okin shear reduction + 'c1_okin' : [0.48], # [-] Downwind decay coefficient in Okin shear reduction 'bounce' : [0.5], # [-] Fraction of sediment skimming over vegetation canopy } diff --git a/aeolis/grass.py b/aeolis/grass.py index 6fbcf636..f9ef54c9 100644 --- a/aeolis/grass.py +++ b/aeolis/grass.py @@ -94,19 +94,17 @@ def update(s, p): # --- Spreading ------------------------------------------------------ dNt = spreading(k, Nt, hveg, Nt_avg, B_c, B_s, p, s) # [tillers/dt] + s['Nt_vsub'][:,:,k] = np.maximum(Nt + dNt, 0.0) + ix_vegetated = (Nt > 0.0) # --- Local growth --------------------------------------------------- dhveg = p['G_h'][k] * (1.0 - hveg / p['Hveg'][k])**p['phi_h'][k] + B_h # [m/s] dhveg = dhveg * dt - hveg / np.maximum(Nt, 1e-6) * dNt # [m/dt] - - # --- Update prognostic subgrid state -------------------------------- - s['Nt_vsub'][:,:,k] = np.maximum(Nt + dNt, 0.0) s['hveg_vsub'][:,:,k] = np.clip(hveg + dhveg, 0.0, p['Hveg'][k]) + s['hveg_vsub'][:,:,k][~ix_vegetated] = 0.0 # --- Mortality ------------------------------------------------------ - - # Flooding - if p['process_tide']: + if p['process_tide']: # Flooding ix_flooded = zb_vsub < TWL_vsub s['hveg_vsub'][:,:,k][ix_flooded] = 0. @@ -131,6 +129,8 @@ def update(s, p): s['lamveg'] = s['Nt'] * s['hveg_eff'] * p['d_tiller'] s['rhoveg'] = s['Nt'] * np.pi * (p['d_tiller'] / 2.0)**2 + return s + def spreading(k, Nt, hveg, Nt_avg, B_c, B_s, p, s): """ @@ -143,7 +143,7 @@ def spreading(k, Nt, hveg, Nt_avg, B_c, B_s, p, s): for l in range(p['nspecies']): comp += p['alpha_comp'][k, l] * (Nt_avg[:, :, l] / p['Nt_max'][l]) - saturation = np.maximum(1.0 - comp / p['Nt_max'][k], 0.0) + saturation = np.maximum(1.0 - comp, 0.0) maturity = np.clip(hveg / p['Hveg'][k], 0.0, 1.0) diff --git a/aeolis/inout.py b/aeolis/inout.py index b4741677..cf37edb7 100644 --- a/aeolis/inout.py +++ b/aeolis/inout.py @@ -216,10 +216,10 @@ def check_configuration(p): logger.warning('Wind velocity threshold based on salt content following Nickling and ' 'Ecclestone (1981) is implemented for testing only. Use with care.') - if p['method_roughness'] == 'constant': - logger.warning('Warning: the used roughness method (constant) defines the z0 as ' - 'k (z0 = k), this was implemented to ensure backward compatibility ' - 'and does not follow the definition of Nikuradse (z0 = k / 30).') + # if p['method_roughness'] == 'constant': + # logger.warning('Warning: the used roughness method (constant) defines the z0 as ' + # 'k (z0 = k), this was implemented to ensure backward compatibility ' + # 'and does not follow the definition of Nikuradse (z0 = k / 30).') # check if steadystate solver is used with multiple sediment fractions if p['solver'].lower() in ['steadystate', 'steadystatepieter']: @@ -401,8 +401,7 @@ def visualize_grid(s, p): # Figure lay-out settings fig.colorbar(pc, ax=ax) ax.axis('equal') - ax.set_xlim([np.min(x) - 0.15*xylen, np.max(x) + 0.15*xylen]) - ax.set_ylim([np.min(y) - 0.15*xylen, np.max(y) + 0.15*xylen]) + ax.margins(0.15) height = 8.26772 # A4 width width = 11.6929 # A4 height fig.set_size_inches(width, height) @@ -476,6 +475,15 @@ def visualize_timeseries(p, t): fig.savefig('figure_timeseries_initialization.png', dpi=200) plt.close() +def safe_quiver(ax, x, y, U, V): + mask = np.isfinite(U) & np.isfinite(V) + ax.quiver( + x[mask], y[mask], + U[mask], V[mask], + scale_units='xy', + scale=1.0, + angles='xy' + ) def visualize_spatial(s, p): '''Create figures and tables for the user to check whether the input is correctly interpreted''' @@ -505,11 +513,15 @@ def visualize_spatial(s, p): import warnings warnings.filterwarnings("ignore", category=UserWarning) + # Remove the nspecies dimension for plotting for rhoveg + if s['rhoveg'].ndim == 3: + rhoveg = s['rhoveg'][:, :, 0] + # Plotting colormeshes if p['ny'] > 0: pcs[0][0] = axs[0,0].pcolormesh(x, y, s['zb'], cmap='viridis') pcs[0][1] = axs[0,1].pcolormesh(x, y, s['zne'], cmap='viridis') - pcs[0][2] = axs[0,2].pcolormesh(x, y, s['rhoveg'], cmap='Greens', clim= [0, 1]) + pcs[0][2] = axs[0,2].pcolormesh(x, y, rhoveg, cmap='Greens', clim= [0, 1]) pcs[1][0] = axs[1,0].pcolormesh(x, y, s['uw'], cmap='plasma') pcs[1][1] = axs[1,1].pcolormesh(x, y, s['ustar'], cmap='plasma') pcs[1][2] = axs[1,2].pcolormesh(x, y, s['u'][:, :, 0], cmap='plasma') @@ -525,7 +537,7 @@ def visualize_spatial(s, p): else: pcs[0][0] = axs[0,0].scatter(x, y, c=s['zb'], cmap='viridis') pcs[0][1] = axs[0,1].scatter(x, y, c=s['zne'], cmap='viridis') - pcs[0][2] = axs[0,2].scatter(x, y, c=s['rhoveg'], cmap='Greens', clim= [0, 1]) + pcs[0][2] = axs[0,2].scatter(x, y, c=rhoveg, cmap='Greens', clim= [0, 1]) pcs[1][0] = axs[1,0].scatter(x, y, c=s['uw'], cmap='plasma') pcs[1][1] = axs[1,1].scatter(x, y, c=s['ustar'], cmap='plasma') pcs[1][2] = axs[1,2].scatter(x, y, c=s['tau'], cmap='plasma') @@ -544,9 +556,9 @@ def visualize_spatial(s, p): # Quiver for vectors skip = 10 - axs[1,0].quiver(x[::skip, ::skip], y[::skip, ::skip], s['uws'][::skip, ::skip], s['uwn'][::skip, ::skip]) - axs[1,1].quiver(x[::skip, ::skip], y[::skip, ::skip], s['ustars'][::skip, ::skip], s['ustarn'][::skip, ::skip]) - axs[1,2].quiver(x[::skip, ::skip], y[::skip, ::skip], s['us'][::skip, ::skip, 0], s['un'][::skip, ::skip, 0]) + safe_quiver(axs[1,0], x[::skip, ::skip], y[::skip, ::skip], s['uws'][::skip, ::skip], s['uwn'][::skip, ::skip]) + safe_quiver(axs[1,1], x[::skip, ::skip], y[::skip, ::skip], s['ustars'][::skip, ::skip], s['ustarn'][::skip, ::skip]) + safe_quiver(axs[1,2], x[::skip, ::skip], y[::skip, ::skip], s['us'][::skip, ::skip, 0], s['un'][::skip, ::skip, 0]) # Adding titles to the plots axs[0,0].set_title('Bed level, zb (m)') @@ -571,9 +583,8 @@ def visualize_spatial(s, p): # Figure lay-out settings fig.colorbar(pcs[irow][icol], ax=ax) ax.axis('equal') + ax.margins(0.15) xylen = np.maximum(xlen, ylen) - ax.set_xlim([np.min(x) - 0.15*xylen, np.max(x) + 0.15*xylen]) - ax.set_ylim([np.min(y) - 0.15*xylen, np.max(y) + 0.15*xylen]) ax.axes.xaxis.set_visible(False) ax.axes.yaxis.set_visible(False) width = 8.26772*2 # A4 width diff --git a/aeolis/model.py b/aeolis/model.py index 3da903c2..b318abb5 100644 --- a/aeolis/model.py +++ b/aeolis/model.py @@ -24,9 +24,15 @@ ''' - from __future__ import absolute_import, division +import warnings +warnings.filterwarnings( + "ignore", + message="pkg_resources is deprecated as an API", + category=UserWarning +) + import os import importlib.machinery import importlib.metadata From 77f2419594e5ced302e149d0f15103b9e2f7c7a0 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Mon, 22 Dec 2025 12:30:39 -0800 Subject: [PATCH 16/23] General debugging script. --- aeolis/run_console.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/aeolis/run_console.py b/aeolis/run_console.py index 195b49f5..7a3ae514 100644 --- a/aeolis/run_console.py +++ b/aeolis/run_console.py @@ -3,14 +3,12 @@ import cProfile def main()-> None: - '''Runs AeoLiS model in debugging mode.''' - - # configfile = r'c:\Users\weste_bt\aeolis\Tests\RotatingWind\Barchan_Grid270\aeolis.txt' - configfile = r'C:\Users\svries\Documents\GitHub\Bart_mass\aeolis_duran.txt' - # configfile = r'C:\Users\svries\Documents\GitHub\Bart_mass\aeolis_windspeed.txt' + '''Runs AeoLiS model in debugging mode. Run this script to start AeoLiS with debugging features enabled, + such as step-by-step execution and detailed logging. Useful for development and troubleshooting. + ''' + configfile = r'c:\Users\aeolis.txt' # Path to the configuration file aeolis_debug(configfile) - if __name__ == '__main__': main() From 8858c14483534abab8682e4ce1b229799ba592c9 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Mon, 22 Dec 2025 12:31:52 -0800 Subject: [PATCH 17/23] Update .gitignore to include simulations folder and run_console.py --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index fb339c2b..896a005e 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,12 @@ ENV/ env.bak/ venv.bak/ +# simulations folder +simulations/ + +# run_console.py +aeolis/run_console.py + # development notes dev-notes/ From dcb30b98189bd47e0f6ffb74d28a2191be5f940c Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Mon, 22 Dec 2025 12:45:45 -0800 Subject: [PATCH 18/23] Enhance wave processing logic to handle cases without run-up and wave processing, ensuring proper assignment of TWL and DSWL values. --- aeolis/hydro.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/aeolis/hydro.py b/aeolis/hydro.py index d2a87205..49733cd6 100644 --- a/aeolis/hydro.py +++ b/aeolis/hydro.py @@ -139,6 +139,11 @@ def interpolate(s, p, t): s['TWL'][iy][:] = s['SWL'][iy][:] + s['R'][iy][:] s['DSWL'][iy][:] = s['SWL'][iy][:] + s['eta'][iy][:] # Was s['zs'] before + + else: # No run-up processing + s['TWL'][:] = s['SWL'][:] + s['DSWL'][:] = s['SWL'][:] + # Alters wave height based on maximum wave height over depth ratio, gamma default = 0.5 s['Hs'] = np.minimum(h * p['gamma'], s['Hs']) @@ -146,10 +151,13 @@ def interpolate(s, p, t): s['Hs'] = apply_mask(s['Hs'], s['wave_mask']) s['Tp'] = apply_mask(s['Tp'], s['wave_mask']) - else: + else: # No wave processing s['Hs'] = s['zb'] * 0. s['Tp'] = s['zb'] * 0. + s['TWL'][:] = s['SWL'][:] + s['DSWL'][:] = s['SWL'][:] + # apply complex mask (also for external model input) else: s['Hs'] = apply_mask(s['Hs'], s['wave_mask']) From 72cb16ce5c30a7f4f794ac80209c2a2903a0dbfc Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Mon, 22 Dec 2025 13:21:55 -0800 Subject: [PATCH 19/23] Fix bug burial response --- aeolis/grass.py | 55 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/aeolis/grass.py b/aeolis/grass.py index f9ef54c9..be036057 100644 --- a/aeolis/grass.py +++ b/aeolis/grass.py @@ -89,8 +89,8 @@ def update(s, p): # --- Burial responses ---------------------------------------------- B_h = p['gamma_h'][k] * np.abs(dzb_vsub - p['dzb_opt_h'][k]) # additive factor - B_c = np.maximum(1 + p['gamma_c'][k] * np.abs(dzb_vsub - p['dzb_opt_c'][k]), 0.0) # multiplicative factor - B_s = np.maximum(1 + p['gamma_s'][k] * np.abs(dzb_vsub - p['dzb_opt_s'][k]), 0.0) # multiplicative factor + B_c = np.maximum(1 - p['gamma_c'][k] * np.abs(dzb_vsub - p['dzb_opt_c'][k]), 0.0) # multiplicative factor + B_s = np.maximum(1 - p['gamma_s'][k] * np.abs(dzb_vsub - p['dzb_opt_s'][k]), 0.0) # multiplicative factor # --- Spreading ------------------------------------------------------ dNt = spreading(k, Nt, hveg, Nt_avg, B_c, B_s, p, s) # [tillers/dt] @@ -149,7 +149,7 @@ def spreading(k, Nt, hveg, Nt_avg, B_c, B_s, p, s): # --- Tiller production rates -------------------------------------------- S_c = p['G_c'][k] * Nt * B_c * maturity * saturation # [tillers/s] clonal rate - S_s = p['G_s'][k] * Nt * B_s * maturity # [tillers/s] seed rate + S_s = p['G_s'][k] * Nt * B_s * maturity * saturation # [tillers/s] seed rate S_c *= p['dt_veg'] # [tillers/dt] S_s *= p['dt_veg'] # [tillers/dt] @@ -173,27 +173,54 @@ def compute_shear_reduction(s, p): Compute vegetation-induced shear reduction. """ - s['R0veg'] = np.ones((p['ny']+1, p['nx']+1)) - R0veg = np.zeros_like(s['R0veg']) - w_sum = np.zeros_like(s['R0veg']) + # --- Weights for normalization ----------------------------------------- + w_sum = np.zeros_like(s['x']) + w_num_R0 = np.zeros_like(s['x']) + w_num_R = np.zeros_like(s['x']) - # --- Species-weighted frontal density ---------------------------------- + # --- Wind convention ID (Numba) ----------------------------------------- + if p['wind_convention'] == 'nautical': + udir_id = 0 + elif p['wind_convention'] == 'cartesian': + udir_id = 1 + else: + raise ValueError(f"Unknown wind_convention: {p['wind_convention']}") + + # --- Loop over species: compute per-species local and leeside reduction -- for k in range(p['nspecies']): - maturity = s['hveg'][:,:,k] / p['Hveg'][k] - density = s['Nt'][:,:,k] / p['Nt_max'][k] + + # Weighting function based on maturity and density + maturity = s['hveg'][:, :, k] / p['Hveg'][k] + density = s['Nt'][:, :, k] / p['Nt_max'][k] w = maturity * density - # Compute shear reduction per species - R0veg += w * 1.0 / np.sqrt(1.0 + p['m_veg'][k] * p['beta_veg'][k] * s['lamveg'][:,:,k]) - w_sum += w + # Local Raupach reduction per species (no weighting / no normalization) + R0_k = 1.0 / np.sqrt(1.0 + p['m_veg'][k] * p['beta_veg'][k] * s['lamveg'][:, :, k]) + + # Leeside Okin reduction per species (optional) + if p['process_vegetation_leeside']: + R_k = gutils.compute_okin_reduction( + s['x'], s['y'], R0_k, s['udir'], s['hveg_eff'][:, :, k], p['c1_okin'][k], udir_id) + else: + R_k = R0_k + + # Accumulate weighted numerators for later normalization + w_sum += w + w_num_R0 += w * R0_k + w_num_R += w * R_k + + # --- Final normalization (separate loop / block) ------------------------ + s['R0veg'] = np.ones_like(s['x']) + s['Rveg'] = np.ones_like(s['x']) - # Normalize weighted sum ix = w_sum != 0.0 - s['R0veg'][ix] = R0veg[ix] / w_sum[ix] + s['R0veg'][ix] = w_num_R0[ix] / w_sum[ix] + s['Rveg'][ix] = w_num_R[ix] / w_sum[ix] return s + def apply_shear_reduction(s, p): """ Apply vegetation-induced shear reduction to wind shear. From a28176182ca118fa335c5a5fd366034066ded1af Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Mon, 22 Dec 2025 14:44:57 -0800 Subject: [PATCH 20/23] Replaced gamma variable from burial response calculations and replaced with dzb_tol --- aeolis/constants.py | 9 ++-- aeolis/grass.py | 10 +++-- aeolis/grass_utils.py | 99 ++++++++++++++++++++++++++++++++++++++++++- aeolis/inout.py | 2 + 4 files changed, 111 insertions(+), 9 deletions(-) diff --git a/aeolis/constants.py b/aeolis/constants.py index 3b009ae3..463d6341 100644 --- a/aeolis/constants.py +++ b/aeolis/constants.py @@ -214,6 +214,7 @@ 'process_inertia' : False, # NEW 'process_separation' : False, # Enable the including of separation bubble 'process_vegetation' : False, # Enable the process of vegetation + 'process_vegetation_leeside' : False, # Enable the process of leeside vegetation effects on shear stress 'process_fences' : False, # Enable the process of sand fencing 'process_dune_erosion' : False, # Enable the process of wave-driven dune erosion 'process_seepage_face' : False, # Enable the process of groundwater seepage (NB. only applicable to positive beach slopes) @@ -417,12 +418,12 @@ 'alpha_s' : [4.0], # [m^2] Scale parameter of seed dispersal kernel 'nu_s' : [2.5], # [-] Tail-heaviness of seed dispersal kernel - 'gamma_h' : [1.0], # [-] Burial sensitivity of vertical growth - 'gamma_c' : [1.0], # [-] Burial sensitivity of clonal expansion - 'gamma_s' : [10.0], # [-] Burial sensitivity of seed establishment + 'dzb_tol_h' : [1.0], # [m/yr] Tolerance burial range for vertical growth + 'dzb_tol_c' : [1.0], # [m/yr] Tolerance burial range for clonal expansion + 'dzb_tol_s' : [0.1], # [m/yr] Tolerance burial range for seed establishment 'dzb_opt_h' : [0.5], # [m/yr] Optimal burial rate for vertical growth 'dzb_opt_c' : [0.5], # [m/yr] Optimal burial rate for clonal expansion - 'dzb_opt_s' : [0.025], # [m/yr] Optimal burial rate for seed establishment + 'dzb_opt_s' : [0.025], # [m/yr] Optimal burial rate for seed establishment 'beta_veg' : [120.0], # [-] Vegetation momentum-extraction efficiency (Raupach) 'm_veg' : [0.4], # [-] Shear non-uniformity correction factor diff --git a/aeolis/grass.py b/aeolis/grass.py index be036057..415d3426 100644 --- a/aeolis/grass.py +++ b/aeolis/grass.py @@ -22,7 +22,9 @@ def initialize(s, p): p = gutils.ensure_grass_parameters(p) # --- Convert yearly to secondly rates ------------------------------------ - for param in ['G_h', 'G_c', 'G_s', 'dzb_opt_h', 'dzb_opt_c', 'dzb_opt_s']: + for param in ['G_h', 'G_c', 'G_s', + 'dzb_tol_h', 'dzb_tol_c', 'dzb_tol_s', + 'dzb_opt_h', 'dzb_opt_c', 'dzb_opt_s']: p[param] /= (365.25 * 24.0 * 3600.0) # --- Read main-grid vegetation state variables -------------------------- @@ -88,9 +90,9 @@ def update(s, p): hveg = s['hveg_vsub'][:,:,k] # --- Burial responses ---------------------------------------------- - B_h = p['gamma_h'][k] * np.abs(dzb_vsub - p['dzb_opt_h'][k]) # additive factor - B_c = np.maximum(1 - p['gamma_c'][k] * np.abs(dzb_vsub - p['dzb_opt_c'][k]), 0.0) # multiplicative factor - B_s = np.maximum(1 - p['gamma_s'][k] * np.abs(dzb_vsub - p['dzb_opt_s'][k]), 0.0) # multiplicative factor + B_h = - np.abs(dzb_vsub - p['dzb_opt_h'][k]) / p['dzb_tol_h'][k] # additive factor + B_c = np.maximum(1.0 - np.abs(dzb_vsub - p['dzb_opt_c'][k]) / p['dzb_tol_c'][k], 0.0) # multiplicative factor + B_s = np.maximum(1.0 - np.abs(dzb_vsub - p['dzb_opt_s'][k]) / p['dzb_tol_s'][k], 0.0) # multiplicative factor # --- Spreading ------------------------------------------------------ dNt = spreading(k, Nt, hveg, Nt_avg, B_c, B_s, p, s) # [tillers/dt] diff --git a/aeolis/grass_utils.py b/aeolis/grass_utils.py index 859a5d48..300382cc 100644 --- a/aeolis/grass_utils.py +++ b/aeolis/grass_utils.py @@ -23,7 +23,7 @@ def ensure_grass_parameters(p): 'G_h', 'G_c', 'G_s', 'Hveg', 'phi_h', 'Nt_max', 'R_cov', 'lmax_c', 'mu_c', 'alpha_s', 'nu_s', - 'gamma_h', 'gamma_c', 'gamma_s', + 'dzb_tol_h', 'dzb_tol_c', 'dzb_tol_s', 'dzb_opt_h', 'dzb_opt_c', 'dzb_opt_s', 'beta_veg', 'm_veg', 'c1_okin', 'bounce' ] @@ -306,3 +306,100 @@ def sample_seed_germination(S_s, a_s, nu_s, dx): dNt_seed[yy, xx] += 1 return dNt_seed + + +@njit +def compute_okin_reduction(x, y, R0, udir, hveg_eff, c1, wind_convention_id): + """ + Compute Okin leeside shear reduction for a single species. + + Parameters + ---------- + x, y : 2D arrays of cell-center coordinates [m] + R0 : 2D array of local Raupach reduction [-] + udir : 2D array of wind direction [deg] + hveg_eff : 2D array of effective vegetation height [m] + c1 : Okin calibration constant [-] + wind_convention_id : int + 0 = nautical (from North, clockwise) + 1 = cartesian (0° = +x, CCW) + + Returns + ------- + R : 2D array of shear reduction including leeside effects [-] + """ + + ny, nx = x.shape + R = np.ones((ny, nx)) + + # grid spacing (assumed uniform) + dx = np.sqrt((x[0, 1] - x[0, 0])**2 + (y[0, 1] - y[0, 0])**2) + W = 2.0 * dx + + deg2rad = np.pi / 180.0 + R_end = 0.99 + + # Loop over source cells + for iy in range(ny): + for ix in range(nx): + + R0_ij = R0[iy, ix] + if R0_ij >= 1.0: + continue + + h_ij = hveg_eff[iy, ix] + if h_ij <= 0.0: + continue + + # Local wind direction at source cell + if wind_convention_id == 0: # nautical + th = (270.0 - udir[iy, ix]) * deg2rad + else: # cartesian + th = udir[iy, ix] * deg2rad + + ux = np.cos(th) + uy = np.sin(th) + + # Max downwind distance (R = R_end) + L_end = -(h_ij / c1) * np.log((1.0 - R_end) / (1.0 - R0_ij)) + r = int(L_end / dx) + 2 # margin + + # Compute window bounds + j0 = max(0, iy - r) + j1 = min(ny, iy + r + 1) + i0 = max(0, ix - r) + i1 = min(nx, ix + r + 1) + + # Loop over target cells in window + x0 = x[iy, ix] + y0 = y[iy, ix] + for jy in range(j0, j1): + for jx in range(i0, i1): + + # Distance from source to target + rx = x[jy, jx] - x0 + ry = y[jy, jx] - y0 + + # Along-wind distance + s = rx * ux + ry * uy + if s <= 0.0 or s > L_end: + continue + + # Perpendicular distance + d_perp = np.sqrt((rx - s * ux)**2 + (ry - s * uy)**2) + if d_perp >= W: + continue + + # Okin along-wind reduction + R_s = 1.0 - (1.0 - R0_ij) * np.exp(-s * c1 / h_ij) + + # Cross-wind triangular weighting (width = 2*dx) + w = 1.0 - d_perp / W + R_loc = 1.0 - w * (1.0 - R_s) + + # Strongest reduction wins + if R_loc < R[jy, jx]: + R[jy, jx] = R_loc + + return R + diff --git a/aeolis/inout.py b/aeolis/inout.py index cf37edb7..8c033298 100644 --- a/aeolis/inout.py +++ b/aeolis/inout.py @@ -516,6 +516,8 @@ def visualize_spatial(s, p): # Remove the nspecies dimension for plotting for rhoveg if s['rhoveg'].ndim == 3: rhoveg = s['rhoveg'][:, :, 0] + else: + rhoveg = s['rhoveg'] # Plotting colormeshes if p['ny'] > 0: From 0cf40682e749e073440dcaddb38dd0c751aea470 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Tue, 23 Dec 2025 11:07:27 -0800 Subject: [PATCH 21/23] Refactor burial response calculations and introduce zeta computation - Replace 'dzb_tol_h' with 'gamma_h' in constants and update related calculations in grass.py. - Enhance shear reduction logic in model.py to ensure vegetation effects are applied correctly. - Add new zeta.py file to compute bed-interaction factor zeta. --- aeolis/constants.py | 2 +- aeolis/grass.py | 91 ++++++++++++++++++++----------------------- aeolis/grass_utils.py | 68 +++++++++++++++++++++++++------- aeolis/model.py | 7 +--- aeolis/zeta.py | 12 ++++++ 5 files changed, 110 insertions(+), 70 deletions(-) create mode 100644 aeolis/zeta.py diff --git a/aeolis/constants.py b/aeolis/constants.py index 463d6341..6e188c80 100644 --- a/aeolis/constants.py +++ b/aeolis/constants.py @@ -418,7 +418,7 @@ 'alpha_s' : [4.0], # [m^2] Scale parameter of seed dispersal kernel 'nu_s' : [2.5], # [-] Tail-heaviness of seed dispersal kernel - 'dzb_tol_h' : [1.0], # [m/yr] Tolerance burial range for vertical growth + 'gamma_h' : [1.0], # [-] Sensitivity of vertical growth to burial (1 / dzb_tol_h) 'dzb_tol_c' : [1.0], # [m/yr] Tolerance burial range for clonal expansion 'dzb_tol_s' : [0.1], # [m/yr] Tolerance burial range for seed establishment 'dzb_opt_h' : [0.5], # [m/yr] Optimal burial rate for vertical growth diff --git a/aeolis/grass.py b/aeolis/grass.py index 415d3426..44390a8d 100644 --- a/aeolis/grass.py +++ b/aeolis/grass.py @@ -23,7 +23,7 @@ def initialize(s, p): # --- Convert yearly to secondly rates ------------------------------------ for param in ['G_h', 'G_c', 'G_s', - 'dzb_tol_h', 'dzb_tol_c', 'dzb_tol_s', + 'dzb_tol_c', 'dzb_tol_s', 'dzb_opt_h', 'dzb_opt_c', 'dzb_opt_s']: p[param] /= (365.25 * 24.0 * 3600.0) @@ -54,6 +54,11 @@ def initialize(s, p): for k in range(p['nspecies']): s['kernel_c'][k], s['radius_c'][k] = gutils.build_clonal_kernel( 0.001, p['lmax_c'][k], p['mu_c'][k], p['dx_veg']) + + # --- Initial main-grid vegetation metrics ------------------------------- + s['hveg_eff'] = s['hveg'].copy() # initial effective height + s['lamveg'] = s['Nt'] * s['hveg_eff'] * p['d_tiller'] + s['rhoveg'] = s['Nt'] * np.pi * (p['d_tiller'] / 2.0)**2 return s, p @@ -90,7 +95,7 @@ def update(s, p): hveg = s['hveg_vsub'][:,:,k] # --- Burial responses ---------------------------------------------- - B_h = - np.abs(dzb_vsub - p['dzb_opt_h'][k]) / p['dzb_tol_h'][k] # additive factor + B_h = - p['gamma_h'][k] * np.abs(dzb_vsub - p['dzb_opt_h'][k]) # additive factor B_c = np.maximum(1.0 - np.abs(dzb_vsub - p['dzb_opt_c'][k]) / p['dzb_tol_c'][k], 0.0) # multiplicative factor B_s = np.maximum(1.0 - np.abs(dzb_vsub - p['dzb_opt_s'][k]) / p['dzb_tol_s'][k], 0.0) # multiplicative factor @@ -118,19 +123,6 @@ def update(s, p): s['Nt'] = gutils.aggregate_from_subgrid(s['Nt_vsub'], f) s['hveg'] = gutils.aggregate_from_subgrid(s['hveg_vsub'], f) - # --- Vegetation bending ------------------------------------------------- - bend = np.ones_like(s['hveg']) - for k in range(p['nspecies']): - bend[:,:,k] = (p['r_stem'][k] + (1.0 - p['r_stem'][k]) - * (p['alpha_uw'][k] * s['uw'] - + p['alpha_Nt'][k] * s['Nt'][:,:,k] - + p['alpha_0'][k])) - - # --- Main-grid vegetation metrics -------------------------------------- - s['hveg_eff'] = np.clip(s['hveg'] * bend, 0.0, s['hveg']) - s['lamveg'] = s['Nt'] * s['hveg_eff'] * p['d_tiller'] - s['rhoveg'] = s['Nt'] * np.pi * (p['d_tiller'] / 2.0)**2 - return s @@ -145,7 +137,7 @@ def spreading(k, Nt, hveg, Nt_avg, B_c, B_s, p, s): for l in range(p['nspecies']): comp += p['alpha_comp'][k, l] * (Nt_avg[:, :, l] / p['Nt_max'][l]) - saturation = np.maximum(1.0 - comp, 0.0) + saturation = 1.0 - comp # np.maximum(1.0 - comp, 0.0) maturity = np.clip(hveg / p['Hveg'][k], 0.0, 1.0) @@ -157,8 +149,12 @@ def spreading(k, Nt, hveg, Nt_avg, B_c, B_s, p, s): S_s *= p['dt_veg'] # [tillers/dt] # --- Clonal expansion --------------------------------------------------- + Nt_clonal_new = np.zeros_like(Nt) dNt_clonal = gutils.apply_clonal_kernel(S_c, s['kernel_c'][k]) - Nt_clonal_new = np.random.poisson(dNt_clonal) + ix_pos = dNt_clonal >= 0.0 + Nt_clonal_new[ix_pos] = np.random.poisson(dNt_clonal[ix_pos]) + ix_neg = dNt_clonal < 0.0 + Nt_clonal_new[ix_neg] = -np.random.poisson(-dNt_clonal[ix_neg]) # --- Seed dispersal ----------------------------------------------------- Nt_seed_new = gutils.sample_seed_germination(S_s, p['alpha_s'][k], @@ -175,10 +171,26 @@ def compute_shear_reduction(s, p): Compute vegetation-induced shear reduction. """ - # --- Weights for normalization ----------------------------------------- + # --- Vegetation bending ------------------------------------------------- + bend = np.ones_like(s['hveg']) + for k in range(p['nspecies']): + bend[:,:,k] = (p['r_stem'][k] + (1.0 - p['r_stem'][k]) + * (p['alpha_uw'][k] * s['uw'] + + p['alpha_Nt'][k] * s['Nt'][:,:,k] + + p['alpha_0'][k])) + + # --- Main-grid vegetation metrics --------------------------------------- + s['hveg_eff'] = np.clip(s['hveg'] * bend, 0.0, s['hveg']) + s['lamveg'] = s['Nt'] * s['hveg_eff'] * p['d_tiller'] + s['rhoveg'] = s['Nt'] * np.pi * (p['d_tiller'] / 2.0)**2 + + # --- Weights for normalization ------------------------------------------ w_sum = np.zeros_like(s['x']) w_num_R0 = np.zeros_like(s['x']) - w_num_R = np.zeros_like(s['x']) + w_num_L = np.zeros_like(s['x']) + + s['R0veg'] = np.ones_like(s['x']) + L_decay = np.zeros_like(s['x']) # --- Wind convention ID (Numba) ----------------------------------------- if p['wind_convention'] == 'nautical': @@ -188,7 +200,7 @@ def compute_shear_reduction(s, p): else: raise ValueError(f"Unknown wind_convention: {p['wind_convention']}") - # --- Loop over species: compute per-species local and leeside reduction -- + # --- Compute per-species local and leeside reduction -------------------- for k in range(p['nspecies']): # Weighting function based on maturity and density @@ -196,31 +208,28 @@ def compute_shear_reduction(s, p): density = s['Nt'][:, :, k] / p['Nt_max'][k] w = maturity * density - # Local Raupach reduction per species (no weighting / no normalization) + # Local Raupach reduction per species R0_k = 1.0 / np.sqrt(1.0 + p['m_veg'][k] * p['beta_veg'][k] * s['lamveg'][:, :, k]) - # Leeside Okin reduction per species (optional) - if p['process_vegetation_leeside']: - R_k = gutils.compute_okin_reduction( - s['x'], s['y'], R0_k, s['udir'], s['hveg_eff'][:, :, k], p['c1_okin'][k], udir_id) - else: - R_k = R0_k - # Accumulate weighted numerators for later normalization w_sum += w w_num_R0 += w * R0_k - w_num_R += w * R_k + w_num_L += w * (s['hveg_eff'][:, :, k] / p['c1_okin'][k]) # --- Final normalization (separate loop / block) ------------------------ - s['R0veg'] = np.ones_like(s['x']) - s['Rveg'] = np.ones_like(s['x']) - ix = w_sum != 0.0 s['R0veg'][ix] = w_num_R0[ix] / w_sum[ix] - s['Rveg'][ix] = w_num_R[ix] / w_sum[ix] + L_decay[ix] = w_num_L[ix] / w_sum[ix] - return s + # --- Compute leeside Okin reduction ------------------------------------- + if p['process_vegetation_leeside']: + R_okin = gutils.compute_okin_reduction( + s['x'], s['y'], s['R0veg'], s['udir'], L_decay, udir_id) + s['Rveg'] = np.minimum(s['R0veg'], R_okin) + else: + s['Rveg'] = s['R0veg'].copy() + return s def apply_shear_reduction(s, p): @@ -241,17 +250,3 @@ def apply_shear_reduction(s, p): s['ustarn'] = s['ustar'] * etn return s - - -def compute_zeta(s, p): - """ - Compute bed–interaction factor zeta. - """ - - # Compute k_str and lambda_str here.... - lam = 1 - k = 1 - - # --- Weibull function for zeta ------------------------------------------ - s['zeta'] = 1.0 - np.exp(-(s['hveg_eff'] / lam)**k) - s['zeta'] = s['zeta'] * (1.0 - p['bounce']) diff --git a/aeolis/grass_utils.py b/aeolis/grass_utils.py index 300382cc..1f270728 100644 --- a/aeolis/grass_utils.py +++ b/aeolis/grass_utils.py @@ -6,6 +6,7 @@ import numpy as np import logging from numba import njit +import matplotlib.pyplot as plt from aeolis.utils import * @@ -23,7 +24,7 @@ def ensure_grass_parameters(p): 'G_h', 'G_c', 'G_s', 'Hveg', 'phi_h', 'Nt_max', 'R_cov', 'lmax_c', 'mu_c', 'alpha_s', 'nu_s', - 'dzb_tol_h', 'dzb_tol_c', 'dzb_tol_s', + 'gamma_h', 'dzb_tol_c', 'dzb_tol_s', 'dzb_opt_h', 'dzb_opt_c', 'dzb_opt_s', 'beta_veg', 'm_veg', 'c1_okin', 'bounce' ] @@ -309,17 +310,16 @@ def sample_seed_germination(S_s, a_s, nu_s, dx): @njit -def compute_okin_reduction(x, y, R0, udir, hveg_eff, c1, wind_convention_id): +def compute_okin_reduction(x, y, R0, udir, L_decay, wind_convention_id): """ - Compute Okin leeside shear reduction for a single species. + Compute Okin leeside shear reduction using an effective decay length. Parameters ---------- x, y : 2D arrays of cell-center coordinates [m] R0 : 2D array of local Raupach reduction [-] udir : 2D array of wind direction [deg] - hveg_eff : 2D array of effective vegetation height [m] - c1 : Okin calibration constant [-] + L_decay : 2D array of Okin decay length h/c1 [m] wind_convention_id : int 0 = nautical (from North, clockwise) 1 = cartesian (0° = +x, CCW) @@ -334,7 +334,6 @@ def compute_okin_reduction(x, y, R0, udir, hveg_eff, c1, wind_convention_id): # grid spacing (assumed uniform) dx = np.sqrt((x[0, 1] - x[0, 0])**2 + (y[0, 1] - y[0, 0])**2) - W = 2.0 * dx deg2rad = np.pi / 180.0 R_end = 0.99 @@ -347,8 +346,8 @@ def compute_okin_reduction(x, y, R0, udir, hveg_eff, c1, wind_convention_id): if R0_ij >= 1.0: continue - h_ij = hveg_eff[iy, ix] - if h_ij <= 0.0: + L_ij = L_decay[iy, ix] + if L_ij <= 0.0: continue # Local wind direction at source cell @@ -361,8 +360,8 @@ def compute_okin_reduction(x, y, R0, udir, hveg_eff, c1, wind_convention_id): uy = np.sin(th) # Max downwind distance (R = R_end) - L_end = -(h_ij / c1) * np.log((1.0 - R_end) / (1.0 - R0_ij)) - r = int(L_end / dx) + 2 # margin + L_end = -L_ij * np.log((1.0 - R_end) / (1.0 - R0_ij)) + r = int(L_end / dx) + 2 # margin # Compute window bounds j0 = max(0, iy - r) @@ -376,7 +375,6 @@ def compute_okin_reduction(x, y, R0, udir, hveg_eff, c1, wind_convention_id): for jy in range(j0, j1): for jx in range(i0, i1): - # Distance from source to target rx = x[jy, jx] - x0 ry = y[jy, jx] - y0 @@ -387,14 +385,17 @@ def compute_okin_reduction(x, y, R0, udir, hveg_eff, c1, wind_convention_id): # Perpendicular distance d_perp = np.sqrt((rx - s * ux)**2 + (ry - s * uy)**2) - if d_perp >= W: + if d_perp >= dx: continue - # Okin along-wind reduction - R_s = 1.0 - (1.0 - R0_ij) * np.exp(-s * c1 / h_ij) + # Possible debug visualization + # debug_okin_geometry(x, y, iy, ix, jy, jx, ux, uy, L_end, W) + + # Okin along-wind reduction (using decay length) + R_s = 1.0 - (1.0 - R0_ij) * np.exp(-s / L_ij) # Cross-wind triangular weighting (width = 2*dx) - w = 1.0 - d_perp / W + w = 1.0 - d_perp / dx R_loc = 1.0 - w * (1.0 - R_s) # Strongest reduction wins @@ -403,3 +404,40 @@ def compute_okin_reduction(x, y, R0, udir, hveg_eff, c1, wind_convention_id): return R + +def debug_okin_geometry(x, y, iy, ix, jy, jx, ux, uy, L_end, W): + """ + Visualize Okin geometry for one source cell (iy,ix) + and one target cell (jy,jx). + """ + + # Extract coordinates + x0 = x[iy, ix] + y0 = y[iy, ix] + xt = x[jy, jx] + yt = y[jy, jx] + rx = xt - x0 + ry = yt - y0 + s = rx * ux + ry * uy + + # Projection point on ray + xp = x0 + s * ux + yp = y0 + s * uy + d_perp = np.sqrt((rx - s * ux)**2 + (ry - s * uy)**2) + + # Plotting + fig, ax = plt.subplots(figsize=(6, 6)) + ax.scatter(x, y, s=10, c='lightgray', label='grid') # grid points + ax.scatter(x0, y0, c='red', s=80, label='source') # source cell + ax.scatter(xt, yt, c='blue', s=80, label='target') # target cell + ax.plot([x0, x0 + L_end * ux],[y0, y0 + L_end * uy],'r--', lw=2, label='wind ray') + ax.plot([xt, xp], [yt, yp], 'k:', lw=2, label='d_perp') + ax.set_aspect('equal') + ax.set_title( + f"s = {s:.2f}, d_perp = {d_perp:.2f}\n" + f"rx = {rx:.2f}, ry = {ry:.2f}" + ) + ax.legend() + plt.show() + + diff --git a/aeolis/model.py b/aeolis/model.py index b318abb5..7be736f2 100644 --- a/aeolis/model.py +++ b/aeolis/model.py @@ -357,11 +357,6 @@ def update(self, dt:float=-1) -> None: #compute sand fence shear if self.p['process_fences']: self.s = aeolis.fences.update_fences(self.s, self.p) - - # compute local vegetation shear reduction - if self.p['process_vegetation']: - if self.p['method_vegetation'] == 'grass': - self.s = aeolis.grass.compute_shear_reduction(self.s, self.p) # topographic steering (including Okin on rotating grid) if np.sum(self.s['uw']) != 0: @@ -372,6 +367,7 @@ def update(self, dt:float=-1) -> None: if self.p['method_vegetation'] == 'duran': self.s = aeolis.vegetation.vegshear(self.s, self.p) if self.p['method_vegetation'] == 'grass': + self.s = aeolis.grass.compute_shear_reduction(self.s, self.p) self.s = aeolis.grass.apply_shear_reduction(self.s, self.p) # determine optimal time step @@ -428,7 +424,6 @@ def update(self, dt:float=-1) -> None: if self.p['method_vegetation'] == 'duran': self.s = aeolis.vegetation.germinate(self.s, self.p) self.s = aeolis.vegetation.grow(self.s, self.p) - # update grass when dt_veg has passed elif self.p['method_vegetation'] == 'grass': self.tveg += self.dt * self.p['accfac'] diff --git a/aeolis/zeta.py b/aeolis/zeta.py new file mode 100644 index 00000000..4a135010 --- /dev/null +++ b/aeolis/zeta.py @@ -0,0 +1,12 @@ +def compute_zeta(s, p): + """ + Compute bed–interaction factor zeta. + """ + + # Compute k_str and lambda_str here.... + lam = 1 + k = 1 + + # --- Weibull function for zeta ------------------------------------------ + s['zeta'] = 1.0 - np.exp(-(s['hveg_eff'] / lam)**k) + s['zeta'] = s['zeta'] * (1.0 - p['bounce']) From 092d277c32a6e89996f562e49ebfc87167d6892d Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Tue, 23 Dec 2025 13:57:44 -0800 Subject: [PATCH 22/23] Cleanup DEFAULTS CONFIG --- aeolis/constants.py | 292 +++++++++++++++++++++++++------------------- 1 file changed, 169 insertions(+), 123 deletions(-) diff --git a/aeolis/constants.py b/aeolis/constants.py index 6e188c80..0027fd9f 100644 --- a/aeolis/constants.py +++ b/aeolis/constants.py @@ -129,12 +129,14 @@ 'Rti', # [-] Factor taking into account sheltering by roughness elements 'zeta', # [-] [NEW] Bed interaction parameter for in advection equation + 'kzeta', # [-] [NEW] Shape k-parameter in Weibull function for zeta + 'lamzeta', # [m] [NEW] Scale lambda-parameter in Weibull function for zeta ), ('ny','nx','nfractions') : ( 'Cu', # [kg/m^2] Equilibrium sediment concentration integrated over saltation height 'Cuf', # [kg/m^2] Equilibrium sediment concentration integrated over saltation height, assuming the fluid shear velocity threshold 'Cu0', # [kg/m^2] Flat bad equilibrium sediment concentration integrated over saltation height - 'CuAir', # [kg/m^2] [NEW] Equilibrium sediment concentration for airborne sediment + 'CuAir', # [kg/m^2] Equilibrium sediment concentration for airborne sediment 'CuBed', # [kg/m^2] [NEW] Equilibrium sediment concentration for bed sediment 'Ct', # [kg/m^2] Instantaneous sediment concentration integrated over saltation height 'q', # [kg/m/s] Instantaneous sediment flux @@ -186,18 +188,12 @@ #: AeoLiS model default configuration DEFAULT_CONFIG = { + + # --- Process Booleans (True/False) ------------------------------------------------------------------------------- 'process_wind' : True, # Enable the process of wind 'process_transport' : True, # Enable the process of transport 'process_bedupdate' : True, # Enable the process of bed updating 'process_threshold' : True, # Enable the process of threshold - 'th_grainsize' : True, # Enable wind velocity threshold based on grainsize - 'th_bedslope' : False, # Enable wind velocity threshold based on bedslope - 'th_moisture' : False, # Enable wind velocity threshold based on moisture - 'th_drylayer' : False, # Enable threshold based on drying of layer - 'th_humidity' : False, # Enable wind velocity threshold based on humidity - 'th_salt' : False, # Enable wind velocity threshold based on salt - 'th_sheltering' : False, # Enable wind velocity threshold based on sheltering by roughness elements - 'th_nelayer' : False, # Enable wind velocity threshold based on a non-erodible layer 'process_avalanche' : False, # Enable the process of avalanching 'process_shear' : False, # Enable the process of wind shear 'process_tide' : False, # Enable the process of tides @@ -220,42 +216,58 @@ 'process_seepage_face' : False, # Enable the process of groundwater seepage (NB. only applicable to positive beach slopes) 'process_bedinteraction' : False, # Enable the process of bed interaction in the advection equation - 'visualization' : False, # Boolean for visualization of model interpretation before and just after initialization - - 'output_sedtrails' : False, # NEW! [T/F] Boolean to see whether additional output for SedTRAILS should be generated - 'nfraction_sedtrails' : 0, # [-] Index of selected fraction for SedTRAILS (0 if only one fraction) + # --- Threshold Booleans (True/False) ----------------------------------------------------------------------------- + 'th_grainsize' : True, # Enable wind velocity threshold based on grainsize + 'th_bedslope' : False, # Enable wind velocity threshold based on bedslope + 'th_moisture' : False, # Enable wind velocity threshold based on moisture + 'th_drylayer' : False, # Enable threshold based on drying of layer + 'th_humidity' : False, # Enable wind velocity threshold based on humidity + 'th_salt' : False, # Enable wind velocity threshold based on salt + 'th_sheltering' : False, # Enable wind velocity threshold based on sheltering by roughness elements + 'th_nelayer' : False, # Enable wind velocity threshold based on a non-erodible layer + # --- Grid files (convention *.grd) ------------------------------------------------------------------------------- 'xgrid_file' : None, # Filename of ASCII file with x-coordinates of grid cells 'ygrid_file' : None, # Filename of ASCII file with y-coordinates of grid cells 'bed_file' : None, # Filename of ASCII file with bed level heights of grid cells - 'wind_file' : None, # Filename of ASCII file with time series of wind velocity and direction - 'tide_file' : None, # Filename of ASCII file with time series of water levels - 'wave_file' : None, # Filename of ASCII file with time series of wave heights - 'meteo_file' : None, # Filename of ASCII file with time series of meteorlogical conditions + 'ne_file' : None, # Filename of ASCII file with non-erodible layer + 'veg_file' : None, # Filename of ASCII file with initial vegetation density + + # --- Other spatial files / masks --------------------------------------------------------------------------------- 'bedcomp_file' : None, # Filename of ASCII file with initial bed composition 'threshold_file' : None, # Filename of ASCII file with shear velocity threshold 'fence_file' : None, # Filename of ASCII file with sand fence location/height (above the bed) - 'ne_file' : None, # Filename of ASCII file with non-erodible layer - 'veg_file' : None, # Filename of ASCII file with initial vegetation density 'supply_file' : None, # Filename of ASCII file with a manual definition of sediment supply (mainly used in academic cases) 'wave_mask' : None, # Filename of ASCII file with mask for wave height 'tide_mask' : None, # Filename of ASCII file with mask for tidal elevation 'runup_mask' : None, # Filename of ASCII file with mask for run-up 'threshold_mask' : None, # Filename of ASCII file with mask for the shear velocity threshold 'gw_mask' : None, # Filename of ASCII file with mask for the groundwater level - 'vver_mask' : None, #NEWBvW # Filename of ASCII file with mask for the vertical vegetation growth + 'vver_mask' : None, # Filename of ASCII file with mask for the vertical vegetation growth + + # --- Timeseries -------------------------------------------------------------------------------------------------- + 'wind_file' : None, # Filename of ASCII file with time series of wind velocity and direction + 'tide_file' : None, # Filename of ASCII file with time series of water levels + 'wave_file' : None, # Filename of ASCII file with time series of wave heights + 'meteo_file' : None, # Filename of ASCII file with time series of meteorlogical conditions + + # --- Model, grid and time settings ------------------------------------------------------------------------------- 'nx' : 0, # [-] Number of grid cells in x-dimension 'ny' : 0, # [-] Number of grid cells in y-dimension 'dt' : 60., # [s] Time step size - 'dx' : 1., - 'dy' : 1., - 'CFL' : 1., # [-] CFL number to determine time step in explicit scheme - 'accfac' : 1., # [-] Numerical acceleration factor - 'max_bedlevel_change' : 999., # [m] Maximum bedlevel change after one timestep. Next timestep dt will be modified (use 999. if not used) 'tstart' : 0., # [s] Start time of simulation 'tstop' : 3600., # [s] End time of simulation 'restart' : None, # [s] Interval for which to write restart files - 'dzb_interval' : 86400, # [s] Interval used for calcuation of vegetation growth + 'refdate' : '2020-01-01 00:00', # [-] Reference datetime in netCDF output + 'callback' : None, # Reference to callback function (e.g. example/callback.py':callback) + 'wind_convention' : 'nautical', # Convention used for the wind direction in the input files (cartesian or nautical) + 'alfa' : 0, # [deg] Real-world grid cell orientation wrt the North (clockwise) + + # --- Output (and coupling) settings ------------------------------------------------------------------------------ + 'visualization' : False, # Boolean for visualization of model interpretation before and just after initialization + 'output_sedtrails' : False, # NEW! [T/F] Boolean to see whether additional output for SedTRAILS should be generated + 'nfraction_sedtrails' : 0, # [-] Index of selected fraction for SedTRAILS (0 if only one fraction) + 'output_times' : 60., # [s] Output interval in seconds of simulation time 'output_file' : None, # Filename of netCDF4 output file 'output_vars' : ['zb', 'zs', @@ -265,10 +277,31 @@ 'pickup', 'w'], # Names of spatial grids to be included in output 'output_types' : [], # Names of statistical parameters to be included in output (avg, sum, var, min or max) 'external_vars' : [], # Names of variables that are overwritten by an external (coupling) model, i.e. CoCoNuT - 'grain_size' : [225e-6], # [m] Average grain size of each sediment fraction - 'grain_dist' : [1.], # [-] Initial distribution of sediment fractions - 'nlayers' : 3, # [-] Number of bed layers - 'layer_thickness' : .01, # [m] Thickness of bed layers + + # --- Solver ------------------------------------------------------------------------------------------------------ + 'T' : 1., # [s] Adaptation time scale in advection equation + 'CFL' : 1., # [-] CFL number to determine time step in explicit scheme + 'accfac' : 1., # [-] Numerical acceleration factor + 'max_bedlevel_change' : 999., # [m] Maximum bedlevel change after one timestep. Next timestep dt will be modified (use 999. if not used) + 'max_error' : 1e-6, # [-] Maximum error at which to quit iterative solution in implicit numerical schemes + 'max_iter' : 1000, # [-] Maximum number of iterations at which to quit iterative solution in implicit numerical schemes + 'scheme' : 'euler_backward', # Name of numerical scheme (euler_forward, euler_backward or crank_nicolson) + 'solver' : 'trunk', # Name of the solver (trunk, pieter, steadystate,steadystatepieter) + + # --- Boundary conditions ----------------------------------------------------------------------------------------- + 'boundary_lateral' : 'circular', # Name of lateral boundary conditions (circular, constant ==noflux) + 'boundary_offshore' : 'constant', # Name of offshore boundary conditions (flux, constant, uniform, gradient) + 'boundary_onshore' : 'gradient', # Name of onshore boundary conditions (flux, constant, uniform, gradient) + + 'offshore_flux' : 0., # [-] Factor to determine offshore boundary flux as a function of Q0 (= 1 for saturated flux , = 0 for noflux) + 'constant_offshore_flux' : 0., # [kg/m/s] Constant input flux at offshore boundary + 'onshore_flux' : 0., # [-] Factor to determine onshore boundary flux as a function of Q0 (= 1 for saturated flux , = 0 for noflux) + 'constant_onshore_flux' : 0., # [kg/m/s] Constant input flux at offshore boundary + 'lateral_flux' : 0., # [-] Factor to determine lateral boundary flux as a function of Q0 (= 1 for saturated flux , = 0 for noflux) + 'sedimentinput' : 0., # [-] Constant boundary sediment influx (only used in solve_pieter) + + # --- General physical constants and model parameters ------------------------------------------------------------- + 'method_roughness' : 'constant', # Name of method to compute the roughness height z0, note that here the z0 = k 'g' : 9.81, # [m/s^2] Gravitational constant 'v' : 0.000015, # [m^2/s] Air viscosity 'rhoa' : 1.225, # [kg/m^3] Air density @@ -279,41 +312,48 @@ 'z' : 10., # [m] Measurement height of wind velocity 'h' : None, # [m] Representative height of saltation layer 'k' : 0.001, # [m] Bed roughness + 'kappa' : 0.41, # [-] Von Kármán constant + + + # --- Sediment fractions and layers ------------------------------------------------------------------------------- + 'grain_size' : [225e-6], # [m] Average grain size of each sediment fraction + 'grain_dist' : [1.], # [-] Initial distribution of sediment fractions + 'nlayers' : 3, # [-] Number of bed layers + 'layer_thickness' : .01, # [m] Thickness of bed layers + + # --- Shear / Perturbation / Topographic steering ----------------------------------------------------------------- + 'method_shear' : 'fft', # Name of method to compute topographic effects on wind shear stress (fft, quasi2d, duna2d (experimental)) + 'dx' : 1., + 'dy' : 1., 'L' : 100., # [m] Typical length scale of dune feature (perturbation) 'l' : 10., # [m] Inner layer height (perturbation) - - # Separation bubble parameters - 'sep_auto_tune' : True, # [-] Boolean for automatic tuning of separation bubble parameters based on characteristic length scales - 'sep_look_dist' : 50., # [m] Flow separation: Look-ahead distance for upward curvature anticipation - 'sep_k_press_up' : 0.05, # [-] Flow separation: Press-up curvature - 'sep_k_crit_down' : 0.18, # [1/m] Flow separation: Maximum downward curvature - 'sep_s_crit' : 0.18, # [-] Flow separation: Critical bed slope below which reattachment is forced - 'sep_s_leeside' : 0.25, # [-] Maximum downward leeside slope of the streamline + # --- Flow separation bubble (OLD) -------------------------------------------------------------------------------- 'buffer_width' : 10, # [m] Width of the bufferzone around the rotational grid for wind perturbation 'sep_filter_iterations' : 0, # [-] Number of filtering iterations on the sep-bubble (0 = no filtering) 'zsep_y_filter' : False, # [-] Boolean for turning on/off the filtering of the separation bubble in y-direction + + # --- Sediment transport formulations ----------------------------------------------------------------------------- + 'method_transport' : 'bagnold', # Name of method to compute equilibrium sediment transport rate + 'method_grainspeed' : 'windspeed', # Name of method to assume/compute grainspeed (windspeed, duran, constant) 'Cb' : 1.5, # [-] Constant in bagnold formulation for equilibrium sediment concentration 'Ck' : 2.78, # [-] Constant in kawamura formulation for equilibrium sediment concentration 'Cl' : 6.7, # [-] Constant in lettau formulation for equilibrium sediment concentration 'Cdk' : 5., # [-] Constant in DK formulation for equilibrium sediment concentration - # 'm' : 0.5, # [-] Factor to account for difference between average and maximum shear stress -# 'alpha' : 0.4, # [-] Relation of vertical component of ejection velocity and horizontal velocity difference between impact and ejection - 'kappa' : 0.41, # [-] Von Kármán constant 'sigma' : 4.2, # [-] Ratio between basal area and frontal area of roughness elements 'beta' : 130., # [-] Ratio between drag coefficient of roughness elements and bare surface - 'bi' : 1., # [-] Bed interaction factor - 'T' : 1., # [s] Adaptation time scale in advection equation - 'Tdry' : 3600.*1.5, # [s] Adaptation time scale for soil drying - 'Tsalt' : 3600.*24.*30., # [s] Adaptation time scale for salinitation + 'bi' : 1., # [-] Bed interaction factor for sediment fractions + + # --- Bed update parameters --------------------------------------------------------------------------------------- 'Tbedreset' : 86400., # [s] - 'eps' : 1e-3, # [m] Minimum water depth to consider a cell "flooded" - 'gamma' : .5, # [-] Maximum wave height over depth ratio - 'xi' : .3, # [-] Surf similarity parameter - 'facDOD' : .1, # [-] Ratio between depth of disturbance and local wave height - 'csalt' : 35e-3, # [-] Maximum salt concentration in bed surface layer - 'cpair' : 1.0035e-3, # [MJ/kg/oC] Specific heat capacity air + + # --- Moisture parameters --------------------------------------------------------------------------- + 'method_moist_threshold' : 'belly_johnson', # Name of method to compute wind velocity threshold based on soil moisture content + 'method_moist_process' : 'infiltration', # Name of method to compute soil moisture content(infiltration or surface_moisture) + 'Tdry' : 3600.*1.5, # [s] Adaptation time scale for soil drying + # --- Moisture / Groundwater (Hallin) ----------------------------------------------------------------------------- + 'boundary_gw' : 'no_flow', # Landward groundwater boundary, dGw/dx = 0 (or 'static') 'fc' : 0.11, # [-] Moisture content at field capacity (volumetric) 'w1_5' : 0.02, # [-] Moisture content at wilting point (gravimetric) 'resw_moist' : 0.01, # [-] Residual soil moisture content (volumetric) @@ -336,10 +376,21 @@ 'GW_stat' : 1, # [m] Landward static groundwater boundary (if static boundary is defined) 'max_moist' : 10., # NEWCH # [%] Moisture content (volumetric in percent) above which the threshold shear velocity is set to infinity (no transport, default value Delgado-Fernandez, 2010) 'max_moist' : 10., # [%] Moisture content (volumetric in percent) above which the threshold shear velocity is set to infinity (no transport, default value Delgado-Fernandez, 2010) + + # --- Avalanching parameters -------------------------------------------------------------------------------------- 'theta_dyn' : 33., # [degrees] Initial Dynamic angle of repose, critical dynamic slope for avalanching 'theta_stat' : 34., # [degrees] Initial Static angle of repose, critical static slope for avalanching + 'max_iter_ava' : 1000, # [-] Maximum number of iterations at which to quit iterative solution in avalanching calculation + + # --- Hydro and waves --------------------------------------------------------------------------------------------- + 'eps' : 1e-3, # [m] Minimum water depth to consider a cell "flooded" + 'gamma' : .5, # [-] Maximum wave height over depth ratio + 'xi' : .3, # [-] Surf similarity parameter + 'facDOD' : .1, # [-] Ratio between depth of disturbance and local wave height + + # --- Vegetation (OLD) -------------------------------------------------------------------------------------------- + 'method_vegetation' : 'duran', # Name of method to compute vegetation: duran (original) or grass (new framework) 'avg_time' : 86400., # [s] Indication of the time period over which the bed level change is averaged for vegetation growth - 'T_burial' : 86400.*30., # [s] NEW! Time scale for sediment burial effect on vegetation growth (replaces avg_time) 'gamma_vegshear' : 16., # [-] Roughness factor for the shear stress reduction by vegetation 'hveg_max' : 1., # [m] Max height of vegetation 'dzb_opt' : 0., # [m/year] Sediment burial for optimal growth @@ -348,35 +399,6 @@ 'lateral' : 0., # [1/year] Posibility of lateral expension per year 'veg_gamma' : 1., # [-] Constant on influence of sediment burial 'veg_sigma' : 0., # [-] Sigma in gaussian distrubtion of vegetation cover filter - 'sedimentinput' : 0., # [-] Constant boundary sediment influx (only used in solve_pieter) - 'scheme' : 'euler_backward', # Name of numerical scheme (euler_forward, euler_backward or crank_nicolson) - 'solver' : 'trunk', # Name of the solver (trunk, pieter, steadystate,steadystatepieter) - 'boundary_lateral' : 'circular', # Name of lateral boundary conditions (circular, constant ==noflux) - 'boundary_offshore' : 'constant', # Name of offshore boundary conditions (flux, constant, uniform, gradient) - 'boundary_onshore' : 'gradient', # Name of onshore boundary conditions (flux, constant, uniform, gradient) - 'boundary_gw' : 'no_flow', # Landward groundwater boundary, dGw/dx = 0 (or 'static') - 'method_moist_threshold' : 'belly_johnson', # Name of method to compute wind velocity threshold based on soil moisture content - 'method_moist_process' : 'infiltration', # Name of method to compute soil moisture content(infiltration or surface_moisture) - 'offshore_flux' : 0., # [-] Factor to determine offshore boundary flux as a function of Q0 (= 1 for saturated flux , = 0 for noflux) - 'constant_offshore_flux' : 0., # [kg/m/s] Constant input flux at offshore boundary - 'onshore_flux' : 0., # [-] Factor to determine onshore boundary flux as a function of Q0 (= 1 for saturated flux , = 0 for noflux) - 'constant_onshore_flux' : 0., # [kg/m/s] Constant input flux at offshore boundary - 'lateral_flux' : 0., # [-] Factor to determine lateral boundary flux as a function of Q0 (= 1 for saturated flux , = 0 for noflux) - 'method_transport' : 'bagnold', # Name of method to compute equilibrium sediment transport rate - 'method_roughness' : 'constant', # Name of method to compute the roughness height z0, note that here the z0 = k, which does not follow the definition of Nikuradse where z0 = k/30. - 'method_grainspeed' : 'windspeed', # Name of method to assume/compute grainspeed (windspeed, duran, constant) - 'method_shear' : 'fft', # Name of method to compute topographic effects on wind shear stress (fft, quasi2d, duna2d (experimental)) - 'method_vegetation' : 'duran', # Name of method to compute vegetation: duran (original) or grass (new framework) - 'max_error' : 1e-6, # [-] Maximum error at which to quit iterative solution in implicit numerical schemes - 'max_iter' : 1000, # [-] Maximum number of iterations at which to quit iterative solution in implicit numerical schemes - 'max_iter_ava' : 1000, # [-] Maximum number of iterations at which to quit iterative solution in avalanching calculation - 'refdate' : '2020-01-01 00:00', # [-] Reference datetime in netCDF output - 'callback' : None, # Reference to callback function (e.g. example/callback.py':callback) - 'wind_convention' : 'nautical', # Convention used for the wind direction in the input files (cartesian or nautical) - 'alfa' : 0, # [deg] Real-world grid cell orientation wrt the North (clockwise) - 'dune_toe_elevation' : 3, # Choose dune toe elevation, only used in the PH12 dune erosion solver - 'beach_slope' : 0.1, # Define the beach slope, only used in the PH12 dune erosion solver - 'veg_min_elevation' : -10., # Minimum elevation (m) where vegetation can grow; default -10 disables restriction (allows vegetation everywhere). Set to a higher value to enforce a minimum elevation for vegetation growth. 'vegshear_type' : 'raupach', # Choose the Raupach grid based solver (1D or 2D) or the Okin approach (1D only) 'okin_c1_veg' : 0.48, #x/h spatial reduction factor in Okin model for use with vegetation 'okin_c1_fence' : 0.48, #x/h spatial reduction factor in Okin model for use with sand fence module @@ -387,49 +409,73 @@ 't_veg' : 3, #time scale of vegetation growth (days), only used in duran and moore 14 formulation 'v_gam' : 1, # only used in duran and moore 14 formulation - # --- Grass vegetation model (new framework) ------------------------------ - 'method_vegetation' : 'duran', # ['duran' | 'grass'] Vegetation formulation - 'veg_res_factor' : 5, # [-] Vegetation subgrid refinement factor (dx_veg = dx / factor) - 'dt_veg' : 86400., # [s] Time step for vegetation growth calculations - 'species_names' : ['marram'], # [-] Name(s) of vegetation species - 'hveg_file' : None, # Filename of ASCII file with initial vegetation height (shape: ny * nx * nspecies) - 'Nt_file' : None, # Filename of ASCII file with initial tiller density (shape: ny * nx * nspecies) - - 'd_tiller' : [0.006], # [m] Mean tiller diameter - 'r_stem' : [0.2], # [-] Fraction of rigid (non-bending) stem height - 'alpha_uw' : [-0.0412], # [s/m] Wind-speed sensitivity of vegetation bending - 'alpha_Nt' : [1.95e-4], # [m^2] Tiller-density sensitivity of vegetation bending - 'alpha_0' : [0.9445], # [-] Baseline bending factor (no wind, sparse vegetation) - - 'G_h' : [1.0], # [m/yr] Intrinsic vertical vegetation growth rate - 'G_c' : [2.5], # [tillers/tiller/yr] Intrinsic clonal tiller production rate - 'G_s' : [0.01], # [tillers/tiller/yr] Intrinsic seedling establishment rate - 'Hveg' : [0.8], # [m] Maximum attainable vegetation height - 'phi_h' : [1.0], # [-] Saturation exponent for height growth - - 'Nt_max' : [900.0], # [1/m^2] Maximum attainable tiller density - 'R_cov' : [1.2], # [m] Radius for neighbourhood density averaging - 'alpha_comp' : [0.], # [-] Lotka–Volterra competition coefficients - # shape: nspecies * nspecies (flattened) - # alpha_comp[k,l] = effect of species l on species k - - 'lmax_c' : [0.9], # [m] Maximum clonal dispersal distance - 'mu_c' : [2.5], # [-] Shape parameter of clonal dispersal kernel - 'alpha_s' : [4.0], # [m^2] Scale parameter of seed dispersal kernel - 'nu_s' : [2.5], # [-] Tail-heaviness of seed dispersal kernel - - 'gamma_h' : [1.0], # [-] Sensitivity of vertical growth to burial (1 / dzb_tol_h) - 'dzb_tol_c' : [1.0], # [m/yr] Tolerance burial range for clonal expansion - 'dzb_tol_s' : [0.1], # [m/yr] Tolerance burial range for seed establishment - 'dzb_opt_h' : [0.5], # [m/yr] Optimal burial rate for vertical growth - 'dzb_opt_c' : [0.5], # [m/yr] Optimal burial rate for clonal expansion - 'dzb_opt_s' : [0.025], # [m/yr] Optimal burial rate for seed establishment - - 'beta_veg' : [120.0], # [-] Vegetation momentum-extraction efficiency (Raupach) - 'm_veg' : [0.4], # [-] Shear non-uniformity correction factor - 'c1_okin' : [0.48], # [-] Downwind decay coefficient in Okin shear reduction - 'bounce' : [0.5], # [-] Fraction of sediment skimming over vegetation canopy + # --- Dune erosion parameters ------------------------------------------------------------------------------------- + 'dune_toe_elevation' : 3, # Choose dune toe elevation, only used in the PH12 dune erosion solver + 'beach_slope' : 0.1, # Define the beach slope, only used in the PH12 dune erosion solver + 'veg_min_elevation' : -10., # Minimum elevation (m) where vegetation can grow; default -10 disables restriction. + + # --- Bed interaction in advection equation (new process) --------------------------------------------------------- + 'zeta_base' : 1.0, # [-] Base value for bed interaction parameter in advection equation + 'p_zeta_moist' : 0.8, # [-] Exponent parameter for computing zeta from moisture + 'a_weibull' : 2., # [-] Shape parameter k of Weibull function for bed interaction parameter zeta + 'b_weibull' : 2., # [m] Scale parameter lambda of Weibull function for bed interaction parameter zeta + 'bounce' : [0.5], # [-] Fraction of sediment skimming over vegetation canopy (species-specific) + + # --- Grass vegetation model (new vegetation framework) ----------------------------------------------------------- + 'method_vegetation' : 'duran', # ['duran' | 'grass'] Vegetation formulation + 'veg_res_factor' : 5, # [-] Vegetation subgrid refinement factor (dx_veg = dx / factor) + 'dt_veg' : 86400., # [s] Time step for vegetation growth calculations + 'species_names' : ['marram'], # [-] Name(s) of vegetation species + 'hveg_file' : None, # Filename of ASCII file with initial vegetation height (shape: ny * nx * nspecies) + 'Nt_file' : None, # Filename of ASCII file with initial tiller density (shape: ny * nx * nspecies) + + 'd_tiller' : [0.006], # [m] Mean tiller diameter + 'r_stem' : [0.2], # [-] Fraction of rigid (non-bending) stem height + 'alpha_uw' : [-0.0412], # [s/m] Wind-speed sensitivity of vegetation bending + 'alpha_Nt' : [1.95e-4], # [m^2] Tiller-density sensitivity of vegetation bending + 'alpha_0' : [0.9445], # [-] Baseline bending factor (no wind, sparse vegetation) + + 'G_h' : [1.0], # [m/yr] Intrinsic vertical vegetation growth rate + 'G_c' : [2.5], # [tillers/tiller/yr] Intrinsic clonal tiller production rate + 'G_s' : [0.01], # [tillers/tiller/yr] Intrinsic seedling establishment rate + 'Hveg' : [0.8], # [m] Maximum attainable vegetation height + 'phi_h' : [1.0], # [-] Saturation exponent for height growth + 'Nt_max' : [900.0], # [1/m^2] Maximum attainable tiller density + 'R_cov' : [1.2], # [m] Radius for neighbourhood density averaging + + 'lmax_c' : [0.9], # [m] Maximum clonal dispersal distance + 'mu_c' : [2.5], # [-] Shape parameter of clonal dispersal kernel + 'alpha_s' : [4.0], # [m^2] Scale parameter of seed dispersal kernel + 'nu_s' : [2.5], # [-] Tail-heaviness of seed dispersal kernel + + 'T_burial' : 86400.*30., # [s] Time scale for sediment burial effect on vegetation growth (replaces avg_time) + 'gamma_h' : [1.0], # [-] Sensitivity of vertical growth to burial (1 / dzb_tol_h) + 'dzb_tol_c' : [1.0], # [m/yr] Tolerance burial range for clonal expansion + 'dzb_tol_s' : [0.1], # [m/yr] Tolerance burial range for seed establishment + 'dzb_opt_h' : [0.5], # [m/yr] Optimal burial rate for vertical growth + 'dzb_opt_c' : [0.5], # [m/yr] Optimal burial rate for clonal expansion + 'dzb_opt_s' : [0.025], # [m/yr] Optimal burial rate for seed establishment + + 'beta_veg' : [120.0], # [-] Vegetation momentum-extraction efficiency (Raupach) + 'm_veg' : [0.4], # [-] Shear non-uniformity correction factor + 'c1_okin' : [0.48], # [-] Downwind decay coefficient in Okin shear reduction + + 'alpha_comp' : [0.], # [-] Lotka–Volterra competition coefficients + # shape: nspecies * nspecies (flattened) + # alpha_comp[k,l] = effect of species l on species k + + # --- Separation bubble parameters -------------------------------------------------------------------------------- + 'sep_look_dist' : 50., # [m] Flow separation: Look-ahead distance for upward curvature anticipation + 'sep_k_press_up' : 0.05, # [-] Flow separation: Press-up curvature + 'sep_k_crit_down' : 0.18, # [1/m] Flow separation: Maximum downward curvature + 'sep_s_crit' : 0.18, # [-] Flow separation: Critical bed slope below which reattachment is forced + 'sep_s_leeside' : 0.25, # [-] Maximum downward leeside slope of the streamline + + # --- Other ------------------------------------------------------------------------------------------------------- + 'Tsalt' : 3600.*24.*30., # [s] Adaptation time scale for salinitation + 'csalt' : 35e-3, # [-] Maximum salt concentration in bed surface layer + 'cpair' : 1.0035e-3, # [MJ/kg/oC] Specific heat capacity air } REQUIRED_CONFIG = ['nx', 'ny'] From 00f308112687a1c1ed45283ce05190c616beeea1 Mon Sep 17 00:00:00 2001 From: Bart van Westen Date: Fri, 26 Dec 2025 13:54:43 -0800 Subject: [PATCH 23/23] Refactor model state variables in constants.py --- aeolis/constants.py | 137 +++++++++++++++++++++++++++++--------------- 1 file changed, 91 insertions(+), 46 deletions(-) diff --git a/aeolis/constants.py b/aeolis/constants.py index 0027fd9f..8725a936 100644 --- a/aeolis/constants.py +++ b/aeolis/constants.py @@ -24,69 +24,91 @@ ''' +# Question; what are the different purposes of INITIAL_STATE and MODEL_STATE? #: Aeolis model state variables INITIAL_STATE = { ('ny', 'nx') : ( + + # --- Wind ---------------------------------------------------------------------------------------------------- 'uw', # [m/s] Wind velocity 'uws', # [m/s] Component of wind velocity in x-direction 'uwn', # [m/s] Component of wind velocity in y-direction - + 'udir', # [rad] Wind direction + + # --- Shear stress and velocity ------------------------------------------------------------------------------- + + # Overall shear stress and velocity 'tau', # [N/m^2] Wind shear stress 'taus', # [N/m^2] Component of wind shear stress in x-direction 'taun', # [N/m^2] Component of wind shear stress in y-direction - 'tau0', # [N/m^2] Wind shear stress over a flat bed - 'taus0', # [N/m^2] Component of wind shear stress in x-direction over a flat bed - 'taun0', # [N/m^2] Component of wind shear stress in y-direction over a flat bed - 'taus_u', # [N/m^2] Saved direction of wind shear stress in x-direction - 'taun_u', # [N/m^2] Saved direction of wind shear stress in y-direction 'dtaus', # [-] Component of the wind shear perturbation in x-direction 'dtaun', # [-] Component of the wind shear perturbation in y-direction - - 'tauAir', # [N/m^2] Wind shear stress for airborne sediment - 'tausAir', # [N/m^2] Component of wind shear stress for airborne sediment in x-direction - 'taunAir', # [N/m^2] Component of wind shear stress for airborne sediment in y-direction - 'ustar', # [m/s] Wind shear velocity 'ustars', # [m/s] Component of wind shear velocity in x-direction 'ustarn', # [m/s] Component of wind shear velocity in y-direction + # 'taus_u', # REMOVE? [N/m^2] Saved direction of wind shear stress in x-direction + # 'taun_u', # REMOVE? [N/m^2] Saved direction of wind shear stress in y-direction + + # Shear stress over a flat bed + 'tau0', # [N/m^2] Wind shear stress over a flat bed + 'taus0', # [N/m^2] Component of wind shear stress in x-direction over a flat bed + 'taun0', # [N/m^2] Component of wind shear stress in y-direction over a flat bed 'ustar0', # [m/s] Wind shear velocity over a flat bed 'ustars0', # [m/s] Component of wind shear velocity in x-direction over a flat bed 'ustarn0', # [m/s] Component of wind shear velocity in y-direction over a flat bed + # Shear stress and velocity for airborne sediment (only topographic steering, no supply-limitations or reduction) + 'tauAir', # [N/m^2] Wind shear stress for airborne sediment + 'tausAir', # [N/m^2] Component of wind shear stress for airborne sediment in x-direction + 'taunAir', # [N/m^2] Component of wind shear stress for airborne sediment in y-direction 'ustarAir', # [m/s] Wind shear velocity for airborne sediment 'ustarsAir', # [m/s] Component of wind shear velocity for airborne sediment in x-direction 'ustarnAir', # [m/s] Component of wind shear velocity for airborne sediment in y-direction - 'udir', # [rad] Wind direction + # --- Water levels and waves ---------------------------------------------------------------------------------- + 'zne', # [m] Non-erodible layer 'zs', # [m] Water level above reference (or equal to zb if zb > zs) 'SWL', # [m] Still water level above reference 'Hs', # [m] Wave height 'Hsmix', # [m] Wave height for mixing (including setup, TWL) 'Tp', # [s] Wave period for wave runup calculations - 'zne', # [m] Non-erodible layer + ), } MODEL_STATE = { ('ny', 'nx') : ( + + # --- Basic grid and bed properties --------------------------------------------------------------------------- 'x', # [m] Real-world x-coordinate of grid cell center 'y', # [m] Real-world y-coordinate of grid cell center 'ds', # [m] Real-world grid cell size in x-direction 'dn', # [m] Real-world grid cell size in y-direction 'dsdn', # [m^2] Real-world grid cell surface area 'dsdni', # [m^-2] Inverse of real-world grid cell surface area -# 'alfa', # [rad] Real-world grid cell orientation #Sierd_comm in later releases this needs a revision +# 'alfa', # REMOVE? [rad] Real-world grid cell orientation #Sierd_comm in later releases this needs a revision + + # --- Bed and water levels ------------------------------------------------------------------------------------ 'zb', # [m] Bed level above reference + 'dzb', # [m/dt] Bed level change per time step (computed after avalanching!) + 'dzbyear', # [m/yr] Bed level change translated to m/y (for dzbavg) + 'dzbavg', # [m/year] Bed level change averaged over collected time steps (for vegetation) 'zs', # [m] Water level above reference 'zne', # [m] Height above reference of the non-erodible layer - 'zb0', # [m] Initial bed level above reference - 'zdry', # [m] - 'dzdry', # [m] - 'dzb', # [m/dt] Bed level change per time step (computed after avalanching!) - 'dzbyear', # [m/yr] Bed level change translated to m/y - 'dzbavg', # [m/year] Bed level change averaged over collected time steps - 'S', # [-] Level of saturation + 'zb0', # [m] Initial bed level above reference (used for wet_bed_reset process) + 'zsep', # [m] Z level of polynomial that defines the separation bubble + 'hsep', # [m] Height of separation bubble = difference between z-level of zsep and of the bed level zb + # 'zdry', # REMOVE? [m] + # 'dzdry', # REMOVE? [m] + + # --- Shear stress and velocity (needed? Also in INITIAL_STATE) ----------------------------------------------- + 'ustar', # [m/s] Shear velocity by wind + 'ustars', # [m/s] Component of shear velocity in x-direction by wind + 'ustarn', # [m/s] Component of shear velocity in y-direction by wind + 'ustar0', # [m/s] Initial shear velocity (without perturbation) + + # --- Moisture and groundwater -------------------------------------------------------------------------------- 'moist', # [-] Moisture content (volumetric) 'moist_swr', # [-] Moisture content soil water retention relationship (volumetric) 'h_delta', # [-] Suction at reversal between wetting/drying conditions @@ -101,13 +123,16 @@ 'd_h', # [-] Moisture content (volumetric) computed on the main drying curve 'w_hdelta', # [-] Moisture content (volumetric) computed on the main wetting curve for hdelta 'd_hdelta', # [-] Moisture content (volumetric) computed on the main drying curve for hdelta - 'ustar', # [m/s] Shear velocity by wind - 'ustars', # [m/s] Component of shear velocity in x-direction by wind - 'ustarn', # [m/s] Component of shear velocity in y-direction by wind - 'ustar0', # [m/s] Initial shear velocity (without perturbation) - 'zsep', # [m] Z level of polynomial that defines the separation bubble - 'hsep', # [m] Height of separation bubble = difference between z-level of zsep and of the bed level zb - 'theta_dyn', # [degrees] spatially varying dynamic angle of repose for avalanching + + # --- Wave and water level variables -------------------------------------------------------------------------- + 'R', # [m] wave runup + 'eta', # [m] wave setup + 'sigma_s', # [m] swash + 'TWL', # [m] Total Water Level above reference (SWL + Run-up) + 'SWL', # [m] Still Water Level above reference + 'DSWL', # [m] Dynamic Still water level above reference (SWL + Set-up) + + # --- Vegetation variables (vegetation.py) -------------------------------------------------------------------- # 'rhoveg', # [-] Vegetation cover (now defined via grass module; overlapping name) # 'hveg', # [m] height of vegetatiion (now defined via grass module; overlapping name) 'drhoveg', # Change in vegetation cover @@ -117,69 +142,88 @@ 'lateral', # [bool] Newly vegetated due to lateral propagation 'vegetated', # [bool] Vegetated, determines if vegetation growth or burial is allowed 'vegfac', # Vegetation factor to modify shear stress by according to Raupach 1993 + + # --- Vegetation variables (new: grass.py) -------------------------------------------------------------------- 'Rveg', # [-] NEW Vegetation shear reduction factor including Okin effect 'R0veg', # [-] NEW Local vegetation shear reduction factor (replaces vegfac) - 'fence_height', # Fence height - 'R', # [m] wave runup - 'eta', # [m] wave setup - 'sigma_s', # [m] swash - 'TWL', # [m] Total Water Level above reference (SWL + Run-up) - 'SWL', # [m] Still Water Level above reference - 'DSWL', # [m] Dynamic Still water level above reference (SWL + Set-up) + + # --- Bed interaction variables (NEW) ------------------------------------------------------------------------- + 'zeta', # [-] Bed interaction parameter for in advection equation + 'kzeta', # [-] Shape k-parameter in Weibull function for zeta + 'Lzeta', # [m] Vertical lift of transport layer due to vegetation and flow separation + + # --- Other --------------------------------------------------------------------------------------------------- + 'fence_height', # [m] Fence height + 'theta_dyn', # [degrees] spatially varying dynamic angle of repose for avalanching 'Rti', # [-] Factor taking into account sheltering by roughness elements + 'S', # [-] Level of saturation of sediment transport - 'zeta', # [-] [NEW] Bed interaction parameter for in advection equation - 'kzeta', # [-] [NEW] Shape k-parameter in Weibull function for zeta - 'lamzeta', # [m] [NEW] Scale lambda-parameter in Weibull function for zeta ), + + # --- Sediment transport variables (multiple fractions) ----------------------------------------------------------- ('ny','nx','nfractions') : ( + + # --- Sediment transport variables ---------------------------------------------------------------------------- 'Cu', # [kg/m^2] Equilibrium sediment concentration integrated over saltation height 'Cuf', # [kg/m^2] Equilibrium sediment concentration integrated over saltation height, assuming the fluid shear velocity threshold 'Cu0', # [kg/m^2] Flat bad equilibrium sediment concentration integrated over saltation height 'CuAir', # [kg/m^2] Equilibrium sediment concentration for airborne sediment - 'CuBed', # [kg/m^2] [NEW] Equilibrium sediment concentration for bed sediment + 'CuBed', # [kg/m^2] Equilibrium sediment concentration for bed sediment 'Ct', # [kg/m^2] Instantaneous sediment concentration integrated over saltation height + + # --- Sediment flux and pickup variables ---------------------------------------------------------------------- 'q', # [kg/m/s] Instantaneous sediment flux 'qs', # [kg/m/s] Instantaneous sediment flux in x-direction 'qn', # [kg/m/s] Instantaneous sediment flux in y-direction 'pickup', # [kg/m^2] Sediment entrainment + 'masstop', # [kg/m^2] Sediment mass in bed toplayer, only stored for output + + # --- Sediment bed composition variables ---------------------------------------------------------------------- 'w', # [-] Weights of sediment fractions 'w_init', # [-] Initial guess for ``w'' 'w_air', # [-] Weights of sediment fractions based on grain size distribution in the air 'w_bed', # [-] Weights of sediment fractions based on grain size distribution in the bed + + # --- Velocity threshold (uth) and sediment velocity (u) variables -------------------------------------------- 'uth', # [m/s] Shear velocity threshold 'uthf', # [m/s] Fluid shear velocity threshold 'uth0', # [m/s] Shear velocity threshold based on grainsize only (aerodynamic entrainment) 'u', # [m/s] Mean horizontal saltation velocity in saturated state + 'u0', # [m/s] Mean horizontal saltation velocity in saturated state over flat bed 'us', # [m/s] Component of the saltation velocity in x-direction 'un', # [m/s] Component of the saltation velocity in y-direction 'usST', # [NEW] [m/s] Component of the saltation velocity in x-direction for SedTRAILS 'unST', # [NEW] [m/s] Component of the saltation velocity in y-direction for SedTRAILS - 'u0', - 'masstop', # [kg/m^2] Sediment mass in bed toplayer, only stored for output ), + + # --- Layer variables for bed composition ------------------------------------------------------------------------- ('ny','nx','nlayers') : ( 'thlyr', # [m] Bed composition layer thickness - 'salt', # [-] Salt content + 'salt', # [-] REMOVE? Salt content ), + + # --- Sediment bed mass variable ---------------------------------------------------------------------------------- ('ny','nx','nlayers','nfractions') : ( 'mass', # [kg/m^2] Sediment mass in bed ), - # Vegetation variables for grass model (original grid) + # --- Vegetation variables for grass model (multiple species, main computational grid) ---------------------------- ('ny','nx','nspecies') : ( 'Nt', # [1/m^2] Density of grass tillers 'hveg', # [m] Average height of the grass tillers 'hveg_eff', # [m] Effective vegetation height 'lamveg', # [-] Frontal area density 'rhoveg', # [-] Cover area density + 'fbend', # [-] Bending factor ), - # Vegetation variables for grass model (refined vegetation grid) + # --- Vegetation variables for grass model (refined vegetation grid) ---------------------------------------------- ('ny_vsub','nx_vsub') : ( 'x_vsub', # [m] x-coordinates of vegetation subgrid 'y_vsub', # [m] y-coordinates of vegetation subgrid ), + + # --- Vegetation variables for grass model (refined vegetation grid, multiple species) ---------------------------- ('ny_vsub','nx_vsub','nspecies') : ( 'Nt_vsub', # [1/m^2] Density of tillers 'hveg_vsub', # [m] Height of individual tillers @@ -417,9 +461,10 @@ # --- Bed interaction in advection equation (new process) --------------------------------------------------------- 'zeta_base' : 1.0, # [-] Base value for bed interaction parameter in advection equation 'p_zeta_moist' : 0.8, # [-] Exponent parameter for computing zeta from moisture - 'a_weibull' : 2., # [-] Shape parameter k of Weibull function for bed interaction parameter zeta - 'b_weibull' : 2., # [m] Scale parameter lambda of Weibull function for bed interaction parameter zeta + 'a_weibull' : 1.0, # [-] Shape parameter k of Weibull function for bed interaction parameter zeta + 'b_weibull' : 0.5, # [m] Scale parameter lambda of Weibull function for bed interaction parameter zeta 'bounce' : [0.5], # [-] Fraction of sediment skimming over vegetation canopy (species-specific) + 'alpha_lift' : 1.0, # [-] Vegetation-induced upward lift (0-1) of transport-layer centroid # --- Grass vegetation model (new vegetation framework) ----------------------------------------------------------- 'method_vegetation' : 'duran', # ['duran' | 'grass'] Vegetation formulation