[3777] | 1 | #!/usr/bin/env python3 |
---|
| 2 | ####################################################################################### |
---|
| 3 | ### Python script to output the stratification data from the "restartpem#.nc" files ### |
---|
| 4 | ####################################################################################### |
---|
[3298] | 5 | |
---|
[3771] | 6 | import os |
---|
| 7 | import sys |
---|
| 8 | import numpy as np |
---|
[3298] | 9 | import netCDF4 as nc |
---|
| 10 | import matplotlib.pyplot as plt |
---|
| 11 | |
---|
| 12 | |
---|
[3771] | 13 | def get_int_input(prompt: str, min_val: int, max_val: int) -> int: |
---|
| 14 | """ |
---|
| 15 | Prompt the user for an integer between min_val and max_val (inclusive). |
---|
| 16 | If min_val == max_val, return min_val without prompting. |
---|
| 17 | """ |
---|
| 18 | if min_val == max_val: |
---|
| 19 | print(f"{prompt} (only possible value: {min_val})") |
---|
| 20 | return min_val |
---|
[3458] | 21 | |
---|
[3771] | 22 | while True: |
---|
| 23 | try: |
---|
| 24 | value = int(input(f"{prompt} (integer between {min_val} and {max_val}): ")) |
---|
| 25 | if min_val <= value <= max_val: |
---|
| 26 | return value |
---|
| 27 | print(f"Invalid value! Please enter a number between {min_val} and {max_val}.") |
---|
| 28 | except ValueError: |
---|
| 29 | print("Invalid input. Please enter a valid integer.") |
---|
[3298] | 30 | |
---|
| 31 | |
---|
[3771] | 32 | def get_yes_no_input(prompt: str) -> bool: |
---|
| 33 | """ |
---|
| 34 | Prompt the user with a yes/no question. Returns True for yes, False for no. |
---|
| 35 | """ |
---|
| 36 | while True: |
---|
| 37 | choice = input(f"{prompt} (y/n): ").strip().lower() |
---|
| 38 | if choice in ['y', 'yes']: |
---|
| 39 | return True |
---|
| 40 | elif choice in ['n', 'no']: |
---|
| 41 | return False |
---|
| 42 | else: |
---|
| 43 | print("Please respond with y or n.") |
---|
[3298] | 44 | |
---|
| 45 | |
---|
[3771] | 46 | def load_slope_variables(nc_dataset: nc.Dataset, slope_index: int) -> dict: |
---|
| 47 | """ |
---|
| 48 | Load all relevant stratification variables for a given slope index (0-based). |
---|
| 49 | Returns a dict of NumPy arrays. |
---|
| 50 | """ |
---|
| 51 | idx_str = str(slope_index + 1).zfill(2) |
---|
| 52 | vars_base = { |
---|
| 53 | 'top_elev': f"stratif_slope{idx_str}_top_elevation", |
---|
| 54 | 'h_co2': f"stratif_slope{idx_str}_h_co2ice", |
---|
| 55 | 'h_h2o': f"stratif_slope{idx_str}_h_h2oice", |
---|
| 56 | 'h_dust': f"stratif_slope{idx_str}_h_dust", |
---|
[3790] | 57 | 'h_pore': f"stratif_slope{idx_str}_h_pore", |
---|
| 58 | 'poreice_volfrac': f"stratif_slope{idx_str}_poreice_volfrac", |
---|
[3771] | 59 | } |
---|
[3298] | 60 | |
---|
[3771] | 61 | data = {} |
---|
| 62 | for key, var_name in vars_base.items(): |
---|
| 63 | if var_name not in nc_dataset.variables: |
---|
| 64 | sys.exit(f"Error: Variable '{var_name}' not found in the NetCDF file.") |
---|
| 65 | data[key] = nc_dataset.variables[var_name][:] |
---|
| 66 | return data |
---|
[3458] | 67 | |
---|
[3298] | 68 | |
---|
[3771] | 69 | def calculate_contents(data: dict, grid_index: int, exclude_subsurface: bool) -> (np.ndarray, np.ndarray, np.ndarray): |
---|
| 70 | """ |
---|
| 71 | For a given slope's stratification data and a specified grid index (0-based), |
---|
| 72 | compute the height array, layer thicknesses, and layer-wise volume fractions. |
---|
| 73 | If exclude_subsurface is True, omit layers whose top elevation <= 0. |
---|
[3298] | 74 | |
---|
[3771] | 75 | Returns: |
---|
| 76 | - height: 1D array of elevations (length = number_of_layers + 1) |
---|
| 77 | - contents: 2D array of shape (5, number_of_layers + 1), each row for a component |
---|
| 78 | - thicknesses: 1D array of layer thicknesses (length = number_of_layers) |
---|
| 79 | """ |
---|
| 80 | top = data['top_elev'] |
---|
| 81 | h_co2 = data['h_co2'] |
---|
| 82 | h_h2o = data['h_h2o'] |
---|
| 83 | h_dust = data['h_dust'] |
---|
[3790] | 84 | h_pore = data['h_pore'] |
---|
| 85 | poreice_volfrac = data['poreice_volfrac'] |
---|
[3771] | 86 | |
---|
| 87 | layers = top.shape[1] |
---|
| 88 | # Compute raw height and thickness arrays |
---|
| 89 | raw_height = np.zeros(layers + 1) |
---|
| 90 | raw_thickness = np.zeros(layers) |
---|
| 91 | |
---|
| 92 | total_thickness0 = ( |
---|
| 93 | h_co2[0, 0, grid_index] |
---|
| 94 | + h_h2o[0, 0, grid_index] |
---|
| 95 | + h_dust[0, 0, grid_index] |
---|
[3790] | 96 | + h_pore[0, 0, grid_index] |
---|
[3771] | 97 | ) |
---|
| 98 | raw_height[0] = top[0, 0, grid_index] - total_thickness0 |
---|
| 99 | raw_height[1:] = top[0, :, grid_index] |
---|
| 100 | |
---|
| 101 | for i in range(layers): |
---|
| 102 | raw_thickness[i] = ( |
---|
| 103 | h_co2[0, i, grid_index] |
---|
| 104 | + h_h2o[0, i, grid_index] |
---|
| 105 | + h_dust[0, i, grid_index] |
---|
[3790] | 106 | + h_pore[0, i, grid_index] |
---|
[3771] | 107 | ) |
---|
| 108 | |
---|
| 109 | include_mask = np.ones(layers, dtype=bool) |
---|
| 110 | if exclude_subsurface: |
---|
| 111 | include_mask = raw_height[1:] > 0 |
---|
| 112 | if exclude_subsurface and not include_mask.any() and raw_height[0] <= 0: |
---|
| 113 | sys.exit("Error: No layers remain above the surface (elevation > 0).") |
---|
| 114 | |
---|
| 115 | filt_layers = np.where(include_mask)[0] |
---|
| 116 | num_filt = len(filt_layers) |
---|
| 117 | height = np.zeros(num_filt + 1) |
---|
| 118 | thicknesses = np.zeros(num_filt) |
---|
| 119 | |
---|
| 120 | if exclude_subsurface and raw_height[0] <= 0: |
---|
| 121 | height[0] = raw_height[filt_layers[0] + 1] - raw_thickness[filt_layers[0]] |
---|
| 122 | else: |
---|
| 123 | height[0] = raw_height[0] |
---|
| 124 | |
---|
| 125 | for idx, layer_idx in enumerate(filt_layers): |
---|
| 126 | height[idx + 1] = raw_height[layer_idx + 1] |
---|
| 127 | thicknesses[idx] = raw_thickness[layer_idx] |
---|
| 128 | |
---|
| 129 | contents = np.zeros((5, num_filt + 1)) |
---|
| 130 | for idx, layer_idx in enumerate(filt_layers): |
---|
| 131 | thickness = raw_thickness[layer_idx] |
---|
| 132 | if thickness <= 0: |
---|
| 133 | continue |
---|
| 134 | co2 = h_co2[0, layer_idx, grid_index] / thickness |
---|
| 135 | h2o = h_h2o[0, layer_idx, grid_index] / thickness |
---|
| 136 | dust = h_dust[0, layer_idx, grid_index] / thickness |
---|
[3790] | 137 | air = h_pore[0, layer_idx, grid_index] * (1.0 - poreice_volfrac[0, layer_idx, grid_index]) / thickness |
---|
| 138 | poreice = h_pore[0, layer_idx, grid_index] * poreice_volfrac[0, layer_idx, grid_index] / thickness |
---|
| 139 | contents[:, idx + 1] = [co2, h2o, dust, air, poreice] |
---|
[3771] | 140 | |
---|
| 141 | if num_filt > 0: |
---|
| 142 | contents[:, 0] = contents[:, 1] |
---|
| 143 | |
---|
| 144 | return height, contents, thicknesses |
---|
| 145 | |
---|
| 146 | |
---|
| 147 | def plot_profiles(height: np.ndarray, contents: np.ndarray, thicknesses: np.ndarray, labels: tuple[str, ...]) -> None: |
---|
| 148 | """ |
---|
| 149 | Create and save plots: |
---|
| 150 | 1. Simple step profiles (each component in its own subplot) |
---|
| 151 | 2. Overlaid step profiles (all components on one plot) |
---|
| 152 | 3. Stacked fill-between profile with relative fractions |
---|
| 153 | """ |
---|
| 154 | n_components = contents.shape[0] |
---|
| 155 | colors = ['r', 'b', 'y', 'violet', 'c'] |
---|
| 156 | |
---|
| 157 | # 1. Simple subplots |
---|
| 158 | fig, axes = plt.subplots(1, n_components, sharey=True, constrained_layout=True) |
---|
[3819] | 159 | fig.suptitle('Simple Content Profiles for Stratification', fontweight='bold') |
---|
[3771] | 160 | |
---|
| 161 | for idx, ax in enumerate(axes): |
---|
| 162 | ax.step(contents[idx, :], height, where='post', color=colors[idx]) |
---|
[3792] | 163 | ax.set_xlim(0, 1) |
---|
[3771] | 164 | ax.set_xlabel('Volume Fraction [m^3/m^3]') |
---|
| 165 | ax.set_title(labels[idx]) |
---|
| 166 | if idx == 0: |
---|
| 167 | ax.set_ylabel('Elevation [m]') |
---|
| 168 | # Add major and minor grids on y-axis |
---|
| 169 | ax.grid(which='major', axis='y', linestyle='--', linewidth=0.5, color='0.8') |
---|
| 170 | ax.minorticks_on() |
---|
| 171 | ax.grid(which='minor', axis='y', linestyle=':', linewidth=0.3, color='0.9') |
---|
| 172 | plt.savefig('layering_simple_profiles.png') |
---|
| 173 | |
---|
| 174 | # 2. Overlaid step profiles |
---|
| 175 | plt.figure() |
---|
| 176 | for idx, label in enumerate(labels): |
---|
| 177 | plt.step(contents[idx, :], height, where='post', color=colors[idx], label=label) |
---|
| 178 | plt.grid(axis='x', color='0.95') |
---|
| 179 | plt.grid(axis='y', color='0.95') |
---|
| 180 | plt.minorticks_on() |
---|
| 181 | plt.grid(which='minor', axis='y', linestyle=':', linewidth=0.3, color='0.9') |
---|
| 182 | plt.xlim(0, 1) |
---|
| 183 | plt.xlabel('Volume Fraction [m^3/m^3]') |
---|
| 184 | plt.ylabel('Elevation [m]') |
---|
[3819] | 185 | plt.title('Content Profiles for Stratification', fontweight='bold') |
---|
[3771] | 186 | plt.legend() |
---|
| 187 | plt.savefig('layering_overlaid_profiles.png') |
---|
| 188 | |
---|
| 189 | # 3. Stacked fill-between profile with relative fractions |
---|
| 190 | plt.figure() |
---|
| 191 | cumulative = np.zeros_like(contents[0, :]) |
---|
| 192 | for idx, label in enumerate(labels): |
---|
| 193 | plt.fill_betweenx( |
---|
| 194 | height, |
---|
| 195 | cumulative, |
---|
| 196 | cumulative + contents[idx, :], |
---|
| 197 | step='pre', |
---|
| 198 | label=label, |
---|
| 199 | color=colors[idx] |
---|
| 200 | ) |
---|
| 201 | cumulative += contents[idx, :] |
---|
| 202 | plt.vlines(x=0., ymin=height[0], ymax=height[-1], color='k', linestyle='-') |
---|
| 203 | plt.vlines(x=1., ymin=height[0], ymax=height[-1], color='k', linestyle='-') |
---|
| 204 | for h in height: |
---|
| 205 | plt.hlines(y=h, xmin=0.0, xmax=1.0, color='k', linestyle='--', linewidth=0.5) |
---|
| 206 | plt.xlabel('Volume Fraction [m^3/m^3]') |
---|
| 207 | plt.ylabel('Elevation [m]') |
---|
[3819] | 208 | plt.title('Stacked Content Profiles for Stratification', fontweight='bold') |
---|
[3771] | 209 | plt.legend(loc='center left', bbox_to_anchor=(1, 0.5)) |
---|
| 210 | plt.tight_layout() |
---|
| 211 | plt.savefig('layering_stacked_profiles.png') |
---|
| 212 | |
---|
| 213 | |
---|
| 214 | def plot_stratum_layer(contents: np.ndarray, istr_index: int, labels: tuple[str, ...]) -> None: |
---|
| 215 | """ |
---|
| 216 | Plot detailed composition of the specified stratum index (0-based) as a bar chart. |
---|
| 217 | """ |
---|
| 218 | layer_fractions = contents[:, istr_index + 1] |
---|
| 219 | plt.figure() |
---|
| 220 | x_positions = np.arange(len(labels)) |
---|
| 221 | plt.bar(x_positions, layer_fractions, color=['r', 'b', 'y', 'violet', 'c']) |
---|
| 222 | plt.xticks(x_positions, labels, rotation=45, ha='right') |
---|
| 223 | plt.ylim(0, 1) |
---|
| 224 | plt.ylabel('Volume Fraction [m^3/m^3]') |
---|
[3819] | 225 | plt.title(f'Composition of Stratum {istr_index + 1}', fontweight='bold') |
---|
[3771] | 226 | # Add major and minor grids on y-axis for the histogram |
---|
| 227 | plt.grid(which='major', axis='y', linestyle='--', linewidth=0.5, color='0.8') |
---|
| 228 | plt.minorticks_on() |
---|
| 229 | plt.grid(which='minor', axis='y', linestyle=':', linewidth=0.3, color='0.9') |
---|
| 230 | plt.tight_layout() |
---|
| 231 | plt.savefig(f'stratum_{istr_index+1}_composition.png') |
---|
| 232 | |
---|
| 233 | |
---|
| 234 | def main(): |
---|
| 235 | if len(sys.argv) > 1: |
---|
| 236 | filename = sys.argv[1] |
---|
| 237 | else: |
---|
| 238 | filename = input("Enter the NetCDF file name: ").strip() |
---|
| 239 | |
---|
| 240 | if not os.path.isfile(filename): |
---|
| 241 | sys.exit(f"Error: File '{filename}' does not exist.") |
---|
| 242 | |
---|
| 243 | with nc.Dataset(filename, 'r') as dataset: |
---|
| 244 | required_dims = ['Time', 'physical_points', 'nslope', 'nb_str_max'] |
---|
| 245 | for dim in required_dims: |
---|
| 246 | if dim not in dataset.dimensions: |
---|
| 247 | sys.exit(f"Error: Missing dimension '{dim}' in file.") |
---|
| 248 | |
---|
| 249 | ngrid = len(dataset.dimensions['physical_points']) |
---|
| 250 | nslope = len(dataset.dimensions['nslope']) |
---|
| 251 | nb_str_max = len(dataset.dimensions['nb_str_max']) |
---|
| 252 | |
---|
| 253 | print(f"File '{filename}' opened successfully.") |
---|
| 254 | print(f"Number of grid points (physical_points): {ngrid}") |
---|
| 255 | print(f"Number of slopes: {nslope}") |
---|
| 256 | print(f"Maximum number of strata per slope: {nb_str_max}") |
---|
| 257 | |
---|
| 258 | igrid_input = get_int_input( |
---|
| 259 | "Enter grid point number", 1, ngrid |
---|
| 260 | ) - 1 |
---|
| 261 | islope_input = get_int_input( |
---|
| 262 | "Enter slope number", 1, nslope |
---|
| 263 | ) - 1 |
---|
| 264 | |
---|
| 265 | show_subsurface = get_yes_no_input("Show subsurface layers?") |
---|
| 266 | exclude_sub = not show_subsurface |
---|
| 267 | |
---|
| 268 | # Load data for the chosen slope to determine number of surface strata |
---|
| 269 | slope_data = load_slope_variables(dataset, islope_input) |
---|
| 270 | |
---|
| 271 | # Compute raw heights to count strata above surface |
---|
| 272 | top = slope_data['top_elev'] |
---|
| 273 | layers = top.shape[1] |
---|
| 274 | raw_height = np.zeros(layers + 1) |
---|
| 275 | # Compute initial subsurface bottom |
---|
| 276 | h_co2 = slope_data['h_co2'] |
---|
| 277 | h_h2o = slope_data['h_h2o'] |
---|
| 278 | h_dust = slope_data['h_dust'] |
---|
[3790] | 279 | h_pore = slope_data['h_pore'] |
---|
[3771] | 280 | total_thickness0 = ( |
---|
| 281 | h_co2[0, 0, igrid_input] |
---|
| 282 | + h_h2o[0, 0, igrid_input] |
---|
| 283 | + h_dust[0, 0, igrid_input] |
---|
[3790] | 284 | + h_pore[0, 0, igrid_input] |
---|
[3771] | 285 | ) |
---|
| 286 | raw_height[0] = top[0, 0, igrid_input] - total_thickness0 |
---|
| 287 | raw_height[1:] = top[0, :, igrid_input] |
---|
| 288 | |
---|
| 289 | include_mask = np.ones(layers, dtype=bool) |
---|
| 290 | if exclude_sub: |
---|
| 291 | include_mask = raw_height[1:] > 0 |
---|
| 292 | # Count number of strata above surface |
---|
| 293 | filt_layers = np.where(include_mask)[0] |
---|
| 294 | nb_str_surf_max = len(filt_layers) |
---|
| 295 | if not show_subsurface: |
---|
| 296 | if nb_str_surf_max == 0: |
---|
| 297 | print("No stratum layers remain after filtering. Cannot proceed.") |
---|
| 298 | return |
---|
| 299 | |
---|
| 300 | # Prompt for stratum index based on surface strata count |
---|
| 301 | istr_input = get_int_input( |
---|
| 302 | "Enter stratum index for detailed plot", 1, nb_str_surf_max if exclude_sub else nb_str_max |
---|
| 303 | ) - 1 |
---|
| 304 | |
---|
| 305 | height_arr, contents_arr, thicknesses_arr = calculate_contents( |
---|
| 306 | slope_data, igrid_input, exclude_sub |
---|
| 307 | ) |
---|
| 308 | |
---|
| 309 | component_labels = ("CO2 Ice", "H2O Ice", "Dust", "Air", "Pore ice") |
---|
| 310 | |
---|
| 311 | plot_profiles(height_arr, contents_arr, thicknesses_arr, component_labels) |
---|
| 312 | plot_stratum_layer(contents_arr, istr_input, component_labels) |
---|
| 313 | |
---|
| 314 | # Show all figures at once |
---|
| 315 | plt.show() |
---|
| 316 | |
---|
| 317 | |
---|
| 318 | if __name__ == '__main__': |
---|
| 319 | main() |
---|
[3777] | 320 | |
---|