From 4d560473f47a2d7a5231bd5b92810bfded3f9701 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 31 Aug 2023 15:13:06 +0100 Subject: [PATCH 001/114] data step: refactor Rewrite as a library (set of functions) and a CLI. --- pyproject.toml | 3 + src/gz21_ocean_momentum/cli/data.py | 55 +++++ src/gz21_ocean_momentum/cmip26.py | 154 ------------ src/gz21_ocean_momentum/common/assorted.py | 8 + .../common/bounding_box.py | 32 +++ src/gz21_ocean_momentum/common/cli.py | 34 +++ src/gz21_ocean_momentum/data/setup.py | 18 -- src/gz21_ocean_momentum/data/utils.py | 115 --------- .../{data/coarse.py => step/data/coarsen.py} | 226 +++++++++--------- src/gz21_ocean_momentum/step/data/lib.py | 95 ++++++++ 10 files changed, 336 insertions(+), 404 deletions(-) create mode 100644 src/gz21_ocean_momentum/cli/data.py delete mode 100755 src/gz21_ocean_momentum/cmip26.py create mode 100644 src/gz21_ocean_momentum/common/assorted.py create mode 100644 src/gz21_ocean_momentum/common/bounding_box.py create mode 100644 src/gz21_ocean_momentum/common/cli.py delete mode 100644 src/gz21_ocean_momentum/data/setup.py delete mode 100755 src/gz21_ocean_momentum/data/utils.py rename src/gz21_ocean_momentum/{data/coarse.py => step/data/coarsen.py} (83%) mode change 100755 => 100644 create mode 100644 src/gz21_ocean_momentum/step/data/lib.py diff --git a/pyproject.toml b/pyproject.toml index 04d80550..bea6c283 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ dependencies = [ "torch>=1.13.1", "progressbar2>=4.2.0" # inference/utils (but imported by trainScript) + + # new CLI + "configargparse>=1.7", ] authors = [ diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py new file mode 100644 index 00000000..e897a9b6 --- /dev/null +++ b/src/gz21_ocean_momentum/cli/data.py @@ -0,0 +1,55 @@ +import gz21_ocean_momentum.step.data.lib as lib +import gz21_ocean_momentum.common.cli as cli +from gz21_ocean_momentum.common.bounding_box import BoundingBox + +import configargparse + +DESCRIPTION = "GZ21 data step: download CM2.6 dataset, apply coarse graining\ +and generate forcings. Saves result to disk in zarr format." + +p = configargparse.ArgParser() +p.add("--config-file", is_config_file=True, help="config file path") +p.add("--out-dir", type=str, required=True, help="folder to save generated forcings to (in zarr format)" ) +p.add("--lat-min", type=float, required=True, help="bounding box minimum latitude") +p.add("--lat-max", type=float, required=True, help="bounding box maximum latitude") +p.add("--long-min", type=float, required=True, help="bounding box minimum longitude") +p.add("--long-max", type=float, required=True, help="bounding box maximum longitude") +p.add("--cyclize", action="store_true", help="global data; make cyclic along longitude") +p.add("--ntimes", type=int, help="number of time points to process, starting from the first. Note that the CM2.6 dataset is daily, so this would be number of days.") +p.add("--co2-increase", action="store_true", help="use 1%% annual CO2 increase CM2.6 dataset. By default, uses control (no increase)") +p.add("--factor", type=int, required=True, help="resolution degradation factor") + +options = p.parse_args() + +if not cli.path_is_nonexist_or_empty_dir(options.out_dir): + cli.fail(1, "--out-dir output directory is invalid", + "if the directory exists, ensure it is empty") + +# store bounding box in a struct-like +bounding_box = BoundingBox( + options.lat_min, options.lat_max, + options.long_min, options.long_max) + +# TODO: lock version, rather than using master? +CATALOG_URL = "https://raw.githubusercontent.com/\ +pangeo-data/pangeo-datastore/\ +master/\ +intake-catalogs/master.yaml" + +surface_fields, grid = lib.download_cm2_6(CATALOG_URL, options.co2_increase) + +surface_fields = surface_fields.sel( + xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), + yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) +grid = grid.sel( + xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), + yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) + +if options.ntimes is not None: + surface_fields = surface_fields.isel(time=slice(options.ntimes)) + +forcings = lib.preprocess_and_compute_forcings( + grid, surface_fields, options.cyclize, + options.factor, "usurf", "vsurf") + +forcings.to_zarr(options.out_dir) diff --git a/src/gz21_ocean_momentum/cmip26.py b/src/gz21_ocean_momentum/cmip26.py deleted file mode 100755 index 4c3d3421..00000000 --- a/src/gz21_ocean_momentum/cmip26.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Compute subgrid forcing on requested dataset. - -Script to compute the subgrid forcing for a given region, using -data from cmip2.6 on one of the pangeo data catalogs. -Command line parameters include region specification. -Reads data from the CM2.6 and apply coarse graining. -Stores the resulting dataset into an MLFLOW -experiment within a specific run. -""" -import os -import logging -import tempfile -import argparse - -import xarray as xr -from dask.diagnostics import ProgressBar -import mlflow - -from data.utils import cyclize_dataset -from data.coarse import eddy_forcing -from data.pangeo_catalog import get_patch -import logging -import tempfile - -# obtain logging config from LOGGING_LEVEL environment variable -# e.g. `LOGGING_LEVEL=20 python cmip26.py ...` -# common numeric values: https://docs.python.org/3/library/logging.html#levels -logging_level = os.environ.get("LOGGING_LEVEL") -if logging_level is not None: - logging.basicConfig(level=int(logging_level)) -logger = logging.getLogger(__name__) - -# Script parameters -CATALOG_URL = ( - "https://raw.githubusercontent.com/pangeo-data/pangeo-datastore/" - "master/intake-catalogs/master.yaml" -) - -DESCRIPTION = "Read data from the CM2.6 and \ - apply coarse graining. Stores the resulting dataset into an MLFLOW \ - experiment within a specific run." - -data_location = tempfile.mkdtemp() -logger.info(f"working dir: {data_location}") - -# Parse the command-line parameters -parser = argparse.ArgumentParser(description=DESCRIPTION) -parser.add_argument( - "bounds", - type=float, - nargs=4, - help="min lat, max_lat,\ - min_long, max_long", -) -parser.add_argument( - "--global_", - type=int, - help="True if global data. In this\ - case the data is made cyclic along longitude", - default=False, -) -parser.add_argument( - "--ntimes", - type=int, - default=10000, - help="number of days,\ - starting from first day.", -) -parser.add_argument( - "--CO2", - type=int, - default=0, - choices=[0, 1], - help="CO2\ - level, O (control) or 1 (1 percent CO2 increase)", -) -parser.add_argument( - "--factor", type=int, default=0, help="Factor of degrading. Should be integer > 1." -) -parser.add_argument( - "--chunk_size", type=str, default="50", help="Chunk size along the time dimension" -) -params = parser.parse_args() - - -# Retrieve the patch of data specified in the command-line args -patch_data, grid_data = get_patch( - CATALOG_URL, params.ntimes, params.bounds, params.CO2, "usurf", "vsurf" -) - -logger.debug(patch_data) -logger.debug(grid_data) - -# If global data, we make the dataset cyclic along longitude -if params.global_ == 1: - logger.info("Cyclic data... Making the dataset cyclic along longitude...") - patch_data = cyclize_dataset(patch_data, "xu_ocean", params.factor) - grid_data = cyclize_dataset(grid_data, "xu_ocean", params.factor) - # Rechunk along the cyclized dimension - patch_data = patch_data.chunk({"xu_ocean": -1}) - grid_data = grid_data.chunk({"xu_ocean": -1}) - -logger.debug("Getting grid data locally") -# grid data is saved locally, no need for dask -grid_data = grid_data.compute() - -logger.debug("Mapping blocks") -# Calculate eddy-forcing dataset for that particular patch -debug_mode = os.environ.get("DEBUG_MODE") -if params.factor != 0 and not debug_mode: - scale_m = params.factor - forcing = eddy_forcing( - patch_data, grid_data, scale=scale_m, method="mean", scale_mode="factor" - ) -elif not debug_mode: - scale_m = params.scale * 1e3 - forcing = eddy_forcing(patch_data, grid_data, scale=scale_m, method="mean") -else: - logger.info("!!!Debug mode!!!") - forcing = patch_data - -# Progress bar -ProgressBar().register() - -# Specify input vs output type for each variable of the dataset. Might -# be used later on for training or inference. -if not debug_mode: - forcing["S_x"].attrs["type"] = "output" - forcing["S_y"].attrs["type"] = "output" - forcing["usurf"].attrs["type"] = "input" - forcing["vsurf"].attrs["type"] = "input" - -# Crop according to bounds -bounds = params.bounds -forcing = forcing.sel( - xu_ocean=slice(bounds[2], bounds[3]), yu_ocean=slice(bounds[0], bounds[1]) -) - -for var in forcing: - forcing[var].encoding = {} -forcing = forcing.chunk(dict(time=1)) - -logger.info("Preparing forcing data") -logger.debug(forcing) -# export data -forcing.to_zarr(os.path.join(data_location, "forcing"), mode="w") - -# Log as an artifact the forcing data -logger.info("Logging processed dataset as an artifact...") -mlflow.log_artifact(os.path.join(data_location, "forcing")) -logger.info("Completed...") diff --git a/src/gz21_ocean_momentum/common/assorted.py b/src/gz21_ocean_momentum/common/assorted.py new file mode 100644 index 00000000..80e1a72c --- /dev/null +++ b/src/gz21_ocean_momentum/common/assorted.py @@ -0,0 +1,8 @@ +def list_is_strictly_increasing(xs: list[float]) -> bool: + """ + Is this list monotonically increasing? Does not permit repeated elements. + + Asserts that a list is in the correct format to be consumed by the + `milestones` parameter in `torch.optim.MultiStepLR(optimizer: list, ...)`. + """ + return all(xl list[BoundingBox]: + """Load a YAML file of bounding boxes. + + The YAML value must be a list where each element contains `float` fields + `lat-min`, `lat-max`, `long-min` and `long-max`. + """ + with open(path, "r") as f: + data = yaml.safe_load(f) + bboxes = [] + for el in data: + bboxes.append(BoundingBox( + el["lat-min"], el["lat-max"], + el["long-min"], el["long-max"])) + return bboxes diff --git a/src/gz21_ocean_momentum/common/cli.py b/src/gz21_ocean_momentum/common/cli.py new file mode 100644 index 00000000..b16f0efa --- /dev/null +++ b/src/gz21_ocean_momentum/common/cli.py @@ -0,0 +1,34 @@ +import os +import sys + +from typing import Optional + +def path_is_nonexist_or_empty_dir(path) -> bool: + """Is the given path either nonexistent or an empty directory?""" + if os.path.exists(path): + # path exists + if os.path.isdir(path): + # path is directory: check contents + with os.scandir(path) as it: + if any(it): + # path is non-empty directory: fail + return False + else: + # path is empty directory: all good + return True + else: + # path is non-directory: fail + return False + else: + # path does not exist: all good + return True + +def fail(err_code: int, msg: str, hint: Optional[str] = None): + """Exit the program with the given message and error code. + + Also prints a hint (extra message) afterwards if provided. + """ + print(f"ERROR: {msg}") + if hint is not None: + print(f"hint: {hint}") + sys.exit(err_code) diff --git a/src/gz21_ocean_momentum/data/setup.py b/src/gz21_ocean_momentum/data/setup.py deleted file mode 100644 index 827ad982..00000000 --- a/src/gz21_ocean_momentum/data/setup.py +++ /dev/null @@ -1,18 +0,0 @@ -# TODO Don't think there should be a setup.py here... Remove/refactor? -# -*- coding: utf-8 -*- -""" -Created on Thu Nov 21 22:11:09 2019 - -@author: Arthur -""" -import setuptools -from distutils.core import setup -from Cython.Build import cythonize -import numpy - - -setup( - name="utils", - ext_modules=cythonize("_utils.pyx"), - include_dirs=[numpy.get_include()], -) diff --git a/src/gz21_ocean_momentum/data/utils.py b/src/gz21_ocean_momentum/data/utils.py deleted file mode 100755 index da5917a8..00000000 --- a/src/gz21_ocean_momentum/data/utils.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""Utilities for handling data.""" -import mlflow -import xarray as xr -import yaml - - -def load_data_from_run(run_id): - """ - Load data from a previous run from mlflow files. - - Parameters - ---------- - run_id : str - unique mlflow identifier for run to load - - Returns - ------- - xr_dataset : xr.dataset - xarray dataset populated with data for requested run - """ - mlflow_client = mlflow.tracking.MlflowClient() - data_file = mlflow_client.download_artifacts(run_id, "forcing") - xr_dataset = xr.open_zarr(data_file) - return xr_dataset - - -def load_data_from_runs(run_ids): - """ - Load data from previous runs from mlflow files. - - Parameters - ---------- - run_id : list of str - list of unique mlflow identifiers for runs to load - - Returns - ------- - xr_datasets : list of xr.dataset - list of xarray datasets populated with data for requested runs - """ - xr_datasets = [] - for run_id in run_ids: - xr_datasets.append(load_data_from_run(run_id)) - return xr_datasets - - -def load_training_datasets(ds: xr.Dataset, config_fname: str): - """ - Load training data from a previous run from mlflow files. - - Parameters - ---------- - run_id : str - unique mlflow identifier for run to load - - Returns - ------- - results : list of ??? - description? - """ - results = [] - with open(config_fname, encoding="utf-8") as config_file: - try: - # AB TODO check that safe_load() is OK rather than load() - # TODO 2023-05-12 raehik: `full_load()` used in another changeset. - # safe_load gives errors. - #subdomains = yaml.safe_load(config_file) - subdomains = yaml.full_load(config_file) - except FileNotFoundError as e: - raise type(e)("Configuration file of subdomains not found") - for subdomain in subdomains: - coords = subdomain[1] - lats = slice(coords["lat_min"], coords["lat_max"]) - lons = slice(coords["lon_min"], coords["lon_max"]) - results.append(ds.sel(xu_ocean=lons, yu_ocean=lats)) - return results - - -def cyclize_dataset(ds: xr.Dataset, coord_name: str, nb_points: int): - """ - Generate a cyclic dataset from non-cyclic input. - - Return a cyclic dataset, with nb_points added on each end, along - the coordinate specified by coord_name. - - Parameters - ---------- - ds : xr.Dataset - Dataset to process. - coord_name : str - Name of the coordinate along which the data is made cyclic. - nb_points : int - Number of points added on each end. - - Returns - ------- - New extended dataset. - """ - # TODO make this flexible - cycle_length = 360.0 - left = ds.roll({coord_name: nb_points}, roll_coords=True) - right = ds.roll({coord_name: nb_points}, roll_coords=True) - right = right.isel({coord_name: slice(0, 2 * nb_points)}) - left[coord_name] = xr.concat( - (left[coord_name][:nb_points] - cycle_length, left[coord_name][nb_points:]), - coord_name, - ) - right[coord_name] = xr.concat( - (right[coord_name][:nb_points], right[coord_name][nb_points:] + cycle_length), - coord_name, - ) - new_ds = xr.concat((left, right), coord_name) - return new_ds diff --git a/src/gz21_ocean_momentum/data/coarse.py b/src/gz21_ocean_momentum/step/data/coarsen.py old mode 100755 new mode 100644 similarity index 83% rename from src/gz21_ocean_momentum/data/coarse.py rename to src/gz21_ocean_momentum/step/data/coarsen.py index 6b55c493..8ac1a1c5 --- a/src/gz21_ocean_momentum/data/coarse.py +++ b/src/gz21_ocean_momentum/step/data/coarsen.py @@ -7,6 +7,91 @@ from scipy.ndimage import gaussian_filter import numpy as np +def eddy_forcing( + u_v_dataset: xr.Dataset, + grid_data: xr.Dataset, + scale: int, + nan_or_zero: str = "zero", +) -> xr.Dataset: + """ + Compute the sub-grid forcing terms using mean coarse-graining. + + Parameters + ---------- + u_v_dataset : xarray Dataset + High-resolution velocity field. + grid_data : xarray Dataset + High-resolution grid details. + scale : float + factor (TODO) + nan_or_zero: str, optional + String set to either 'nan' or 'zero'. Determines whether we keep the + nan values in the initial surface velocities array or whether we + replace them by zeros before applying the procedure. + In the second case, remaining zeros after applying the procedure will + be replaced by nans for consistency. + The default is 'zero'. + + Returns + ------- + forcing : xarray Dataset + Dataset containing the low-resolution velocity field and forcing. + + TODO: we edit u_v_dataset, and for some reason we were returning it (but + calls were silently ignoring it, or something). + """ + # Replace nan values with zeros. + if nan_or_zero == "zero": + u_v_dataset = u_v_dataset.fillna(0.0) + + # Interpolate temperature + # interp_coords = dict(xt_ocean=u_v_dataset.coords['xu_ocean'], + # yt_ocean=u_v_dataset.coords['yu_ocean']) + # u_v_dataset['temp'] = u_v_dataset['surface_temperature'].interp( + # interp_coords) + + scale_filter = scale / 2 + # High res advection terms + adv = advections(u_v_dataset, grid_data) + # Filtered advections + filtered_adv = spatial_filter_dataset(adv, grid_data, scale_filter) + # Filtered u,v field and temperature + u_v_filtered = spatial_filter_dataset(u_v_dataset, grid_data, scale_filter) + # Advection term from filtered velocity field + adv_filtered = advections(u_v_filtered, grid_data) + # Forcing + forcing = adv_filtered - filtered_adv + forcing = forcing.rename({"adv_x": "S_x", "adv_y": "S_y"}) + # Merge filtered u,v, temperature and forcing terms + forcing = forcing.merge(u_v_filtered) + # TODO logging + #logging.debug(forcing) + + # Coarsen + forcing_coarse = forcing.coarsen( + {"xu_ocean": int(scale_filter), "yu_ocean": int(scale_filter)}, boundary="trim" + ) + + forcing_coarse = forcing_coarse.mean() + + if nan_or_zero == "zero": + # Replace zeros with nans for consistency + forcing_coarse = forcing_coarse.where(forcing_coarse["usurf"] != 0) + u_v_dataset = u_v_dataset.merge(adv) + filtered_adv = filtered_adv.rename({"adv_x": "f_adv_x", "adv_y": "f_adv_y"}) + adv_filtered = adv_filtered.rename({"adv_x": "adv_f_x", "adv_y": "adv_f_y"}) + u_v_filtered = u_v_filtered.rename({"usurf": "f_usurf", "vsurf": "f_vsurf"}) + u_v_dataset = xr.merge( + ( + u_v_dataset, + u_v_filtered, + adv, + filtered_adv, + adv_filtered, + forcing[["S_x", "S_y"]], + ) + ) + return forcing_coarse def advections(u_v_field: xr.Dataset, grid_data: xr.Dataset): """ @@ -33,6 +118,8 @@ def advections(u_v_field: xr.Dataset, grid_data: xr.Dataset): "xu_ocean": u_v_field.coords["xu_ocean"], "yu_ocean": u_v_field.coords["yu_ocean"], } + # TODO got "ValueError: zero-size array to reduction operation fmin which has + # no identity" when given 0 bounding box gradient_x = gradient_x.interp(interp_coords) gradient_y = gradient_y.interp(interp_coords) u, v = u_v_field["usurf"], u_v_field["vsurf"] @@ -44,35 +131,9 @@ def advections(u_v_field: xr.Dataset, grid_data: xr.Dataset): # result = result.chunk(dict(xu_ocean=-1, yu_ocean=-1)) return result - -def spatial_filter(data: np.ndarray, sigma: float): - """ - Apply a gaussian filter to spatial data. - - Apply scipy gaussian filter to along all dimensions except first one, which - corresponds to time. - - Parameters - ---------- - data : ndarray - Data to filter. - sigma : float - Unitless scale of the filter. - - Returns - ------- - result : ndarray - Filtered data - """ - result = np.zeros_like(data) - for t in range(data.shape[0]): - data_t = data[t, ...] - result_t = gaussian_filter(data_t, sigma, mode="constant") - result[t, ...] = result_t - return result - - -def spatial_filter_dataset(dataset: xr.Dataset, grid_info: xr.Dataset, sigma: float): +def spatial_filter_dataset( + dataset: xr.Dataset, grid_info: xr.Dataset, sigma: float + ) -> xr.Dataset: """ Apply spatial filtering to the dataset across the spatial dimensions. @@ -95,7 +156,7 @@ def spatial_filter_dataset(dataset: xr.Dataset, grid_info: xr.Dataset, sigma: fl # Normalisation term, so that if the quantity we filter is constant # over the domain, the filtered quantity is constant with the same value norm = xr.apply_ufunc( - lambda x: gaussian_filter(x, sigma, mode="constant"), + lambda x: gaussian_filter(x, (sigma, sigma), mode="constant"), area_u, dask="parallelized", output_dtypes=[ @@ -112,97 +173,28 @@ def spatial_filter_dataset(dataset: xr.Dataset, grid_info: xr.Dataset, sigma: fl ) return filtered / norm - -def eddy_forcing( - u_v_dataset: xr.Dataset, - grid_data: xr.Dataset, - scale: int, - method: str = "mean", - nan_or_zero: str = "zero", - scale_mode: str = "factor", - debug_mode=False, -) -> xr.Dataset: +def spatial_filter(data: np.ndarray, sigma: float): """ - Compute the sub-grid forcing terms. + Apply a gaussian filter to spatial data. + + Apply scipy gaussian filter to along all dimensions except first one, which + corresponds to time. Parameters ---------- - u_v_dataset : xarray Dataset - High-resolution velocity field. - grid_data : xarray Dataset - High-resolution grid details. - scale : float - Scale, in meters, or factor, if scale_mode is set to 'factor' - method : str, optional - Coarse-graining method. The default is 'mean'. - nan_or_zero: str, optional - String set to either 'nan' or 'zero'. Determines whether we keep the - nan values in the initial surface velocities array or whether we - replace them by zeros before applying the procedure. - In the second case, remaining zeros after applying the procedure will - be replaced by nans for consistency. - The default is 'zero'. - scale_mode: str, optional - DEPRECIATED, should always be left as 'factor' + data : ndarray + Data to filter. + sigma : float + Unitless scale of the filter. Returns ------- - forcing : xarray Dataset - Dataset containing the low-resolution velocity field and forcing. - """ # Replace nan values with zeros. - if nan_or_zero == "zero": - u_v_dataset = u_v_dataset.fillna(0.0) - if scale_mode == "factor": - print("Using factor mode") - scale_x = scale - scale_y = scale - # Interpolate temperature - # interp_coords = dict(xt_ocean=u_v_dataset.coords['xu_ocean'], - # yt_ocean=u_v_dataset.coords['yu_ocean']) - # u_v_dataset['temp'] = u_v_dataset['surface_temperature'].interp( - # interp_coords) - - scale_filter = (scale_x / 2, scale_y / 2) - # High res advection terms - adv = advections(u_v_dataset, grid_data) - # Filtered advections - filtered_adv = spatial_filter_dataset(adv, grid_data, scale_filter) - # Filtered u,v field and temperature - u_v_filtered = spatial_filter_dataset(u_v_dataset, grid_data, scale_filter) - # Advection term from filtered velocity field - adv_filtered = advections(u_v_filtered, grid_data) - # Forcing - forcing = adv_filtered - filtered_adv - forcing = forcing.rename({"adv_x": "S_x", "adv_y": "S_y"}) - # Merge filtered u,v, temperature and forcing terms - forcing = forcing.merge(u_v_filtered) - logging.debug(forcing) - # Coarsen - print("scale factor: ", scale) - forcing_coarse = forcing.coarsen( - {"xu_ocean": int(scale_x), "yu_ocean": int(scale_y)}, boundary="trim" - ) - if method == "mean": - forcing_coarse = forcing_coarse.mean() - else: - raise ValueError("Passed coarse-graining method not implemented.") - if nan_or_zero == "zero": - # Replace zeros with nans for consistency - forcing_coarse = forcing_coarse.where(forcing_coarse["usurf"] != 0) - if not debug_mode: - return forcing_coarse - u_v_dataset = u_v_dataset.merge(adv) - filtered_adv = filtered_adv.rename({"adv_x": "f_adv_x", "adv_y": "f_adv_y"}) - adv_filtered = adv_filtered.rename({"adv_x": "adv_f_x", "adv_y": "adv_f_y"}) - u_v_filtered = u_v_filtered.rename({"usurf": "f_usurf", "vsurf": "f_vsurf"}) - u_v_dataset = xr.merge( - ( - u_v_dataset, - u_v_filtered, - adv, - filtered_adv, - adv_filtered, - forcing[["S_x", "S_y"]], - ) - ) - return u_v_dataset, forcing_coarse + result : ndarray + Filtered data + """ + result = np.zeros_like(data) + for t in range(data.shape[0]): + data_t = data[t, ...] + result_t = gaussian_filter(data_t, (sigma, sigma), mode="constant") + result[t, ...] = result_t + return result diff --git a/src/gz21_ocean_momentum/step/data/lib.py b/src/gz21_ocean_momentum/step/data/lib.py new file mode 100644 index 00000000..c7fee071 --- /dev/null +++ b/src/gz21_ocean_momentum/step/data/lib.py @@ -0,0 +1,95 @@ +import gz21_ocean_momentum.step.data.coarsen as coarsen + +import xarray as xr +import intake + +from typing import Optional +from typing import Tuple + +def preprocess_and_compute_forcings( + grid: xr.Dataset, + surface_fields: xr.Dataset, + cyclize: bool, + resolution_degrading_factor: int, + *selected_vars: str, + ) -> xr.Dataset: + """ + Perform various preprocessing on a dataset. + """ + + # transform non-primary coords into vars + grid = grid.reset_coords()[["dxu", "dyu", "wet"]] + + if len(selected_vars) != 0: + surface_fields = surface_fields[list(selected_vars)] + + if cyclize: + # TODO logger + #logger.info("Cyclic data... Making the dataset cyclic along longitude...") + surface_fields = _cyclize( + surface_fields, "xu_ocean", resolution_degrading_factor) + grid = _cyclize( + grid, "xu_ocean", resolution_degrading_factor) + + # rechunk along the cyclized dimension + surface_fields = surface_fields.chunk({"xu_ocean": -1}) + grid = grid.chunk({"xu_ocean": -1}) + + # TODO should this be earlier? later? never? ??? + # TODO logger + #logger.debug("Getting grid data locally") + # grid data is saved locally, no need for dask + grid = grid.compute() + + # calculate eddy-forcing dataset for that particular patch + return coarsen.eddy_forcing(surface_fields, grid, resolution_degrading_factor) + +def download_cm2_6( + catalog_url: str, + co2_increase: bool, + ) -> Tuple[xr.Dataset, xr.Dataset]: + catalog = intake.open_catalog(catalog_url) + grid = catalog.ocean.GFDL_CM2_6.GFDL_CM2_6_grid + grid = grid.to_dask() + if co2_increase: + surface_fields = catalog.ocean.GFDL_CM2_6.GFDL_CM2_6_control_ocean_surface + else: + surface_fields = catalog.ocean.GFDL_CM2_6.GFDL_CM2_6_one_percent_ocean_surface + surface_fields = surface_fields.to_dask() + return surface_fields, grid + +def _cyclize(ds: xr.Dataset, coord_name: str, nb_points: int): + """ + Generate a cyclic dataset from non-cyclic input. + + Return a cyclic dataset, with nb_points added on each end, along + the coordinate specified by coord_name. + + Parameters + ---------- + ds : xr.Dataset + Dataset to process. + coord_name : str + Name of the coordinate along which the data is made cyclic. + nb_points : int + Number of points added on each end. + + Returns + ------- + New extended dataset. + """ + # TODO make this flexible + cycle_length = 360.0 + left = ds.roll({coord_name: nb_points}, roll_coords=True) + right = ds.roll({coord_name: nb_points}, roll_coords=True) + right = right.isel({coord_name: slice(0, 2 * nb_points)}) + left[coord_name] = xr.concat( + (left[coord_name][:nb_points] - cycle_length, left[coord_name][nb_points:]), + coord_name, + ) + right[coord_name] = xr.concat( + (right[coord_name][:nb_points], right[coord_name][nb_points:] + cycle_length), + coord_name, + ) + new_ds = xr.concat((left, right), coord_name) + return new_ds From 21e0add105a6fd3d40248cca687597d39cea1d7b Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 31 Aug 2023 15:33:17 +0100 Subject: [PATCH 002/114] use new code paths in training step; fix MLproject Cleaner subdomain configuration. --- MLproject | 9 ++--- src/gz21_ocean_momentum/step/train/lib.py | 10 ++++++ src/gz21_ocean_momentum/trainScript.py | 12 ++++--- training_subdomains.yaml | 42 +++++++++-------------- 4 files changed, 39 insertions(+), 34 deletions(-) create mode 100644 src/gz21_ocean_momentum/step/train/lib.py diff --git a/MLproject b/MLproject index d3eee54e..88fe4ff4 100755 --- a/MLproject +++ b/MLproject @@ -6,15 +6,14 @@ entry_points: main: parameters: ntimes : {type: float, default: 10000} - CO2: {type: float, default: 0} + #CO2: {type: float, default: 0} # unclear how to do flags with mlflow lat_min : float lat_max : float long_min : float long_max : float factor: {type: float, default: 0} - chunk_size: {type: string, default: 50} - global: {type: str, default: 0} - command: "python src/gz21_ocean_momentum/cmip26.py {lat_min} {lat_max} {long_min} {long_max} --CO2 {CO2} --ntimes {ntimes} --factor {factor} --chunk_size {chunk_size} --global_ {global}" + #global: {type: str, default: 0} # unclear how to do flags with mlflow + command: "python src/gz21_ocean_momentum/cli/data.py --lat-min {lat_min} --lat-max {lat_max} --long-min {long_min} --long-max {long_max} --ntimes {ntimes} --factor {factor}" train: parameters: @@ -35,9 +34,11 @@ entry_points: submodel : {type: string, default : transform3} features_transform_cls_name : {type : string, default : None} targets_transform_cls_name : {type : string, default : None} + subdomains_file: {type: string, default: training_subdomains.yaml} command: "python src/gz21_ocean_momentum/trainScript.py --run-id {run_id} --forcing-data-path {forcing_data_path} + --subdomains-file {subdomains_file} --batchsize {batchsize} --learning_rate {learning_rate} --n_epochs {n_epochs} --train_split {train_split} --test_split {test_split} --time_indices {time_indices} diff --git a/src/gz21_ocean_momentum/step/train/lib.py b/src/gz21_ocean_momentum/step/train/lib.py new file mode 100644 index 00000000..4092f1b8 --- /dev/null +++ b/src/gz21_ocean_momentum/step/train/lib.py @@ -0,0 +1,10 @@ +import torch + +import gz21_ocean_momentum.models.transforms + +from gz21_ocean_momentum.common.bounding_box import BoundingBox + +def select_subdomains(ds: torch.utils.data.Dataset, sds: list[BoundingBox]) -> list[torch.utils.data.Dataset]: + """TODO requires xu_ocean, yu_ocean""" + return [ ds.sel(xu_ocean=slice(sd.long_min, sd.long_max), + yu_ocean=slice(sd.lat_min, sd.lat_max)) for sd in sds ] diff --git a/src/gz21_ocean_momentum/trainScript.py b/src/gz21_ocean_momentum/trainScript.py index 391e50da..8a2f7e58 100755 --- a/src/gz21_ocean_momentum/trainScript.py +++ b/src/gz21_ocean_momentum/trainScript.py @@ -27,7 +27,6 @@ Subset_, ComposeTransforms, ) -from data.utils import load_training_datasets, load_data_from_run # Some utils functions from train.utils import ( @@ -43,6 +42,9 @@ from utils import TaskInfo +import gz21_ocean_momentum.step.train.lib as lib +from gz21_ocean_momentum.common.bounding_box import load_bounding_boxes_yaml + from typing import Any torch.autograd.set_detect_anomaly(True) @@ -169,6 +171,9 @@ def check_str_is_None(string_in: str): parser.add_argument( "--targets_transform_cls_name", type=str, default="None", help="Depreciated" ) +parser.add_argument( + "--subdomains-file", type=str, required=True, help="YAML file describing subdomains to use (bounding boxes. TODO format" +) params = parser.parse_args() @@ -265,10 +270,9 @@ def argparse_get_mlflow_artifact_path_or_direct_or_fail( # ------------------ # LOAD TRAINING DATA # ------------------ -# Extract the run ids for the datasets to use in training global_ds = xr.open_zarr(forcings_path) -# Load data from the store, according to experiment id and run id -xr_datasets = load_training_datasets(global_ds, "training_subdomains.yaml") +subdomains = load_bounding_boxes_yaml(params.subdomains_file) +xr_datasets = lib.select_subdomains(global_ds, subdomains) # Split into train and test datasets datasets, train_datasets, test_datasets = [], [], [] diff --git a/training_subdomains.yaml b/training_subdomains.yaml index d653be1c..7e1991a1 100644 --- a/training_subdomains.yaml +++ b/training_subdomains.yaml @@ -1,26 +1,16 @@ -#Configuration file for the subdomains used in the training and validation phase -!!python/tuple -- !!python/tuple - - training - - lat_min: 35. - lat_max: 50. - lon_min: -50. - lon_max: -20. -- !!python/tuple - - training - - lat_min: -40. - lat_max: -25. - lon_min: -180. - lon_max: -162. -- !!python/tuple - - training - - lat_min: -20. - lat_max: -5. - lon_min: -110. - lon_max: -92. -- !!python/tuple - - training - - lat_min: -0. - lat_max: 15. - lon_min: -48 - lon_max: -30 +- lat-min: 35 + lat-max: 50 + long-min: -50 + long-max: -20 +- lat-min: -40 + lat-max: -25 + long-min: -180 + long-max: -162 +- lat-min: -20 + lat-max: -5 + long-min: -110 + long-max: -92 +- lat-min: -0 + lat-max: 15 + long-min: -48 + long-max: -30 From 8cd24a4c6fb2470c8342c5912369b8bdb693e33d Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 31 Aug 2023 15:43:41 +0100 Subject: [PATCH 003/114] data: enable selecting Pangeo intake catalog Also locks intake catalog to current HEAD. --- src/gz21_ocean_momentum/cli/data.py | 9 ++------- src/gz21_ocean_momentum/step/data/lib.py | 17 +++++++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py index e897a9b6..86885594 100644 --- a/src/gz21_ocean_momentum/cli/data.py +++ b/src/gz21_ocean_momentum/cli/data.py @@ -18,6 +18,7 @@ p.add("--ntimes", type=int, help="number of time points to process, starting from the first. Note that the CM2.6 dataset is daily, so this would be number of days.") p.add("--co2-increase", action="store_true", help="use 1%% annual CO2 increase CM2.6 dataset. By default, uses control (no increase)") p.add("--factor", type=int, required=True, help="resolution degradation factor") +p.add("--pangeo-catalog-uri", type=str, default="https://raw.githubusercontent.com/pangeo-data/pangeo-datastore/master/intake-catalogs/ocean.yaml", help="URI to Pangeo ocean dataset intake catalog file") options = p.parse_args() @@ -30,13 +31,7 @@ options.lat_min, options.lat_max, options.long_min, options.long_max) -# TODO: lock version, rather than using master? -CATALOG_URL = "https://raw.githubusercontent.com/\ -pangeo-data/pangeo-datastore/\ -master/\ -intake-catalogs/master.yaml" - -surface_fields, grid = lib.download_cm2_6(CATALOG_URL, options.co2_increase) +surface_fields, grid = lib.retrieve_cm2_6(options.pangeo_catalog_uri, options.co2_increase) surface_fields = surface_fields.sel( xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), diff --git a/src/gz21_ocean_momentum/step/data/lib.py b/src/gz21_ocean_momentum/step/data/lib.py index c7fee071..0fa09c01 100644 --- a/src/gz21_ocean_momentum/step/data/lib.py +++ b/src/gz21_ocean_momentum/step/data/lib.py @@ -44,17 +44,22 @@ def preprocess_and_compute_forcings( # calculate eddy-forcing dataset for that particular patch return coarsen.eddy_forcing(surface_fields, grid, resolution_degrading_factor) -def download_cm2_6( - catalog_url: str, +def retrieve_cm2_6( + catalog_uri: str, co2_increase: bool, ) -> Tuple[xr.Dataset, xr.Dataset]: - catalog = intake.open_catalog(catalog_url) - grid = catalog.ocean.GFDL_CM2_6.GFDL_CM2_6_grid + """Retrieve the CM2.6 dataset via the given intake catalog URI. + + Will download if given an `http://` URI. Will use local files such as + `/home/user/catalog.yaml` directly. + """ + catalog = intake.open_catalog(catalog_uri) + grid = catalog.GFDL_CM2_6.GFDL_CM2_6_grid grid = grid.to_dask() if co2_increase: - surface_fields = catalog.ocean.GFDL_CM2_6.GFDL_CM2_6_control_ocean_surface + surface_fields = catalog.GFDL_CM2_6.GFDL_CM2_6_control_ocean_surface else: - surface_fields = catalog.ocean.GFDL_CM2_6.GFDL_CM2_6_one_percent_ocean_surface + surface_fields = catalog.GFDL_CM2_6.GFDL_CM2_6_one_percent_ocean_surface surface_fields = surface_fields.to_dask() return surface_fields, grid From 8f35643e08924bf0c5353cfc0a85118647bfe35b Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 1 Sep 2023 14:54:58 +0100 Subject: [PATCH 004/114] MLproject: main->data, rename project --- MLproject | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/MLproject b/MLproject index 88fe4ff4..61871d5e 100755 --- a/MLproject +++ b/MLproject @@ -1,9 +1,7 @@ -name: cmip2_6 - -# conda_env: conda.yaml +name: gz21_ocean_momentum entry_points: - main: + data: parameters: ntimes : {type: float, default: 10000} #CO2: {type: float, default: 0} # unclear how to do flags with mlflow From f9dec0aba3c3a6c85a090c082f0c5bb871b22ffb Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 1 Sep 2023 15:23:53 +0100 Subject: [PATCH 005/114] tweak MLproject, readme, data step CLI help --- MLproject | 15 +++- README.md | 106 ++++++++++------------------ src/gz21_ocean_momentum/cli/data.py | 11 +-- 3 files changed, 56 insertions(+), 76 deletions(-) diff --git a/MLproject b/MLproject index 61871d5e..820fd445 100755 --- a/MLproject +++ b/MLproject @@ -1,17 +1,26 @@ +# vim: ft=yaml + name: gz21_ocean_momentum entry_points: + data: parameters: - ntimes : {type: float, default: 10000} - #CO2: {type: float, default: 0} # unclear how to do flags with mlflow + out_dir : str lat_min : float lat_max : float long_min : float long_max : float + ntimes : {type: float, default: 10000} factor: {type: float, default: 0} #global: {type: str, default: 0} # unclear how to do flags with mlflow - command: "python src/gz21_ocean_momentum/cli/data.py --lat-min {lat_min} --lat-max {lat_max} --long-min {long_min} --long-max {long_max} --ntimes {ntimes} --factor {factor}" + #CO2: {type: float, default: 0} # unclear how to do flags with mlflow + command: >- + python src/gz21_ocean_momentum/cli/data.py + --lat-min {lat_min} --lat-max {lat_max} + --long-min {long_min} --long-max {long_max} + --ntimes {ntimes} --factor {factor} + --out-dir {out_dir} train: parameters: diff --git a/README.md b/README.md index e8cc9015..391e5825 100644 --- a/README.md +++ b/README.md @@ -22,30 +22,28 @@ refreshing the code and making it available for easy reuse by others._ ## Architecture The model is written in Python, using PyTorch for the CNN. We provide 3 separate -"stages", which are run using different commands and arguments: +"steps", which are run using different commands and arguments: * data processing: downloads part of CM2.6 dataset and processes * model training: train model on processed data * model testing: tests the trained model on an unseen region -For more details on each of the stages, see the [`docs`](docs/) directory. +For more details on each of the steps, see the [`docs`](docs/) directory. ## Usage ### Dependencies Python 3.9 or newer is required. We primarily test on Python 3.11. -#### Python To avoid any conflicts with local packages, we recommend using a virtual environment. In the root directory: - virtualenv venv - source venv/bin/activate + python -m venv venv + +or using [virtualenv](https://virtualenv.pypa.io/en/latest/): -See [virtualenv docs](https://virtualenv.pypa.io/en/latest/) for more details. + virtualenv venv -Alternatively, if you are using python to manage virtual environments using the -`venv` module, then the first line above can be replaced by `python -m venv -venv` (where the second `venv` is the virtual environment name). +Then load with `source venv/bin/activate`. With `pip` installed, run the following in the root directory: @@ -57,106 +55,76 @@ With `pip` installed, run the following in the root directory: that the Poetry build is not actively supported-- if it fails, check that the dependencies are up-to-date with the setuptools `pyproject.toml`.)* -#### System -Some graphing code uses cartopy, which requires [GEOS](https://libgeos.org/). To -install on Ubuntu: - - sudo apt install libgeos-dev - -On macOS, via Homebrew: - - brew install geos - -On Windows, consider using MSYS2 to install the library in a Linux-esque manner. -The [mingw-w64-x86_64-geos](https://packages.msys2.org/package/mingw-w64-x86_64-geos) -package should be appropriate. If this doesn't work or isn't suitable, cartopy -or GEOS might have more ideas in their documentation. - ### Running unit tests There are a handful of unit tests using pytest, in the [`tests`](tests/) -directory. These assert some operations and methods used in the stages. They may +directory. These assert some operations and methods used in the steps. They may be run in the regular method: pytest -### Running stages +### Running steps Execute these commands from the repository root. See [`docs`](docs/) directory for more details. +For command-line option explanation, run the appropriate step with `--help` e.g. +`python src/gz21_ocean_momentum/cli/data.py --help`. + +#### MLflow specifics MLflow parameters: * `experiment-name`: "tag" to use for MLflow experiment. Used to share artifacts - between stages, i.e. you should run the training stage with a name you used to - run the data processing stage. + between steps, i.e. you should run the training step with a name you used to + run the data processing step. * `exp_id`: TODO: one way MLflow distinguishes runs. May need to set to share - artifacts between stages...? + artifacts between steps...? * `run_id`: TODO: one way MLflow distinguishes runs. May need to set to share - artifacts between stages...? + artifacts between steps...? -For old MLflow versions (TODO: which?), replace the `--env-manager=local` flag -with `--no-conda` +For MLflow versions older than 1.25.0, replace the `--env-manager=local` flag +with `--no-conda`. -In order to make sure that data in- and output locations are well-defined, the -environment variable `MLFLOW_TRACKING_URI` must be set to the intended data location: +When invoking steps with `mlflow run`, you may need to control the path used for +`mlruns` (which stores outputs of runs). You may set the `MLFLOW_TRACKING_URI` +environment variable to achieve this. In Linux: export MLFLOW_TRACKING_URI="/path/to/data/dir" -in Linux, or -``` -%env MLFLOW_TRACKING_URI /path/to/data/dir -``` +In a Jupyter Notebook: -in a Jupyter Notebook, or + %env MLFLOW_TRACKING_URI /path/to/data/dir -``` +In Python: + +```python import os os.environ['MLFLOW_TRACKING_URI'] = '/path/to/data/dir' ``` -in Python. #### Data processing -The [`cmip26.py`](src/gz21_ocean_momentum/cmip26.py) script runs the data -processing stage. It generates coarse surface velocities and diagnosed forcings -from the CM2.6 dataset and saves them to disk. You may configure certain -parameters such as bounds (lat/lon) and CO2 level. +The CLI into the data processing step is at +[`cli/data.py`](src/gz21_ocean_momentum/cli/data.py). It generates coarse +surface velocities and diagnosed forcings from the CM2.6 dataset and saves them +to disk. You may configure certain parameters such as bounds (lat/lon) and CO2 +level. **You must configure GCP credentials to download the CM2.6 dataset used.** See [`docs/data.md`](docs/data.md) for more details. -Relevant parameters: - -* `factor`: the factor definining the low-resolution grid of the generated data - with respect to the high-resolution grid. -* `CO2`: 0 for control, 1 for 1% increase per year dataset. -* `global`: TODO "make data cyclic along longitude". Set to 0; currently fails when set to 1. -* `ntimes`: the number of days to process, knowing that the data set is at a - time resolution of one per day. If not specified, uses the complete dataset. -* `lat_min`, `lat_max`, `lon_min`, `lon_max`: the spatial domain to process. - Direct call (without MLflow) example: - python src/gz21_ocean_momentum/cmip26.py -85 85 -280 80 --factor 4 --ntimes 10 - -MLflow call example: - -``` -mlflow run . --experiment-name --env-manager=local \ --P lat_min=-80 -P lat_max=80 -P long_min=-280 -P long_max=80 \ --P factor=4 \ --P CO2=1 -P global=0 \ --P ntimes=100 \ --P chunk_size=1 -``` + python src/gz21_ocean_momentum/cmip26.py \ + --lat-min -80 --lat-max 80 --long-min -280 --long-max 80 \ + --factor 4 --ntimes 100 --co2-increase Some preprocessed data is hosted on HuggingFace at [datasets/M2LInES/gfdl-cmip26-gz21-ocean-forcing](https://huggingface.co/datasets/M2LInES/gfdl-cmip26-gz21-ocean-forcing). #### Training The [`trainScript.py`](src/gz21_ocean_momentum/trainScript.py) script runs the -model training stage. You may configure various training parameters through +model training step. You may configure various training parameters through command-line arguments, such as number of training epochs, loss functions, and -training data. (You will want to select the output from a data processing stage +training data. (You will want to select the output from a data processing step for the latter.) MLflow call example: diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py index 86885594..57a99b08 100644 --- a/src/gz21_ocean_momentum/cli/data.py +++ b/src/gz21_ocean_momentum/cli/data.py @@ -4,10 +4,13 @@ import configargparse -DESCRIPTION = "GZ21 data step: download CM2.6 dataset, apply coarse graining\ +# up to date as of 2023-09-01 +DEF_CATALOG_URI = "https://raw.githubusercontent.com/pangeo-data/pangeo-datastore/d684158e92fb3f3ad3b34e7dc5bba52b22a3ba80/intake-catalogs/ocean.yaml" + +DESCRIPTION = "GZ21 data step: download CM2.6 dataset, apply coarse graining \ and generate forcings. Saves result to disk in zarr format." -p = configargparse.ArgParser() +p = configargparse.ArgParser(description=DESCRIPTION) p.add("--config-file", is_config_file=True, help="config file path") p.add("--out-dir", type=str, required=True, help="folder to save generated forcings to (in zarr format)" ) p.add("--lat-min", type=float, required=True, help="bounding box minimum latitude") @@ -15,10 +18,10 @@ p.add("--long-min", type=float, required=True, help="bounding box minimum longitude") p.add("--long-max", type=float, required=True, help="bounding box maximum longitude") p.add("--cyclize", action="store_true", help="global data; make cyclic along longitude") -p.add("--ntimes", type=int, help="number of time points to process, starting from the first. Note that the CM2.6 dataset is daily, so this would be number of days.") +p.add("--ntimes", type=int, help="number of time points to process, starting from the first. Note that the CM2.6 dataset is daily, so this would be number of days. If unset, uses whole dataset.") p.add("--co2-increase", action="store_true", help="use 1%% annual CO2 increase CM2.6 dataset. By default, uses control (no increase)") p.add("--factor", type=int, required=True, help="resolution degradation factor") -p.add("--pangeo-catalog-uri", type=str, default="https://raw.githubusercontent.com/pangeo-data/pangeo-datastore/master/intake-catalogs/ocean.yaml", help="URI to Pangeo ocean dataset intake catalog file") +p.add("--pangeo-catalog-uri", type=str, default=DEF_CATALOG_URI, help="URI to Pangeo ocean dataset intake catalog file") options = p.parse_args() From fdb9fa0629cedc1dd520cdb4f39d3a315d23fe5c Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 1 Sep 2023 16:07:29 +0100 Subject: [PATCH 006/114] cli/data: re-add logging --- src/gz21_ocean_momentum/cli/data.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py index 57a99b08..bd9667da 100644 --- a/src/gz21_ocean_momentum/cli/data.py +++ b/src/gz21_ocean_momentum/cli/data.py @@ -4,6 +4,12 @@ import configargparse +import dask.diagnostics +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + # up to date as of 2023-09-01 DEF_CATALOG_URI = "https://raw.githubusercontent.com/pangeo-data/pangeo-datastore/d684158e92fb3f3ad3b34e7dc5bba52b22a3ba80/intake-catalogs/ocean.yaml" @@ -22,6 +28,7 @@ p.add("--co2-increase", action="store_true", help="use 1%% annual CO2 increase CM2.6 dataset. By default, uses control (no increase)") p.add("--factor", type=int, required=True, help="resolution degradation factor") p.add("--pangeo-catalog-uri", type=str, default=DEF_CATALOG_URI, help="URI to Pangeo ocean dataset intake catalog file") +p.add("--verbose", action="store_true", help="be more verbose (e.g. display progress)") options = p.parse_args() @@ -34,6 +41,12 @@ options.lat_min, options.lat_max, options.long_min, options.long_max) +logger.info("retrieving CM2.6 dataset via Pangeo Cloud Datastore...") +if options.co2_increase: + logger.info("-> using 1% annual CO2 increase dataset") +else: + logger.info("-> using control dataset (no annual CO2 increase)") + surface_fields, grid = lib.retrieve_cm2_6(options.pangeo_catalog_uri, options.co2_increase) surface_fields = surface_fields.sel( @@ -46,8 +59,14 @@ if options.ntimes is not None: surface_fields = surface_fields.isel(time=slice(options.ntimes)) +if options.verbose: + dask.diagnostics.ProgressBar().register() + +logger.info("computing forcings...") + forcings = lib.preprocess_and_compute_forcings( grid, surface_fields, options.cyclize, options.factor, "usurf", "vsurf") +logger.info(f"writing forcings zarr to directory: {options.out_dir}") forcings.to_zarr(options.out_dir) From 5f9dab7af2089db832229e295f52f55b5c93a6b3 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Mon, 11 Sep 2023 11:24:02 +0100 Subject: [PATCH 007/114] cli/data: +log bounding operation --- src/gz21_ocean_momentum/cli/data.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py index bd9667da..1463c26b 100644 --- a/src/gz21_ocean_momentum/cli/data.py +++ b/src/gz21_ocean_momentum/cli/data.py @@ -49,13 +49,6 @@ surface_fields, grid = lib.retrieve_cm2_6(options.pangeo_catalog_uri, options.co2_increase) -surface_fields = surface_fields.sel( - xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), - yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) -grid = grid.sel( - xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), - yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) - if options.ntimes is not None: surface_fields = surface_fields.isel(time=slice(options.ntimes)) @@ -63,10 +56,17 @@ dask.diagnostics.ProgressBar().register() logger.info("computing forcings...") - forcings = lib.preprocess_and_compute_forcings( grid, surface_fields, options.cyclize, options.factor, "usurf", "vsurf") +logger.info("selecting bounding box...") +surface_fields = surface_fields.sel( + xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), + yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) +grid = grid.sel( + xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), + yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) + logger.info(f"writing forcings zarr to directory: {options.out_dir}") forcings.to_zarr(options.out_dir) From 0748393299067abd0eaf36f23183967052355377 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Mon, 11 Sep 2023 16:00:25 +0100 Subject: [PATCH 008/114] step/data: simplify gaussian_filter call No need to repeat sigma according to docs. --- src/gz21_ocean_momentum/step/data/coarsen.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/gz21_ocean_momentum/step/data/coarsen.py b/src/gz21_ocean_momentum/step/data/coarsen.py index 8ac1a1c5..e8cfd542 100644 --- a/src/gz21_ocean_momentum/step/data/coarsen.py +++ b/src/gz21_ocean_momentum/step/data/coarsen.py @@ -77,6 +77,7 @@ def eddy_forcing( if nan_or_zero == "zero": # Replace zeros with nans for consistency forcing_coarse = forcing_coarse.where(forcing_coarse["usurf"] != 0) + u_v_dataset = u_v_dataset.merge(adv) filtered_adv = filtered_adv.rename({"adv_x": "f_adv_x", "adv_y": "f_adv_y"}) adv_filtered = adv_filtered.rename({"adv_x": "adv_f_x", "adv_y": "adv_f_y"}) @@ -91,6 +92,7 @@ def eddy_forcing( forcing[["S_x", "S_y"]], ) ) + return forcing_coarse def advections(u_v_field: xr.Dataset, grid_data: xr.Dataset): @@ -156,7 +158,7 @@ def spatial_filter_dataset( # Normalisation term, so that if the quantity we filter is constant # over the domain, the filtered quantity is constant with the same value norm = xr.apply_ufunc( - lambda x: gaussian_filter(x, (sigma, sigma), mode="constant"), + lambda x: gaussian_filter(x, sigma, mode="constant"), area_u, dask="parallelized", output_dtypes=[ @@ -195,6 +197,6 @@ def spatial_filter(data: np.ndarray, sigma: float): result = np.zeros_like(data) for t in range(data.shape[0]): data_t = data[t, ...] - result_t = gaussian_filter(data_t, (sigma, sigma), mode="constant") + result_t = gaussian_filter(data_t, sigma, mode="constant") result[t, ...] = result_t return result From 0346a4e714026a51c8f40b7c4eca2a7366eb0b12 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Mon, 11 Sep 2023 16:00:43 +0100 Subject: [PATCH 009/114] cli/data: fix forcing compute call arg order --- src/gz21_ocean_momentum/cli/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py index 1463c26b..fc4d369a 100644 --- a/src/gz21_ocean_momentum/cli/data.py +++ b/src/gz21_ocean_momentum/cli/data.py @@ -57,7 +57,7 @@ logger.info("computing forcings...") forcings = lib.preprocess_and_compute_forcings( - grid, surface_fields, options.cyclize, + surface_fields, grid, options.cyclize, options.factor, "usurf", "vsurf") logger.info("selecting bounding box...") From c46a6e0580c8c75e8e29e686beb47511c5224b55 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 13 Sep 2023 13:28:37 +0100 Subject: [PATCH 010/114] step/data: remove code copied from unused debug Also does more operations up front in the CLI for testing purposes. --- src/gz21_ocean_momentum/cli/data.py | 31 +++++++++++++++----- src/gz21_ocean_momentum/step/data/coarsen.py | 21 ++++--------- src/gz21_ocean_momentum/step/data/lib.py | 31 +++++++++++++------- 3 files changed, 49 insertions(+), 34 deletions(-) diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py index fc4d369a..7c085af9 100644 --- a/src/gz21_ocean_momentum/cli/data.py +++ b/src/gz21_ocean_momentum/cli/data.py @@ -2,6 +2,8 @@ import gz21_ocean_momentum.common.cli as cli from gz21_ocean_momentum.common.bounding_box import BoundingBox +import gz21_ocean_momentum.step.data.coarsen as coarsen + import configargparse import dask.diagnostics @@ -49,24 +51,37 @@ surface_fields, grid = lib.retrieve_cm2_6(options.pangeo_catalog_uri, options.co2_increase) +logger.info("selecting input data bounding box...") +surface_fields = surface_fields.sel( + xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), + yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) +grid = grid.sel( + xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), + yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) + if options.ntimes is not None: surface_fields = surface_fields.isel(time=slice(options.ntimes)) if options.verbose: dask.diagnostics.ProgressBar().register() +surface_fields = surface_fields[["usurf", "vsurf"]] + logger.info("computing forcings...") -forcings = lib.preprocess_and_compute_forcings( - surface_fields, grid, options.cyclize, - options.factor, "usurf", "vsurf") +#forcings = lib.preprocess_and_compute_forcings( +# surface_fields, grid, options.cyclize, +# options.factor) +forcings = coarsen.eddy_forcing(surface_fields, grid, options.factor) -logger.info("selecting bounding box...") -surface_fields = surface_fields.sel( - xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), - yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) -grid = grid.sel( +logger.info("selecting forcing bounding box...") +forcings = forcings.sel( xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) +# TODO previously removed -- seems to not change output +for var in forcings: + forcings[var].encoding = {} +forcings = forcings.chunk({"time": 1}) + logger.info(f"writing forcings zarr to directory: {options.out_dir}") forcings.to_zarr(options.out_dir) diff --git a/src/gz21_ocean_momentum/step/data/coarsen.py b/src/gz21_ocean_momentum/step/data/coarsen.py index e8cfd542..6434901d 100644 --- a/src/gz21_ocean_momentum/step/data/coarsen.py +++ b/src/gz21_ocean_momentum/step/data/coarsen.py @@ -71,27 +71,18 @@ def eddy_forcing( forcing_coarse = forcing.coarsen( {"xu_ocean": int(scale_filter), "yu_ocean": int(scale_filter)}, boundary="trim" ) - forcing_coarse = forcing_coarse.mean() if nan_or_zero == "zero": # Replace zeros with nans for consistency forcing_coarse = forcing_coarse.where(forcing_coarse["usurf"] != 0) - u_v_dataset = u_v_dataset.merge(adv) - filtered_adv = filtered_adv.rename({"adv_x": "f_adv_x", "adv_y": "f_adv_y"}) - adv_filtered = adv_filtered.rename({"adv_x": "adv_f_x", "adv_y": "adv_f_y"}) - u_v_filtered = u_v_filtered.rename({"usurf": "f_usurf", "vsurf": "f_vsurf"}) - u_v_dataset = xr.merge( - ( - u_v_dataset, - u_v_filtered, - adv, - filtered_adv, - adv_filtered, - forcing[["S_x", "S_y"]], - ) - ) + # Specify input vs output type for each variable of the dataset. Might + # be used later on for training or testing. + forcing_coarse["S_x"].attrs["type"] = "output" + forcing_coarse["S_y"].attrs["type"] = "output" + forcing_coarse["usurf"].attrs["type"] = "input" + forcing_coarse["vsurf"].attrs["type"] = "input" return forcing_coarse diff --git a/src/gz21_ocean_momentum/step/data/lib.py b/src/gz21_ocean_momentum/step/data/lib.py index 0fa09c01..5f96bc7f 100644 --- a/src/gz21_ocean_momentum/step/data/lib.py +++ b/src/gz21_ocean_momentum/step/data/lib.py @@ -6,23 +6,20 @@ from typing import Optional from typing import Tuple +import logging + +logger = logging.getLogger(__name__) + def preprocess_and_compute_forcings( - grid: xr.Dataset, surface_fields: xr.Dataset, + grid: xr.Dataset, cyclize: bool, resolution_degrading_factor: int, - *selected_vars: str, ) -> xr.Dataset: """ Perform various preprocessing on a dataset. """ - # transform non-primary coords into vars - grid = grid.reset_coords()[["dxu", "dyu", "wet"]] - - if len(selected_vars) != 0: - surface_fields = surface_fields[list(selected_vars)] - if cyclize: # TODO logger #logger.info("Cyclic data... Making the dataset cyclic along longitude...") @@ -39,7 +36,7 @@ def preprocess_and_compute_forcings( # TODO logger #logger.debug("Getting grid data locally") # grid data is saved locally, no need for dask - grid = grid.compute() + #grid = grid.compute() # calculate eddy-forcing dataset for that particular patch return coarsen.eddy_forcing(surface_fields, grid, resolution_degrading_factor) @@ -50,17 +47,29 @@ def retrieve_cm2_6( ) -> Tuple[xr.Dataset, xr.Dataset]: """Retrieve the CM2.6 dataset via the given intake catalog URI. + Returns a tuple of `(uv dataset, grid dataset)`. + Will download if given an `http://` URI. Will use local files such as `/home/user/catalog.yaml` directly. """ + + logger.info("retrieving CM2.6 dataset via Pangeo Cloud Datastore...") + catalog = intake.open_catalog(catalog_uri) grid = catalog.GFDL_CM2_6.GFDL_CM2_6_grid grid = grid.to_dask() + + # transform non-primary coords into vars + grid = grid.reset_coords()[["dxu", "dyu", "wet"]] + if co2_increase: - surface_fields = catalog.GFDL_CM2_6.GFDL_CM2_6_control_ocean_surface - else: + logger.info("(using 1% annual CO2 increase dataset)") surface_fields = catalog.GFDL_CM2_6.GFDL_CM2_6_one_percent_ocean_surface + else: + logger.info("(using control dataset -> no annual CO2 increase)") + surface_fields = catalog.GFDL_CM2_6.GFDL_CM2_6_control_ocean_surface surface_fields = surface_fields.to_dask() + return surface_fields, grid def _cyclize(ds: xr.Dataset, coord_name: str, nb_points: int): From eb8586ab4f0436f03e94ab306032964e2d1d047f Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 13 Sep 2023 15:52:15 +0100 Subject: [PATCH 011/114] step/data: fix coarsening scale args --- src/gz21_ocean_momentum/step/data/coarsen.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/gz21_ocean_momentum/step/data/coarsen.py b/src/gz21_ocean_momentum/step/data/coarsen.py index 6434901d..981dadf8 100644 --- a/src/gz21_ocean_momentum/step/data/coarsen.py +++ b/src/gz21_ocean_momentum/step/data/coarsen.py @@ -50,13 +50,12 @@ def eddy_forcing( # u_v_dataset['temp'] = u_v_dataset['surface_temperature'].interp( # interp_coords) - scale_filter = scale / 2 # High res advection terms adv = advections(u_v_dataset, grid_data) # Filtered advections - filtered_adv = spatial_filter_dataset(adv, grid_data, scale_filter) + filtered_adv = spatial_filter_dataset(adv, grid_data, scale/2) # Filtered u,v field and temperature - u_v_filtered = spatial_filter_dataset(u_v_dataset, grid_data, scale_filter) + u_v_filtered = spatial_filter_dataset(u_v_dataset, grid_data, scale/2) # Advection term from filtered velocity field adv_filtered = advections(u_v_filtered, grid_data) # Forcing @@ -69,7 +68,7 @@ def eddy_forcing( # Coarsen forcing_coarse = forcing.coarsen( - {"xu_ocean": int(scale_filter), "yu_ocean": int(scale_filter)}, boundary="trim" + {"xu_ocean": int(scale), "yu_ocean": int(scale)}, boundary="trim" ) forcing_coarse = forcing_coarse.mean() From ab1880dc16563728d3e01e9aaf7596749bbe3310 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 14 Sep 2023 11:27:18 +0100 Subject: [PATCH 012/114] step/data/coarsen: cleaning --- src/gz21_ocean_momentum/step/data/coarsen.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/gz21_ocean_momentum/step/data/coarsen.py b/src/gz21_ocean_momentum/step/data/coarsen.py index 981dadf8..53a90134 100644 --- a/src/gz21_ocean_momentum/step/data/coarsen.py +++ b/src/gz21_ocean_momentum/step/data/coarsen.py @@ -20,6 +20,7 @@ def eddy_forcing( ---------- u_v_dataset : xarray Dataset High-resolution velocity field. + Is changed in function. grid_data : xarray Dataset High-resolution grid details. scale : float @@ -36,9 +37,6 @@ def eddy_forcing( ------- forcing : xarray Dataset Dataset containing the low-resolution velocity field and forcing. - - TODO: we edit u_v_dataset, and for some reason we were returning it (but - calls were silently ignoring it, or something). """ # Replace nan values with zeros. if nan_or_zero == "zero": @@ -124,7 +122,7 @@ def advections(u_v_field: xr.Dataset, grid_data: xr.Dataset): return result def spatial_filter_dataset( - dataset: xr.Dataset, grid_info: xr.Dataset, sigma: float + dataset: xr.Dataset, grid_data: xr.Dataset, sigma: float ) -> xr.Dataset: """ Apply spatial filtering to the dataset across the spatial dimensions. @@ -133,7 +131,8 @@ def spatial_filter_dataset( ---------- dataset : xarray Dataset Dataset to filter. First dimension must be time, followed by spatial dimensions - grid_info : xarray Dataset + Is changed in place. + grid_data: xarray Dataset grid data, must include variables "dxu" and "dyu" sigma : float Scale of the filtering, same unit as those of the grid (often, meters) @@ -143,8 +142,11 @@ def spatial_filter_dataset( filt_dataset : xarray Dataset Filtered dataset """ - area_u = grid_info["dxu"] * grid_info["dyu"] / 1e8 + area_u = grid_data["dxu"] * grid_data["dyu"] / 1e8 + + # TODO 2023-09-13 raehik: is this bad? we're changing the dataset here dataset = dataset * area_u + # Normalisation term, so that if the quantity we filter is constant # over the domain, the filtered quantity is constant with the same value norm = xr.apply_ufunc( From 228ca93d77679a35b029b0c265afec876790ae74 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 14 Sep 2023 14:30:31 +0100 Subject: [PATCH 013/114] step/data: simplify ufunc call --- src/gz21_ocean_momentum/step/data/coarsen.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/gz21_ocean_momentum/step/data/coarsen.py b/src/gz21_ocean_momentum/step/data/coarsen.py index 53a90134..22b834b1 100644 --- a/src/gz21_ocean_momentum/step/data/coarsen.py +++ b/src/gz21_ocean_momentum/step/data/coarsen.py @@ -131,7 +131,6 @@ def spatial_filter_dataset( ---------- dataset : xarray Dataset Dataset to filter. First dimension must be time, followed by spatial dimensions - Is changed in place. grid_data: xarray Dataset grid data, must include variables "dxu" and "dyu" sigma : float @@ -144,9 +143,6 @@ def spatial_filter_dataset( """ area_u = grid_data["dxu"] * grid_data["dyu"] / 1e8 - # TODO 2023-09-13 raehik: is this bad? we're changing the dataset here - dataset = dataset * area_u - # Normalisation term, so that if the quantity we filter is constant # over the domain, the filtered quantity is constant with the same value norm = xr.apply_ufunc( @@ -159,7 +155,7 @@ def spatial_filter_dataset( ) filtered = xr.apply_ufunc( lambda x: spatial_filter(x, sigma), - dataset, + dataset * area_u, dask="parallelized", output_dtypes=[ float, From 30e10c58feed61d6d8d8d18ad6f4dbe43f52ce5e Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 14 Sep 2023 15:15:15 +0100 Subject: [PATCH 014/114] readme: clarify data step CLI --- README.md | 25 +++++++++++++++++++++++-- examples/cli-configs/data-paper.yaml | 11 +++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 examples/cli-configs/data-paper.yaml diff --git a/README.md b/README.md index 391e5825..53a22bbc 100644 --- a/README.md +++ b/README.md @@ -111,15 +111,36 @@ level. **You must configure GCP credentials to download the CM2.6 dataset used.** See [`docs/data.md`](docs/data.md) for more details. -Direct call (without MLflow) example: +Example invocation: python src/gz21_ocean_momentum/cmip26.py \ --lat-min -80 --lat-max 80 --long-min -280 --long-max 80 \ - --factor 4 --ntimes 100 --co2-increase + --factor 4 --ntimes 100 --co2-increase --out-dir forcings + +Alternatively, you may write (all or part of) these options into a YAML file: + +``` +lat-min: -80 +lat-max: 80 +long-min: -280 +long-max: 80 +ntimes: 100 +factor: 4 +co2-increase: true +``` + +and use this file in an invocation with the `--config-file` option: + + python src/gz21_ocean_momentum/cmip26.py \ + --config-file examples/cli-configs/data-paper.yaml --out-dir forcings Some preprocessed data is hosted on HuggingFace at [datasets/M2LInES/gfdl-cmip26-gz21-ocean-forcing](https://huggingface.co/datasets/M2LInES/gfdl-cmip26-gz21-ocean-forcing). +You may also run the data processing step directly from Python using the +functions at [`step/data/lib.py`](src/gz21_ocean_momentum/step/data/lib.py). See +the CLI script for example usage. + #### Training The [`trainScript.py`](src/gz21_ocean_momentum/trainScript.py) script runs the model training step. You may configure various training parameters through diff --git a/examples/cli-configs/data-paper.yaml b/examples/cli-configs/data-paper.yaml new file mode 100644 index 00000000..79d14b83 --- /dev/null +++ b/examples/cli-configs/data-paper.yaml @@ -0,0 +1,11 @@ +# Approximates data step configuration used in the 2021 paper. + +lat-min: -80 +lat-max: 80 +long-min: -280 +long-max: 80 + +ntimes: 100 +factor: 4 + +co2-increase: true From 5dbc603b7bc308a82d4c1b3ad5ab2fb156e56911 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 20 Sep 2023 14:29:47 +0100 Subject: [PATCH 015/114] step/data: clean up, log more --- src/gz21_ocean_momentum/cli/data.py | 70 ++--- .../common/bounding_box.py | 16 ++ src/gz21_ocean_momentum/step/data/coarsen.py | 190 ------------- src/gz21_ocean_momentum/step/data/lib.py | 258 ++++++++++++++---- 4 files changed, 257 insertions(+), 277 deletions(-) delete mode 100644 src/gz21_ocean_momentum/step/data/coarsen.py diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py index 7c085af9..8b01a084 100644 --- a/src/gz21_ocean_momentum/cli/data.py +++ b/src/gz21_ocean_momentum/cli/data.py @@ -1,17 +1,12 @@ import gz21_ocean_momentum.step.data.lib as lib import gz21_ocean_momentum.common.cli as cli -from gz21_ocean_momentum.common.bounding_box import BoundingBox - -import gz21_ocean_momentum.step.data.coarsen as coarsen +from gz21_ocean_momentum.common.bounding_box import BoundingBox, bound_dataset import configargparse import dask.diagnostics import logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - # up to date as of 2023-09-01 DEF_CATALOG_URI = "https://raw.githubusercontent.com/pangeo-data/pangeo-datastore/d684158e92fb3f3ad3b34e7dc5bba52b22a3ba80/intake-catalogs/ocean.yaml" @@ -30,10 +25,21 @@ p.add("--co2-increase", action="store_true", help="use 1%% annual CO2 increase CM2.6 dataset. By default, uses control (no increase)") p.add("--factor", type=int, required=True, help="resolution degradation factor") p.add("--pangeo-catalog-uri", type=str, default=DEF_CATALOG_URI, help="URI to Pangeo ocean dataset intake catalog file") -p.add("--verbose", action="store_true", help="be more verbose (e.g. display progress)") +p.add("--verbose", action="store_true", help="be more verbose (displays progress, debug messages)") options = p.parse_args() +# set up logging immediately after parsing CLI options (need to check verbosity) +# (would like to simplify this, maybe with `basicConfig(force=True)`) +if options.verbose: + logging.basicConfig(level=logging.DEBUG) + dask.diagnostics.ProgressBar().register() + logger = logging.getLogger(__name__) + logger.debug("verbose mode; displaying all debug messages, progress bars)") +else: + logging.basicConfig(level=logging.INFO) + logger = logging.getLogger(__name__) + if not cli.path_is_nonexist_or_empty_dir(options.out_dir): cli.fail(1, "--out-dir output directory is invalid", "if the directory exists, ensure it is empty") @@ -44,44 +50,40 @@ options.long_min, options.long_max) logger.info("retrieving CM2.6 dataset via Pangeo Cloud Datastore...") -if options.co2_increase: - logger.info("-> using 1% annual CO2 increase dataset") -else: - logger.info("-> using control dataset (no annual CO2 increase)") - surface_fields, grid = lib.retrieve_cm2_6(options.pangeo_catalog_uri, options.co2_increase) -logger.info("selecting input data bounding box...") -surface_fields = surface_fields.sel( - xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), - yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) -grid = grid.sel( - xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), - yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) +logger.debug("dropping irrelevant data variables...") +surface_fields = surface_fields[["usurf", "vsurf"]] if options.ntimes is not None: + logger.info(f"slicing {options.ntimes} time points...") surface_fields = surface_fields.isel(time=slice(options.ntimes)) -if options.verbose: - dask.diagnostics.ProgressBar().register() +logger.info("selecting input data bounding box...") +surface_fields = bound_dataset("yu_ocean", "xu_ocean", surface_fields, + bounding_box) +grid = bound_dataset("yu_ocean", "xu_ocean", grid, bounding_box) -surface_fields = surface_fields[["usurf", "vsurf"]] +logger.debug("placing grid dataset into local memory...") +grid = grid.compute() + +if options.cyclize: + logger.info("making dataset cyclic along longitude...") + logger.info("WARNING: may be nonfunctional or have poor performance") + surface_fields = lib.cyclize( + surface_fields, "xu_ocean", options.factor) + grid = lib.cyclize( + grid, "xu_ocean", options.factor) + + logger.debug("rechunking along cyclized dimension...") + surface_fields = surface_fields.chunk({"xu_ocean": -1}) + grid = grid.chunk({"xu_ocean": -1}) logger.info("computing forcings...") -#forcings = lib.preprocess_and_compute_forcings( -# surface_fields, grid, options.cyclize, -# options.factor) -forcings = coarsen.eddy_forcing(surface_fields, grid, options.factor) +forcings = lib.compute_forcings_cm2_6(surface_fields, grid, options.factor) logger.info("selecting forcing bounding box...") -forcings = forcings.sel( - xu_ocean=slice(bounding_box.long_min, bounding_box.long_max), - yu_ocean=slice(bounding_box.lat_min, bounding_box.lat_max)) - -# TODO previously removed -- seems to not change output -for var in forcings: - forcings[var].encoding = {} -forcings = forcings.chunk({"time": 1}) +forcings = bound_dataset("yu_ocean", "xu_ocean", forcings, bounding_box) logger.info(f"writing forcings zarr to directory: {options.out_dir}") forcings.to_zarr(options.out_dir) diff --git a/src/gz21_ocean_momentum/common/bounding_box.py b/src/gz21_ocean_momentum/common/bounding_box.py index ebc3433f..feefce7e 100644 --- a/src/gz21_ocean_momentum/common/bounding_box.py +++ b/src/gz21_ocean_momentum/common/bounding_box.py @@ -1,3 +1,5 @@ +import xarray as xr + from dataclasses import dataclass from typing import Optional from typing import Tuple @@ -16,6 +18,20 @@ class BoundingBox(): long_min: float long_max: float +def bound_dataset( + dim_lat: str, dim_long: str, + data: xr.Dataset, bounding_box: BoundingBox + ): + """Bound an xarray `Dataset` to the given `BoundingBox` using the given + dimension names as spatial axes to bound along. + + The spatial dimensions should be `float`s. Argument order is latitude (y) + followed by longitude (x). + """ + return data.sel({ + dim_lat: slice(bounding_box.lat_min, bounding_box.lat_max), + dim_long: slice(bounding_box.long_min, bounding_box.long_max)}) + def load_bounding_boxes_yaml(path: str) -> list[BoundingBox]: """Load a YAML file of bounding boxes. diff --git a/src/gz21_ocean_momentum/step/data/coarsen.py b/src/gz21_ocean_momentum/step/data/coarsen.py deleted file mode 100644 index 22b834b1..00000000 --- a/src/gz21_ocean_momentum/step/data/coarsen.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""Routines for coarsening a dataset.""" - -import logging -import xarray as xr -from scipy.ndimage import gaussian_filter -import numpy as np - -def eddy_forcing( - u_v_dataset: xr.Dataset, - grid_data: xr.Dataset, - scale: int, - nan_or_zero: str = "zero", -) -> xr.Dataset: - """ - Compute the sub-grid forcing terms using mean coarse-graining. - - Parameters - ---------- - u_v_dataset : xarray Dataset - High-resolution velocity field. - Is changed in function. - grid_data : xarray Dataset - High-resolution grid details. - scale : float - factor (TODO) - nan_or_zero: str, optional - String set to either 'nan' or 'zero'. Determines whether we keep the - nan values in the initial surface velocities array or whether we - replace them by zeros before applying the procedure. - In the second case, remaining zeros after applying the procedure will - be replaced by nans for consistency. - The default is 'zero'. - - Returns - ------- - forcing : xarray Dataset - Dataset containing the low-resolution velocity field and forcing. - """ - # Replace nan values with zeros. - if nan_or_zero == "zero": - u_v_dataset = u_v_dataset.fillna(0.0) - - # Interpolate temperature - # interp_coords = dict(xt_ocean=u_v_dataset.coords['xu_ocean'], - # yt_ocean=u_v_dataset.coords['yu_ocean']) - # u_v_dataset['temp'] = u_v_dataset['surface_temperature'].interp( - # interp_coords) - - # High res advection terms - adv = advections(u_v_dataset, grid_data) - # Filtered advections - filtered_adv = spatial_filter_dataset(adv, grid_data, scale/2) - # Filtered u,v field and temperature - u_v_filtered = spatial_filter_dataset(u_v_dataset, grid_data, scale/2) - # Advection term from filtered velocity field - adv_filtered = advections(u_v_filtered, grid_data) - # Forcing - forcing = adv_filtered - filtered_adv - forcing = forcing.rename({"adv_x": "S_x", "adv_y": "S_y"}) - # Merge filtered u,v, temperature and forcing terms - forcing = forcing.merge(u_v_filtered) - # TODO logging - #logging.debug(forcing) - - # Coarsen - forcing_coarse = forcing.coarsen( - {"xu_ocean": int(scale), "yu_ocean": int(scale)}, boundary="trim" - ) - forcing_coarse = forcing_coarse.mean() - - if nan_or_zero == "zero": - # Replace zeros with nans for consistency - forcing_coarse = forcing_coarse.where(forcing_coarse["usurf"] != 0) - - # Specify input vs output type for each variable of the dataset. Might - # be used later on for training or testing. - forcing_coarse["S_x"].attrs["type"] = "output" - forcing_coarse["S_y"].attrs["type"] = "output" - forcing_coarse["usurf"].attrs["type"] = "input" - forcing_coarse["vsurf"].attrs["type"] = "input" - - return forcing_coarse - -def advections(u_v_field: xr.Dataset, grid_data: xr.Dataset): - """ - Compute advection terms corresponding to the passed velocity field. - - Parameters - ---------- - u_v_field : xarray Dataset - Velocity field, must contains variables "usurf" and "vsurf" - grid_data : xarray Dataset - grid data, must contain variables "dxu" and "dyu" - - Returns - ------- - result : xarray Dataset - Advection components, under variable names "adv_x" and "adv_y" - """ - dxu = grid_data["dxu"] - dyu = grid_data["dyu"] - gradient_x = u_v_field.diff(dim="xu_ocean") / dxu - gradient_y = u_v_field.diff(dim="yu_ocean") / dyu - # Interpolate back the gradients - interp_coords = { - "xu_ocean": u_v_field.coords["xu_ocean"], - "yu_ocean": u_v_field.coords["yu_ocean"], - } - # TODO got "ValueError: zero-size array to reduction operation fmin which has - # no identity" when given 0 bounding box - gradient_x = gradient_x.interp(interp_coords) - gradient_y = gradient_y.interp(interp_coords) - u, v = u_v_field["usurf"], u_v_field["vsurf"] - adv_x = u * gradient_x["usurf"] + v * gradient_y["usurf"] - adv_y = u * gradient_x["vsurf"] + v * gradient_y["vsurf"] - result = xr.Dataset({"adv_x": adv_x, "adv_y": adv_y}) - # TODO check if we can simply prevent the previous operation from adding - # chunks - # result = result.chunk(dict(xu_ocean=-1, yu_ocean=-1)) - return result - -def spatial_filter_dataset( - dataset: xr.Dataset, grid_data: xr.Dataset, sigma: float - ) -> xr.Dataset: - """ - Apply spatial filtering to the dataset across the spatial dimensions. - - Parameters - ---------- - dataset : xarray Dataset - Dataset to filter. First dimension must be time, followed by spatial dimensions - grid_data: xarray Dataset - grid data, must include variables "dxu" and "dyu" - sigma : float - Scale of the filtering, same unit as those of the grid (often, meters) - - Returns - ------- - filt_dataset : xarray Dataset - Filtered dataset - """ - area_u = grid_data["dxu"] * grid_data["dyu"] / 1e8 - - # Normalisation term, so that if the quantity we filter is constant - # over the domain, the filtered quantity is constant with the same value - norm = xr.apply_ufunc( - lambda x: gaussian_filter(x, sigma, mode="constant"), - area_u, - dask="parallelized", - output_dtypes=[ - float, - ], - ) - filtered = xr.apply_ufunc( - lambda x: spatial_filter(x, sigma), - dataset * area_u, - dask="parallelized", - output_dtypes=[ - float, - ], - ) - return filtered / norm - -def spatial_filter(data: np.ndarray, sigma: float): - """ - Apply a gaussian filter to spatial data. - - Apply scipy gaussian filter to along all dimensions except first one, which - corresponds to time. - - Parameters - ---------- - data : ndarray - Data to filter. - sigma : float - Unitless scale of the filter. - - Returns - ------- - result : ndarray - Filtered data - """ - result = np.zeros_like(data) - for t in range(data.shape[0]): - data_t = data[t, ...] - result_t = gaussian_filter(data_t, sigma, mode="constant") - result[t, ...] = result_t - return result diff --git a/src/gz21_ocean_momentum/step/data/lib.py b/src/gz21_ocean_momentum/step/data/lib.py index 5f96bc7f..f8f1a342 100644 --- a/src/gz21_ocean_momentum/step/data/lib.py +++ b/src/gz21_ocean_momentum/step/data/lib.py @@ -1,7 +1,11 @@ -import gz21_ocean_momentum.step.data.coarsen as coarsen +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Data step API: CM2.6 downloading, forcing generation and coarsening.""" import xarray as xr import intake +from scipy.ndimage import gaussian_filter +import numpy as np from typing import Optional from typing import Tuple @@ -10,37 +14,6 @@ logger = logging.getLogger(__name__) -def preprocess_and_compute_forcings( - surface_fields: xr.Dataset, - grid: xr.Dataset, - cyclize: bool, - resolution_degrading_factor: int, - ) -> xr.Dataset: - """ - Perform various preprocessing on a dataset. - """ - - if cyclize: - # TODO logger - #logger.info("Cyclic data... Making the dataset cyclic along longitude...") - surface_fields = _cyclize( - surface_fields, "xu_ocean", resolution_degrading_factor) - grid = _cyclize( - grid, "xu_ocean", resolution_degrading_factor) - - # rechunk along the cyclized dimension - surface_fields = surface_fields.chunk({"xu_ocean": -1}) - grid = grid.chunk({"xu_ocean": -1}) - - # TODO should this be earlier? later? never? ??? - # TODO logger - #logger.debug("Getting grid data locally") - # grid data is saved locally, no need for dask - #grid = grid.compute() - - # calculate eddy-forcing dataset for that particular patch - return coarsen.eddy_forcing(surface_fields, grid, resolution_degrading_factor) - def retrieve_cm2_6( catalog_uri: str, co2_increase: bool, @@ -53,8 +26,6 @@ def retrieve_cm2_6( `/home/user/catalog.yaml` directly. """ - logger.info("retrieving CM2.6 dataset via Pangeo Cloud Datastore...") - catalog = intake.open_catalog(catalog_uri) grid = catalog.GFDL_CM2_6.GFDL_CM2_6_grid grid = grid.to_dask() @@ -63,28 +34,28 @@ def retrieve_cm2_6( grid = grid.reset_coords()[["dxu", "dyu", "wet"]] if co2_increase: - logger.info("(using 1% annual CO2 increase dataset)") + logger.info("using 1% annual CO2 increase dataset") surface_fields = catalog.GFDL_CM2_6.GFDL_CM2_6_one_percent_ocean_surface else: - logger.info("(using control dataset -> no annual CO2 increase)") + logger.info("using control dataset -> no annual CO2 increase") surface_fields = catalog.GFDL_CM2_6.GFDL_CM2_6_control_ocean_surface surface_fields = surface_fields.to_dask() return surface_fields, grid -def _cyclize(ds: xr.Dataset, coord_name: str, nb_points: int): +def cyclize(dim_name: str, ds: xr.Dataset, nb_points: int): """ Generate a cyclic dataset from non-cyclic input. - Return a cyclic dataset, with nb_points added on each end, along - the coordinate specified by coord_name. + Return a cyclic dataset, with `nb_points` added on each end, along + the dimension specified by `dim_name`. Parameters ---------- + dim_name: str + Name of the dimension along which the data is made cyclic. ds : xr.Dataset Dataset to process. - coord_name : str - Name of the coordinate along which the data is made cyclic. nb_points : int Number of points added on each end. @@ -92,18 +63,199 @@ def _cyclize(ds: xr.Dataset, coord_name: str, nb_points: int): ------- New extended dataset. """ - # TODO make this flexible + # TODO 2023-09-20: old note from original import: "make this flexible" cycle_length = 360.0 - left = ds.roll({coord_name: nb_points}, roll_coords=True) - right = ds.roll({coord_name: nb_points}, roll_coords=True) - right = right.isel({coord_name: slice(0, 2 * nb_points)}) - left[coord_name] = xr.concat( - (left[coord_name][:nb_points] - cycle_length, left[coord_name][nb_points:]), - coord_name, + left = ds.roll({dim_name: nb_points}, roll_coords=True) + right = left.isel({dim_name: slice(0, 2 * nb_points)}) + left[dim_name] = xr.concat( + (left[dim_name][:nb_points] - cycle_length, left[dim_name][nb_points:]), + dim_name, + ) + right[dim_name] = xr.concat( + (right[dim_name][:nb_points], right[dim_name][nb_points:] + cycle_length), + dim_name, ) - right[coord_name] = xr.concat( - (right[coord_name][:nb_points], right[coord_name][nb_points:] + cycle_length), - coord_name, + return xr.concat((left, right), dim_name) + + +def compute_forcings_cm2_6( + u_v_dataset: xr.Dataset, + grid_data: xr.Dataset, + scale: int, + nan_or_zero: str = "zero", +) -> xr.Dataset: + """ + Compute the sub-grid forcing terms using mean coarse-graining. + + Parameters + ---------- + u_v_dataset : xarray Dataset + High-resolution velocity field. + grid_data : xarray Dataset + High-resolution grid details. + scale : float + gaussian filtering & coarsening factor + nan_or_zero: str, optional + String set to either 'nan' or 'zero'. Determines whether we keep the + nan values in the initial surface velocities array or whether we + replace them by zeros before applying the procedure. + In the second case, remaining zeros after applying the procedure will + be replaced by nans for consistency. + The default is 'zero'. + + Returns + ------- + forcing : xarray Dataset + Dataset containing the low-resolution velocity field and forcing. + """ + # Replace nan values with zeros. + if nan_or_zero == "zero": + u_v_dataset = u_v_dataset.fillna(0.0) + + # Interpolate temperature + # interp_coords = dict(xt_ocean=u_v_dataset.coords['xu_ocean'], + # yt_ocean=u_v_dataset.coords['yu_ocean']) + # u_v_dataset['temp'] = u_v_dataset['surface_temperature'].interp( + # interp_coords) + + # High res advection terms + adv = _advections(u_v_dataset, grid_data) + # Filtered advections + filtered_adv = _spatial_filter_dataset(adv, grid_data, scale/2) + # Filtered u,v field and temperature + u_v_filtered = _spatial_filter_dataset(u_v_dataset, grid_data, scale/2) + # Advection term from filtered velocity field + adv_filtered = _advections(u_v_filtered, grid_data) + # Forcing + forcing = adv_filtered - filtered_adv + forcing = forcing.rename({"adv_x": "S_x", "adv_y": "S_y"}) + # Merge filtered u,v, temperature and forcing terms + forcing = forcing.merge(u_v_filtered) + logger.debug("uncoarsened forcings follow below:") + logger.debug(forcing) + + # Coarsen + forcing_coarse = forcing.coarsen( + {"xu_ocean": int(scale), "yu_ocean": int(scale)}, boundary="trim" ) - new_ds = xr.concat((left, right), coord_name) - return new_ds + forcing_coarse = forcing_coarse.mean() + + if nan_or_zero == "zero": + # Replace zeros with nans for consistency + forcing_coarse = forcing_coarse.where(forcing_coarse["usurf"] != 0) + + # Specify input vs output type for each variable of the dataset. Might + # be used later on for training or testing. + forcing_coarse["S_x"].attrs["type"] = "output" + forcing_coarse["S_y"].attrs["type"] = "output" + forcing_coarse["usurf"].attrs["type"] = "input" + forcing_coarse["vsurf"].attrs["type"] = "input" + + return forcing_coarse + + +def _advections(u_v_field: xr.Dataset, grid_data: xr.Dataset): + """ + Compute advection terms corresponding to the passed velocity field. + + Parameters + ---------- + u_v_field : xarray Dataset + Velocity field, must contains variables "usurf" and "vsurf" + grid_data : xarray Dataset + grid data, must contain variables "dxu" and "dyu" + + Returns + ------- + result : xarray Dataset + Advection components, under variable names "adv_x" and "adv_y" + """ + dxu = grid_data["dxu"] + dyu = grid_data["dyu"] + gradient_x = u_v_field.diff(dim="xu_ocean") / dxu + gradient_y = u_v_field.diff(dim="yu_ocean") / dyu + # Interpolate back the gradients + interp_coords = { + "xu_ocean": u_v_field.coords["xu_ocean"], + "yu_ocean": u_v_field.coords["yu_ocean"], + } + # TODO got "ValueError: zero-size array to reduction operation fmin which has + # no identity" when given 0 bounding box + gradient_x = gradient_x.interp(interp_coords) + gradient_y = gradient_y.interp(interp_coords) + u, v = u_v_field["usurf"], u_v_field["vsurf"] + adv_x = u * gradient_x["usurf"] + v * gradient_y["usurf"] + adv_y = u * gradient_x["vsurf"] + v * gradient_y["vsurf"] + result = xr.Dataset({"adv_x": adv_x, "adv_y": adv_y}) + # TODO check if we can simply prevent the previous operation from adding + # chunks + # result = result.chunk(dict(xu_ocean=-1, yu_ocean=-1)) + return result + +def _spatial_filter_dataset( + dataset: xr.Dataset, grid_data: xr.Dataset, sigma: float + ) -> xr.Dataset: + """ + Apply spatial filtering to the dataset across the spatial dimensions. + + Parameters + ---------- + dataset : xarray Dataset + Dataset to filter. First dimension must be time, followed by spatial dimensions + grid_data: xarray Dataset + grid data, must include variables "dxu" and "dyu" + sigma : float + Scale of the filtering, same unit as those of the grid (often, meters) + + Returns + ------- + filt_dataset : xarray Dataset + Filtered dataset + """ + area_u = grid_data["dxu"] * grid_data["dyu"] / 1e8 + + # Normalisation term, so that if the quantity we filter is constant + # over the domain, the filtered quantity is constant with the same value + norm = xr.apply_ufunc( + lambda x: gaussian_filter(x, sigma, mode="constant"), + area_u, + dask="parallelized", + output_dtypes=[ + float, + ], + ) + filtered = xr.apply_ufunc( + lambda x: _spatial_filter(x, sigma), + dataset * area_u, + dask="parallelized", + output_dtypes=[ + float, + ], + ) + return filtered / norm + +def _spatial_filter(data: np.ndarray, sigma: float): + """ + Apply a gaussian filter to spatial data. + + Apply scipy gaussian filter to along all dimensions except first one, which + corresponds to time. + + Parameters + ---------- + data : ndarray + Data to filter. + sigma : float + Unitless scale of the filter. + + Returns + ------- + result : ndarray + Filtered data + """ + result = np.zeros_like(data) + for t in range(data.shape[0]): + data_t = data[t, ...] + result_t = gaussian_filter(data_t, sigma, mode="constant") + result[t, ...] = result_t + return result From 92ee219ff50ef9d893e14ee6203272fc01152dda Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 20 Sep 2023 14:43:16 +0100 Subject: [PATCH 016/114] bounding box: add validate function --- src/gz21_ocean_momentum/cli/data.py | 14 ++++++++------ src/gz21_ocean_momentum/common/bounding_box.py | 11 ++++++++--- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py index 8b01a084..8d57bbcb 100644 --- a/src/gz21_ocean_momentum/cli/data.py +++ b/src/gz21_ocean_momentum/cli/data.py @@ -1,6 +1,7 @@ import gz21_ocean_momentum.step.data.lib as lib import gz21_ocean_momentum.common.cli as cli -from gz21_ocean_momentum.common.bounding_box import BoundingBox, bound_dataset +from gz21_ocean_momentum.common.bounding_box import BoundingBox +import gz21_ocean_momentum.common.bounding_box as bounding_box import configargparse @@ -45,9 +46,11 @@ "if the directory exists, ensure it is empty") # store bounding box in a struct-like -bounding_box = BoundingBox( +bbox = BoundingBox( options.lat_min, options.lat_max, options.long_min, options.long_max) +if not bounding_box.validate_nonempty(bbox): + cli.fail(2, f"provided bounding box describes an empty region: {bbox}") logger.info("retrieving CM2.6 dataset via Pangeo Cloud Datastore...") surface_fields, grid = lib.retrieve_cm2_6(options.pangeo_catalog_uri, options.co2_increase) @@ -60,9 +63,8 @@ surface_fields = surface_fields.isel(time=slice(options.ntimes)) logger.info("selecting input data bounding box...") -surface_fields = bound_dataset("yu_ocean", "xu_ocean", surface_fields, - bounding_box) -grid = bound_dataset("yu_ocean", "xu_ocean", grid, bounding_box) +surface_fields = bounding_box.bound_dataset("yu_ocean", "xu_ocean", surface_fields, bbox) +grid = bounding_box.bound_dataset("yu_ocean", "xu_ocean", grid, bbox) logger.debug("placing grid dataset into local memory...") grid = grid.compute() @@ -83,7 +85,7 @@ forcings = lib.compute_forcings_cm2_6(surface_fields, grid, options.factor) logger.info("selecting forcing bounding box...") -forcings = bound_dataset("yu_ocean", "xu_ocean", forcings, bounding_box) +forcings = bound_dataset("yu_ocean", "xu_ocean", forcings, bbox) logger.info(f"writing forcings zarr to directory: {options.out_dir}") forcings.to_zarr(options.out_dir) diff --git a/src/gz21_ocean_momentum/common/bounding_box.py b/src/gz21_ocean_momentum/common/bounding_box.py index feefce7e..6bb40e3b 100644 --- a/src/gz21_ocean_momentum/common/bounding_box.py +++ b/src/gz21_ocean_momentum/common/bounding_box.py @@ -18,9 +18,14 @@ class BoundingBox(): long_min: float long_max: float +#@staticmethod +def validate_nonempty(bbox: BoundingBox) -> bool: + """Validate that a bounding box represents a non-empty region.""" + return bbox.lat_max > bbox.lat_min and bbox.long_max > bbox.long_min + def bound_dataset( dim_lat: str, dim_long: str, - data: xr.Dataset, bounding_box: BoundingBox + data: xr.Dataset, bbox: BoundingBox ): """Bound an xarray `Dataset` to the given `BoundingBox` using the given dimension names as spatial axes to bound along. @@ -29,8 +34,8 @@ def bound_dataset( followed by longitude (x). """ return data.sel({ - dim_lat: slice(bounding_box.lat_min, bounding_box.lat_max), - dim_long: slice(bounding_box.long_min, bounding_box.long_max)}) + dim_lat: slice(bbox.lat_min, bbox.lat_max), + dim_long: slice(bbox.long_min, bbox.long_max)}) def load_bounding_boxes_yaml(path: str) -> list[BoundingBox]: """Load a YAML file of bounding boxes. From 3cfb29869eb56c945e10d695c19ee0c5e22f52e7 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 20 Sep 2023 14:45:06 +0100 Subject: [PATCH 017/114] step/data: clean up some to-dos --- src/gz21_ocean_momentum/step/data/lib.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/gz21_ocean_momentum/step/data/lib.py b/src/gz21_ocean_momentum/step/data/lib.py index f8f1a342..97e191fc 100644 --- a/src/gz21_ocean_momentum/step/data/lib.py +++ b/src/gz21_ocean_momentum/step/data/lib.py @@ -179,16 +179,14 @@ def _advections(u_v_field: xr.Dataset, grid_data: xr.Dataset): "xu_ocean": u_v_field.coords["xu_ocean"], "yu_ocean": u_v_field.coords["yu_ocean"], } - # TODO got "ValueError: zero-size array to reduction operation fmin which has - # no identity" when given 0 bounding box gradient_x = gradient_x.interp(interp_coords) gradient_y = gradient_y.interp(interp_coords) u, v = u_v_field["usurf"], u_v_field["vsurf"] adv_x = u * gradient_x["usurf"] + v * gradient_y["usurf"] adv_y = u * gradient_x["vsurf"] + v * gradient_y["vsurf"] result = xr.Dataset({"adv_x": adv_x, "adv_y": adv_y}) - # TODO check if we can simply prevent the previous operation from adding - # chunks + # TODO 2023-09-20: old note from original import: v + # check if we can simply prevent the previous operation from adding chunks # result = result.chunk(dict(xu_ocean=-1, yu_ocean=-1)) return result From bff998930e316441005ee4d8a5020e1280f16cea Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 20 Sep 2023 15:18:17 +0100 Subject: [PATCH 018/114] fix data step pytests --- .../{data/test_coarse.py => step/test_data.py} | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) rename tests/{data/test_coarse.py => step/test_data.py} (95%) diff --git a/tests/data/test_coarse.py b/tests/step/test_data.py similarity index 95% rename from tests/data/test_coarse.py rename to tests/step/test_data.py index 335f34c6..d3b6c30f 100755 --- a/tests/data/test_coarse.py +++ b/tests/step/test_data.py @@ -1,14 +1,13 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""Unit tests for the data/coarse.py module""" +"""Unit tests for the data step.""" import pytest import xarray as xr import numpy as np from numpy import ma import matplotlib.pyplot as plt -from gz21_ocean_momentum.data.coarse import spatial_filter_dataset, spatial_filter, eddy_forcing - +import gz21_ocean_momentum.step.data.lib as lib class TestEddyForcing: "Class to test eddy forcing routines." @@ -18,7 +17,7 @@ def test_spatial_filter(self): Check that number of dimensions stay the same through spatial_filter(). """ a = np.random.randn(10, 4, 4) - filtered_a = spatial_filter(a, 5) + filtered_a = lib._spatial_filter(a, 5) assert a.ndim == filtered_a.ndim def test_spatial_filter_of_constant(self): @@ -49,7 +48,7 @@ def test_spatial_filter_of_constant(self): } ) - filtered_data = spatial_filter_dataset(data, grid_info, (5, 5)) + filtered_data = lib._spatial_filter_dataset(data, grid_info, (5, 5)) assert data["a"].values == pytest.approx(filtered_data["a"].values) @@ -88,7 +87,7 @@ def test_eddy_forcing_chunks(self): ), } ) - forcing = eddy_forcing(data, grid_info, 4) + forcing = lib.compute_forcings_cm2_6(data, grid_info, 4) usurf_0, usurf_1 = forcing.usurf.isel(time=0), forcing.usurf.isel(time=1) # remove nan values at the boundaries from the test usurf_0 = usurf_0.data[~np.isnan(usurf_0)] @@ -144,9 +143,7 @@ def test_eddy_forcing_chunking(self): ) # new forcing: apply - forcing_new = eddy_forcing( - data, grid_info, scale=scale_m, - method='mean', scale_mode='factor') + forcing_new = lib.compute_forcings_cm2_6(data, grid_info, scale=scale_m) # new forcing: post-chunk for var in forcing_new: @@ -172,7 +169,7 @@ def f(block): - S_x and S_y, the two components of the diagnosed subgrid momentum forcing """ - return eddy_forcing(block, grid_info, scale=scale_m) + return lib.compute_forcings_cm2_6(block, grid_info, scale=scale_m) # old forcing: apply template = data.coarsen( From ca49b1c8e7404cb42038b878d9c5549214d047fe Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 22 Sep 2023 14:42:11 +0100 Subject: [PATCH 019/114] MLproject: remove data step --- MLproject | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/MLproject b/MLproject index 820fd445..6c63307c 100755 --- a/MLproject +++ b/MLproject @@ -4,24 +4,6 @@ name: gz21_ocean_momentum entry_points: - data: - parameters: - out_dir : str - lat_min : float - lat_max : float - long_min : float - long_max : float - ntimes : {type: float, default: 10000} - factor: {type: float, default: 0} - #global: {type: str, default: 0} # unclear how to do flags with mlflow - #CO2: {type: float, default: 0} # unclear how to do flags with mlflow - command: >- - python src/gz21_ocean_momentum/cli/data.py - --lat-min {lat_min} --lat-max {lat_max} - --long-min {long_min} --long-max {long_max} - --ntimes {ntimes} --factor {factor} - --out-dir {out_dir} - train: parameters: forcing_data_path: {type: string, default: None} From 53f126337a1027ca430d61cf6f3a3949426425f2 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 22 Sep 2023 16:28:19 +0100 Subject: [PATCH 020/114] readme: fix example commands --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 53a22bbc..5924b4ee 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ See [`docs/data.md`](docs/data.md) for more details. Example invocation: - python src/gz21_ocean_momentum/cmip26.py \ + python src/gz21_ocean_momentum/cli/data.py \ --lat-min -80 --lat-max 80 --long-min -280 --long-max 80 \ --factor 4 --ntimes 100 --co2-increase --out-dir forcings @@ -131,7 +131,7 @@ co2-increase: true and use this file in an invocation with the `--config-file` option: - python src/gz21_ocean_momentum/cmip26.py \ + python src/gz21_ocean_momentum/cli/data.py \ --config-file examples/cli-configs/data-paper.yaml --out-dir forcings Some preprocessed data is hosted on HuggingFace at From 08967b7980daaa0f373fcbd65c4a5600d396029f Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 22 Sep 2023 16:37:46 +0100 Subject: [PATCH 021/114] readme: +note on --help for data step CLI --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 5924b4ee..35b7cdc4 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,10 @@ and use this file in an invocation with the `--config-file` option: python src/gz21_ocean_momentum/cli/data.py \ --config-file examples/cli-configs/data-paper.yaml --out-dir forcings +For command-line option explanation, append the `--help` flag: + + python src/gz21_ocean_momentum/cli/data.py --help + Some preprocessed data is hosted on HuggingFace at [datasets/M2LInES/gfdl-cmip26-gz21-ocean-forcing](https://huggingface.co/datasets/M2LInES/gfdl-cmip26-gz21-ocean-forcing). From 4a022d1046e25ea317cc03a947a453841491f5c2 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 22 Sep 2023 16:38:44 +0100 Subject: [PATCH 022/114] cli/data: fix bound_dataset call --- src/gz21_ocean_momentum/cli/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py index 8d57bbcb..77709c76 100644 --- a/src/gz21_ocean_momentum/cli/data.py +++ b/src/gz21_ocean_momentum/cli/data.py @@ -85,7 +85,7 @@ forcings = lib.compute_forcings_cm2_6(surface_fields, grid, options.factor) logger.info("selecting forcing bounding box...") -forcings = bound_dataset("yu_ocean", "xu_ocean", forcings, bbox) +forcings = bounding_box.bound_dataset("yu_ocean", "xu_ocean", forcings, bbox) logger.info(f"writing forcings zarr to directory: {options.out_dir}") forcings.to_zarr(options.out_dir) From 16158b7b259b3d14f38cdf2982242329bf3aa62c Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 22 Sep 2023 16:40:07 +0100 Subject: [PATCH 023/114] readme: +note on old GEOS req --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 35b7cdc4..4b8013a8 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ With `pip` installed, run the following in the root directory: that the Poetry build is not actively supported-- if it fails, check that the dependencies are up-to-date with the setuptools `pyproject.toml`.)* +Note that if you are running Python 3.9 or older, you may also need to install +the [GEOS](https://libgeos.org/) library, due to `cartopy` requiring it. (Newer +versions moved away from the C dependency.) + ### Running unit tests There are a handful of unit tests using pytest, in the [`tests`](tests/) directory. These assert some operations and methods used in the steps. They may From cd85ea9cd830bec9dcb9a3a2ef379442cf860899 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 17 Oct 2023 16:56:56 +0100 Subject: [PATCH 024/114] rework figure 1 generator notebook * Hardcode subdomains instead of loading from hardcoded file on disk (less surprising). * Clean up plotting code. * Clarify dependencies. Generated figure matches previous visually. --- .../generate-paper-figure-1.ipynb | 168 ++++++++++-------- .../analysis/latex_table.txt | 8 - src/gz21_ocean_momentum/analysis/utils.py | 99 +++++------ 3 files changed, 140 insertions(+), 135 deletions(-) delete mode 100644 src/gz21_ocean_momentum/analysis/latex_table.txt diff --git a/examples/jupyter-notebooks/generate-paper-figure-1.ipynb b/examples/jupyter-notebooks/generate-paper-figure-1.ipynb index 7e223da9..a9d26cc5 100644 --- a/examples/jupyter-notebooks/generate-paper-figure-1.ipynb +++ b/examples/jupyter-notebooks/generate-paper-figure-1.ipynb @@ -4,81 +4,53 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Code to generate Figure 1 " + "# Code to generate Figure 1\n", + "## Dependencies\n", + "* `ipympl` (`pip install ipympl`)\n", + "* `cmocean` (`pip install cmocean`)\n", + "\n", + "## Steps\n", + "### Locate forcing data\n", + "Ensure that this path points to some existing forcings, which are generated with whatever configuration is expected." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ - "%cd ../../src/gz21_ocean_momentum\n", - "import os\n", - "from utils import select_experiment, select_run\n", - "from analysis.utils import plot_dataset, GlobalPlotter, plot_training_subdomains\n", - "from data.utils import load_training_datasets\n", - "import mlflow\n", - "from mlflow.tracking import MlflowClient\n", - "import xarray as xr\n", - "from dask.diagnostics import ProgressBar\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "\n", - "import cmocean\n", - "cmap_solar = cmocean.cm.solar\n", - "cmap_balance = cmocean.cm.balance\n", - "\n", - "mlruns_path=os.path.join(os.getcwd(), '../../mlruns')\n", - "%env MLFLOW_TRACKING_URI $mlruns_path\n", - "\n", - "plt.rcParams[\"figure.figsize\"] = (4, 4 / 1.618)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Select an experiment by experiment ID, and get the associated run" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "exp_id, exp_name = select_experiment()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "experiment_id = mlflow.get_experiment_by_name(exp_name).experiment_id\n", - "cols = ['params.CO2', ]\n", - "run = select_run(cols=cols, experiment_ids=(experiment_id,))" + "forcings_path = \"~/sh/gz21/tmp/generated/forcings/paper\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Get the data set of the selected run " + "### Set subdomains\n", + "We use the subdomains used in the 2021 paper." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "To load the net from the paper, use the function load_paper_net().\n" + ] + } + ], "source": [ - "ml_client = MlflowClient()\n", - "data_fname = ml_client.download_artifacts(run.run_id, 'forcing')\n", - "data = xr.open_zarr(data_fname)\n", - "#data = data.rename(dict(xu_ocean='longitude', yu_ocean='latitude'))" + "from gz21_ocean_momentum.common.bounding_box import BoundingBox\n", + "\n", + "bboxes = [BoundingBox( 35, 50, -50, -20),\n", + " BoundingBox(-40, -25, -180, -162),\n", + " BoundingBox(-20, -5, -110, -92),\n", + " BoundingBox( -0, 15, -48, -30)]" ] }, { @@ -90,23 +62,72 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "282aa309cc034388bce26a2867874ec4", + "version_major": 2, + "version_minor": 0 + }, + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAZAAAAD3CAYAAAAzOQKaAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8pXeV/AAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOydd3hU1fa/3+k1M+mNVEggoSO9I6A0AQUVUVQUQQUExAYqggoCooiIqCgiijRFEARROtJ7JyQQ0nsyyWR6O78/BnKN4L0Kot97f+d9nnkgZ07Ze88557P32muvJREEQUBERERERORPIv2nCyAiIiIi8t+JKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeEKCAiIiIiIjeE/J8uwF+Fw+HA5XL908UQERG5BSiVStRq9T9dDJHfIvwPYLfbhcjISAEQP+JH/PwPfiIjIwW73f5Pv2qEBQsWCPHx8YJKpRLatGkjHDx48Hf3XbJkyTX1UKlUtfbx+XzClClThMjISEGtVgs9evQQ0tPTb3U1/jL+J0YgLpeLoqIicnNzMRgM/3RxRERE/kLMZjOxsbG4XK5/dBSyatUqJk6cyMcff0zbtm2ZN28evXr14sKFC4SHh1/3GIPBwIULF2r+lkgktb5/++23mT9/PkuXLiUxMZEpU6bQq1cvzp07998x4rrVCuXxeIRXX31VSEhIENRqtVC3bl3hjTfeEHw+X80+N6vCVVVVAiBUVVXdiiqIiIj8g/xfeb7btGkjjBkzpuZvr9crREdHCzNnzrzu/kuWLBGMRuPvns/n8wmRkZHCnDlzarZVVlYKKpVKWLFixV9W7lvJLR+BzJ49m48++oilS5fSqFEjjhw5wmOPPYbRaGTcuHHA/4AK/x9BEAR8Ph+nT5+msLAQs9mMx+NBJpMhk8moqqoCQK1Wo1arUalUqFQqDAYDERERxMXFIZPJ/uN1fD4f2dnZnDp1iuLiYmQyGTabjWPHjmGz2WrmouRyOQqFArlcjlwuR6PREBcXR2JiIgkJCdStW5fQ0NBb2iYiIn8FLpeLo0ePMnny5JptUqmUnj17sn///t89zmKxEB8fj8/n47bbbuOtt96iUaNGAFy+fJmioiJ69uxZs7/RaKRt27bs37+fBx544NZV6C/ilgvIvn37GDhwIP369QMgISGBFStWcOjQIcD/0ps3bx6vvvoqAwcOBODLL78kIiKCdevW/Vc04t+Jz+fD5XJRWVnJ+PHjUSqVVFRUcObMGfLy8pDL5TfsTKBSqRg+fDhWqxWLxYLX60UikZCYmMiFCxfIz88nKSmJtWvXXnOsVCqlRYsWBAYGolQqkUgkrFu37j9e84033iAuLo6ysjKcTieBgYE0atSIrl273lAd/n/C5/MhCMIfEv3/37mek40gCNeYlK52qn5LWVkZXq+XiIiIWtsjIiJIS0u77jUbNGjA559/TtOmTamqquKdd96hQ4cOnD17lpiYGIqKimrO8dtzXv3u/zq3XEA6dOjAokWLSE9Pp379+pw8eZI9e/Ywd+5c4H9Dhf8OvF4vc+fO5cUXXwT8vXuPx3PNflftxMeOHSM6OhqFQoHb7UYQBAICAvB4PCxZsoSXX34Zk8lU61in08m+ffsIDAxEr9cjl8txOp1s2LCBlJQUOnbsyKVLl2odc/Uldr0X2bfffst99933b+v12muvXXf76dOnady48X9sl+vh9XrJy8vDaDRisVjYu3cvmzZtQqlUEhwcjMvlwufzUadOHeLi4hgyZMg1L5LfIz09nQYNGtTa1rZtW7Zv345Wq72h8v4ZNm/ezJAhQwgJCSE3NxeNRsPtt9/OiBEj6Nevnygm18HhcGDUaHEh1Nqu1+uxWCy1tk2dOpVp06b9Jddt37497du3r/m7Q4cOpKam8sknn/Dmm2/+Jdf4p7nlAjJp0iTMZjMpKSnIZDK8Xi8zZszgoYceArghFXY6nTidzpq/zWbzLSr9rWf37t0cPXqU1q1b06lTJwD69+9PdnY2AwcOJDU1lRYtWrBq1Spef/31muM6depEWVkZGRkZtdoC/A+MUqnEaDSyatWqGhEODQ2lrKwMAIVCUeuYZcuW0aZNG5KTk/9QuQVBwOPxIJFIfvfle++99yIIwjXbL126xKpVqygsLGTBggU12/v370/jxo1p3LgxKSkpf6gcv2Xz5s306dPnTx1z//33/2EBuV59jh8/jtfr/VPXvFHeeecdzGYzZrMZvV5PQEAA69evZ/369TRr1ox69eoRFxdH165dGThw4B+u1/9lSi9ev4f/R3G5XLgQ6KIJRX6lPTyCwG5L2TWON9cbfYD/2ZHJZBQXF9faXlxcTGRk5B8qh0KhoEWLFly8eBGg5rji4mKioqJqnbN58+Z/uH7/JLdcQFavXs3XX3/N8uXLadSoESdOnGDChAlER0fz6KOP3tA5Z86cWetlepWK/Bw85gBkKg0Ags+LROrvkUmkMrw+X82+MqkUEPB53DXfX90XBLy+2i8KmfRfD6Lg813ZVwAkmO0OlHIZ4b+5kUqKinh9xkz0aiVPjRrFvkOHyM/Pp7q6mrvuuovx48dz8ODBmv0rKys5c+YMP/zwA+DvhYPfB/63w++dO3eiVquvEY8jh49QLz6OC7kFLH/zzVo9/KviAeB2u2v+/83a72nZonlNmdUKObbiPJSGIASvD5NPik6l5JNFn9T0ziQSyTUi9EepV68eL7/8Mr/88gsLFiygc6dOrF21Atxuyjxgdrg4e/EydreXaKOejFITMomEKoeLOXvKeadPLGaHk2+WfIq2yyDuT43EZHfy49df4HM5al2rfYcOhDdry/cfvXdNOXoOHIQiLIap3+1AKpNxW4SBzEobXRPCCTfocLg9KGUyjBolTq8PucdJVGgIr732GsOGDWP+e+/x+ssvIdfoMZmqKC6rIBA3RW4JkYEBKGUyfB4XXoeDdz78iLdmz8Zms6HRaLCYTMz94IM/3dvdsmULhw8f5u233+bIkSMUFBTUfHfy5ElOnjwJwLx581i8eDGPP/74NeeYNm1azXWrSkuY/sbrjH94CMrAUASft+b5AfA67TjLi8Hnxed04HP770OvtQqf2w2+K8IplSGR+dclS+RK//Mkk/n/L5MhkSuQqjTItXrkOgNyrQ6p4sp+UimCz4fXacfrdCCRypCp1Ag+L4LXh0z518yDqqUyFBJ/Gd2C/11gMBj+kOemUqmkZcuWbNu2jbvvvhvwj763bdvG2LFj/9D1vV4vp0+fpm/fvgAkJiYSGRnJtm3bagTDbDZz8OBBnn766T9Zu38GiXC9LtVfSGxsLJMmTWLMmDE126ZPn86yZctIS0sjMzOTevXqcfz48Vqq27VrV5o3b877779/zTmvNwKJjY0l69IlNFfMCA6XB5fXi8vjxferKkolklpioFEoUMhlKOQylLJ/iYJw5cGQSK8u1pfg8nqRXTm+tvBIAQmOKy9lk9VOuEGP9MqQuXP3Hpw+c6ZWHYxGI2GhoVz8lUkoISGB0aNH4/V68Xg8fP3112RlZeFwONBoNNjt9lrnuJ6wpDZogNVmIyc3t2bbrFmzGDp0KHPnzr1uez751NMIgg+tRkOD5GRua9mS5Ng6uC1mvE4HrooiAhu2whh2fVfFG+Hzzz9nxIgRAGi1WsaOHs2YZ8aTUWoi32xDr5RTaHEQoJQTrFGSEhHM2cJyjhWbqReoQa+Uo5RJOVViRiOXUW73cLTATUKwlCKTnXObvyF945c11wsKDeOxxx5n7pzZNds2/7Kf2Ihw3F4vQVoNCrkMjQwErxe3xYxCb0Cm8r+8bMV5SOVK7IVZBDVugyHkX5P/06ZN46n77yZr0XSSXl6IqbSEZye9TNs2rcnMzOTChQu1JlojIyM5fewo7743j5lvv/2H28xisZCenk5paSknTpzAZrOhVqs5f/48P/74Y60Owq8JCQlBqVTSpEkT+vTpw9ixY5HLr+07FqedxllejLuiGI/FjFSlRhkSgVxnRKbVIdfo2bVvP2fTL1JttSKVybl06SIhISFMffF57IXZKOUy5DojqqAQZCoNLrPJ33lz2pEqlAhe75V21WB1ujBZ/fe0XqXE7nbj9vrQqRQoZDJkUikyqQSr1UrdpCSqqqpuyE3fbDZjNBq5Sx9dS0B+sBT8qXOuWrWKRx99lE8++YQ2bdowb948Vq9eTVpaGhERETzyyCPUqVOHmTNnAv75vXbt2pGUlERlZSVz5sxh3bp1HD16lIYNGwJ+J6NZs2bVciA6derUf40D0S0fgdhsNqTS2hFTZDIZviujgRtR4d+b6Np3KQ+tTo/L6z+3TCpBo5Dh9Qn+/8vlSK+IR6hOg16lJECjArcTr9WK40rv6tcjF6lCiVShQKpQIgekMgUgQSKV4XO7cFvMNaMXT0UxMo0OvcVMdYGFsooKCmQBtcQjMjKSoqIiqqqqaryirpKVlcWLL75IixYtmDFjBlOmTKn57rfiAVx3svz8r3zO1Wo1M6dPJ7+wELVSybx583jssccYN24cp0+fxufzYdDr+XHNShQBQdhsdgqLCgHo0aUz86a8hMZeiSI4gllz3vlTL7v/xK9NVF8t+Zz6zVtxJLeYkyXVBChlnCmz8f1JD03iYEhqEHmmamID9RjVShRXerp6lYJQnYaYoAAAKm0Opu3M5JcMDT/MnMzcYCn9unQgq7CY3n374ZQpeeDRx4hXePE6HSj0BlzmclylBbgsVczfup+JIx4F6b/mEezFefjcLjyWKlRhdVBHxOKsLKe0shylIQh7aQEj77oDn9PBmTot6BAZWWPm2vjDhuvW/Y477uCDjz9B9QfmTKqrq3nqqac4fvw4GRkZNfNeKpWKsLAwSkpKSE5Oplu3bkRGRvLKK68QGhrK5s2bOXjwIIIgsHHjRk6cOEFhYSE///wzrVu3pmPHjtdcKyKlCdOmrWHC008SGBHFtGnTGPdEd6x5mUjcSk5nnuXeYY9ct5xffPFFrftx2LBhzJn8HD6no6Y93WYTgs+Lx2pGGRiKSqGgTqCeKrsLi9OF2eHC4faQXlpJlcONTCrB6xOQuh3XveafRSX51whEyp837Q0ZMoTS0lJee+01ioqKaN68OZs3b64xv+fk5NR615lMJkaOHElRURFBQUG0bNmSffv21YgHwIsvvojVamXUqFFUVlbSqVMnNm/e/F8hHvA3jECGDx/O1q1b+eSTT2jUqBHHjx9n1KhRPP7448ye7e8N3qwKX+1hLPphGxKVhjCtyi8cgoDd7aXK4aaOQUNyeBAyiRSpVILPJ2DUKPHYrDgriq8MtaX43G4kUhnSK+aZq70miexfJi7/0NqLx25FplIjVSjxuV1owqIxhIRQUlTEwo8/Zu3atZw6deq6ZZZKpTUiepXIyEhMJhNOp5M1a9awbds2Fi5cWDP6+L2J8//Eyy+9hNfrZdacOdd8N23aNJ5+YDBShRKF3kBpQT5Tp09n48/bCA8y8vFzT9L87mGExCf+6eteD0EQ+Pjjjxk9enTNts+/XMa7l8KptEpoFCPQNVFJSoie2EA9BrUSncr/8dks+DwuFHqDX7BtVgSff7SgjYzh9ZmzmDp5EtWX0whITMFlNmHNTkcXXx+3xYwmLApnZRml29aw9EIZE0c+zvtfrmD0gJ5YM06hCI4gousAKs8dQa43ogqO4I2XX0TweZEqVTw/5mlkGj0SmRSvzYrXacdnt2KpLOfptxey5/DRWnVt3bo1/fr1o1mzZjRo0IC6deuiUqk4ceIEb775JmPGjKFt27ZcuHCBhg0bolarKS4uZuDAgVy4cAGJRFLj6GA0Gpk5cyatWrXCaDSSkJCAUqm8rifRrzl48CDt2rWrte1qOYxGI507d6Z+/fosWrQIm81GSEgIGo2GXr16MXr0aFQqFZXFhQheL1Xlpbw5azbbd+8hp6Cw5v6NioriwaEP8O7cf5kJt65ZSeMrL8qrz43X6fA/Y07Hv54puRK5Vodco6t5xpxeH16fgMPtxup0k1lYwsBuHW56BPJAQDzKKwLiEnysrM6+4XOK+LnlAlJdXc2UKVNYu3YtJSUlREdHM3ToUF577TWUSiXgf6lMnTqVRYsW1ajwwoULqV+//h+6xtUbZPHSZfTs1hWFXIbb40WrVuLMycCaeQZXaSE+px2fx4XPZsVjrcZlquDQmlxWOwp5sJ6ButEGAmMiUEVGowqNQhEcgdwQhPSKTVim0qAIDEUVEoHHbvHbgIHoZq2vKdPFixdrJqRPnjzJjh07mDBhwh9ut4cffpivvvrq3+5jMBhqHBSuuhI+8MAD7Nix45rJvq7duqGQyXjl1Vfp2rVrzUunqrSEUosdtUKOTCq98q+E8xfSaXell7p79Vc06dKDWe/Mva4I/VEEQaBbt27s3r27Zlvc/a8iq9uVhjECBrWEhXc1wpx+Cq/diioiFqUhCLlWh+D14nO7sUoUqGz+l6pNYyRAo0YueLF7qTEhSjKOc3baSxx9/hMeUvhHVB6zCXV0PJcD4ojNP0n57h+QKBScX7GfDutWExZdh+rLacg0eqQKBfa8TBSBoby/bDWvvPIKTlMZEtmVUafZREybLuQe2EGV1UGTnn1r1bNNmzZ88cUXpKam1tru8Xj48ssvGTly5DWdB/C7ff561fLVDpbX6+WJJ54gLCzshtp8x44d5OTk8Msvv1BaWkpKSgput5uDBw9y7NixGnOwXq9nwIABFBYWsmPHDh566CHGjx/P5g3radziNjLPneaXvfv4/sefrrmOTCYjKCgIQRCIj41l/vz5JEeFoTIGA+C8YhVQyPwdO4fLjUwqpdxiQ63wG0K8Pl+NteCq+UoQBI6fOMm99wy8aQF5OCCxloB8VX1ZFJCb5JYLyN/B1RsE4Luln3NbfATVZw7hLM7Dba5EptViuZhB6cky8koULLcXECaVI729bc2iu6s9e6PRiEQiYeDAgRgMBurWrYvRaESn0yGTyWjSpAlffvklo0Y9xdvvzGHB/Pfxer04nU72799PTk4Op06dQhCEa+YbJBLJNV48kZGRvPXWW8jlciZOnMhXX33FuHHjKC4upm7duqSnp2Oz2Wodc3UkUrduXTIzMwF44okniIqKonXr1uh0OoY99BCBRiOdOnfm7LlzXLhwgfLycgCSkpJo2rQpGo2G916fUjPi+nVPsbqqkrotagvjuHHjrjuH8kd5+eWXa+zD4DdjjXhnEeV2D13iggjSqAnVazBoVHh9AjqVErPdgdvrJVAhRSKT4ZH4y1hptaOUydAJbt6e/wGTnpuINS8TqUqDLyQa8+avKN/zM4LXR+zD4yg7uJ00OxTqIvHJ5Cz+7DMyL1+ucUG+yvZvV5AYpOP95d8y6fnnEXxe3ln4CRNHPIrLVIq7ohhdvcZ+W74hiLcWLrpum1y6dAm1Wo3X6yUyMhKFQsGjjz7Kl19+yf33389bb71FUlJSrWM6depEly5deOuttxg9ejQffvjhDbf1n8Hn8+F0OtFo/B0li8VCvXr1KCkpqTHJXE/wjh49islkoqKigvLycioqKigoKGDp0qVYLBZCQ0Pxer1YrVYEQSA8PJzRY8Zyz+B7qbZa0ajV+CR+Bw2p4GPPL7vRa7WEh4ViKykkNC6BHzf9yKtvTge4aQF53FAX5ZX7xyV4+dycKQrITfI/JSBD7x7A9Amj0UVEowoKrfEYKf1lE5e//5qVGcV4giP59pfDaLVaWrduTYsWLYiLi6NJkyZs3bqVgwcPYrfbcbvdVFZWkpWVdc38g0QiITk5mc6dO3PkyBFOnjx5XZPUb4mNjSX3V5PbV5k9ezZSqZStW7eSn5+PzWarEYbr0bNnT7Zu3VqrPHB9F9OrfLxgAc1btGD+hx8SGhrK/PnzAfjhhx/Ys2sXTw+8E6/VjL5eI7/3lc/L0uUrmTBxYs05HnroIZYtW/Zv6/hbBEGgrKyMrKws2rRpU+u7pLuewNjufnqnKLg9IYSIAC1en48grQav4O+NKmUypFd6pFqF/+H3uV2gUOG1VDHngwW88soUzr3+BLGPPE/6288T2fc+Alt2Y0zHgRS6LQx+8xXmzZtHfn4+SqUSlUpFly5duOuuu1AqlWg0Gh588MGacjVt2pSYmBiSkpLo0LwJ7Vu2wKQ0QEUxZXYn36xcyZQxI6nTvA0+n48DBw4wb948Lly4gNVqRSKR0LJlS1atWlXz+zRs2JCzZ88C8Oijj/Lpp59y//331yy2XLt2Lf379/8/s47D6XSyfv16cnJyeOKJJ7DZbISFhZGXl0dkZCQSieR3XV5tNhs7duzg8OHDaDQa9Hp9jZWhoqKixp0fQKfV4vF4cP7O4leFXI5ep8VUZb5pAXk6IAnVFQFxCl4+qr4oCshN8j8lIADNGjWkc7s2dOvTH5QaSlUGzlc42bjyGy59/zEA4eHhnD59+ncDoP0Wl8uF1WrF6XRy7tw5srKyOHjwIPv376d+/frccccdSCQSOnXqRGhoKFarFalUysaNGykqKqJRo0YoFAr27NnD2bNnOXjw4DUT6L/Hr0cZV3nooYfo2rUro0aNqtmmUCgINBp5ZuxYXps2Dblczi9bf+a+hx4mLz+/Zr/t27fTqVOnGvMhQPfu3ZkyZQrBzkrkuWl8lV7BXQ8NJ14np/fdg2pefD/99BN33nnnHyr3rl27GDduXK05ILVajUwViy4gFl+fp+l8m5Fog5Tu8YGE6tREG/WY7U68goBSJsXu9hKkVaGUy1ArFOhUSsotVvQeOx6bFY+1ijnz5vPChHF4ouph/eFzvHYLW6Zv4u5lU1h5/CJvz5mDw+HgoYceYvTo0TRv3vy67seVlZVs27YNp9PJzz//TGVlJcePHycnJwe9Xs/Y4cP4Yftuzpw7B/gnsfv06cMvv/xSM7K7Hi1btmTUqFEcOnSIxYsX12w/ePAgc+bM4dtvvwXg+++/Z8CAAX+obf9bKSoq4rvvvqsxd1mtVkwmE3a7nWnTpuHxeNiyZQtGoxGpVIrJZOLs2bMs+vgjzqVduGkBGR+QXEtA3q/OEAXkJvmfEpB27dpx7ty5axYWfj55HFs3rGP5mZxa20tKSm7Irnyz+Hw+Ro8ezapVq+jatStGo5HTp09z8eJFTp06RVFREfv37ycjI4OioqKaSdOvv/6a5ORkPvvsM6KiorBarUycOJHvv/+e4uJi6kRHo1TIuZydw4jHHmPunLe56+67+WXPXmJjYsjNy2PatGlMnTqVDRs21LywwsPDKSkpQSKRcPTMeQJ1GhQyGW6vl4YN6uNwOrFYLOh0ut+t06VLlzh+/Djnzp1j9+7dbN++ndCkRtzeuzdOpR67JpQL9ngKti2HvLN4LLl07dWDcrMFjQyKsy+TnXGBV774lgfaN0cqkWDQqPD5BAJ1anweNx6JjLffmsELz4zG67QjeH2oI2PJ/fo9yn/ZRt1xU5FIZZQaY5g2ahg/HDzB2LFjmTx5MtHR0YDfM2bNmjXk5+fTr18/WrVqVasejRs3rhHMvn37UlVVxd69e69b57CwMJ5++mk6d+6MXq/n4MGDtG/fntDQ0Jre+q/nQbxeLwcOHMDtdlNaWsr9999f811mZiaJiX+No8L/Glef75sVkOcD6tcSkHeq00UBuVluWZjGX5GXlyc89NBDQnBwsKBWq4XGjRsLhw8frvn+r4rG+3uf/rd3vu72Vq1a3Yrq/m1UV1cLhw4dEnr37l2rXh07dRI+XbpMeOv1adfU2Wq1CoIgCC+88MI13/Xq3UdIu5gpmIoKhKqyUqG4sFB47bWpAiA8++yzv1uOBQsW1JxDqjEKAfVuE8Jj7xCiE/oKUYO2CtontgunL1wUfjp0Qnj6xVdqXbNBgwbCbbfdJrRp06Zm2/blnwulxcXC8889J5TnZgllWZlCycU04aXnJgqlmRlCycU04dKm1ULxhbPC/lPnhbKsTCH9m0+F0wtfF9a+M02oHx8jaLXaayKams1mISAgoNb18/Pza+2zffv2372PHn/8caFfv37Chg0bhDVr1giVlZU3/Nvt3r1biI+PF6ZNmyaUlJTc8Hn+l8nIyBB69OghSCSSm4rGe/X9MCmgvjDNkCpMM6QKkwLq/5+I8Pvfzi0XkIqKCiE+Pl4YPny4cPDgQSEzM1P46aefhIsXL9bsM2vWLMFoNArr1q0TTp48KQwYMEBITEz8wwlkrt4gy96fIzw/fpzQqEGyAAhGuUwAhLDQUGHqa1OFeg1Sr3kp/Dfi8/mEr776SlAqlQIgKBWK677wunfvft3tixcvvmbbRx9/IlSUlAibDh4XTl+4KOTm5gnFhYXCruNnhEcefkRQKBTChg0brinLmjVrhODgYEGuiRD2Hjwk7D91Xggau1X4+Oe9wv5T54XVuw8Ln27ZJ7z/4y9CZla2cOjE6VrXrVOnjhAREVHzd6fGDYSfDxwV0i5mCtnZuULxhbPC+EceEMbe11/IP7ZfuLxtvXD205lC+jefCqt3HxZOvvuicOyrD4U3H75b6NQgQQCERo0aCWfOnKkpo8PhEObMmSNER0fXXCcsLEyYNm2a4HK5rqnTzJkza/YLDw8X1Gq1sHv37lv6m/7/RmlpqWAymf7tPps3b651r9ysgEwz1BdmGVOFWcZUYZpBFJC/gltuwpo0aRJ79+7ll19+ue73giAQHR3Nc889x/PPPw/4vS0iIiL44osv/lAwxatD1EML3kCnUuDIy8SpUDP2y40cS/fPH6ya+Sqf/PQL23fuIjU1ldtuu40HH3ywJqzAr6moqCA/P58mTZrcRM1vDQ6HgyFDhrB+/XqCgoJYuGABdoeDic89R2JiIsePHwf8Lr5ajYaiK+68arUan9eDy33tOpKQkBC++2k7yGRYXW4+PlJKbKCU3nWD6RATTMxLp9HvGk1pfi6ff/45jz32GAAFBQXUqVMHkPDN5i0kx8fh8vqIldjRhEWTXmIiMSwImURCvsnsDw/i8rA/M58l6zZTUVGJzVRKmEbC/T27EZFUny6NG/Du7rOMjoHjY5+k/aoNeJ120mdOQB0ZReTdI5DrjWR/+iZzvjxOE4mWTxRlFJdX0LFjR0aMGMHDDz9ca6X1iy++yNy5c+nQuhW/HDjIHXfcwffff1/jdXQ9ysrKOHPmTC2XZ5Eb48iRI2zdupXCwkJGjBjB9u3befbZZ9HpdNcEM8zLy2Px4sUcOHCA3bt31/JAvFkT1gxjfdRXTFgOwcsrVaIJ62a55QLSsGFDevXqRV5eHrt27aJOnTqMHj2akSNHAvyloUzO/LSO0Li6SBVK3GYTLlMpAMqgMN7+YgXvX/E8Wrp0KQ0aNKBt27a1zuv1elm4cGFNnpJGjRpx9uxZOnXqRPfu3QkMDCQ2NpZWrVqRkJDwVzbTH+app55i6dKlfP7pp9xx553IJBKsThdmu5MqUznLvvqKAb178eniz8jNyeHQ0WPMmDEDpVJJZWUl8+fPp7q6miYNUzHG1UMvlxIWEsSwEU8SERaK3e2lQWQI5wpKcXt91JdYWF7g5j6jgyb9n6G60r/ALSkpifbt2/Pll19y+8TZjBp4O/VDjSjlMqK1ciy+f3lPqfFS6fbx1fHL3FXfHzQuKjAAhdNKvlMgq6KaJlWXcFeW4bNb0MTVx1laQFDLrlSd2ociOIKgJm3xOu1sv7MnTac9jyauPq7yYvIVQXTt1ZtVq1bVzCkIv1lc17JlS44dO8bOjRvoMeBupFIp7777Ltu3b6e0tBSLxcInn3xyzf0g8udxOp3k5+dTp04dBEFg0qRJvP/+++j0AcjlMqoqK2v2DQkJYdKkSWRnZ3P+/Hmqq6s5cuQIOq2WBk2a0qVTJ8KObUbeuD0vvP/JTQvI7KBkNFcExC54eckkTqLfNLd6iKNSqQSVSiVMnjxZOHbsmPDJJ58IarVa+OKLLwRBEIS9e/cKgFBQUFDruPvuu0+4//77r3vOqVOnXtc0c/HoQeG50U8K+cf2CyUX04SSi2lC0flTwqm0DEGj1QmAoA8w1Lb79+ol9O7dWzh8+LDQufO1cyV6vV7o3auXEBgYJMiV6prtPXr0EDZs2CCcPXv2mrLfSsLDwwVAOHTwoJCbmydkZGYJGZlZNSaf7OxcITc3TyjPzRLemOafA1mzZk3N8SUlJULr1q0FQJg8abJQ50ou+Sb1k4RtE4cKhWePC/tPnRf2nDgrVJWVCUfPXhBOnE8XDp1JEyYs3yLMfH+BEFnvPkGlDhUkErmgVIcJ8zbtFkqLi4XSzAzh8pZ1NfMnVWWlQkV+bo1J7NCZNGHG9zuFMV9vFYYt3iIUXzgr9Fvws3B6wVRh17CuQtbOjUJZVqawevdhoSwrUyjNzBAubVotnF21SJg+fLAwZuhg4dtnhghF508Jl7etF/KP7RcyTxwRJBKJMGPGDGHHjh1Chw4dhPr169f8Jl6vV3jwwQcFQHj++eeFF1+9/r0zatSov+03/F+goqJCmDBhgtCyZUshJiZGCAoKEuLj4wW1Wn1N23bt0kXIvJQpDBpw13XbPio6WmgaEy6oNFph4MhnhKeHDhbyDu8RKkpKhIr8XOG77Xv+EhPW3OBk4aPQFOGj0BRhbnCyaML6C7jlIxClUkmrVq3Yt29fzbZx48Zx+PBh9u/fz759++jYsSMFBQW1QhpfDbF91Zf+1/zeCOTjDdu4s4l/cZYBLzK1GrfFjEWuIVin4eSFi9ikSi5dOMerLz5PZWnJdcvconlzHhs8kDv7D2RttpknmvgDC24ucaF120g7eYzVS5dw7vy5mmM++eSTWm61t4rJkyfz7rvvEhoSwuPDhyMPi2bAHT0IMugB0KmUWJ0uck3VrPr0IxYu/JBXXnmF6dOn15zj+PHjPP7445w4cYLGjRszc+ZMhg8fTlxMDO2bpPLSqOGkqSOJDzbgcHtIDAvG6nRxNKeI5Et7MbbohMcQxndnLpMSEkDLcIM/kqo+CKm5rFbQvAvlFvbklPJ4szgOFpioG2JEp1LyzanLDImSc2H6eJrOXYmzohiPzYJEJsNQrxG5qxeiiUtG8HpZsPNYrZHoL+tWE+6sAECm0fPUh1+z9acfa7XT8OHDWbJkCceOHaNly5bcfvvtrFj6BUu+/JLJr/pjjN1+++2MHDmSsLAwunfvfk3MNpFr8fl8fPnll7z44otYrDZadL4dXVAIWq2GQlM1KrmcKqud3JMHMAQFE6KSY9Co+GX/AXw+H5MmTapZTLrj+zWEKXzodXpmf7OJZzo14rPth3l16jQkMhnT33wDiUTKPXXUdJw896ZHIAsi6qG5sljW7vMytviSOAK5SW55MMWoqKhawcMAUlNTWbNmDXBjMfF/L5hiR2kFqspibIZwqtKOoGnWCTQGcLsprbbRNCkBqVyBKucsXZo3Yv2WfwlIWHAQpRUmALp078E9w0di0KgYoS/H67Sji46nf4SXSqsdj1rPnG49OHo+A3uVibPbN/Hkk09SWFjIa6+9dktt5jNnzqRHjx7cc889LPzkE0wmEwuCg3nkwaHc2bsPUWGhaAJDcHt9vPryZA4eOsh7771XS0BatGjB0aNHyc/PJyYmBolEwqJFixg8eDA+uwXLA4OoV1mGMf4OlDIpJWYLZruTlIhgynQ9iYoIAwTuq6NFplLgMptQBYVQYrERqdIg0xspMVs4kl7ApUo7XeJCSDfZ0SkVvL3nEnN6JNNpzyKe+GALLw1I8kc+lsqQqTSYzxxkt0NHamkBhqbt0SU35bsxk2q1Qe6Fc8Tc1hyJQoHCEMwXb01hmSmdSYf+Fdn4iy++ICsri5MnT2IICGD5ksVk5Rcy+dUpNYs+p06dKmY+/BOcOXOGp59+mj179pDaqQdPjRpDg9g6/tA35VZCzB6i9HJcPoHWkS8QG6jn3M/rGPH8ZO666y5mzpxJSkpKjYAkNmqKXi5DrtXxxkR/x8+3+Re8TgcXLR5GPvsiZ4sqiNQBk+fedPnlcgG51N9flvtuab/5/xtuuYB07NixVnwf8Gd1i4+PB/7amPjKek3RBGix7NlIWWpXirMKSYkIwuH2EBUYQGFuDm27dafaaq113KuPP8BjTz3DR6eLGBYtJShAj+v0PsoAZ3EuuuSmaMKiKK+2orx0nI5yBfI6t+ETIDZQz+WmjSgvL2fatGnk5eWxaNGiWyoiPXv2pLq6GkEQSEtL45133mHZqtW8t8Af+iIqMpKnn32eni/5nRJ+HT7kKlKplNjY2Jq/+/Tpw5NPPsmnn35K4/5DaNWsCfmnRyCTSPh2wt3EPDgeTWAMOpURl9eLxG5FofePPORaHR6blXDAoTBirqzG4faQEKSnRZ1QQgO0FFZWcyi/jHtTQ/k5s4T3jQ8z7NsnqWu8Ei1AFUhAwQkGZzZhtXUTUU+/yWfvzmT2oiGYfrPocsbnX7PyzgGEyATMp/bhsVbR65XpTBo4tNZ+O3furPl/r/4DOHXaHxV506ZNdO3a9b8m4un/BVasWFGzWr9/53b0vrMjqdEGDny7iLh7RxGoktOxYRA6pYLYIAMGlT8w4mdHTxAXF8eGDRtwuVz07t0bgNCICBRqLW++/BwytY5xDw5CIpXx/JinEXxeQvUavD4fjSKDkUn+mpe9TA7yK4NM2b8PGiHyB7nlY/Znn32WAwcO8NZbb3Hx4kWWL1/OokWLavKDSCQSJkyYwPTp01m/fj2nT5/mkUceITo6uiZxyx/FmX6c/LWfAZCocNOo5AzBuFDuW0dmaSV5FmdNlN2QXuN5aMEacg7u5qFu7TCGR/FKv/bI6jbxR2INiyaoVTdCej+IIrERh7MK0amUZIfW54A8ksIqC2nlFhJCjKxd933NYrPPPvuMESNG1ErYdKuQSCSkpqayePFiCgoKOHjwIIMGDaKwqIjSK7khZsyYwaRJk/7DmUCj0fDxxx+TkZHBRx99xImz5zHJfPx4ZD/L8t24TKX+JD/F2VfEIwC5VocyJBKF3oA6JByvPgidSkmQTkNMsIEklZeYYCMKmYzEsCAGyMuJDzLQt1Fd3uldh55lx1EYglAagvjwcA4VezfxXfI5wu+4n03LPmPSnPdp9BtPuBYtWpBXVMLQoUM5dOw4l6rdPDJzIa3ufvC69VIoFPTo0YPAoGA6dOjAZ599Ro8ePUTx+JPExMQQFBQEwIZfDjBmyht079mTlz9cTKzUSbxRQ77ZTpnVgVatpPzQdiqO7yGlbjy5ubkcOHCA++67j23btlE/qR47Vy5F6bLx6pz3eXbEI7hNZbjKi6k+fwTB6yVUqyTAXIzRWYXP8seiNvwn5HKQKwT/55Z3nf//4JY3Y+vWrVm7di2TJ0/mjTfeIDExkXnz5tWktIW/Lia+MjCUyHaj/SaVwBBKpVrMTi8rA1pxemcuqx5swZ7p43FVFKNvkIoqQo0mLAqptBWnJj6AOiqC5OfmYskrwlmYTUBiA9xe0CpkGNRKzHYnpVYHSpmU3EoLMomE17efIbhZh3/V9/Y7WLJkCQaDgXnz5v1VzfgfkcvltGnThoULF7Jx40Z2bPLnovizsZUSExNJSUmhRYsWHDp0iI0bN+INqUP24rex3d4PQ++HkUoFXOZKfBo9aqkEnw+qL19AqtLgUihQqDTIVGpsFjPVFjMeaxVBjdvisVRh0KgwXzpLrNuFLyoe6+U0zk95kikvzMJUGo4yNJqKg1vIOe8fLezZs6dW+d5//30qKysZMGAAjz4znkC5FHlAIG/OmOEPIjlsGEqlEq1WS3p6OhERETUvPpEbp3PnzlRUVHDp0iV8Ph8vv/xyTRiWA+tXM+qFl5HKlVjyMhGqTWji6uMxV9A2KR5BEGpygz/69FimvvQCIXottuI8bGnHkesNhLa/g8qzR1CGRaMOiaDkl43I9UZsWecxV/41AqJQw5Vwaij+ngzE//P8T4UyeXbpBuwyDc91SCJQp0Fit2I6/gvGJm39yWy8XlwVxQheL1KV2v/CKy1Abgii+uwhlKHRSFVqdPUaI4+IxZWTjs9p5yAhtIgJQymTcaqglLOl1RRYPbi8AgkGBbnVbk5mVXPhwCGat2vK1ikPIfh8BAUFMW/ePB5++OG/dS3B3r17a0KGb9++vSaMx2/Ztm0bCxYs4PLly/h8vpp8zb92eBg1/BFefeZpdOFRfL5kCWUXzqAwBqGKiMVtrkCiC8Rx6Qz6uqn4PG6kai1SiYQKqw2d04JNHYBGoUAwFeMuL0Jdpy4+l4OKfT8T2fdBSnesI+GuhxjSqzvKqHhsGaeoPLiFdLOLtz76nOiwYGIa38Ynq767bh1iY2P54YcfaNq06S1pS5Hr06BBA3Jzc7Hb7fTu2JbP5r2LPSQGo8+BoDNiv3AM2+U0jM06MPjxUZxMS8fYqCMH33sRud6I9fJ5PGYT1oun0SY0IOaeJ2rS2h4vNlPPlIlEoaAyIhmn3Ubb25rf9CT6iuZ10V7pUNm8XoaeEKPx3iz/UwKy+eHuxHXrjbFlN/QxdbE6XehUShzlxci1Oiw+GUpLGZb0U8i0elylBSCVoU9uii62LoJUjtXpz4qmVsgpt9gwaFQEa9VUO90czSmmyukmr9rBsSIXPh+4vHAuT0KYQaB+hIS7kwIY++C9FOXn1ZQvMjKSixcv/ttYUn8npaWlvxtIsnmLFpy4shjx12Rs/Z7PlnyJEBGHx2bF57BCYDgWpwtNZRH4vCiMIfi0Brw+H1qFDJPdRQBukEhQaAPIMZmJCzLgrCzDJFUjPbIVXce+aIou8WDrBpRs20L9STP9KVDtVgSvF6/dwrffr+flL9dh1Kqpsvmz02m1WmbMmMH48ePFhX5/M2azmdDQUGQyOQ6HP1L1+vkzaTZwGIE6DfkblqKKiEURFIpMo8ctV3GouJod777K+AcHU33+KMEd++Jz2nGW5OJzOjDe1hVXaQFl29cS2v0eLOePoq3XEJ/TQf6+7fT8eP1NC8iqVvXQyq8IiMfLkCOiF9bN8j/lt1h37OsogiNQGoLweVzI7WZ8HheqoFAkUhlatRJXeTGVR3b4Rx6BoYR26I00ui6mk/txluZj0KgI1SrReF3EaOSY7U62pedyIq8EryBwtNhKiEZBHYMMuxtiA6V0aQBdk+T0SzKSb3OzdO0PjJi9EH2g33RSVFTEhg3XT2/6d3P27Nla4nF1NfaQuwcwcfhDlF1Jaftbtm1Yi1xvIP2zpRxf8h2hifXRu6pJiAglLKUpIQlJ6PU6gjRKPDIFal0AMcEG5MYQbEo9KsFDw5hIpKZiPOcOkhCoI7RzXyx7fuDypoOU3f4oe0a9T/WZQ6jCopFpdGhi6qGKiGVQ7ztpWj+pRjzGjBlDRUUFEyZM+MvEw+fzcezYsZqVzxkZGcydO5fOnTvXCp3/a9xuN7t27eKjjz5iw4YN/zGc//8CDoeDQYMGIZPJasSjS487SOzaF0VVCXkVVUT3G4ZMq0Mfl8z8ZauZ//lSmhYeZ9LLr6CKiid22ERsmWcRPC7Cut1DWvOBWM77s0DGjXgZdasexIx4hbDbBwEQ0n3gX1J2mUJa6yNy8/zPtWJIxz6cq3JSbHH481pI5bgtZgSfl/yKKtxmEwGpLdEkpKBL9OflVuNF17QDQmAEjvISbEV5eJ12yjygVshJCArA6xPIqLCSYFByrtxBhc1H8yg5AUopRrW/GS0uDyFqJTKplCf63M7cDz8B/JPd/1fCoixduhTwOzeUlpby7LPPArBq3XrmfvE1eYVF9EyJo1f3brWOa9B/GHkKI4Wp9Ul+6lGefOophO4Pcc+Dj3BPiI9Hnn6Gkc++SP3egxn80CPc+9DDjHlyFM+NeIRHPBd5ZuwzDIo38NCLUxn31SbGTHyBgq+WYWzajibjnuCH7vfR4cOxSFVqKo/uAqBs51oEjwuZRsfH773D80+OQK1SYTabfzcXBfjFYPHixfTr14+cnJzf3e8qXq+XBx54gJYtW6LT6QgODqZ+/fo899xz7Nmzh0OHDl1zzKpVq0hKSqJbt26MHj2aAQMG8PPPP//Rn+G/Eo/Hw9ixY9mxYwf3DPzXS90QGEiYUsL8r1bi+vZ9Xh7xID6ng6mz3+HBKClPd26GVKOjeP0XWC+ewnR4O7rkpvicDkxHd9HSchlVVAL2nHTsOenITMXIPC6q00+irdsIwfnX5ESXKGVIr3wkyhvLu/Lhhx+SkJCAWq2mbdu21703rvLpp5/SuXNngoKCCAoKomfPntfsP3z4cCQSSa3PVU+1/wb+VgGZNWtWjdfVVRwOB2PGjCEkJAS9Xs/gwYOvScf6R3FXV1J1+gCNQ/UYbCZsRXnkm8xUS5V4nQ4Sw4IJatYeiUJBQGIKXpsVqUJB9eU0nFnn0amUqIJC0cckkun0N01UoAGlXEZCcADtY0PQKqQ0DFEzpGEIUXq/R1enOgZSgzXUDzVgcXuwON0kRQTTvl4dwJ8sqFGjRjfXeH8Rs2fP5tKlS8THxxMWFsZbb711zT7jxz/LJ6+9wOHT5xn22AhOHj1KUoMU6oYYOSdNJlotwVtRjO7x0Zwb2IvwO+5n0eFLZJdXEbFgHI00XuxuD+kmO2W71hM/cgpelQZD47bIjvyEtrKQtJcfJnX8eOR6I1VnDzHmxE+0/XoLmjsexG2uwFmcCxIpx39YxeUje9C7bTz/7LPcP3gQJ0+evKbMDoeD+fPnM2XKFDZv3swTTzzBpk2biI+PJ/9X+VCuR5s2bfjmm29odGW90tU85Ff5bWranJwcHnjgAYKDg2tt/ydSA/xdWCwWBg4cyBdffME7c+bw1qsv13xnzs3ik0ljeWboIGJGvMLURcswxTTm5SeHI/R9AkfDjlSfPUTCqClE3DEETVwyHnMFmrhkdPUaoY5OQBOdgLFlN8I69EIdGUvR5hU48i/hLMxC8Fw/2dSfRaZSIFNf+aiuzQnzn1i1ahUTJ05k6tSpHDt2jGbNmtGrVy9KSq6/IHnnzp0MHTqUHTt2sH//fmJjY7nzzjuvuR979+5NYWFhzWfFihU3VL9/gr/Nme3w4cN88skn10x2Pvvss2zcuJFvvvkGo9HI2LFjGTRo0O/mYPh3qILCkYZGU2BzUicyFtxONEd3IQuOQJHUCFtxLqZD21GGRVOVdhxnYTaWC8cJaNgKTUQMXqedHLOd3EoLsYF6IvRqzhWU4r1imiizOiiw+G/mc+UOtHIJ7aINaBQygrUqHG4PScEB1A01gtXMmUtZANSrV+/mGu8v5NixY7Ru3Rq5TIpRp6XK6jfZDO3RiW7NUulx74MIkYkEKEByOY23p7+JRgZuixmJ28WItqHsL7bRvtqH44uF/GzxkrJlNU83bY9aA2UzvsZVkklEbAoqm4nzzfugNhgwVZhJHzqcNvPfYlO5wO33PEb2xGcxmZxE1o+ieNZTKB58hqqDW4ga8DhOr48hL73O8cyrc0mf1tRh6FD/eo8NGzbwwgsvUF5ejsfjofJKnKXY2FhCQ0Mpu+LKHBMTw5tvvsnTTz9NSEhIrfb46aefOHbsGL3uGoCqz1jcP3+LO+sYDw0awPzv9mI+vZV7772Xp59+mnfffReVSlWT+fGBBx6oyVo5e/bs3134+t/K+fPneeONN8jLy6OwsJCS4mK++uA9WrRqTauu3Wr2233oMLuBPjOCWPbsGFShUTz09DM48rKpWDSLyL73EdT2DuxFudgyzxKQ2so/37FzHeG9hiLX6LAXZuGxmKk8vI2IO4YQ2X84uJ1UnTuKQvHXzB1KFXKkV9ywpPx50+fcuXMZOXJkTTDRjz/+mI0bN/L5559f11X+66+/rvX3Z599xpo1a9i2bRuPPPJIzXaVSlWzoPq/jb9FQCwWCw899BCffvpprRXRVVVVLF68mOXLl9O9e3cAlixZQmpqKgcOHKBdu3Z/6jrKwBCcXi/B9gryt3yFx1pFzH1jQGeguNqKWdDQoP8juMyVuCrLUIb4fzR9TF28Ph8+m4UYjZxohRbwUWZzYXW5Kar223pLbC4CVXLijRqiDTqyTNWU2pzoFXKMagWxgXqKq224PF4qpQoqrmSq+/UK+38Kq9VKz549OXDgAAA9Gifxet92nCqppsGjE6mSa2kdKKfiwBY01iqqfF6Kf/iKuMdeojzvEtqW3alOP8HJ9Ye57c5GxJ7dxoOd78G8aAq7Fh/Fu6wnDwaGYnC5ebfvWO6+K4zm89YQpbAiyzmPac7LRH+zDklQALctmU7gY5NRD3iUehIfFGeR8vJsin5aTtSAx6k6tQ9peBzHM/No0aIFr7/+OmazGavVikKhYNCgQWzatInx48dTUFBAm3sepKHGx/qNGyksKaO4uPia0cCUKVP46KOPSEtLIyAggIyMDEpKSvj8888BkJrLCN8wnX27DlBmdzHvwmn6NEyg/fTXmfn223z44Yd07tyZIUOGEB8fT58+fZg5cyYrVqygb9++vPTSS3z55ZccOXLkv2qNiSAInDlzhsTERPR6fc32kydP0rx5c2QyGTFxcTRv1JCXFn9GncQkfl77DeUV/xqlJac0ZPTDDxAfYqT7mMkEfTQR5+LX8Ha5C+O0pUhVClSWMlSBoXjMJvTxybjCYolu2AqZSoO9IIuAeo04U2bBGpYCW1Yh+LxUHt2DsWlbqsqv38P/s0gUMiQK/yvvqnz8Nvnc70W5cLlcHD16lMmTJ9dsk0ql9OzZk/379/+h69tsNtxu9zUj1507dxIeHk5QUBDdu3dn+vTp13R0/q/ytwjImDFj6NevHz179qwlIEePHsXtdtOzZ8+abSkpKcTFxbF///7fFZDrxcICMKefJKpxS6orSwnrMRh1SAQemxV3WQHq0gJCE1JxW6op2rQMfWpLDPWbcqnKQfTlNAo0obg8XoK0avIqHVhcHvRKB3VDAzGolVidbuqFGCiutmH3eMmttGBxebgtOgSTzYHd7XcsTw4PwndmH7rEFIICjYB/Ev2fvCGKi4uv6eH0S4lBG1OXfg/cRc4Xs4jSGSjUBxDcvjcFaz7lsyUneGRADDlL3yGsxz2Yd61DXr8ljQdrUSiknE/uSvzSGQyVP8rmwzPY3q4XF4c2JnbYs7T6/kvq14/lxw4dqTeoLYZhE2nw6nu4gwJQVRTA/c9TuOkrchcuIbhxEDFd+lJ17ghh3e7BbTbhtVnR6Q3Ex9ShWbNm9O/fv1bZP/vsM0aOHInBYODt+QsY2qMzrqoKPlnin995/qlR/Lz/4DWmgoKCAtLT06/JQgjw4+59qNVqHn/8CVq3bs2FCxdYtmwZ37z8KuDPVPjr+3HlypUYjUb69u1L586d+eWXXzh79iwHDhygW7duf8XPdsvJyMhg7NixNXM3r7zyCv369aOiooJhw4YB8Piwh3jjxWfRRsRyOKuAzLJKVL8Kgz/40RHMGf8kJpURhc9DU0sW1imLCazMp2LfZuIat0Wm13PB5iRFrUbTrBOW7AyUQHVOOorAULICE7EXmGihB6lWyf6Gd9IlIZygtndiuXCMsKadYepHN11f/wjE/8q7arv/dTQGgKlTpzJt2rRrji0rK8Pr9RIREVFre0REBGlpaX/o+i+99BLR0dG13ne9e/dm0KBBJCYmcunSJV5++WX69OnD/v37//Qarn+CWy4gK1eu5NixYxw+fPia74qKilAqlQQGBtbaHhERQVFR0e+ec+bMmbz++uvXbPdYq7HmZeIqL0YRGIbTVIbSGES21U1Ucgt8djOSgCCCO/WjQhNMhdlJfIiR/ZlWbk8MxW0xo9AHUGa1Uzc0EKvTxb6sIlrFhGFQKzmeX0ZqeCAhei1muxO314tMKiU+NgKFz4PVJ0EnFZC06oqjvJjYpAZIpVKWLl3K22+/fdNteSO88847vPDCCwDoVAqWDelK/c49qDy6h3HPf0AH+UrufaUXPqeDQ+/+SI7jJwa/2J3W335JRMkxpBodPqcdW+ZZXGfTyAyIp5nSQ8r5LQTe8wTLl79PsXkK/Y/vQCGXUWm10zdeS77JTKcdOzFoVLgt1Tg9LioWv0HEXY9g+/AZgh8cS9PnnyY9vxCvw4YiKBSF3oDHbiWqz1DOp18kOy+/JuTNrxk7dix16yWx/YuFKAICcZtNqEMiUatUOJxOQusmMSmlHoebN2T2p0trHftr08FVRo0axZQpU6hTp04tr6633nqL8vJytFotGo2m1ncBAQFER0dTUFDAhAkTavLd/LeIx8yZM3n55ZdrbZsxYwYzZswgMTGRyspKvv12DZ2bpCBVKMlZ/SG33fs0MqmEk1v+NfpofPQnLk05SuO3l7Dv/gFkpUno9tYwLm7fRP2X3sVWkMWGgc/QZkRLChs04+TMRSQN6UTkU9MJiYzBKVVS99Ip3JVlyEJaUfzzKiIvniHtysR5xYl06ox/8S+ps0ypQqb0v/JkV8K65+bm1nLj/XfOGTfDrFmzWLlyJTt37qw1Qv11vqMmTZrQtGlT6tWrx86dO+nRo8ctKctfyS0VkNzcXMaPH8+WLVv+0mH95MmTmThxYs3fV6PxhrTsSl61A33jOC5XWSiqqCbBJhCsVVNitlDl8FC3PA2f20WdsCh8bjf2nHQ61YnF4XaTaXaisXtQymQUm61oFHLubppEidmCyeage/1YSsxWLhRXUOVwcbKkmj5JkSisdsINelTmCnLcUsINetSBoaRoHDxx/z3MmTOH3r1715jp/k5effXVmv8PVybw7jfHmHgii4YvjudNn5dlqy6xo/Fgehftp//x1/DmZZAx+wWK3t6OftunqMOiseZdQvB58WzdS9DpUwhdehLVdxhep4PM/hNpnXMciaIRlZogQvRavJYqQpxmcFZhNYEuOh6JTErSM29RlXaclLe+ourgFrxl+USrZHht1XgtZsoObEFhCKbq6E5kci0Ar7/+OjKZjAEDBpCRkcF3332H0+kktXETtp3PIi/tFBabg4wzJ2lfN5pzeUVMmfQiVkftiddDWzaSUengofsG12y744472bLlZwICAqiurmbt2rV07tyZrKwssrKy6Nat2+9OjPt8Pux2v2nz8uXLAH/a5PpPcr1kWldHUpcvX0aj0fDjxh/o1qoFACEDR/Lih0s4+O2XnL8S2y4wMJD73luI0WDEnpfJ+cmfkl7h4v4OMbwhacsHOgPpgp4hhzdgK8giMKUF6h5DMMoEspZM51K3EdxWeQFj47Y4EhuT9cFLyPQBVF/IoNHMxdhy0ol8oQOnRt31l9RZolD8y4Ql+DsDBoPhD60D8a97kV3j4HO90f1veeedd5g1axZbt279jwte69atS2hoKBcvXvyvEJBbupBw3bp13HPPPbWGYl6vF4lEglQq5aeffqJnz56YTKZao5D4+HgmTJhQ42L6n7i6UOj46sVoXDbOv/M+JQUyus4dS1piBxqXn/f3oht3JUirIauiijq5Jwhs2oH1F4uJDFDTLiaUiyYrBrUSh9uDQiYjJthITnklBVUWqhxuog1arC43OVU2+jVMQCjORhsdT0aZmSCtGqPPgenoTmQ6IycDk2mndaOLrUeXLp1xeAX27Nnzt3vqZGdnk5CQQK+GCcx8sB+hPQbzYPd7McpUOAUvU+9vwuSVx1m0YBLZXy0BoOEb7+GxmKnYu4nEka9h8knJrjDz1RefY6qqpsgtZ9Osl8gwO3F5r5rugpGay1AFhSKVK3CZK3GrdGSWmkjWgKM4F010Apc/noYuuTGxg5/knomvkWrJJqJhc0YOvQ9HUS7SpBa4vF6UMhnjX3iB9d+u/rfrK65OaqekpFBVVUV2djYAIx56gHU//kyVuYoPv/6W7q1bEKbX8ND9g9m40x8epX2HDmi0OrZv3XLdc/fu3Zsff/zxut8BHDhwgBkzZvDTTz8hk8kYP348s2bNuqHf6Z/g16PTyNg4tqz+mibtOwN+R4W9e/fy9epvWbt6JZsOHCdt3w7An2V0xYoVOPVhJGX6M34uOHaAw3ll9EiuwyOpLWqukaoM5r72WgpfXMTAxonYivLQRsZwPLcEw8YP0TdojiMvE2VYNB6zCWdxHmF33Ic9JwNHYRY+pwOnxkD752be9ELCLSN7o1P6va+sLjd3fLr5T52zbdu2tGnThg8++ADwdyLi4uIYO3bs78abe/vtt2vukT/SwcjLyyMuLo5169YxYMCAP1jDf45bKiDV1dU1D/RVHnvsMVJSUnjppZeIjY0lLCyMFStWMHiwv2d44cIFUlJS/u0cyG+5eoOcO3ceXVk20sTG+HwCeqmXi+9Pov7zc3EJEmznDlN99hBlv2ylyYLvUcpk2IpzkQZHsv7MZTrER3CqsJwqpxun10dysJ6iagf9m9Sl0mpHJpUQotfh87jIKDNTXG2jY70YHEW5qCNjcZUX4SjOJbBhKwo2LMXYojPyqAQu7N1Bv8eexGg0Mm7cOMaNG3fLhsq/ZteuXbzwwgscPnyYOY8N5vuVO2ilCGNT+xmYz/7Lprx/yTN+t1mpDGdxLgkPT+ThVccJ1UmY3CUZl8dLsNvMZ6u+o/DkQazpZ0ke8gRni02E6zWEB+jwuhzIlGp8HjeVTjdKmRyFw4yrrBCP2URg8464qivxVJmQ6fyTteUZZ5HqjRiDQug5aiJNQnS4rVU4inJRBoUhi4xn648bGTZiZK169e3bl6FDh3LXXXdhNBprmZaee+455s6dy749e2iU0gB7tRlPWSHqsGi/l51LytrlX/LWlQjFd/btR9/hT0HaIQrNdma/807NuX6bR+X3uCpw/235RKZOncobb7zBqJEjmTNzJl6fj/HPTuCrr5cD0KRxI06fOVvrGKVSiUwmw263k6zQ8WJwDJGJArNPltXss2LnWnpvcrLm4STk5/Yh0xk4Pek5qkogIlVD3dEvMnzIOJ4MD+GTkvKa415sHELsvf14ZtqXzH9lKJUnj9J0znJOFJTTs0PrmxaQrU8NQHfFfdfqdP/p1e2rVq3i0Ucf5ZNPPqFNmzbMmzeP1atXk5aWRkREBI888gh16tSpiX49e/ZsXnvtNZYvX07Hjh1rzqPX69Hr9VgsFl5//XUGDx5MZGQkly5d4sUXX6S6uprTp0//Le+Im+WWmrACAgJo3LhxrW06nY6QkJCa7SNGjGDixIkEBwdjMBh45plnaN++/Q2ZAy6Mu5dGT05Ao9KQqwrlUH4F/UfPIqPMjFImRRbTkPLARFo+OJ6LxRW4vD5cXgWa8ipSw4yklZjw+gTurB+D3eXB4faQEhHMprOX0SvlaBQyNOVmXF4vbeLCaRAZyoncIuqGRuD2ejlm9mIITEBnMRPVbxi2ojwsx3/BEpvKx3NmMu/zr3jxxRfZtm0bmzdv/kva+Hr4fD5mzpzJq6++SmyAhlSlnrAtJ1l9cAtlO9cS8uoUsnBxwe23ZWd+OJ2Qjl0xNO2AXG/AWpDDzDvqsaH1XZyOd1PvsftYP/M73MN64a2uJKDxbVQU5hMbEIgUH/klZag9DqzpJzA274jU66Xk4FbcJhO6eg2QqrXYTx5CGRKBPDAcl9MOgoC2biN8Hg/KQCOJ1TnI6rRA8HkxNmmHBIFHRjzBhg0b6NmzJ5s2bUImk1FdXY3RaPzdul996J566ika1E1gwN330LffXXgqy0jLyuaDDxbguniK96e9glEmoYHKSZjWQuhTY7Hb7XhK8yivquarDZtrwpf/J/7bhOMqzzzzDJs2bWLXrl1UFeYiCQyjwx19UcgVuE3FfLX+2ntUHtuMFu1bMiHFSJcRE+g2+GsUvZqyeVEIR60SWuoEBJ+XoLfvYf9nofQ6tJMvUntwWSLQySBh3z43b+wah1Gmot28V2nesCOqUzsQfF6CW/fA53bxARDa7R4Et4u1GUW0tf/nxaB/BKlKXbP+Q8qfn6AeMmQIpaWlvPbaaxQVFdG8eXM2b95cM7Gek5NT61746KOPcLlc3HvvvbXOc3WiXiaTcerUKZYuXUplZSXR0dHceeedvPnmm/8V4gH/QCysbt260bx585pItQ6Hg+eee84/JHY66dWrFwsXLvxTftFXexi5lzMpd3jYmF5A98QI4kOM5Jn84qGQyQg36nEVZiMJi0Fz5f4RvF6seZn+sOIhkWxNy8YrCGjkMlxeH7c3iEMhAYlUhstswnLprD8BUoNWBGo1OIpy8dotaKPj8bnduMwmPEFRKKpKUAWFYE47QcWejSQ88Qous4kVS5cw8e351K9fH5lMxnvvvUevXr1uul2FX+UBf+2115g+fTqDE0IpLPQwfWAq9V96F2VgKOgMDP/mJB/c1YDVp7JoHxtCgdnGZ8cqCdFJeK+Vntxlc3EUFTKuwWskR8LHdzdjx4UcsrauI8/mQa9WILdXI1OqqTqxB5neiCG1JWV2FwG2Cir2byN64KNIJBJ8goDgdOCxVSNRKBHcLnweNz6HHUVQKF6VjkC1nJFPjeZicQWJYUG4vV627trNQw8MYdGiRTzxxBN/OGSJIAgsX76cdevWkZ6ezqlTpxjQoxvDRo1m5MgRqNT+yfDi4mK0Gg31U1Np1qIlFXlZ7Nj9Cw1iozmblUvrtu3YvXv3Tf8u/9c5ceIELVq0YMIzYxk4/EnCA7Qo5TIKL56ny519AEhKSkLSoAPPDe3LwNs7c19SE745sg2AnCWzCUi9jbC7HuVCYRmjv8+nW30ZE9onEaBRU7lvM2mzZxDZowNxY2fiyDiJ22xCGRKB7XIaqrBopBodisRGyO1m3BYzO0wCRrWCUJ2GWGc5W8vcDO9z+02PQHY8/yB6lRIAi9PF7e8sF2Nh3ST/U8EUM08ewxsQjM5SjlyrQ9AZUcmk2NxeHG43uaZqoo16QrVK7F7QyMDkcFNmsdeIzJG8UrZcrqZukJw+SZGcKTbh9PpoEhFIsFaN1+cjSKvBqFFiLy1AFRiKVKFEIpVSlXEamUqD12nHmNwUj92C1+nArtBh/2Ud2nqNUIZG0+WugaRfmYhMTEwkLS0NpVL5h+r6888/8/PPP2MwGBAEgWnTpiGXy5HL5bRu3ZrExESOHDlCXGgQjxYU02nHTo7mFNO1XhQemxWv086FGc8Q/4TfsyWgXiNOTnyA6sxyNGEaWn25lYslFcSHGClaOR/B4yLo/gnY3f52ineWURlYB5fHS5xGik2mJlCnoezQdgSfF11iCj63G3xetNHxuKpMyFQaTD4pRp+/LfRSLx65CveVyXmi62HQqLEWZGNSBxIu97H0mzU8/9IkbDbbdSd8/yivvfYan3/+eY1L77Jly7jvvvv46aef2L17N9nZ2WzZsoXg4GB0Oh2nT59Go9Hw+eef1/KQ+S0bN25k6NCh1K1blzVr1vyfWiz6ZygvLyc0NBS9TselixdRyGS4vV4Kl73DM7MW4QqLQJpVjkwiIUyupa9WR/Car2kRE0awVo29tACpXEnVmYMYGraieNMynMX5BLbuhruyDFVUAoGN2yL4vBwutdDYZ8JQrxFFW9egDIkgZ+l7RA9+HF29xrjKi5AmtaBi5buEdb+XNImB5LAgPBXF1GvR6qYFZNekR2sJSNdZS0UBuUn+pwTk+537uK1eHGa7k8zyKuqGGIlQSlDoDfg8Li5+8DIBqS1RBEcQfFsnpHIleRVVKOUyZFIJxWYbMUEByM2lyDV60Bk4nFVAtEFHbqWFCrsLo0pBtFGHUiZFp1Li8nhxe701rr16lRKtWglWMzKVGo/d6nczDYvCbTGjCgrl5xVLGfLsvxYkjR49mvfffx/5v8lyU1xczLhx41i9ejXRUZGUlpXXJK167P5BqFRqft6+g6zCYnw+HwZDANs/moPk0gmUwRGEdBlAiSIAi9NNdKAejbUCuy4YVWUxl9FxqaKa+qFG4jyVyDQ6pAolVacPEtisAw6FBqVchsRahVN1Zf7CYqPM6iBUpybCZ0UaHIlUKsVVmI0+JhGXuRKZ3kjVyX24K0txFuehT22J4PGX2ee0Y2x7B5VWO0qZDLVSjsLnoay4iMUff8iCZatJrl+fY8eO3XTAREHwOzCUlZVx9913X3M+n8+HRCJh8eLFjBw5stZxv8cLL7zAO1fmS3r16sXatWtvSuj+Kex2O1qtlkYtW7N53Vqk5QXkfjmH5z/axtxn+rD84yM8+kofSnds5/QBN03aKUj6eAMZJSbyzXbWXajm8eYhtAkA85mDeKor0Cakom7cnoySChqH6rFkZ2Davxl7ziVihj2LNSQW3y9rORDbgX6xftdtr92CNioBZ2UZP5V5aR0bRoTMQ7rFC24nnVrfdtMCsvvVkejVVwTE4aLL9E9FAblJ/qcEZPnPuwgICMDu9tJOUU1RQDQ/ZhQxpkUcCr2BnPJKDMUXsUfXR3npOD63C+cVTw99akt8TgdSlRptTD1sORkow6JxFGQhVSgJbtGJSqsdtcOMo7QAbXQCbpUOlc+F48pKWWVUPI6sNPTxyWSUmUkKD0YmlSL4vJzMLyUh2Ihe6mXBkSzefXRArVWwQ4cOZcmSJb9r+7z33nvZsX0bk58eyZAB/fB63BwuqmJnnpmI+LqMa18fk8PNE8Mf4cix45jNZh4Zch9HCyqY9fBAUqPDUEfEYss6jzIsGtuls+iSm6KLq4/bWoXH4i+LTKPHVVGE7XIaYX2HUV5txWAzoY6M5XR+CcnhwWhkkFdlo47eX1aP3YoqMBRbcS7y0GhkEgkSn+fKuhoDUrn/ofV5XNi9oJDLKKmyECo4sFw6S1Cz9kgVSorOHuO+MRM5cz6NoUOHsmDBgr81GZTL5arV/rt376Zz587X3ffs2bM0adKkRmSioqJ47rnnWLVqFV26dKkRl//rjB8/nvnz57Pk88/pFOQPoz/rqXd47dQOck3VHC0w8epaN/HnjoE6nN0reqGUybhcWkGYvZzqtOM4i3M51Lg/X5+y8M2wZjhcblyn96EMicBrt1AeloRaIScm2ECl1YFe6kWm0lCVdtwfeVmlwSZTc6qglOYx4ShtVSiCwvCYK3Cq9GQWFNO1bcubFpA9b45Fr/b/vhaHk05TFogCcpP8TyV2LLY6SYwMp7jahjG5CVWrFzIkMIzKM4UEJDdFeeIXNC274bt8ivSgetjdXlpHJ6CJiGHNuVyaxQWTcGW5iuDzIlOpUYZEYM9Jp3jXenx2K+oe92LyKZCV+/3BHTIZkoh4tAqZ3xwDuKpMJIaFIjhtWE3lKA1BJElsqAQtpuP7uK+ymJSPFjLkoWE1ZV+xYgVGo5GPPrr+itvs7Gw6dezEPe2aseDOp7j3qZY0adyaXVM/ZMhHz7LxvIZm0SGMGjGC7Tv9i+W+XPUNALO8DlauWoWkNJfCpPaE6jQo4xph0CrJNllIjIglY+VLOAZPJDksGE1YNHKdEV9lGcU2Ad+JnVha9yMlMoTs8ioMaiVenw+3VI5aoSDf4iTE6cKqCSJEIsErCEgcDnxuF64qE6qgUKrSjiPT+Ecvvph6GKsKUSc2QGkMQipXYi3IZvyMd0i/lMmBAwdo06bNLblH/h1KpZKvv/66Jlvmo48+yokTJ677gmnUqBHvv/8+48aNA0CtN/D88/4c9IcPH+bMmTN06NCBmJgYqqqqcDgcmEwmhg8fTsMrQRv/afbt28cHH3zAbV17kImOlrmnqTq+n5RlS8iaeB+3fXwlq6W8mNXDrAR3SOFYdiHt6tbBNG044a98QGTPwTjKi+mRk0E71SnKfjqHJi7ZLwxaHbKYZPR71qNPbsZlrxfZjhVoez+IU6okQxuNpcJNhwglQs4pUi1miAmn6sxBjB36ILhdBBgUXK4w/4ea/DEkciWSK50Zify/vt/8f4JbPgKZOXMm3333HWlpaWg0Gjp06MDs2bNp0KBBzT5XJ9JXrlxZayL9t2EDfo+rPYy03VsJjo7BfO4InzmjidIr6FkvmkBLCfq4ZI5lF9IgMgSVz0XVuaOooxKwZJxEFRGLXG9EaQii6sxBKvb/RPL42f7ESW4XrsoynMW5KIIjCKjfDLepFJfWiEGjIq/CTJBOg9XpIsBpRhIYhsLnwVlZhjzUnwnwYnEFMUEBGDRqzHYHZrs/DEtkgIaQqNrZAl999VXeeOONWmYWQRAICwvj7i7teGzq24yccBBj7xiUMkh/fzZBj73EzmF1GNy8MxtzL/DchHEsXraCzh07sHvvPlYs+oge3W9H0BnxFGZx2K6kpdxC9ZmDyANDwedFIldiaHcnLo+X/ZcLiQjQoL/iM//NuXwm396EynNHsUQmc6qwnHohBtJLq6gXYiBB5sClD0VSkIE6LBqZSo3p5H4c+ZcI7TIAz5Vw+vr4ZLwOB16VhrJqG3UC9fiQ4CjKZev673jk1Rm8++67tRaJ/t34fD46depUE99oyJAhLFu27Lrmxd+OWH6LTGHA6zYjVagw6DTY7HZ0Wi07duygWbNmt6wOf4QffviBESNGUG734a0uo7XSwE+Xz4JCRcnWb9F1HoDD7eGn9DzutKZzKLgRzdO3YD51EOfjb1FhdxKqUxOq01BSbUO3ahaGpu2IvPN+ssqr2N1xABGhHpRaqPvwIGw5l5DrAwjv9QDq5GY4Mk7iLM5FFRGLoX5TKk8fYivhaOQywnQqEnOPUZ3SkfMllTQJ1dGsSeObHoHsnzMJvebKCMTupP0Ls8QRyE1yy/0Pd+3axZgxYzhw4ABbtmzB7XZz5513YrVaa/Z59tln2bBhA9988w27du2ioKCAQYMG/elrLcz0rwzWJqTyhKqAO4oOEWgpoVIfTuW5I6SoPQjF2ZQ6fRRHNeREkYntRLCmSo02MgZb3iWkKg0NnnsPW0E2+Ws+BkMwmohYcr5agM9upXTHd5TtXo/abcfrE6gTqMeTk0aQ1IdUoUTucVJodVEs1SFUm6g+sYc4T+WV/X1QcIkQr5XQAC0SmZyFn39ZU/6W7Tsyffp0Zs+eXateNpuN8vJyGnTsQbHFzsgXkvmwfzyvdA3n02/m8HbfUNbnVBHy0FCMoWF8tmwFAv51ODqthjqxcchUatQKOaWaEJpUXUKdkMLu2I4cCm5EdcMuFCe2wnHpDJKiLNqrbDSPjSAxUEtSRAhj2tVn09nLmCOSAOjbqC4ahZzYQB0rzhZQJg+gzGJDlZCKU6XH63RgbN2d6LseRapQogqJ8IfPdzjItvrnQGKCjbgFqLY7UYVG8MpHS+jYsSNPP/30n/7d/0qkUikvvfQSAHqdjlWrVtXkUPktSqWS7OxsQkNDGThwIM8//zxbt24lKcnfTsNbRHHs9DnKsi5y7uw51r/6NAEBATRv3py1a9f+qXK5XC7ee+89GjVqxMKFC//wcb/uH54/f56+ffvSrl07+vfvj0magLfav37jsXenU/DDUsotNiZXJTF/fzpWp4vO5zciVSipdLqR93iQ+i+8T06VFY1Cxg8ZJajS9tMsNgLdE2+QUa8jWeVVBGk1NPx5NcdnLKLd198Q3Kkfyc/OwdC0HTKNnstzxuFzOghq6Z9oF7xejM06kBpm5Hajj2/TylghrcuenBJiA3Vsv1jwp9rq95Ao5FdWo/9rRbrIzfG3z4FcTae6a9cuunTpQlVVFWFhYSxfvrzGXzotLY3U1NQ/vJjw1wsJV6YVcSDHzddD/a6nGy9VYlRLmdK9MRtOZ1Lp9JASqidCr2Xh9CmUmK1czs3liccf5/777kdmMSENDKXSaidMr+H0Sw/idTqJf+x5FIGhAGgiYzAd34PPaUff4DYUegMuswl9TCLnCspIjQzGZTaRbvGiuWL7BbCdO4wxpQUOZKjxYi/OwxMSQ/rhvfyw5yDDnxjFrBlv8u3qVZSUlNRE7ayuriYkJISWdw/jm7lvUmlzsOtyEWu/W8vZ7ZtwVpXTqUMHNny/7rrtk9qwEfcOuoenh96LqzQfX0JjrJu+wNX1AeJ1Cg4VV7E5s4L20QForjgU6JUKXF4vaoWcaKMedWk2eRp/JsMIg5a04gpah+kxHf+FiK79seRdRhOdgC07HZlWh0JnRKrVY7l0BltYAoFuC7LgCDxlBfxS7qFRZDBKuQyXx8vBbZt5/OkxbN68+S9xab5ZfD4fw4cP56uvvkKv1zNgwIBrQnP/O0wmE8HBwYQk1Gfx1ytoFBXCwkOXyKnykhQgMPtBfx1HjBhBs2bNeOaZZ/7t+SZOnMh7771Xa1t5efk1UV1/W4d58+bxyiuv4HA4kEgkNWIS17wtLW7vxZtPPsyksU+yaet27l2wnmc61qXAbKNeiIHv0wrZku5GrYDx7YPRvvowquAA6k2cgTemPiF6Hdu6tyP5qYcBUASGoenYH6lUgsbrQqE3UGm1Y3Y4OVlQztFiC6EaGf3qRxNmL2feRTvtog2khAehVymRSiVo8fDN+XxSvniJRu+tYdyGUxzKFBjQwMl7j/a/6RHIoYUz0Gv8NmqL3UGb0a+II5Cb5G9fAVVVVQVQc/P/p4i818PpdGI2m2t9AKodTtaddrN4cEMazTzN4aJqHm8exdADC8n99A2aH1jGIJ2Z2wwydCoFU15/gzOqFLIzM5kwYQKPD76LJRfKyK+oQji+naq049Sb/hVNZi9D37R9jRnmUpWDsA698LXoASFRnKywI4+IpezQDmLsJVwqraTUpyA+xEhSRAhpReV8fewi8vq3IVNp8BVkUn05DV9YLGqlnNvatuONV18hIdSIgD/cS0HBv3pdAQEBDB06lAtb13Hfoj18eiybUJmX7Z/OpW54EKbSEs5u/ZHVq7/h3KlTHHl3Et9+u4bYOn7z2PlzZ3lz+nQatutEQrd+PP7440w6VobN5abMA419Jp6yHeX2cDWZlTZSS89xW3wUMYEBmGxOMkpNnPAF4PJ6KTBb2ZaRT3JYEPOO5XE6oilmu5NqvV9c1WHRSIMjEXx+77Sq4DiO5JZSKtXi8/nQRsRye4M4AGRSCVaXG12w/9jfS8zzdyOVShk/fjwA0bFxLF++nDlz5vzh41u0aIFcLmfBnFlErZ1DsAJebBLMJ72SGJOoYNWqVQQFh7B48eJ/O9nu8XiYOnUq77//PuHRMbz23Hhee/cD5HI599xzzzWhyH/Nm2++yXPPPUeduv7RkCAI3DX6JSYt3wR3Tif9ZBJdXs+lqMuLaB7ZgEGvYfW5IvRKOfWMalJDtEQa4bv7kmhuzqTxV1twvPQJ6nqNMdudvF23M42nvoyr6wPE3T8GfXJT1Eo5nrQjFP6wFEd5MWaHk40X8jlTZuWZdkk8nmSkyuFi7MFqRkou0d4gYeulAu5YdJ4Ss5XspXPoVnKMr3tPo+7UM3zYJ4Wf2pXQrs7vLxz9M0jlCqQKpf8j//MJpUSu5W8VEJ/Px4QJE+jYsWPNSvQbicg7c+ZMjEZjzedqSOZtl4qY1TsMLR5+DP6W5zunEl+VTeRdD2No2p6ExyZRsWcTAEZnFcWLpvFNo0o+qO+PT7XzxFna1gkmIcSIsUMfjsvCMNsdOKVKqg5vR5eYSsXxPURU5uJ1OrA63Xhy0pBJJCw5nM4WeR1K9P4FkMtP5XC6oJTDWQU0jA4jJVSPWqlg0YFz2ENiWFwi5/uzWagVcrZdLmX18XT2Zxfz4lNPoFap6NjRb866KiTvvPMOgiBw8t3H+WbyKEbc3ReFQlHTY+/51ASqgurw5qFitib3IbVpc9oM9vcOg4KDCdBqqKy2+Ou5fRsbP/uAEUMG0a51K97dn8HIsuZ8klZB3UAtq4QYvjh8nq9OZuPy+pBJJJwtrcbqcmNUK9HIZVidLl7o0pDuSdFkllVSXG2jsLIal0KN1elCrtVhdbqI1srpnRiGQibFZLVzsbicarsDjVKO3eUhQQ3u8AQ6d+5ckxDq/wKpqamo1Wrq1Ymkc7+BvPLKK9eE5fktJpOJoUOHkp2dzX0PDqN353ZE3PUwJTvWoQmLRqZW85NFTb4+ms279vDU2Gew2+3XdRd2Op088sgjvPXWW4x84gmO7dnNU48+wpO3t2LEuIns3r2bjz/++HfL8t1335GYlIzH7k8YptbpKI1sj02i4ttHYpn5ZiOevV8KWXsw/vQEx1Z9zKYVLnbnVvJzZgmdFWbe7VOfV/dk8mpBEG/sSGPC+lLSisoJKs2k/rolfOSOJ1SvxWx3cFbQc+ierrivpFLwWM0EW0u5V1rAcHcaiuIsFDojBWYbHWLVyA3BKA1BdIoLZ/1jSYQbdCxOvIcB5xuw64KPns38TiyauPq0ifhrBORf5iv/R+Tm+VsFZMyYMZw5c4aVK1fe1HkmT55MVVVVzSc3NxeAfrJSUiKCuWdlOmW9nyK7vIpD0nAG7tPS/3A4xwpNPOQcSKvJRSy7XI38sdf5qdXDTC5LZsAbi5i9aiPN64Rgc3vJKK6gfd1oVJdPsfLEJb71RVNxaBu6hBR/hFqfhKSIEC6rw2kWGci95HJv/ShkUgmB2ScZcOAT6mbsoaHSjSMrjYRz26k6vJ12a15HfvkU95fupf3+pZjOHCJ62RRaHVtNxKaFRAVoWLdlBz169OD1adNISUlh9OjRnD9/nl27dtG8WTNS68bz8ssvc+rUKU6cOAHAN1WtWHm8jMIqgX25Tgbe+x71jP51CaaKCpq0aV+rDeNjY7HZHVSaKmhoVDL/rniGNImja70ohjZLpGtCBK2jDBjVCnKqbLSKDkIjlxOkUdG/ST0MGhXniyrYeD6XFK2ExqF6ogIDcHu8BGo1mJ1eAhVSqrwS7DIl4QY9UYEGEgwqTFYHBZUWdmQW8vm5YjrXjSLt/Plao65/Gq1Wy4IFC/hx63Y6B0owGI30798f75Xgkb8mPz+fl156iZiYGFauXMkDwx5m0ONP8cT3ZzitiuLnsJZIpFLWnskmMUjPQ7clUe1007RJM0pLS2ui+V7lapiMb775lglTp3Mk7l6qPAKFMj1nPBreenY03bv3YNWqVbz66qvXfZ4mT57M5YsZNEhtyCNPjELeYRaFFh3ffCNwx8J83txRTrs6gZz5ajZFxcWc+HENMzOfYXLHZPRKOYEpzckoNaGUSTh6WWD1AQmdkiW8u6+Ax49KsXu8FFl8bDifzdy9FzCoVVTN/Apfix58cLacT7IcnPFoUDXrzM7QZjSf62V7TjmtYsN5pGUyrxcYGPTNRQwaFV6fwMi157C4BH55pA5Lh8TyxX23cTqvBF9INDk2z1/ym171wvq1N5bIzfG3zYGMHTuW77//nt27d5OYmFizffv27fTo0eOmIvJetXF2n7WWHqlBpITo0V+J+781qwKAKJ2CnVl20r8rJfCOMNonyFh9xIdaKZAQBl4f5JZD+3oSmkWoWHfWQVyIBKUMpvdMRYuH1/dc4lC2l8515bzavQmrj2ewPdvKgGQjFpeHAouLZzo25Kl1J3m4SQjZVTai9H6ba4uYMPQeOz63C3teJj63C3VkLOenjkFhDECu16NPbsSZBWtpNHoAgttNhdnMx19vYFt+FRU+DyqVCqfTiU6n44svvuDee+9FKpUiCAIyuRyvx0OT1u1Y//VSVLoASswWDlzK5f7WjfECd39xkgktFFyyCWzP8vBA4wCyzA7uaxRLXEggF4vL+f5CASEaOVE6NRqFfz4kOSwInUpJYWU1Xy//mhJTFfWDdaBQYbY7qLziVZYQFECVw41BoyLfVI1H8FFpdxOuV5OgV3Ln0Ec5U2SifpgRs8NFqtyf8+HQ7h3cM+4lVqxY8W9Xf/8T3HnnnVxMT+eOju1YtHwVJ0+erAnJbbPZ+OGHHxg/fjxFJeVENW2Lp0TCkZWv4LVb8ZgryElsC0CruAgqju8hpGUXxnx/gnc6RFCcm02zO/szd+7cmvt806ZN9OvXD6VSxchps7ijaxc6RhlwW8y4jeEYNUqWHctgyZTnOHr4IOCfzP91grWrjBs3jg8++IDOj40n5fb+aBQSJjcNRq7RYzq6k2Pvvc0D+zIACAoNY91P2/AhQSqVMP9QAbfHazlT5uDFTsm49m0krHNfdmaX8ex6Ey0TBQanGEkI0tPAoGRVejE/X7IxONVA44ggzpdUcr7cRkaFh5c6xlFH6mTU9iKCtBLijXLsHoF6gSrCtCoOF5opsXo5mSdQUCFh7kAj7+2txOsDrwCZ2Q7Mnw246TmQ46sXE6D1pwmottlocf8IcQ7kJrnlrgiCIPDMM8+wdu1adu7cWUs8AFq2bIlCoWDbtm21IvLm5OTQvn37653yd2kWJafA4sHmrmblMTedkiUY1VJC1DLOlztxuGHWGym0lFv4NN/HhG4K7qgXySvbsni1S2xNlsHGkUHEBJg5U+r3FLM6XaxMy2PvJS/tEqUopRIGfXUcqxN+eCCJ2YeyCFHLuKt+FOcKSpnarS7zDlymZaSGt3ZWcF9TFRkVVlpFB+HyejGEJfPjxRLqVcA9n/5AzooPMDRuS9nOdZwrl6NfuZ7UlyfzzDD/uoKD65dxotDErB8PEptcH9euNQwZMoRBgwYxaNAgMjIyePDBB6msrGTWrFn8sPln7mnfApcmlBd+gHdfeQuJTIVMn4i6S1taBErJspTROm0z/fsOY/mFQgLzyzCqFNQP0uK90qfokhzHuYJSjueVEqZT0zA6FJnXg9thJ6fcg16lpNxqJ8KgQ+rzUGGuRuKwUmWRkWWy0zgyGL1WjtPl4ESpiyFaDb0T5QgaHQoJ/Hg+mxZ1Qpn7nT9oX9++fW/qXrsVvP/++wwcOJBFy1cBcPr0aSwWC1u3bmXJkiVkZWXRIDqccIWGhW30NJj0AV+dL6J73bp4fQKL9uZQUi1wR1IFGnk4UWcv0zhMyUWnjPOSEAbeez9Tp07l6aefRq1Ws3fvXgA++W4jEWEhtEusQ2FlNT/l2ugktbLzUj63140i5e23+XLNOqL1KmbNmsXevXux2+3cfvvtNekT5s+fT1FREecO/ET34Q+x7HQl2ogYspbOwVGYQ4eFX0Nz/3obU1kp/Qc8S9JTL5J2ScGTvWUMuy2Zy0tmUlA5kpkVSeyYkklqkodfxqRid7vJrjBztLCC5zfbaRAhQaeSMGObmYHNbASqpJwochOqk/Dx0Vz2ZPjo2kBKbqWPx5uH8UNGCZsyrBRVWVErIFAL7RKl2GMElp2uZGRrPW9tsyKVCrRr4OXnv+C3lMr98x/+//81o5r/37nlAjJmzBiWL1/O999/T0BAQM28htFoRKPRYDQa/7KIvBnlHjolKah2+Xihm46fLlmJ0ktYvN9N3QiBggo4UFBFkVEFuAhQytiTXcJX9zfnZG4xSpmUNWnVzNpRTXIUnMuTEBEoEK7L52Sxk5e7BRMbqOe17TlU2+GR27TMP5bNxXIP3+V6SK/IQSmXsOWcj0AtBKqdjGkXQLMov8OAQaNi56UComN0dIwNRKdUEPdcGqHHCrD20rO7TRtilAdw2QR8ditf/fAFHTZq+EWt55XzIUAyPzwSQn5gESlxUWz8ZScXCv1umAqFgpkzZzJr1izqJSVx29cOAnd/jqZeL4i6HfOZD/AVHmDIHTORSuVI7pnJMqErUT+cpfFALSVmOyF66ByvpsTmIUqnYMWxC9QJ0BCmU1Nhd3IoqxC5VIpRrSS9wkLX+qGEBQdRVm1DkCvw+gTkAUEoZHKamc6j8upwSuUU251IlBoyyyqvrB+wsT+3nFbRQWSUVnLvoEH8snMHOTk510Rv/qdJTU3l+PHj9O/fnx07dtSkepXI5HTv1o2Bj4zgsiSIA1Up/NBRSkO9kV7JUladyaFZeAD3pgTSIiaMDedzWXfOTt8GPh5rEIpXH4RSLiPy0Uf4/tvVHD58mM6dO2M0GlEoFFx2yehmzsZr0aNXqfEKAiabg3rB/oyZzRNjaT3tZTYfO4tszhw6deoE+DtkP//8c42TSpcuXVizZg0NT3zP62VFlO4rIXrYc7jyM3EGRhCfkEB2VhYA5vxtXFpaQcg9s1lzTEKCMR1Lyt3MWFCMYfNs4usNId2dROcP00gIEwjRg8kGEzsEsb/AzGc7wVjk5fsjFRQmBqE0ybmrt5cLxT7ySmXs+iWb8rB4Hi8ooNomRSGXYNh3kvOJtwGg2zUDgKD4uyh4vgXPddNwpszBxdqZiW8YiVyB5MrkueT/00n03NxcJBIJMTExABw6dIjly5fTsGFDRo0a9afPd8tNWL8Xx2jJkiUMHz4cuPmIvFeHqDHj1yFV6QjUCTSqIyFSLyXL5CXaIKV7fCDdk6I5W2TiTEkl352z0ixaTohaxjennNg2XwCZiui7EwgPkKBXSugeb2D1uUoUMn8dgrUSGoeqOVPmYNdnOXg7xtC1gZQfv/PhrSshOlhArYCx7QJpHBmM3e3B5fGSrIFCn4Itl4qI0vkXMvVJiuBggYkH55nwSkHjEpDuf+u69TNGdsAT3oaUvir27pWj/mUGby55k0qnh2CXmWeGDsLmdBEcHExFRQX6x9ZxW0MtlTawHnQS1jCX0vXvcyn9Qq3zqpoMRFPgTwjkdFSAIBAU0ZqZiyfT0XKRTxzRZFd6ebplJBcrqgnWKPlh1deYqsxodDoa9x5E03ADXp9AtFGHTCIh3KCriQ2240IOm1YvRy8TUKm1dLv7Xiwujz9+VoCWqMAAXF4fl7Jz6di+LbGxsfzwww80atToD/3ufydnz56tEbemzZujyM4gS6KivKICY3AIn67bzNr0Ssa1ibmSkEzKzD35zLqjLpfKqmiTEIX95B7U0QlUakOQSSVEBRrYm57FmIfuJzY2hl27dtGjRw9O5ZSwcuVyzpZW8XDLBmw4k4leKad9YjRujxeFXIb10BbCO/fF4fbQuGlTSktLAVCp1fTt04dFixZhNpt54IEHSLt4iZ937SU87zTKJh04V1hGtFHP6cMHmPX226SlXcDl+pcJ7I7J73Mgpwl9unrZt0nAHiclMsiHWgGpURKOLC8hPzKKpk09pOVLUeZBuzt8xAbK0MolVLt8dI01cmdKHIlvnEZdAAGpPqKD/WZigPLLcrp08lBpg6MZMgINPrqkQN1AOdVuH7mVXuxuMKglnL1s4ezMe27ahHXmxzUE6HQAVFutNO4z+P87E1bnzp0ZNWoUDz/8MEVFRTRo0IBGjRqRkZHBM888w2uvvfanzvc/FQsrqcWLuGO7o7WUgM9DdWAM2kZQaZXiLZSidPuo1kuJiPHQtb6ENtFawrQq6oUY+OZsPqeL3Ry9LEGtFGgaCwqZhJwKgS/uTcJgM3FhxjNE9B6MLrkpzuJc9MnNWF/koF1sGGVWB0XVdvo28pvoqk7uQ57Sivn70zmc6yE5TEr9YCXpFS5iAuQ0DtNT5XBztNjKgcs+skqkUCzlzju8DEg2cqbUypq3d1NZsBvVbS9QpVOS/mY0PrcLtz6Y+fvTsbl9VNgFtk+dSXH+9pr2uK95EgGd7+LA2hV0D9Xw9aUy5GoNoaGhnD9/HoDGjRtz5syZa9oyMCyCNq98xW115PRMDCXfbOO2OqG8s+8yl8sEErM34XbYiQgKYvKTw/khy8Tg5snYSwtQhkTiKi/CbfbnGVGHRfPWR4tx2m3oy7J4euQT7JaEUz/UiMvrpW5oIOUWG1suFdFS5eSRxx5DLpezf//+PxXO/+9AEAQmTJjA/Pnz0el0NG3alG7dujFz5ky00UnEj1hIRCB0jFfQOEzH+XIrKpmEFpFGQnUaXB4v50urGNwwltI9Gwnr1I/Dw/tQcM5FWcc4Xli9lUWLFjFq1CjCgwLpM/1THmydjMvro0NMMBafjKyKKprVCeO1bWfYcNLHlJ566oUE0LWFX9i+nTONzCoHL073Z0XUarVI1TqGTJjEUUkTnu9kpEWdUDJKK7mQns5Ljw0FoHnb9pw4+C+X+WdnzuWQtDEVm7IoaVYXg1bA64PoYIFT6XLuauflh0MyFEoBX5UEn06gc2MvFRbIKpEi/XZmrbYLjO5C/m2dSI3zkhAq4cTHW8hr2puYOh74cSuV9Xph00iQeCHcZKUsQYtm/VtIZUqM9YZSFBiE6bubXwdybtsmAvRXBMRipWGPvv/fCUhQUBAHDhygQYMGzJ8/n1WrVrF3715+/vlnnnrqKTKvZJj8o/xPCciwj9aRY9GSsV9KVbCUoEAvhgNpCO4qZL3b4HBDozrwdq8kVJdPUXlsJz6bFUVIBI7CbGKGTuCEXUqmyYrF5WVUu1RO55cQX5VN6ZZvKNi8l6JcKR1ee4DQ2+9GGxFLzv9j773DoyrX9f/PzJreJ5Nk0ish9N47SBMQREUFUQRFsXcFGyI2RGyIHRQbKiII0hFBkN4JkJCQ3iaTZEqm198fA+zNrqLuc87vnO99XbkymVlZa71r1nrv92n302RnU3Et+2p8bP5VQrs2IZpc0CUTBmUo+anUi0IqYvMxMZqNb+AZ+yAGTRTpUQfBrnpu7iWh2hmioDZKQakEc3yYbtlR8uIklNrC3N8rhUa3D4c/SAezEZ1SjtPrZ+XpGt5eD09NFHNrporNn73PjNeX/d21UalU+P1+vvrqK/r06UNmZiaPPfYYCxYswOVyUVxcjMViQa/XM3nyZCorK5F3nUSmKJesW4Yxb1g6qe46tNltcPrDfPTRhzTbHawv8yHvch0vXxlHvFrBR0fqEMTQOTHmbvnyqI+XRiZwcM3XuIJhmgMR7rzjDhIbzyGSxKrTZaYkgjYrztOHUGW35czhA1z/6DNMnjKFd95557/hTvr3qK+vJyEhAUEQaG5uJj09ndGTb+GDl+Zx1SfHaW6JbTepq5Rcg4Ir8tIotDRhVCkwVZ6gJL41M761clUXERtORLmqswiH28/3D9+Ix9Vy8ThX9u7KiGff5tdqL1Pax+HwBTlQ7+ZgeYQVbavR5HVmtz1CSaOdJ68ZAcD48eOZ+ugzLP90GeuXvUdyfgfWf/MVxqZyShc/j+HFL8kxaYmGw8SlxlLfn3/pFU60hPnu5acuHrtvnz4k3Pw8xfWQoINrOyjJ1KmY88QeEibkMyRXysp3qqhunUrbjDAFpRIyCw4wdU4fZIKYRJWMcCRKrklLulGLVBCY9OVpmlasp7b3eJLiw7g/W4Dptid4caSJnVV21n9io+M1Oq5rY6DU7iFNq+D5bU7qGwV65zjZPvuPWyCFv2y7hEDaDBr+f45ANBoNBQUFZGVlMX78ePr3788TTzxBZWUl+fn5eL3ey9rf/6p6/hvbmfGIJBTkujhQHSTdIGb01X355rSNfSWQboqyeFxbLEtm07j3MKWnoS4oQgoU4qH6zfW0lcUhICIXCdvTguTeMg5fai7G3sMJe93kdxuEu/gElZ8swNh3JGn9RzM6LwWTwsqeIhd2NwRDIm5oa2CgSUKOQc2iPY2kmMKQeQ3hNS8T6fYYbcbrKFy6ldK8Eew9F0W2p5q4yh/wBlzsFWQc1aTR+s5JrCqsp9kbwRuE57c5GdFOjFIi4to2SZzsUUPXpATkBj3j73+aFVdOZ/2b8/li/V9Cju3bt+f5559n9OjRQMxdKJPJEIlEaLVaunXrdnHbAwcO8Oabb/LKK68Q138gfVXtueG9WvxrP2P6m09zc+dMvnzlNRLFKgwTriPw0xGeFndjVj8lZy1RfEEoa/SSESfivQnp2Lw+iuw+Qj4P8UYDgkiERK0n5HbgrS6lZuW7iMQCUkM8kYCXdI2E64YN4L333kMsFvPaa6/90z4p1dXV7N27l5aWFvr160ebNm3+o/fWBfy1ZfT222/j9QWZftMUuvW7C6W+Fa8uvoGDdU5OWoKsPBpkd3UhAK3jZNS6TXy+oZFMc5RACAJfvcK5tCf4bEwuw959j5lzFqEQeXFXF3K0rIZIiQe9Uszi/c10ShHI0knp0EOgMr4HefZaBpvTqXR4Lp7P2rVraZ+eTNbQiZhsqaR168745+pRVe5m36YN+K01VDsEPtq4g/SMDIZdOY7lrm50Knj7kjHu3bePidMgsGY19usmMqlTLk6vj++WjWFXeT3fn3ajH5lE36QIM7qksrPCClf1YXybNM5YbKw4ZePqfB2rCi04fHVk6iVsvr0rvmkd6baoAF9AxP6vZ+MuOopO1o/PHWHyrtLRKVHGplIHgTC0j9ey6uYsMkwGbDWV5P7jluOXhVgM5IKYYuCP7/D/h2jfvj3vv/8+Y8eOZevWrcyfPx+A2tpaTCbTZe/vf4wFsmTJEhYuXEh9fT2dO3dm8eLFv1mR9cIKY2lODkWNMo6FbUxP1iKRwvqaCD2kMrSqMO1vHYTtyGHCviBZt91DwsAxNB3Yjr7fldTZW/B99BS2IwVsOxLBKQpzJtCMVpAxWDCSmxIk/cquZNw6m6IX70PXpj3OwlNk3TEb297NRCNhwl432TOfpfKLRShTc6hb9y3qnGzih0xAbk5HLFeyriFIqlZJF3ELEo2OFeUutDKBk41eDlSEcWwsx1W9iVDnu3l4hoyburZCQZiGn9cgT85E37EPEY+L0nefQZPfmcQrruOUzcN9a2uxLn0NQ8ZVeHqmo7AVMO+6ntw4bhQffPDBZQXInnrqKV566S/xGJkpFXG3h5DmdqWdYyX9kpTYQzDhxqnEffgotoJqthWLyBIL9J/RAW3bbqjzOiHkdubtJe/SaLOTk2Lm+jZm9J36ITfG4/SHsXm86JRyTBoVp2sbcfr85DorWbp6PQs/WErHjh358MMPOXPmDHv27EEsFpObm8umTZvYunXrxfPTaDTs2bOH5uZm2rVrR0JCwm+/8X4nysrK6NChA/KEK3ju/cc5bvHhDcGh79zcdZ+JNK0CjVxCsyfAcaubH18/ADI9FTn5bHwgkRWn6jlYHqH8pAQhOUK79AhPD07i13WrmDtvHjJ5HKax35LSLcKIfAmJKikzerbGH46w+1wNRoWMFx+7n8qzhYwZNYJPNu7EWRdr/TrjjlmYhl/PhlMhpBLYMKMDp+sa+bWqmabCo3y96EUaGmPJF/dMvpaJt9zGm599zY8rYrpsusy2HPvwBRzHdpE26R7OvvogtTuL6PXBO9gSckiN0xNutnDo9kkUnRLTc0IS6bc8hH3/Nhp+3k6Hhct48qiDHIOUaUY3dauXkTxxBlJdHB9X+Bn96/u4K8ox9RuKf8Q0nL4AzR4/u2ucTO2YSn5SPAenj6TsgBtzRgSbI8y0s6V/2AIpPrgHrSamCN3icpHXs99l7/Ny56mVK1fyzDPPUF5eTl5eHgsWLLgk2zAajTJ37lw++ugj7HY7/fv357333iMvL++yx/lbsGPHjosqBtOmTWPZspjX4sknn6SwsJDvv//+svb3P4JAvvnmG2655Rbef/99evfuzZtvvsnKlSspKioiMTHx3/7/hRtkmi6HQWoJBz1RUqMSBo4zIRILhD1eBJWSuH7D0HcdiCSjDdU2Jwt+rWTzrxIG9AhR1QQ3d5czICOeLaUNPNQvH5svSPHMsWTcfDtJI69n7elKdle7+HafCJUigrdO4LtH4skP2wg0WRBUaiRqPXu8cr45Y2PHKTH98yM8PywLzbnDOA5uJ9hiR2owkT7lQWqDYhRHt+EuOYGgVBPx+zD0HIagVOOtLEaenIkqLRefpQrLxi+xHTpK+o23IGh0uE4fRpnVhoLcQby008boNrGYRb45jiJLM1m1J/CHof3Eqdxzzz2X7RIqLCzkzJkzCILA66+/zs6dO8mZ/hrW7z8guX9vgltWMViVzANPjUffZSCBJguJg6/Ch8CxaguL9lg5XSUifHIlAY+XhHgl6h7X4Q9ARZWE9BorIkchaFsRkcgJSlXIrUfwO4sJXzWVXOEsx5a9gM1SB0BuXmvC4RCV5eW0yc7ktrvvo3f/gZh0GoYOGUR9QyyInJWVxfHjx/+jbgm3283AgQM5ceIMd370LT+XKVjbrY43Pals/6iSaQ/nMjgrkY0l9Xx5KMiySSm8vKsWuweWXJVJgcXGY2tdyMojpPWBqg/eJhyMuQ6i0Sjq+K5se2MItp1rCdib8dbW0FLaxJkSCXppFIMhRLdX5tDplvvxe2Kp5u07dKCosJBJkyax8cd1FB7Yg/3wTow9h3H62ZmkTXuQnfVu7r/3Hnr06EHPnj1ZtGgR5swcdv+8nQc3nOXwgldR9e7Knhem8WnvSfwYiLUsGCw1U46XiqCTBIkKayhm9UzTJZA/OoWGQ1XU10tITQ9hyE+k/nADAb+IMw6B/u0itH16HnFdB1DxxSIsW7ZScixMh1HxmOZ+SkF9M+l6NR3TEmkpK8J28CdqVn1LJBAr2NS1zaKhuIJrfj7zhwnk3NFDaLXnCaTFddldDi93ntqzZw+DBg3i5ZdfZty4cXz11VcsWLCAI0eOXEzIWLBgAS+//DLLly8nOzubZ555hpMnT3L69GkUCsVlj/WfwePxoDpfAxMOh3E6nZf02ikvL0elUv2m+fav8T+CQHr37k3Pnj0vTnKRSIT09HTuu+8+Zs/+97brhRtkz4LHEFvKyXjkLSLns4t8lioU5nQaNYkYmyooVyfz4s5aGlqguSWWOQVwqlxA/eOlWVAKdRIN/W8jMzVEOAKeUyBtI8L7xSsXt9F2uA9/JzV9YpJDKKXQIUHOolnzUKiTaH/fNE5Xi3himIrRMjvRYBB5u148uP4Uc4fkUNhgIxyJ0kfagv3gdsRyBaYBY2nYtAKRVIYiNQdlWi7OE3vw1VUgi09G16kf3qRckg06IMrS/YUka+RkffcSB78spPv1uYgEMc9tPcqWs9WsXr2aq6+++nd/P36/n7Zt26I1xlFa4iAaDaNMGcKTr88gTaugweMnHI1ypN6HRibC6o5ycG0Qha2IpspNBLz1hIMeDPEdkcgN+N11RML/3oUQjUYI+JoRpGo0+myU5gG4tak4MtXkp4W5p4+OYXlpVB3aza+HjuD2eHnujcUsW7aM6dOn/+7x/ivU1dVx4403smvXryQNWsynC/tR6fBwVZoGu1TD5uIa9tX4kAnnt3dEef/qfBpdXlonGqldtxxpnJnqjG5Y3T76ZiczZ8sZDn9WRt/p2Txg2849T34AQD9pIuV4qA26Lh5fIZYQjIa5wxyHsHgxs++dRfn5NFyxWEwkEgHgwJb1ZLdqTQsSflj+MQs/WkZtbR1Dhw5l8+bN9OnThyNHjjDlw00oZAKvjelMwUv3sGPZMdb6G3huQgea9jYwaNlCtHmdkKhisQPrr5t49db53P/iTUhHT6PYaqeb3I/InMnCXWfI0svI1KkYmp9JJBTggwPF7K/xc1Welt5lO5DGmZHq4nCktSNNKSHodhD2uFGn5+C3NVL2/jwSR93ALzOfpqRF4OZ372bLQ29xv7XkDxNI2emT6LTa2HstLWS363hZ+7zceeqGG27A7Xbz448/XnyvT58+dOnShffff59oNEpKSgqPPPLIxV4yDocDs9nMp59++qcW1apUKoYNG8b48eOZMGHCb26V8e/w304ggUAAlUrFd999d8kkN23aNOx2Oz/88MO/3cfFnuglJXx4tJIbO6Rx/Rfl5JihYk09trPLEfWaw+dPJrKtrJFHBrXn1pXHMSpF3NwxgeuWNTG8c5gDr/5lJfjvoNRl4nVeqo1kzLmOsDKB6uQ4MtNDiMXQtHQB6luewP3ZXyTa9Un96HzHIE5+ehx79RaikTAisUCg72z6DwrxUJ9kKh1umrxBvKEwt/fK53RtIw5fgFd2NXFVWxnJajkDbAXUrl7OyvUWUqMSDkZbLq4Oe4/syqIffqJ9+/b/MNvqcjFnzhxeeeUVbphyE4eaRTTs3U9K3o1cN3sYghhSNDJkYjEJajkOX5APDzlw+qBu13fYm33Izx3AlNwPReIAmhIyUfqD+I8sRBPXFlfzmcs+H2POddR3yiMnKUK3DDED0jRoHfVcM/Fqvv/+eyZOnPiHx/y3WLt2LdOmTcPp9GAwdaT01AbGZbYFYO6wDFqqXPRdsZLpP1mZ1d1ED5mXxh2rsWzZjDo7jartJVgapeR3AUPndshMCZR9sw1Djg6pXoux10CWP70SOyHqIx4MYjltUNJEBLcojICI5qifBJEcezTISL2UzLdeYMo9D1JbV4fJZKKpKZYn+9gt1/PoU3NZ/PEyXli4CIlEwrvvvsu0adOQyWRMnjyZr7/+mhSxgtqI7+IYM1KSqayNWX3LPv+S3r378PGRck7Vh7m9m5GCRhetjSpe2+2g8TDIA37qUtQQgfTj2xAZ2uOv3YI8ZSQOvZlPZ8ejkEqIRKIcqrPx3NogaWWN9Lwxjtu6JJF45EdCLifKjDzUuR0QiQVsCgNpcXpqN3xBXO8R9PvoLOde++NB9IrioksIJDMv/zfv8/fMUxkZGTz88MM8+OCDF9+bO3cua9as4fjx45SWlpKbm8vRo0fp0qXLxW0GDx5Mly5deOutty57rP8MlZWV/PDDD/zwww/s3r2bzp07M378eMaPH0/Hjh1/937/24PojY2NhMPhv2NEs9lMYWHhP/wfv99/iXTDBVXSvg+dQhWVsjJShK+dgif6JzHHA7azED3wMq/teYKpHfWsO1lKuRVOemHJhDSOjT+LqF1vRK1u5zOXgaV7gyh/+hmHZf8lx5WrElAlDSakSsbRRoOnWYxm+4sXP7eVfofo+tl8fo2O9SU2jldH6fvUE+zb+hfJMUPqUPrd1RtvCIKu8otdDA3Z12HuFcKsFfPa3joKa/9SnevY/SNt2vXgRFjA44fXfwoxb4yESDBIc0E9TdEgAxKjDL9mMIaeQ3i1ycjrd99Aq1at2L//0jH8XojFsTF889WXxKdnkdMtn3OHV/D2bV+jSOpHi0nJFW3E9DDFzO4RGj1Rv4+WfClRmZamvldy5aQptE+KwxcM8fWpGkrHPkG5FVTh8ZTVCcTVBgkXvIc2fSx1Ka1IrjxOsOXc3xHMBaIOBESEo1DrjCATxKz/JVbF/Z/oZrhu3TomTpzIyCGDKOx0L9uG+S6SB0Di8CvR11XScvoQH/VujePYRs7t2krTiRqCPmipLUEihTbdxSjMRuzHT1N5OkKXW3qQMf0Jvug3hR9WvUsHmYmsqIwpw418sL2BzUELCrGEWVk6BGmU46VqtgUs9JAmcNQBPW0VzJ7zJPfffx+LFi1iwYIFnDlzhpFTptNSdJTyikoyMzM5ePDgJbGhkpISgEvIA6Bdz75U/hDzg5eXlbJjyyYqyssYMHQ4t346iEFdQnSIFyOIQGuvpqlLBpkHjuAb1JmhDw2jyh6hS9Kt/FoRJEOIWUOZjgqMHXqzudRKbmqYllotGrmI9nopTXIluJx4ywtp+mU9YqkU05AJ2A3xKDPycErVhE7+vf7Y74FYIr2ownvh998qGsvl8n/YJOz3zFP19fX/cPsLxdQXfv+rbf4sZGRkcN9993HffffhcDjYsGEDP/zwAwsXLiQuLu4imQwePPiiksFvwX87gfwevPzyy8ybN+/v3pef+RJFfDds1VvhBNy6NgHvmNuR3jibSATqHVGe3Ogk3RTFelrAWLaD/K9eQaXPweOIuQ3k3R6jKUlKmkhApc9Bpk5DpEwm6q0jGvYTcpYgEctRliowNRYQMLZGLJYRjYaxDpjIsLww0z50kl5eQtTXQKEymRn35SFMfZo1J4M82F9Lky9Ir9Q47vnsLm56tipWtwKMaCXjjo6pNPz0Hc9mdqfFCyOyjIiSW7HiTBU5BhXpJkjQRahwBmhsN5i8WVYea6zD0GMYwdY9+OhIOUd2fEc0EmbRokWozxdO/VE89thjSCQSnn/+eRqrymmsKo9dL0UioertiGvE7DgpZr+hNT51IuasBuJUIprcURRSJ3EGNc4zNop/tlF2VkJSkxNPBzUmbZSysxKECFjjZWQmDaTFmIW5+GdCQSdSbS5GYye89TvxuWMPla30OwDM1Tn41GkczByA228nWFKGXC5He36V+WdhzZo1zJgxAz0CLXuOk7b3Dm6N3S6sOraLkNeN+1wBpctX8vDijQB0lcZzNNjII/lxqOLEbDkNuWKBcF2EftPMJF4xnuD7H/HIki2w5C9ZcwWBJgqAzZv/cnxfJMQnFW4c4diiaYzUTF5qkOam2IPerXAzE0cM5YEHHmD58uVcffXVvP3Ki8zKkNFVqeOLigrWrl3LbbfdBsD69es5dOgQA7q0Z/exUxePc93o4Tw3phsZsjCrN2/j0LaNbPhlDwA7d+3mlRdf4N03P2WKNcijNwxi7EMT2CfIONutC4+pK/FWFkMkTLNpLIO8FfS58mrCkSiBJjFNLjfDs+N5oF0Y77Q4Qod/wn60DE/3K0mL09H446eoMnJBLKDJbc+vTQF6q30YdWqm3KLk5b+6Hr8X4UiUcCR68TVwUcn7AubOnctzzz33xw/2PxgXUvYnT55MMBhkx44drF27lunTp9PS0sLixYsvtnX+d/hvJ5D4+HgEQcBisVzyvsVi+afFZHPmzLmk7anT6SQ9PR1rv5sx5Mox/JJOTVIyInOYdqkR8hLEnK6LUHhEgqlVrPp1yIgIuwoHodxYicfxl+KZaBuBkdlhKjMG4vGDLyiiXVqUFm8WxXvFqHxefGIBr1yKXJuFTCRQkdeZ4T1DXJEjwxuM0NTRz7nEVihOp0HVBt7Y0Ir4+DCPDFXSOyMRg6uBs5EoW8qs5A8Ts7cwFaUiyvIDAV7ZWEnU05u4pDBLJproFG7Cs3sXtwwaT7U3xMyu8F1hM0qJiAM1zaxx9yU7XUQ3kQJVWT2dE9REWydxBHjjjTcYP378n/I9GQwG5s2bh16vJxqN8tNPP7Fx40b8vr/08JBKdYgcJYicpTTUQQOg0GbgVGdQYlKRU2dF5G8mLRoi4DyLpNSGRxFPWvx5iyHkBomalhQBbceBSMRgdYjxB0TQPhcAuSyKWhFFq4wiFeBMqQRNS0xWIzpwPJGtG5g0aRJr1qxBqVT+4XHbbDZuvPFGhECIl82ZbHn6fd5MraNx+2qchSUctgfok53Ftqtu4qPG5ov/dzQYy3JaVNRMilRDR9R0vzYdXYfupEyYwTdnLdy8cToJ82fxzieHqQj+677fF8gDYEPQwrhaM72u0LLtsWVEIiKGqXxsD4dYsmQJS5cu5bbbbkM05kreuHcK727axe233859991H9+7d2b17NwCev1nY35sm5rHX32djUXXsOOfJ4wJmP/U0I0eOpH9vE68u+5JXPv6c/LbtuH7COE6MuArDp++gSE7k4c82cfT4CbqmzuaTB2/lQfEoKn6uJlz0CQFvM73mPEHBu3vxOEoR9crGUHuIqDqDLS/0pn7tJ5T4BYqb3TQrpcy7/SiuxD9Ht8ofDOELhi6+hpi0x1+7sP5Zi+LfM08lJSX9y+0v/LZYLCQnJ1+yzV+7tP5s+Hw+Tpw4QUNDw8WY2YgRIxgxYgTp6emEQr/9ev+3x0AgFpzq1asXixcvBmLBqYyMDO69997LCqInpgxGLI5xoqjXHCDmuhKkSnSpo0iYkM85iwiPT4RKEeX6HmJO1IVRy+BwmQjpuk9RGNoiEhQglhPQZdFgVJPRKkS6KSbDENlZDYFm3Nb9BLx/mTC0pg5I9G2IyOOwGeIxWc4SchTS0lSAJq4t1gETeXiEQIMnxMnamFbWVa01jDXLWf7ZckJKHZ8eb6C2SUxafASXD0bmqciL13PKYiNNr6HR7cUfipAbr+dITSPNvghHSlQIncZBCN67RYVMELPkhbns3rqREydO/CH/5r9CNBrFarUSDAY5dOgQJSUlzJs3D19AQC6Pw5TSH1XKCFKuSuJAkUD7rJiK8ZItIBJHEYtBo4oQrhKjyI4wpadAskZKnStIrkGBIBIxMCeZBI0Sv60RkSAQdNqQpeZQ0+wgHImSqFMTiURxb/8WiUaHfsA4Zr3zJesWPsktU6eydOnSPzzOhx9+mLffepPlA/Pp88jTdPo+hYoF7Sl6ZhoVW8/R89XH8VtrWTf7a/qNM5E6aSYrp73ChuBfJo5pugRCYREJ5hDGPAO5D85DYU7niDOM1e2ny/Ef2Dl/Na3ag1SnIPf+Z5Cb02kp2I/z1AF8tdUsWFV4kUQ6yEwIQEs0hB4pSsRkSMHXQcQTu8/Sr18/ampq6NyxI+/Mnc2hVcv57sfN1AahqK6JapuTHm3zyElP49stP188zyunzsDn9VF7YCc+sYSKigr6d+3Mr0ePX9zm+uuvZ968eWRkZLBlyxa++OILNmzYQFx8AtPnLiDZWsLj81/G7YnF4iZ3y2PI02/zWbHAqWMSEpubiYokSHzNiAenUVYhQWOIEAxBXkoElRxMGniodyoz742pKzSVfkdD7c4/LmVScPqiddrS0kKHDu0uO4h+OfPUDTfcgMfjYd26dRff69evH506dbokiP7oo4/yyCOPXDzXxMTEPz2IfgGbNm3illtuofF8GvdfQyQS/cN2Bf8K/yMI5JtvvmHatGl88MEH9OrVizfffJNvv/2WwsLC35QtcOEGib/rB0J2LeOGh/mlCOQn3LhOv4v25kcRfq3GZ9mJOPs6FD3luFYsQ5U8jLgxWVid0LeViDN1UVq21eOp3oAy/SpEYT9RTxUiZTItxixsiTGXQfq5ekTuKpyW3QgSJUG/42Isw5g5Dru5I7JQCKXlEAFXBSKxhFD2BFxZEhL1MWG53NQwkQjc3F1BwabvcbvcWNwBDpRFyU2K0i5ewdF6L2JRTFIlXhWLQYhF0OCOIBZBIByle2Yct912O9vKGvnmcJjb+0rZ8Nqz/LrrFxoaGv5LaiIu4MSJEzzyyCNs27aNYVNnMnnadJbsc9EzU0yvZBWpOiUOXxCZICZFp0Yjl6JTxFZ8Wgm0lBWiSslC0OhxnNwHgCIpHUGupPnA9ovpzoJahzItF2mcGWVSOu6yQhQpmUjiU/AFgrzy+hsseWMRZ8+e/UP59NFoFKPRSL/+A/jsnTepXrkEfZeBKDr0RXDZaCk6SvPezSiSM/GUFxFoamTD6jp2By/trNhDmsC116YRdLnRtMrjuyUHGT3KQNbtj6HtMoANp8rYVNrCPT1SmLmqhnqbmCHtI/RLV3Cw1k9enIRZvfPwH9+FwpxO7fcf0HLmDLVHHSS2lqPOTsNX10BLlZO61unMXb8XqzsW2xjVsRWt9ApqK2rplmUmt8GPsV8rOj38Mj9s3sbdjz5x8Tznz59Px2GxGoW+8Qp81lr0rTvx0+aNXHvzrfTr14+ysjJkMhklJSVIJJKL33vnzp3/7vq1kSkpDMSSUlZ//z0dJR4kOiPRYJBQbhcsTje7KxvpnRrH3O11BMJwRSsp7/8S5p4hAj2T9cxa1YR691HO7nv8DxPIkeMnLyGQbp0vLwvr381Tt9xyC6mpqbz8ckzKZc+ePQwePJhXXnmFsWPH8vXXX/PSSy/9XRrvK6+8ckka74kTJ/70NN4LyMvLY+TIkTz77LN/SibWf7sLC2JMbbVaefbZZy8209m0adNlD/DewVKONIU49JWVKbOSuXJyK9yBBTh8QXrPzKfW3pszVgcPfOMjudVNNLVW4m6KMigfDpVHubazlF+VSfgCM7B7oEeWCKcvjcJaSFGCORJGKgF12wRqmxNob+pGVRPo1dDijQW9tUropY1iaRHjC/YiL6EPnRMVuAJhtpf6yUsQU58WJkkjJhyFrkkGjoajuIIhXMEoXfJUlDREaCWR0iNLSY0rQLxSwB2MsO9wFFNGhB6pco5V2ZEJIryhMO2VYXJ65jIuz8UNX9QyYcx1HN2/hwkTJrBr167LCor9EXTq1ImtW7cyYsQIvBUx4cbseBidY7joc24dr0enlOP2B1BIJRRamtAp5PiCIZox4ipvIsvo59t6FQa5QHLQTfsECafiOhHo3pH2CVrSDFqO1DdzvN6FyWHFGzJwV0IqvvoqGjd+jqTdQLTq9/nwww8vqxXt30IkEhEXF0dJcTFNEYGc257G6fXh9PpQq/RskmXRd+rTyAt+5uwHX9Pu0buwj+hMyeyXkcr1fLziWV4ZMpxDQSt3j3mUssyeNEaj/KK3ktFFRffu+fjtTfRIS2BrWQtTvqrFoIZIRMS23RIKyw4Qclex11XFUn+sFbRILDDhpcd5ZGYrsosPIxILRIIBxHIFjiM76TtuGl1vOEnfa2M+7M0nS7gQPlhdFbOYp+XnY/3kXQ4cOcnTN4xBLpHQ4HDSdvtXNO/9nmqXnzaPPIlcpUGfkEhGchLdu3TmrmvGsGr3IdasWXMxVrB69Wo+/PDDi9csNT2Dep+OOKkR8m4l7dSj1DTamHjNNQBkSpSYJXJkIjFP9TYzrFc3NPmdMai6ULyiiMSnurAu/DrJ+pl4TBnc2sfB9dPG0Lnj47/7e7yAYDhMIBS++Ppy8e/mqcrKyovJJhCzNr766iuefvppnnzySfLy8lizZs0litOPP/44brebO+64A7vdzoABA9i0adN/hDwg5h57+OGH//ek8f4ZuLDC+PXlh9FptSRNupvmHasJu5y4eo/nQHUj4xQO5AkpnAvK2FjSQLckLT3SzUz47AzNLSJaPCIWX6tlU6mTA6VR8pLhZKUIm1NM24ww4Qj0zxVobYytmL2hCEfq/ZyqiRKngRyTiGSNhDpXiEy9FK1MQCsT6JORiMPrxx0IopBKWHYsttICeKhPOq2Mah5b8AaV1maCUiVjr59MG5OG7RU2ZnTNBOBglZUGT4BkjZxUnYrO6WbeffddaqxNBAUpt9w6A6kg5sdiC/3TDOSY9Hy+diOvPHovy5cv55Zbbvkv+y68Xi9t27ZlcJ9eGK6exWMD8nH7A+wqq0cmiFFKBNqajZj8DmQ6I4JCQWGDA3cgSCAcxqhUUGCxUe7wo5KKUEoE8uLUZMXp8AZDHK9rxu4PkaiS0RIIkalXMSgvHbvbh14pI+C00bhzLZPmv01idh7bt2//9yf9LzBlyhT2bN/Gto/fYocii9nrPCQZY1akShFBd7IZb/l3BLzNaOO7ING1Im5sLmsmZTN/fyVLf4bDj2fj/upVjLfMYcrXRTi21NPr5mSeS3MjadOD0kY7Zq2KxftLafZGOdcQ5UylQHy1H++xRZecjzFtBC+8Npw+qiBnoxoStSrioz481efwW2Kxi+SR13N2zXI+fe9dVh6rwheN0BIJcTkP+vffrKBnVgrqtBwEuZJwJELI2cz4yTez7+BBAHJycigtLUWuVOL/Kw2lqyZei/LKWewrgV33tOHEqw+wU5rOa2/+JS11y+hO/LDXy6jWoM1JIOuO2Uhad6Po7vF8ttPGUx89gbeyGEVyFkFjEh3HTvrDFsjO/YfRnK9Ed7lcDO7d/f+cFtaMGTPo37//xYSKP4r/VQRyr7YV8SIpeaYQra7tTuKVU5AnpBJorkeqi0OqMxI6X7kb9roolZlok2TCU7AffbvuHK+34w2FqG/xkapTEghHqGnxopQIHLa4SddKSdMqKLC6EcSw41wQqxMCYUg3xZRYs/RykjQKuilDyHRGfql1UNjkYmtJgJ7pEloCEVob5ZiUUs7ZvXSI17Di8+WEfV6Mei23Tr8NnSKm15ObYOCns1UMa5WCr8mCVx3H/goLcUoZq778nGaHk6456dx7991UNtmRCmKUUim1DheNbi8vPf4QNquFgoKC/xIrxO/3M27cOHbt3s2VT73F/Ouv4KzVTiAcoUNSHOcaHeTG6/EGQ5y1OkhQy9lW3sz9fVujkYpxV5US9nuJeN0gFqPObI3cYKLB6cbmiblklNKY0XzWaqfA6iJFI6MlEKZrkgGAnHgDjS4P99x3HzXFZygpKfmnelq/BVu3bmXkyJHojG0xzHwbaYEP94k36f/ME1zXxsDwdCMFNh+tQs34rbUk9BlO89FfcWV0QFm0j7VCJq/d/RKRcAClNo0t6+ex41wtD6zwcfzpHCRlJ1jtM+INhTnbHKTRHaXFG+uQCWBQQ3EdJBkgSS/C6Yvi8ces3TF5araWufl1/gK0pg54s8cy7joxcwbm8/KuIkwKgS+f+hjPldMZ2jbEtHwtzt1rebY8iXbeY9QWFXDg0GF0ej092rdl+56Y2/DN1xYy6cYpqGUSTpw+w7HjJ7hy3Dga3X7KrE0EvT4OnjrN10uX4XbYWfDmGxSdPcuSuX9xhyWNvovWV0ykS5rAgFQdV2Qn8ND99/LZ6lhR3Q+ffMiQqyZi+XkNex9+lY8aY/UrN2sTuGbfj5yZdwcisRhNm45U793NVWsO/WEC2bbnIOrzBOJ2uRjer+f/OQLxeDxMmjSJhIQEOnbsiPRvesPff//9l7W//1UE8tLKTYjlSu7q1w5fMESg8ixykxmxREbI68JZsJ/EweNxeAPIXI346qvw1ZQilitQJGdRt2YpGY+8hSzoQ6zS0OTyUGhp5mCdg57JegB0ChlmbUwSoM7h5ueKRgLhKE3eCBpZrG+IRCyitVGBPxxBLohjkvHxes5a7bRJNGJUKzlcWU8gHMEVCPHjN18S9fvomJnCdTfdwtGaRjokGVFIJcRLwN8cC8i26MzolAqOVVtY89UXqMUR/CIJnUZfzfXtM6h0enH5g8RrlCiqz3DHtjK2vHAvS5cuZcaMGf/x72Hu3Lm89NJLfPnlVwwZNJAD5XXsq3GQpZdzrMFDl0QVPdPi0SnlFFqaKbW56Z5iQiaIkUliBGdQKWhyeQhHosRrVChkEqTnya/J5WF/hYUmX4BElZzW8bHWuEaVHJc/SLuUBHzBEK59m1j5zdc89e0Wzp07R05Ozh8aV05ODi5zJ66/7z62F0YY3V7gl5Iw5e++xNzZ1zK2lRlVZh6BJgs7QgYOW9zM7JaJWQjhs9YSSs7lvh+L2HlAwr1Xgd0fYUSWgVyTDkEsJtWoY19ZDW/utWL3QL09dh+lxEXJN4vokCDH7gtflNffVOokxyjhkUEdGbjkCDVNYuLOuuh3g5qeyUoM8tjiJF0r58lbnwbguWXPM7j+IIbug1lW4uDFHyJ0aR3k2N46EmvKCXTL4el2jWT3GEC79GQ04jAHjx1n9FUTADAlJCISi+jasw+Pz30eo1JOjw4xAct16zeSk9eahT/uwlN8jPrj+3j4qbn0TDWhTEimzh2gzulme1kjpsZSHrnrdlZ8/jkdevVlfVENH73vpC5JzaieIWweUEihe4qUQCSKUiJiYqj0D7WfvTA/bNi17xICGTOwz/85Alm6dCmzZs1CoVBgMpku6dckEon+b8u5nz59huPWFho8frRSCT3TEwhHIsS7Ggg6myk3ZLO2uIFcgxy5IMYfjjBWaCTobOZ0Umd6G8Q4C/ZjGDSBlsM7UHUdjNPrQ+u0oEzL5fPDReQY1HTPSMIXDHKqrokuaYlo5VLCfi8iuYpAUz0tch27Suto8ATom27iXFMLdn+IYTlmLC0e1DIp7ZOMiCUyyqzNfPjRR8ijIdRqDffdczenaxtplWik2tZCld1FglpBvEZJQX0zuSYdrRLjeO7VRciiIZQqNX3HTeSExUmPFCMdUxNYf6qcbKOGZL2aa6dOo+7sKWpqav5jflWI1UpMnDiRe268hrufX8ieCgunm7ykaKQIIhEDMhNYUVCDViZmaGY8VrePwWYVnspiZCYzqpRMWsoKOY6BvskGAlIFCqkEx8l9RENBxHIFYrmSkNOGoetAthVWEI5G6ZAUh8nvwK0xoQu6CWuMVFmbGDVyOLnpaezfv/8PW18DBw6kWaTgmRdeYne1kwcUlexqCnHbeYHK4xtW0ZLShoSiXSzw5lJkiaUVm7VivMEoyVqBUTnxpBliUidPrg4wunuYLo89QKt1y2kdr0cmEVBIJQjFh2PqxH4vr1ZJOFkbIUkvItMg0BKIoJWJ2V8RompniE/nZ9AhXoMrIuANBkmQi2natxVNfjek5jTKrDZMlSdYGTJjUEiY2DEHwe/FXrCfb8LJvPhDhE6tQywYlUKKXoPGa2PD7gMUnStleN8ejJw4CYAuXbrQtWtXPvnkEwAaK8uwNNtp36UrAG07diI0bgH9WktI1gh8fyzE9qtVtJw5RMqYm/Ba61AmJPPM1hNMbJ3I2CVWdFteRKFOQpk6iu43JXOiOsrdfdXUugI0eMKYFLGsvEy9iromG3eMu+IPE8gPO/ZcQiAThly+mOL/35GUlMT999/P7NmzL4nX/F78R4Po5eXlzJ8/n+3bt1NfX09KSgpTp07lqaeeusStcOLECe65556L1bL33Xcfjz9++UGzdacrGNomiwE5GuocLrzBEOFIFJsuiaqImqWH6vhgVCs2lVlxBUMY5FIW1GiY2DqLXqmJrDh2jmv7jOW1XWfYcMpEUnEB9/Uy0Sk1hSfXH+f53DC67FyKG53kaGKWyOFKCw5/kNFtM1l/4hzlTh89k0MMzEkmEAqzp8KCXi5lSKsULM4YeZxrbqGgwc7uKg8D0lXYvH7szhaqfG7OrisgTiXCeqiO8Xk6UnUqipqclNlceEJhzhbX4z1TSyAUZkuxhWyzjsFCLE5Q4/TQM0tyMf21xu5i0tRbeO7emTz99NO89NJLf8id869w6NAh5HIF1zwyF4BVZ1y8MiILhVTCnnILTl+AaZ3TOWt1cKjOxtXtMvi6qAqVJJHsiBqhpomAEIdSLKbaGyJbI+VYlYWsVl2RSgSk5y0UmSAQcNoYnJeGVBBo8fpZWerh6naJrDxr44au8RTt/gl7g4VPf9r2p7jupFIpiR4H7Y+vpbPWyKmwgXsfeODi50dOnqJPYxWB3uN43lmPP7EWkSBg6DqQN3efwu6PsL+mmZLmFsodAZ4bL2XZq2fZdOM7nNWG+fhsHVedWknqHXOZbzPwwTdhBJmUK7tFeHZoEt2Sjbz8azHJaglfHwkwop2AYjiMfaOBrY8KJJQfRuz3siO5K1Z9O1oFpPzy62m8oSh3mxQM3vw26ty2CK1nICgURPxeen50P7t690ZpbkP00GEkOe15+4fNvPz2EgBePT+24uJiWrVqxU8//RTrInrLLUTlKg4VHwNiRWlnTp5gdJvPKNJPZ9upMEPbiigW62k34gaOP3I9SeOmcCSjB9M6pXGmwU5yUohez83m17NQ+tH9FJ2WY56ykHKHny++DKF22xCH/dS3SyE/zcOCoZcvM/6P4AuFEJ+v//BdRq3D/yYEAgFuuOGGP4U84D9MIIWFhUQiET744ANatWpFQUEBM2fOxO1289prrwGx1cHIkSMZPnw477//PidPnmTGjBkYDIbL7tFr94eZ8Gw1M6YItAQjFFrC1DvginyB/DgF9/dK4e0jFdyVb2D5OR/jkxSkG5I419TCobqz5BnVaMRh7uiRw43t/SQbtDS5PJyqa+KNsZ2J+j08v+MM7UxK0tplYdZKidcoEURiVp8sZWTrNAotzdS3+LCW1qGRSeiXaaba3sKbe0tI10pp8oZI08o4Z/fTIUGGPxzBHQzjDUUYlm3k+l4pHKhpZliGgkA4NvEMyIjH0uLFEwrTIUFDOBJlO9A1SUVzGKZ9bSEvOcroPDkjPjxKgg6eHpROToKBAX16M3rqbbz55pv8+OOPzJ8/n2uvvfZPu4EuIBKJYNJrOWJx0EsisHhcaw5VNbC3toXnBrVmyqozpOhi0iPDMrVU21oYk5+OSaOizt6C3lGH7dBPZFx/N5FQkDKrjRS9hiqbk45pZsKRCL5giKK6RuI1StShMBVNjpg1l6DnVH0jaVolZVYbi9b+THZ29h9ujRsKhVi1ahX79u1j5uRJxE+YyRWjRnKmsIiOHTvSq1cvli5dyoYqB4NvmEaySkHZmndIueUJSq12istrydIpSFDL6ZtsYHeNjW4pJlaeruXN1/qiU8hZV9/MzF6tqWt9P6drrdwllDJrVJgzaT3pEqxHKnbjrrbxeI909llamHV3MutPlRMIu8kZGaHc1sIZVS5N4iBnS+2s3ChGd+hNrp0/ixpnhG3GeHpltUYzfiYHLM20MccIVVDIyLlrHpFgAEd9La36DCAcjjBmzBjeeecd3nnnHVJSUmjVKqYS2qpVK7Kyslj++eeIBIHuXWPWx7p165g5cyaHft7KU7fOotYdZtkq2Db3QTRxbfnmunw+vm0RE6bkcov5AWqbxHx9SyKyRXczp/8wrosPcbb4DDlaK58//hX2EQ/xwwNdKLe1kKJTc3rnZgb3fvAPfY8XEAhHkJwPLgUuBJn+j2HatGl88803PPnkk3/K/v7LXVgLFy7kvffeu+hre++993jqqaeor6+/uDqePXs2a9as+acaM3+LCybq99t3k5YYT7Jeg9sf4LZVJdg9sQDkoCwZgzJMnLI6GJqTzJ4KC03eILf1bsPxKgvxagWyfesIu53E9RtNyOWgWJXCoVobV7dLJxiO8P3pama1SwBdHKM+Psn84fEopRIy43RsKKpieG4KeyosnGz08tSQdlQ3O1HLpZyobbp4rjJBTPtkEzaPD51CRqHFxpeffYok5CciVdBj3HV0TTJwyurApJCRpFXS7PEjiEWk6NQ0ur0EwhE+XrYMt8uNTKni3jtux+rx0zpeh14hw6hSsmjPWXINMgZkJCKIRZSVlLBwwUvs2b2bm2++mU8//fRPJZGRI0fi9/t59vV3qHF6yI3T4vQHqG/x4QmFGZqTzObiWm7u0ZpVx0vonBRHvEZJok6D0+tje3E1qToVPbOS8TU1UBEUSDPqoKkOhSmRsERGqLGWoNNGNCWPPeV19M40U9rowBsMsam0mRSNBJkgZuOSV7Fbajl8+PDvHk9paSljxoyhqKgIlTaTyW+8Q7DkCJ+9Mpc33niDWbNm8fDDD/Pee+9RVlZOy7qPMU+6h2NVFnpmpeC31qAyp/HruWoCoQgyiZjuGUlsOlNBkkZBu+R4nF4/slO7Seg/mmaPD4vTQ0bIzpPHXXiDUNkc5VS5gHnPag5se5Zfz1WzeH8jeQlicg1ydlV6uSJbxTUdc5BKBOZvL0AuiJjTP4+vTsaEPsPnH+9Onz5B5oIVzPv5LLd2SuLlXXW8PCITyZZPWVMfZO6C2ILuX7l0AoEAixYt4rnnniMQCNCxY0eOHz/Ojh07GDZsGIvf/xBd605sr2ihojnK0BwZI3PNpPub+LYhyq/VXjb/KuGVKTIKGn28NDSfd9Zs5qv3F/P90g+IHN0GwA9JA7ilWyu+Xfgcj7z98cXj/1EX1tIN21GpYy4sj9vFbWOG/Z9zYd1///189tlndO7cmU6dOv1dEP3111+/rP39l9eBOBwO4uLiLv69d+9eBg0adIlrZdSoUSxYsACbzXaJZv0F/DMxxUStir1VVgwNdjqYjdzcVc3g+oPUt7+CU1YHlQ43Y9qkY3P7GBas5kdpMjd/c5yH+iSyu6KBtY6OZMaJWZiWg/PcKcKRCJ3MOqrtLhQSAbs/zPoqJ1+erOG9Cek0ur1kxuk4VdfE4CwzNo+PwfZTjExNRyGVYnF5qKzx0C3FhFGlIEGj5FClhRO1jcSrFZy1Os4Tg4oaqw+DLELpT2s55A1gUslx+gKopBKCkQi+UJhGb5AEpQypICIa8KGVCWTEqUk3xB6K53fUcktnHeWOWMZSslrBkdomru3cCocvg29WfM1Lr7/Be28sYsaMGQwZMuRP+U59Ph8//fQTTzwxG28wxIj8DE7XNdLsCZCkVRAIRfi5NNby9omNJ1h4RR5VrgA/ldSgl0sJR6IIIhGVdjeVx0oQxCKG5aWhlsto8XsJiiVUN9rJVOs5G5AS5/PTO9OMNxBzQ+gUMtqaFDj8IYZlm5EP6s39c57hxx9/ZNy4cZc9nr1793LllVeil0vZvPIr8nRybPvXU9JxIJ8Ruz/lcjnbtm2j2xVXUlDfzOeKwWTuOsO4vESaD/zEcUMegcYyRuenUfXtu+gn3Ikk5KfD7k9Jvu1ZnF4fkqNbCZ9fCVfZWqh1etjY5OOGdvH0MCkpevl+DF37Yn70XnpeMZcRT1yL0we5BjlH6n3MHZJFrdPNK7+cYXROHM/2zcZnrcVrqWa8PoBYIsNZcAB1bnv819+O/5fVLBw4mrOvPsjD56poUc7gsMXHm+99TO/evfnxxx//5WQqk8mYM2cOkydP5vvvv+fqq69GJBIxYMAANBoN77yxiNeeeJBApBVpBhGdE7Wkuuuo1abw4fPfsfDNMbx4hYHAhmW0pAwk/a6djLwyg+eXfIxFUHAodRATqeGa5mNAK34knaTkZMZMuIZl7y/5HXfmpQiGoxctj2D4//eh39+FkydP0vW89fi3Kt1/HVD/rfgvJZCSkhIWL1580X0FMUXK7OzsS7a7UORSX1//Dwnkn4kppqqlpPt9lCoNvLa3khfTnTBoIlK7izmfB7hysJezzbF89bamTLoY1JQ7AvTJSSPfsx9D53gGq3zM3nKSnUUR7unvYrw+wD6/lgG5qTwmjk3M4ye24nCDk6w4HZvPVnNzj3x+Lqrk1xoHMnErTC0SMgvL6eIsJT6jE+bmcuRCCl+X1JBtUKNXyBDOf1kyQUx9ixe1TII7ECDiDyCIRUjCYqThIJFoCKlIhFQEaqWYUCSA1x9GFI2ilkkotXl4bW8lwzLVPDskhaw4PSdqrSw52Ag46ZuiZf72kwxI05NuDHPPHXfw3huLqKio+Lvr93vx3HPPIRYEuvcdQBtzHArCdDXraI7TUdpoJ16toHuGmWpbC8NzZeyvtWHWqMg36XD4Yn1B9AoZDl/goqVV0eSgQ4qUYrGeblIpmWoppa4AbZJMiMViapodZCfE4Q4EEUQipnRrTUlDM41uL+kDr6RrhxUsWrSIsWPHXvaD8fTTTxONRvFJ2vHgOy4WhuZh6tGBc9JYnUVjYyOHDx+muLiYO2+ZxZKDjVzbVsPYdll8driYazv0pdPxnYjlSopffwtj35HIPTZqf1qFLM6M2NmITKlnqZBP52QNysKjpDRZSAp46eS0sU05gHSDln0Tn6bcEWBAjZNflt9C2fvzuC0hCYf5YW7tkoU9GCE7wUh3g4xr1tQQjlrJiBNR3hglSQ+fTWrPW2UBhivi6NKvMyuPn+MaRPwkSeXTE/sp+ykmn9GzZ09Wr15NfHz8b7o+WVlZl2jRSaVS1q9fz7XXXstVM+5ixYoVdHKVQ8L1+CUmZKsW81DZjxg/2U3c42/izGnP3k8+xr12Jc7oRAYMnsdpTxSZIMKf24tINMrC3UXcevUolNeNoUeK8U8hEG8oDOcLCb2hP0fh9/9v+Pnnn//9RpeB3+XDmD17NiKR6F/+/K37qaamhtGjRzNp0iRmzpz5h056zpw5OByOiz9VVVUANPkjKBJSSNarsXmizDytZd7PZ/mpzMqW+CW8nGZjhsqKVibmqo45JJ3dzezB7Whyuak2ZNF21yf8YBOY2TWdpc2L6XvgC84IcWjkEnzBIDdubmKvM4pEpSYv0YjLHyTHqGZbYQWldg+P90jnoR6Z3JAqp31SHCtDZrIUIM7ugMiQwLWdW5HvqcGolJPpb2RM6xSGZpvplZtOYpyRxDgjSSYj6Qkm7CEQFApSE+JRqNRIFEokciUimQIXEnRaLYJCiUGr5ro2Ruz+EKsKLdz87RkW72/kxSsy6ZuiJUGtQCIW0ewNcLTailkfk3K4XM2bf4SmpiY++OADFi5cyHU3TGZo13Yk6tSU2NxUuQK4/QG6pJvJNOnZXlzNzvIGZBIBQSRCIZWQqFVh1qrQyKWo5VJS9Gri1UrUcimCWMwPBWWUNLdQYmki7PcSX3MKhVSK3e0lXROzWPPidWTEG2hwujhrdaCQShCJREycfic7duxgxYoVlz2uwYMH43Q6yWo+wcOFiyh44kN+GHAPn7z/Dnl5eQwYMIDvvvsOlc5ARocuPL7jafpX7mb96XLKnUHuWXuWB2uS2S5No3zcg/ja9eegPYR45C1U9bya4y0Rmj6aS/+37iJ78xLEciW6dj3wVhZTv34VN7VN5altpdzRpy3P9khmVKtkwn4vc9s+zJ4Bt7G22MrmkjqMCil93jrKwPvPsGKQhLcal/F6VyXztjzA4o4RZm85iUwsIhyNsvpkKT0lLlJa5TP33aV0GzSMJUuWUFtby4EDBy4R8/s9GDRoEMXFxSQkJDB58mTqUjvFVHZdXiY7R1H57vskjZuKs/Ao0o4DuPOKPgD8tG41D81/mS7xGgadWEUwHOaz45Wka6W0jtfT1mzks0Nn/9C5XUAgHMF//uf/agzkz8bvskAeeeQRbr311n+5zV/n3tfW1jJ06FD69et3iewB/HPFyguf/SP8M83+laeqGNM+i2XHzrFodC7V9hbK7G4y9CrcN8/l+3o7yWo5w7d/yJMtk2lr6kBelYWMwh3kjbiB6msfYkqCEde5AnzDJlKT1ZP8llifacfqb1ksDhOvmoivyYLUaaPcp6TU7uHOzhmEE7y8e7yGGzpmIOgSkYlFXN0uHYdYTKJSQfPR3TjS2uEyZNHRHMc2WwvFh0u5rXcbrr9xMnaPj1SNnLDfh0+qxOn10+j2crbRicwTIFElQy+X0uDxIxdivC+IRMQpZeTG64lTyjEpXIzPi8m3n2100ODxs7XMwegcA8PbZHKksp7S4hixZ2Vl/cvv719h69atvPvuu6xZs+b8vrKZOuN29tfaSNEFqLK7Ltne5vEzNicRn7WWsCBQ4/Ti8AWJU8mptLsZmJOM2x/grNXBwfoWWhsVJGkU9M5IxKRVI4mG8dsaUSRn4aosxmROQyQIVDbZEYtEaOQyiq12ztm9nGnycLA6yDUde9A6vw3bt29nypQplzW+jIwMAIwPPUfLgO6MOr4S2XUP8KM+jtzsLEQiEevWraNb3/7M7JhKcWIC4QHXcL1Jz+Cf11B36AvaPf8xkWCAlqKjVC15F4NKiVOjpeusuQhyBbIHFvBWzgmmdIjnsMNDVVUDrbtPYmfCaPRLX+S9a+/k14kDSb1mIpV9JrHsWIh5Q1NJs5fz+Q2PkjUtn2O3zmfDEB+7P5vDA8eWUFBwI6ENy+h4z1uM8akpbXQzrJueg7UO7P4wJ4/H1HXfeuutyy4Y+y0wGAwsW7aMq666ikcfe4xNmzah3vElO6dNJWBrRJp2BS2Hd9CyaTm9Rt/A/nWZPPLwQwzu05uvC2uZfWo8q4Z6eHRgWwKhMP6jO3ijJYlWqj/HURKIRBGfd10FIv83XVh/Nv7jQfSamhqGDh1K9+7d+eKLL/4urfJCEN1isVwM6Dz55JN8//33lx1Ef3T6TWC34io+jaCQYxo0ls/f/ogRuQZM/YciizPT+Mt6xFIJglKNSi7jqlZJCDNfJFsl0LRvK0TCuLuOZPmJamZ2y8Tlj0lsbDlnpVuSloFJGqJqPQfK68g9vQ1914EsPufFpJRwdbsMvj1ZTp5RzeC8NEqtdmSCGG8wRKPbR/cMM0WWZjpqBQS5ku2VTXRNS8CkUdHi9bPmdDmJKjk90hMpbbTjDYYxa1UopQKNbh9Wt4/W8XoUUglGtZKdJdWxHgfRKHUuPzmGWIFjhdNLuSPA9e2ScAeCvPKLlXAEbu6qJlBSwP1330llZeXf9UL4LXj++eeZO3cu2a3ysGeNYs7U/qCLY2TbbLIT4oAoDU43iToNlU12zlhspOhUNLp9aORS4tUKEvx2ZMmZ/FJcxeL9zdRtsvHEExmk6pSYtSr2VVmZt8nDNd1FzOiSTka8AVfBfiJ+H9rWnXBJlGgjATy15Ygz21LR5CDTpMfu8WFpianAri5qYNWCZ2ifoGLbtm2/aWyRSIQ5c+bw6quvglRJYnwPft75KXsqLFxpiPDypytYunQpQ4cOZePGjSx7YyH6nkP56lQzr6bb2X3nUwzZvZ1Glwd92RGkhnh+8ml4e28L668yIBILHJx+Iz+WRJn14FAkhnikhnhkCSlo8zrhb7YgUev5uspDpl7JvlonD7XRU/npK7R+5HXsBQdQZeQR0cVTbXOyu8LKiFbJXL28lHv6KxnnKcRVdAxFciaG0VMRxCLe/rWQu3MUFISULH55Pj+uXvUfDxz/+OOPTJo0iR656UxcsJQJkUr0vUfwS3EVy4834wtC+UYna5d0Y3NxDSqJQI+DX/PkS9/ywuNXIzenEbQ38rxuDG+OaYvt4E90ue7WPxxEf2bFehTnW/P6PG7mTx77fy6I/mfjP0ogNTU1DBkyhMzMTJYvX34JeVywLhwOB/n5+YwcOZInnniCgoICZsyYwRtvvPGb03gv3CB33zKFkCYOfyiMSRwm4vfSKKgIhCPE1Zwi7PPiyutJuk6JxR2gygffv/Ycx2us5ydqKTJXI46ju9F26E1JVEVq1THiug+mftMKvDWlpE95kHdO1KE93/Ta7gtzW48cpJZy1Nlt+erIWa7pmIN9ywoiAS8J42OaMyK3g2g4zN5GH4JYhFIqoXO6mZpmB+XNLdS7fYwSmtB37EPE46LaG8JgKcad0oZ4rYpSqx2NXIrLH8TS4iFeraDK4cYVCNEqTkuz149Zo8TpD6CWSXH6AhQ2ueibZiLdVYuhTVd8TRY++HoVL738Eh6P57JjA1VVVWRkZPDA/ffT6spJXFF/AMexPRh7X4F64Hi0cileay1edRyrCipIVsvJMmpIMWiQCgIVTQ4sLi8yQYxGJuW1vXV8dWNXatZ9hnTwtYQjERpdXsLRKOkNRQRyu/L5sVjB4JyhHXFVl6EwJdIchGpbC95QiG46AZnOyK+VVoqb3QTCEVoCEUrtIQ6vW0nFpuXYbLbf1Bvk5MmTdOrUid69ejKq0MaQUfG0f3s1IrcDuSGeIx8v4MlNh6ksPUdD4nCOP9KdL6VtSdZISVTJOd7gIksvp6rFT5M3TFuTgkFnN+E4foBdK6u4/ounUGW15ctKNzfnGvCrYvE9masRZ8F+4vuNxnZ0N1JDPIJSg0gQ2NAUJUmjoN4Vy2brnhxHfnI8lY12NpXUMb5NGvrmSoK2Rr4OJnK2OcjRyijW42J+ea0VxVYblQ4Pyw65OfDkSADeffdd7rrrrsv67i8XX3/9NZMnT2bJV6sY2ac7e8rrGdM+m6qPnidobyJ+2DW8bosjcPcsHlv2JKM+SSJ/pITWCQKP9GtNlc2JUiohx6TF5fWTnp39hwnk0c9+RH6eQPweN6/dMu7/EcgfxH80iL5161ZKSkooKSkhLS3tks8u8JZer2fLli3cc889dO/enfj4eJ599tnLrgEBcMs0uEMitHIFCr0aqd+DwxfF6Q3QmNWd1ol6EuoriUj0mNWQqQpTu245yTojDXn9iNeANiEVTX5XSl57jNTrZxLXfTA+BNTDJ+N1eYAo93fLpOHn1XhKT6PKyseoyOft2igpzhImdc7FFwjh6D4GnUKGLxAiUnKUkNOGutcI+mLDsnkFIacNpjxIsjiIQyWnlxYkqtaIiSJotHidjZyTmOkXdFJqDdAuJQGn10ezx4deISMcjZKiU6FXyLB5/PRIN7PmdDm9UuPxBUN4g2HO2QMEwlZe/0nEs1eeIceg5sSZQlJTU39XxoXNZgNgaP9+BNQKXD3Hkj/2ZoTz6cBOr48GVNgaHdyUoaY0osAXClPcYEMmCFQ63MQpY7GLzulm3mk+h/30YTRtuqJUKxFEIhJ1Gk7WNGBL60Arg5aHemQS8roosTSj0yUSra9G6nbQLr8bCqmUzw8V8nNFPQtH5dMnJ5U3d51mX1WQwhoRo4b04+wPH/L222/zxBNP/NNxXUBzc0yttq7DNFZKg2zp3p6OW09R0Rxh97Fa9HEjIW4UG0auJW7QVdTq0hj+4m2YR1+Lo/sYemQk0ujyMpxCFPmZFL/6MKkvfEJT/xu48zkttZ4QB+ubGZyVyC/WFobmy/CWnsJRVkjQbqV23XLiB49HZU7Db4+lfneQRMmWBvF6GvHndcAXDLHqeAnjkxTcIKomuP8Y8t4jOBLRM+TzZ5l+9/P4M2oQ39qVXaW1ZBk1TO6WRtsECyvtc3jn1Zf/adOkPxOdOnUCQN5cQ7yqL8MNUQJ1FezpMZkuSUaoPcUd9VuI7ttAzXO38sPIYcjiU4gGAyhk7Ui1FmPdvorArbNpqf5zEj4CkSiiyP9zYf2Z+F8lZXLHvfcz/Y672FneQI5BRVuzkfhQCwqTGb+tEb/KiNTRwAuffk3I50VbeZLrXnofc91pAtZa7Ed+wTRwHIqULILmLNR+F2JDPC3HdqPOaoPClEgkFEQkFjhUacGsVSEWiShtctAvLY6QRB4L8hYeJWhvRJvXCakxgXA0SrTFhre2nECTBXWvEciCPpwIqP0uZHojYomUJpcHp9ePVBBIi4tpbzm9PsRNtUjM6RddYgqphMIGG/FqBZFIlHqXlySNkniNkkaXl0A4TEdFmAapFpvHT6XDTYZeTbJeTe9evZg2dQpvvfXWv7mqlyIUCjFw4EBOnTrFr98sx7LkeXos3YBYIqXE0kyaJER1SIJaLiXZoMVjqSbi9yGSyPBbazgiT7mYbgyglknR2apRZ8f6ih+prKdbRhJiovxQUMaYLBMhrwuHXE+yQYevyYLr3Cniug3gcHUjgXCYpcesvDskFde5UzRldOJck5P1JQ7axkvZUxWgV6qM5UvepXz7dxw/fvwSGe2/RU1NDQMHDqTS2sLqzRvQq1SYDqwhYchEJCo1HiQETu5h2rWzeG7XDtolx6OQSgi7HNgO70DbrgfngjLiC7ZT3WYwJywOkjVylBKBQXnpCGIx7toKwl4XioQUPNXnsO3fSsBaT/q0R5Eb4vEhYHN70TSUItXF0STXk6iLrZid+7ZQs/IjkidM5Ul7DglqMe3uv5exXzxPfK+hnH3jMVIm3Y3fYOZUXROdwk0EUvKQnDtGXNf+HCyvo4tZT8eBQ0lPjGf//v2X+ZRdHgKBAGq1mrlPzmFwxW7SptzLCp+J23vlU1jfRLpRiyCKLTxO1TdiVCrwBUPoFDJcgWBMnTkUoZvYwdmo9g8JH16YH+5cuvYSC+SD28b/PwvkD+LPLUf+b4as+DDqHV9ytesUA00SdAo5UrWehp1rEeLMWFrc+HTxsYn/3DGUma3JxMMpXTa+7qNIfegNdH1Gos5uS+jgFsTSWLqtLr8r7vJCwpEo0XCYso9fpEu8BoNKQZJSiLWdRYJcEBMIh1EmpRPX6wpCbieBpnr8lcXIdMaY5lN2W0R2C5ZAFLVcRiQUIOhy4rXWIW+uxdhUgd5Rh8dSRcjros7hQpmQjOD30toYy1BqdHvpmpaA0xfA4QugkUnwhkIU1DcjFosobGrh7SIHZxrsvLGvjiyjhkyTjnWnKwh43ZfdCyAajTJ//nwOHDjAp6/M44QylZSrb6Dmh2VYfl5DujyKTVCSm2DApFUDIg45o/zqkqBOySSa25ksoxadQkaV3YVRpaDC5uRAMPYwNx/4icyqowTDYb4+VkIHs5FXD1WhMqehUypYd/IcLx6pR9d9MHsrLKwuaqC7Qcbz4sP4LFUc0+Uw7c6N9EhP5IkB2fRINjAwQ86qEwE6T5yGKSmFe++992L7zr+F3+9n+vTpVDQ42LHsddJ+Xo4gEiHRxrHkVAOv7C1lcfvhyBNSWH1qP62Djaw8cQ63P0DY76UpfwB+ay28+wi+7qNo9vjpZNaTpFEyMMPEyZoGnF4fTXI9glKD9efVIBZQX3sfgSlPUhVVUuPyU2dvwR0IYmjThaKgjPJmJ9XNTo5VWTiR0J7sl1ewL6ELH45pzZ2Va2m1cQVFpjx+LqpEO/1ZnjnmwOn1o5RKCKTkoZBK8GR2pOdNO/nwiIWPj5Yz85abOXr06EVr65+htLSU77//nu+++w6Xy/Uvt/1HOHz4MKFQiLS2HTE99TE7hBSm92iNNBKiY2oi4vICqCpELBbRzltLirOabplJZCcYUUol9E4x0isrGUlGG6TCnzNNxepAYj//V+tA/mz8j2go9WdB16E3mVMeIBCOcLzKQhd1EH+zBfPQiTR7fGRKw9hCYQSxGF37nigNRrzV52gbCSN41EhTsvDUlhNy2pAlpOBvstD843JMg8ajyW1PoKmeiC6ejJsfpjkItTY7XdLNuBJz8DndCOelT8QiKalEEZRqwl43QXsjXpWaoNOGVGdEotRgsDcgyMwozOl4LFVI4lOQR2OptSKxQNgfq1dJdlsIKcxIdHEIYhFq/HTUaaizt5AVpyNNryIkEiixNGPWqDCqFWhkUo7XNROORLmnZyK+YIi71xaRpI4QCoUue8U1Z84cFixYwFNPPI65/yhyEoyo2s+g6dBOjJ37IpbK0NZWEJaLqXEFSJFG6CrxEEhO52S1Bb1SjsXlIU9ppG92CuFIhB4mJZ7qc4Sj6ZQntUchEZAU7OeqxBQkcTpm95VjLzyGzBirzRmUJUISDdNZHkS/6w1kV3yEtkNvfg1oGGqS8e1kO+8eOMf3z37EnHfuo8kXpne2mCq7lO7jJ7Pp/YXs3buX/v37XzK2mpoabr75Zn7+eSfyka/ydTCFrC43EK5spHf+QGZJA5x+aiabP36XcxIDbYIBouEwV1TuwR+sQMhqS5oxDpFfj236S+QZtJgjbsRxCewsruanMh+tE/Ro5VI0UjEhkRbzyOvZX2ujg6UU/eGdCGodcX1GoEgwE4xCZZODjiY1dRs+J+naWWTp5AhyJbvO1dASCOGKCCRNmE6OOQ13bSXqlAwanG7md9IgMWgJf72QuNufwnF6P8bkLFaOPABuAaFOTcXQEURefIFvv/2WWbNmXXItvv/+e9auXcu2bduoqam55LMxY8bwzTffcPbsWQwGw79UOI5Go3z55ZfI5XIqRVoaz9YwNVtLzfvPkDjmJmx7NiE3p+O3VCE+sQddp34oElKobHLg8PpJMWjwICAApVYbatmfM015Q1HCwfMurND/I5A/A/+rCKTeE+RkdQO1Tg9KqYBUo6PaG+LMeXeTLSQhw6DGFwwRjELQ7URmMiOWyhBJZLjOnYJImFCLDakxHkGlxjRoPOqUTHxNFoJOG2KvG79Ehi4hBX3ERyQUJNmgxe7xIohFOLx+OqbFVvhiqYxIMIgyLQdvbTlSXRz2wzsRiQXEcgWu4uMoM1ojM8TjKTqKNrc9p61OsnETsNbG2rRqdASdNkRigUp/lDSlBF9QTDAcJgkfxY0hDlQ3opIIZBjU1DrdGJVyrshLw+UPYPP4CIYjPD0oFb/TzqfB4CVKAP8IF0QXr776avbs2cOrr77Ko488wv0zptFSfIK3B81j5tfP05LdhaA3iFEs4aBboL2OWHGfP8CpZh99NGp0xEgxPTsFr7WWkDSBk7VWmj0BhrXuihAK0MpVhf3gdmRXXEfY60LcXI8oIRVVXidcJ/YikkoJuZwEE1LwVp/DPGYyEz4vYNW12VwRCSPRxREYOoXbpRJuf6wzqV1aYXV5OVptpc7t45Azlpb71VdfcfjwYbZu3UpLSwtnzpyhqamJaFTMd9+tZGjv7oQkcnYWVxOORhm7xMrLk6Rc/8pS8nUJ1DlcHHP42FQt5vahkyl3ujHLVSR6HNiKT5DVbMHPYNRpOTgL9jOq6wDc5wtDD1VakApisuL0OH0h8hKMaOTxSNR6Qu5Yc62gy4m/yUJGdhtOVltw95iAIRAEJNQ12Mgx6dEpZBRbbXiDUTYdO05bk4LuNGJUyQmZ0vD7A8QNugpvfTXurM5sqbBQm30l3cw6ukabsRccIxwOEwgEKCgoYNGiRVRWVhIOh9m5cycpqalcMewK+vTrR/vOXamyWPn4ww/YsGHdxXaw8fHxWK3Wf3r/rFq1iiVLlvDwpHHc3Kcdar+Lw/YAMz4p57a37uHOzUtwF5/AWXCY9GkPQySMz1KFZeGTfPxzEzfPm0avqffwc1EleoUUQfhzJvtgGM7fjrHX/w9/GP9lMRC/30/v3r05fvw4R48epUuXLhc/+6NqvBd8nA9MHMWd467AeeogydfcgTMhm0aXl87pZgJN9UiUGoJuB0veex9PIITUbWfasN4oM1rjrTyLOq8TYa8bQakm4o9VnUeCAQSVmmg4HFNYbdeDGrsLnTLWhrXW4SJFr8Hm8ZFm1CGPBIjIlEjOWxMhj/uiNeGpLEYkFvBbqgi5Hahbdbpo7Uh1cUg1Oqw71yJLiAUTZSZzrMgstx1eax3O04fQ9BmNWi4j7PdSZvcgkwikauSIpTLONthQy6Tsr2wgw6CmzOZCKRE4a/NQ2BgiuGclm775nJqamn9Y4X8Bfxtg7ztsJN1ve5RB6UZSvniGM5OeJUElRyOXYnX7GNY6ncL6JmSCQH6SCb+1BrEkFixXmBKB2P7sbi+WFjd58Tq8YThcWR/rJnj6cCw2lNuBsNeFOrstvmCIJpeHs1Y7Q1tnUL/uU0wDxuKpLEaVkUfJG4/T9vlPaHJ50AXdCHIlZ+bPQt+5Fyc6XsUdX7rRbXqRq1+Zw7drQjR/NxoAiURCSut2hBU6Gk8XIpXpMfV9Bf+p97n6lTk80q8VqwoqmNw5B+f6ZQSbG8i541nqnW4EsRh9xIdEqWbV6SqSNAraJ5uQCgJNLg+CWIxUECOIxRjUSoKWaiJ+L+r0HCLBANFwhIDThrf6HCJBgPwe1Npd5OtkRIKxinyboIxZyEo5MlGUWqcXmUSg2tZCsl6NSatGEIk4WdNAh3gNDT+vQZmRhzwhhYjfR/O+LWjb9UAsV+IpKyR55CTCfh9VXy8mfuhEft57gKn3/EVJ2KDX079vH4KhMB06dSJj+NXoZFIEsYgOZiOBcIRAOMzbry9i1eefXPy/oqIiWrdu/Xf3TjgcpmvXrlR6JbS6dQHKF28EYIA0kd3BBp4bnoUqIxVBqSZj+hM4ju4mGgljHj2FlsIj3P7C62zdsoXuciPvPTyOVg8tYl9R6R/q3XFhfhj/5mqkypjbNOh1s/bBif8vBvIH8V9mgTz++OOkpKRw/PjxS97/M9V4mxNb0dx9LFWthmD21iI7tg1jZTGBcdNibiG5krC1FmViKq6yYkRiEeq8TvittUjjzHirSwm7HfiqSxGr1BCOIEtIRpXVFmVaDhKlBr+tEVMkjD8iQy2Xxfy5REnQKGOBeo0Rm8OFUa1ELZfhqT1FwFqLKju2j0gwSNjvRZGWg/c8objtjQgqNcbuQ0gYPB5P9Tn0bbrSUlaILrc9Yb+XsNeNoXM/Is5GAnIl0UiYpKATuTqeaCRM+flaCJvbS8/0BCwtHtom6DljdWCQS2ildLBoxWdMnXrTvyQPgB07djBkyBBysrOZf/8sDiZ1Z96ILtzzw1Eq8x5mZYYen6UKf0UtGZEwpUYtXdKT8FpriXhcSJQaQl4XCpOZSCiI0x+m0eUhO8EYa+TV5EVtTqerxBPLwspuQyS/KzJBwF54lKZfNyLR6NFKpXQLBrFs2k3S6MmULX0RY+8RyI3xtH3+Exq2fMMuc3e2lrl578o8ch94mfUWPz2TjOxtuwqvLo7pH1dS9M1YzqZdx942o8lr3YbHX67BceZDjPGduW/JM9zbvz3Nx+Wsv/5epnsaWPLSnZR/uwfr0RqOzvuIrOPn8ITC3Nwpi/Gt+vHCxHa00+lJnjgDnGU4s7uQnRCH396IIFcgkqsQRUI4ys+Q0G80ZdZmjE0VSLPb0yTXI23dg2SDlpayInJVaoIuH6cDUjqnm4n3ugmfz5KKBAMkq2UIcgVqeYyQfYEgu0trSVDHjmMePYWTNQ2Y5SrcqBCGTkamiyUrnArrEJxuXF+/TuKYqahTMjls8jBk3pekWA7SFJEw9ZoJdMlMJlUjx4dAuLqYOrUxpngsl2H76TtsnYZz0x13k5+bzasvzCcUCjJixAjKysr+TpBz/fr1nDx5kvU//ED8rmUYv3iNYLMFeXIWD2S3wSHXE9zyObpO/ahb/TG6Tn2xH/yZGucS0q+/m0cffJCtW7Zw2G/jtbMe7vliMcbzk/4fRTAEhP7q9X8Izc3N3Hfffaxbtw6xWMy1117LW2+9dbGd7j/afu7cuWzZsoXKykoSEhK4+uqrmT9/Pnq9/uJ2/yhzcsWKFdx4443/sbH8O/yXEMjGjRvZsmULq1atYuPGjZd89uWXXxIIBFi2bBkymYz27dtz7NgxXn/99csmEINKTluTmhRLIZWJ+QgJeeQNmoDT68egVuKvLI7FI85bANLkNMJeN3JzOiGnjWgoQChy3nJw2gEQK9WI5QpaCo9i6jWMgFRB1FqNWhnEb6nGHpeCVqkgEomw2+ol3hNFr5Dh9gfwBYMI6W2Q+L0E7VYEpSZGVjojQXsj0XAYQa0jGgnjrSwGQKKNI+L3UvHlm6iy2hC0NyKLMyPVGRHkCvxNFsJyHyFjMqpkI1G3k6DbQVZCCv7z8gzeYIiceD2ljQ4EkYjuKUZe3LABv9/3T2VkrFYrhw4dwu12s3PnTgAeuf9ehgwdyNiUTAYuOcKKlMOk3DyNis8XsWH+BjYELXyx8XPONNjRH96Apk1XPEo9Bo0Gf5MFHxYCTRZ0rTshrq8HgwqVOR2720utpQmv1Eh6YiYRmQxfeSEupw19u+40WWsJ2q2I5UoUyVko03KwHd9LyrV34quvovitJ/Bb68l94GX67l5PL5eTaDgXny6esSYparmMg4OnYt63ihs+Xsj3HRfwxBXvsVX9GVe9EJM22fXLUqa378bGG68hJS6eTrcPxvT9Z5z8poUvUkXkth/HqOlNDEgxsaaqhcEFayg76GbcN6vJ7ZrL3rI6aoAVp2wMidQyzaDhpxonrkAzV+SlolUqOGLIZxRRZPvWIW7XA3/hIRLTchGLZZyubSQlMRO1XOBEXTO5jlKEZCO204eQmcxINDo8Lidyk5n6TSsQKzXITGa82V1I16vJM8dxutaKUiqhS3oSgXAYsyZMSCQQqKtAolTTubmQll8OYOg9HKlGR8BpY3rDz9zgKMI88SaMnfsS9nvxIAGiyCMBQoZ4EioLETQ6ipVmsvK7og3ZSWypoqn/cL7afiVCYzXXXjORQYMG0blzZ5YuXUqrVq1QqVQcPHgQjUZDO72Us9fPpiEcoXXoANruQ6hZ+gKpk+6mSa7EV3OO9CkP4ijYT8KISYjlShxnT9CrWxdm3n47H338Md+uWsW3q1YxefSwy5oH/hmCYTjvGOA/KYV10003UVdXx9atWwkGg0yfPp077riDr7766h9uX1tbS21tLa+99hrt2rWjoqKCWbNmUVtby3fffXfJtp988gmjR4+++LfBYPjPDeQ34D9OIBaLhZkzZ7JmzRpUKtXfff5nqvGKPC7e/ugThEgQsbSAiN/Hz3IFhIJExAIRt5Owz4OjsYFowI+34ixBeydCLgd+SxUiQcBTXoQ8PplIKEDE40YkCLjPnUIsldG4bytSXRwSnRFBoSBkSkPlsSFRSAn7ffSW+wgYE/AGQsRJwXn2OBJTErLstvibLATtjefjH0pkCSmEvS7cRceQ6IyIJDIEtR5BpUZQqYnPHI+vvgqxXEHY60KqM2I7vpe4rgMIOG0oxGHE0TBRuYJoJEzQ5USs1pFs0JEMNDhd5MTryUswUutw0TPFwBZArf7Hq7n58+ezePFiAOJMJuY8/jhX9e2OVKNjXGZbdMCdwAs/rydtyj1cc+Qe3h4yE191KaInHmLtex/x9TY/X2evpfH8ORkGTaA2rMC9/gtSJ0wn6GrBdnwvEb+XhKR0oil5aCXgravAb6miKrUznSJh4vuOQCQWqPlhGSGnDWmcGVG73oQs5chMSRj7jiIaDOCpKEbdqhPeymKkGh3y6lKiQP64ORxe+wKySXdTpBjA+8+9QvsuIda9uJ9WwZhMzrmZYzl31zdkJkYZd302E9v35pEffqZk86/Ur3ofXVJvwhmdua5tTLk0MS8OS62Eyd9fTUlDrKGV71wBR564n5XBBnK2b6NH8xlenDaPK078TP2q9zHv2Y73qcVIB1+La/caIn4v0VAQQalGsXcTjR43tc1WZIB85jM4Co8iEgS8lWdpOXOYjKmP0FJ8AlVue6SGBKQaHTJbHULRUSSmqzCqFMgkAuFIBKkI3DUVuM8VoMnvhs9ai9QQj67LACQaPbbDO5Bo41DltMc8ajLN+7dydvsqdO17QZ+xiF2NhFwOBLmSQHM9CqWa5NpTKNr1oKX4BIaOvbji9GHwwjplIgvfeIvN69fx5Zdf4vf7OXXqFG3y83n11YUM7toBbVY+hWdquLl7PqeVcjYdK+GqqY/TGAiRetU0zr0/F0GtR5vfNfasBYNE/F5q925n+08/XXJvqlt3gU3bf9N886/gC4EkGHv9n+ondebMGTZt2sTBgwfp0aMHAIsXL2bMmDG89tprpKSk/N3/dOjQgVWrVl38Ozc3lxdffJGpU6cSCoWQSP4yTRsMhn8q8fTfgf8ogUSjUW699VZmzZpFjx49KC8v/7tt/kw13kg4SIurBZkgELLbEeQKwk4HYoUKwkEiYgmCXI1YpgCRGJlGGyvwy+uEvkNvHCf2EHLaCNqbkMYlIjXE46+twFdXgTw+GWVGHiGXA6khHqc/jFYpR1An47c3IZbIkOqMYK0iLjMPhzeArnUnwn4f7sqYGFzQbkVQ6/FbqpDGmRGUGgSNDm9lCbKEJFxnDgEgi08hEvAiNcSjzm6DWCoj5HGjyW2P1xrrjxF0xUhTqtERVeuJAoLXjVOQoZBJMaiVtHh92DxelFKB6tISzGbz3/mtN27cyB133EF1dTVqlYp9a79DTZCIz8PtV9zA/V0MVKQM4dcv7kVmiOerUgebPEHuVypYe3MKqtz2tL+pJ0tvmIgGqBvfhvzXv8Oy/BVcRcdoc8ezlFuqOfPS3bS6fwHOk3sw9h6Bq/Ao6nCYaGYezXs2okjOoo0ihESppuyTV9B3GYi7uABDzyGxe+n0fsIqdeyayZWIdUY85YVItEZSxk7lnV8LmNw5m/c7jaJVsAGlOQ1X6SkeLvuSqqaj8NyNTF+7DMFlYELJOhbOX8X79rvJmnod1Ss3MOn7H8jISCTaYuP9OV9wPPAWSz99ie9P7sFdcZZTzz7GgOcn4yo6SsPCe1GM6oDtWCGT501kxM6t5GFH3WMwe/q+itLdjHf4VGydx1EZlpGv09CcmoPMmICgjGXjHXlzA/kbVyOIxeSbNLSUFRLx+1AkpWPo0Avz0Ktx11YScjmQAI7DO0i66lZkOiNeUxqCXEmcsxaFzsyJmgYEkQi9OgFdr9EoJCCWSpFqdBfvnbDXhUgs0Lx3E64zhxBJZKiy8nEVHcOgMxLUGRGJBQSVGkWfsbEOkKEwkaCbUG4X3NWlKNNyUJnTGLtvG8qhfZmQoUXz5qv0G3M11bU1hAJ+9u74ifHXXItbJGXQiVW0aMeS1Gyh797NRMJjkAM2pZrkq2cSDQVwnNiD3JyORBdHfZONkTfPwvFXacPJPUcwZ8ZUPn778vpU/CMEQxA9TxwXCOTC4vMC/pnO3m/F3r17MRgMF8kDYPjw4YjFYvbv38/EiRN/034uxGb+mjwA7rnnHm6//XZycnKYNWsW06dP/11FwX8WfheBzJ49mwULFvzLbc6cOcOWLVtoaWlhzpw5v+vk/hnmzJlziZy00+kkPT0dmacFqdeJOrcdfq8TcTiAoFEjVmpiD6JCSTQaQZfXHl9TPdSWIqj70LD5a0RiAVVWPnJzOnJzTCPKW1mMPCUzVjUuFvBWlyLVGQl73Si9LgIJKTQJahpagiRqpaTojUg1sYCcQa2kzNpMqlxAakggGgwgT0jBV19F0O0g4nWjympLyOUkGgwQPe86iwaD+OsqUGbkEXY5ad639SKZaNv2RJArEORKJMqYPzUSCiD4vERCgZhlFAwTiUSQhPwYFTKkgkAwHObHNd/T1NTEsmXLLknfPHLkCNXVMZnyz995C71agaCM59lBk3nns5d5J5DJsw/eTfPu9ZhufIjvrxtCplTHR8s/pcPYB5Dv+ZaGXw/x+n1XkvbAQlx71lPx6n2Epz9Pa41A85HdiCbeS65cRNU3bxNy2okGgwSbLZQunouxR1+M1z+IVCJQ+cbD6Lv0x9h7BJFggKrxDxHvrsC2ZxOKtBwM3YdgP7wDZUYeEl1sNa3I7YCnvppb0+XICfNU6S7shcdoOXeKh8+ImTj8Lm7+aC8DF9zBJy16vvy4hVW9R7Fxx3U8NeQmHoqECY6dxZDNy9DJBuCVypi95WPEciXH77uF1KtLse7YRsYNkxBLZQSaLXxb5+fWg6f5+nSA+3qW0OrRl5ky7DoUYglzDAYm9Whk3qgcOt01h2C1laIvf6VizV7y77rxokty2/wlDHfVsnTM/ewXw8CHB7Ms9zpai0LcmCwBvw/3uQJ+uvNVut3aBVlCEvXrPkUkjVnqzeZ0FB36UtrUQpf0JEJeF9FwhDqfH4VOjVgqw1tfTcjtQJvbHpFYIOL3oevYj5ZT+2k5cxSpzkDT/mP4rfXE9RuBtm0PIsEg6mgQqVxJRAhQ6IjSNkmJozqAt7oUx9HdJA69Gq+lGnmPEUgVMggFCYXClJSVY3O2kKRTIxKLUd06B48vgD8xh+zeIxBCATy1FSiT0vBZ6wj7vajzOuM+V0BT2VkefuOjS8hjSN/efPjcXThO7v1T5o3QX7mwLohR/60e3Ny5c3nuued+9zHq6+tJTEy85D2JREJcXBz19fW/aR+NjY3Mnz//71z4zz//PMOGDUOlUrFlyxbuvvtuXC7Xf0QY87fiP6rGu337dvbu3ft3jN6jRw9uuukmli9f/qeq8T704kKkvhYcJ/YgqNth6NAbx+lDRM9Prrb9WzFfeTNhrwvniT2IuuTiqzl3MeMqYK0DIGhvIuR2om3bDc+5MwAIShVSk5loJEzY7SBob0TXuhM4vSikEnzBECGRAH43zuITaLLakmEyUFTfhC+spGtmJiKxGIXJTMBhw3XuFH5rDdp2PVCkZOKrLsVVdAwAaVziRUKJBLxI1LFAmqvoCHJzOgF7I/I4MxKVmpDHfVGEL+RxI9XoiCg1iOQqxGIxQjiA0+unqSkmjfG3K64LfUGm3TyVAYMG4qk+x7uj7mbeT8tRZbbmQY+XltnXIDOZOVlr5avt3+GrPsejtQGuqVuPMr8r/gXX41UrkAV9BDsPIzO/G9GIm7OvPouvrgFDl66cLDyFVKcj7ca7efSa+3jt+8UEmhuo37SJoo9+IKFzPG9uqmKi7ghDPnsNQ/seZH/8Ii1KNRWrfyZvVhrn3nqSzavraaUN0+u56SSPmYpIFGVnCwzKSqPwpbuJhiOkXDuTkMvB+yP64REUFG9YyfZGJ3cmCoxp8y4vvbYHb/fXuP2XdSRrBEreeAxNjyHYD22/6BJTpuXQ6fWPUSSkkHHTg9RtXEH9um9oOtPMk4+NJWCtZ0jZGeq2HyX1oVg1/eMDkzCPGM3AvE44T+zFunUljyzZwlO9zYTe/wqV4yyFC+bz5fR36JAgBYLcV7wDT9FRgk4bpTdOxS5WMOrdR3AVHSPQ3MCE7V9SJ2hIwsOBW67nTImEYXd2w354F6FvP0CdnUdlXCK6sTMobLDT2lFOY2ZHlDIlJKQjA/ZPGU3/1btw11Yi1egwdOxF9bfvApB6dQYhpw1fdSlhrwtBqUGRnIXovLBpgtOGO9IaQ7vuuP1B4qQCQZeT5j2bEMsV0LYHT959G3c8/QIA11x7He7qUhQJyQhyFaqwD5WEWDfJcASRVEbLuVMxS1Kpwe9o5qNNv/DxytXY7I6L9+WBLT+SmZmNRKUmkOr5h/PA5SIQBuFvCKSqquqSLKx/Zn381kXzH4XT6WTs2LG0a9fu74jsmWeeufi6a9euuN1uFi5c+N9KIP/RNN7KyspLJqza2lpGjRrFd999R+/evUlLS/tT1Xgrz5XgO/IznE+VlZnMyM3pnH7mETKmTiVp1BTc1eeQ6mJ1EEG7FWfBfqKhIL7qMiQ6A9FIGEGpRhZnJuz3IhKEixO4NM5MqKWZkNOGSCJDZjKjyeuEwmSmytZCIBSOpdUadZfoQwFUNDnQK+Uky8WEvK6Yr9ke8ztLNPqL5+w4tgsgFjg3xOOtLiXUYkNuTkMWn3KxZkVqjEceZ0ZhSowJCHp9mDR/iTEFnHZ81trYQ6rUkZ2XB/wl/TIUCjFhwgQ2bNjAjddfz1tvvYUvEELSVE3Y78V5Yg/V366g7bw3KZz3EIHnP2N7hY2+KToGm1XIjfGcqW/G5vVxwuKkfYKWQXkZFL36AK6SYlRZmThOniGuVw9UWfmse/QzIlEYPCWHym1FaOLFZE65Hl9dBWGPh5RJs4h43dT/+Dl7lp9mtT+2iHhmQArGrp0RlCqSJs7EKjfQ+PiNHN/lZbXfQoJExaw+BlQZKWwdO4d7+rbhs8PFDMs2E1dfhHXzN6TeeC/VX72FJr8zAWsdht7DqfrsDRTmFDJvexK5MZ5IMICz+ATK5CxEQqyQM2BrxG+pQlBqsO3dTNqUB4hGwvittYScNsJeFy0FBzn+8S7aXdeOsN/H3m8q6LH9W54ZMJhMqY7nls/j0GPzWVLTzCipmTapQd4qj1WB6wU5j1yZSf5TbxMNBmIJHtXnAGg5dRBZXCLaTn1xnTlE5bdryZ42BcRiMq6/m7pNX1P5xUdk3/kIxq4D8VlrKXljNs5iC53feJdSZRLdMpM5UhFbFCk+fYaM257EcXgH0jgzgcZamvdsQ56UgjwxLVb/5LSh7dQ3lrxgTkei0RGwN+KrPoc0zkxc5/+vvfMOj6La//C7M9t3s9lk00kPPXSQGESplyL2ehVFvSqKXRSxixUUuxdFuSrqtTdURBBFVKT3ElpCEgjpdbN9d2Z+f0zYa+wGuHr9zfs8eUh2yjkzzM7nnPNthQSbG6j/9lNiRp+L2FRDxNvCFpyIdQcQfK3Eb1+Go+cgkoadjGAw0lq6C9FixxTnYmeDl7SaXSiShGBQvz833fsgb320kPPPP5+FCxdiNeq5JMXKeZMvJqbHINX+1+MYsrp0O2w33q63fIhoUm2AUtDLnkd+uxtvXV1ddBD2c+Tm5vLvf/+bm266KZo3DtQ0QGazmXffffcXl7BaW1sZO3YsVquVhQsXYjabf7G9Tz/9lJNOOolAIPBfyW/2U/xXc2GVlZWRk5PTLg7kSGbj/ey848k9bSIAG1MGkD7/NvJufASD3UFL0Xq8uzcR8boxp2YBIPm9GBPSCBwsQQmHCTXWYkxMwZSQhuT3ojMYkIMBwg01yJEwBqcLS2YXDM5EQg01yH4PgsWuLqnYY4nEpVLZ4om6QO6tbSQt1k6ozeUjPT6WkCQhhPw0hiFOkAm2iQiyRKCqDGN8Cv6KEgzOBFp3rI3GpegEkXCrGlBo79YPc2o24eY6zOl50ZgLncGopkxxOJFkhVCDOmW+6ra7+GDBRzz22GPRpb/t27fTu3dvAD57+98cO2wkiiQRbK6nEitxFdtZeuHtvNZax9wnp5J2+mQ8Jdv59vwrWN6osDPUyPXZ8eTfMoXEEy+gYek7pI47j2HPbeSSQRZOiwmw15xMTu0uGpZ/hGiPwRifjL1bfxpXLab07S+IzbaTM2U6B157horVdfSYdAJyMEDOlTMIGCzUvvwAobpq1r2+i4+Djdz/jyF8NuJ6Lj+mC18XHyR93k2UfFXLsfddQvLfzqWlaD37X3kSJRwh5+o7ie87hLqVi2n4ZiGhpkaCtfV0OnsSMT0GEUrMQLd3E4LFRsPyBSRedBsr9lVS3uLn8v5ZVH7yCk1rlpMx6UZsWV1pXL2UmF4FNK35HFtuL4J1lejtDvSOuLYA0eXoBBHX0Am8WXAWw+84kdLXP6KxRuC4x24g3FxH/ddLSB53Jr59RRjikwgcLKV2xUYe3lIPwOzJI7CkZ1OxYCHdp99FqK6SQFU5zmNGIodD7H/pCfQxNnYvrqTPhf1JPXMygcoyyuY9SdUOPyfMf4zmNV9Q/Noinipr5FRjMqf+ayoRj5ukEafj1Rl4cUMxJ617ifoV60geeTz2ngORPG5MqdnUfvYGBqcLT8luUk+9gFBDDZHmBpJPugjBYKB11yaMiWk4OucTamki4nXjK9+DaLWr9hO7A//+PRjjUwjWVaoDrLx83EXrkfwe7N0GIJrMhN3qy9W7dyun3PUoe0rVWbBJ0LHks0U4M/NYUV7LkPVvknflDNytniOSjTfnpg8R2gREDnopfezIx4Hs3LmTnj17sn79egYOHAjA559/zrhx46ioqPhJI/qhPh4qlbxo0aKfdDj6IQ8++CCPPfbYr6alOZr84QIC7QMJExISuPbaa39T9tRDHHpAtmzbTuS9p5j9xOc8vXEJszdW8vdd72DLzUeRpbYv5H6at2zHmplG8eIy+t9wMvZu/VEkCUWWiDTXE2qsQQmH0TtdtGxehafkAI7uuerLu0d/DI54nAOHIfm9+CvLsKTnqSM4n5ooD0c8G/ZX4w9LpDls9ExLRI6oNap9NQeQgwE8e7cgGFVvLGQJX9kuHH2GYLA70FtsBBpq0YkCcjCAHA6pwYced9RbLL5wHKHGGozxydFzA+hEEYMjDo/egtlgIByROOu0k1m3aTMLFizg1FNPBVQHh7y8PEpLS5nzyCzOOvVkDHYHzWGZk1/eTZ8MHU0+hb3zPiSScyrvPpRNjlXk9PwCbsiNp9PIXtT8/S4GZ6dS8dQ0kk+5mKI7ryN1wonUfrkU15AhGJwJKOEQhvhkUiZcSP23Cymb9ySW1ESMrkR1Tb5PgRqDU7YLW7f+fGfNYWyqFX/FPgJVZbRs/o6mjdvZsgmGnptF2pmXU/n+PGomPUCP3ctoXLOMhBMmoHfE0bhqCeGmRtLOvBzR7kAniNTaU0h1xtDy5TuknTgRd0kRRXdeze4NEbotfosBCTYq3p1DfOE4PLs34dm9Bb3DSdakW9CJAjPyx3Dh1QVsm7+GzV64dM1C/HNvI6Znf1qLNqFEQgSqarB364YjfzCm1CwOvv0soslM2tlXUvPZ64hGM/EnnEyovpJAUz0VLWpA5cAkB0VPv0Fsqp6Wqgj9F3+OpbqES8dO5JKkWEYtep+9j97MlgX7KfzmI2JLN2JKTCPcXE/tkrcINzWSNO5sYnocQ8vmb7Fmd0eRJWqXvIWjdyHO/kP59owz6D/7fqrT+2B8exampHR8ZXtIP/86WovW07BiCbe9oTpvPDplFJkX38ruB6/F0qkTmRffimAwcPDdZ/FXlHFgWTHZ43uRdfldlL/4EILBQNaltyMFA8hBP8G6Svz792BwJgIQaW1EMFqw5nRvE5WDWNPzCLubohkWGqsqWLtuHXu2buKsy68hkKEuB+6pd3NW72zCHjdNtTV0GzLssAUk/foF7QSk4qnTjkog4fjx46mpqWHu3LlRN95BgwZF3XgPHjzIqFGjePXVVxk8eHA0Fs7n8/Hhhx+285RMTExEFEU++eQTampqOPbYYzGbzSxdupSbb76Zm2+++Scdiv5b/KWy8c7LymVts8CW6c/y0amJBKoPYE7JwFuyA2NiGgdeeYxdH+0lc4AVZ79+JI1VjZrevVsxJqZFbRCC3sAzT3zJeQVWciZfj/6YMQhl2/GV7CDcXI/BmYAhPhlbXi+23jiJ/s++gyEukf0NzSR4amnduZ7UceexvbKePfVuWsMRCjMScZiN1Hv85KfEEWioQQ4GCDXVEaxRjdiCyYxgsqCEQ9jyeiEH/YSb66OR7DpBRJGl6L+iyULE04I1q6u6LGCyEPG2RPc1OOKQggFOv/ASsnvk8/bbbwOqeMyePZvp06dz8aRJPHjHbYhyGL3FjhuRymYPO+tauOUjH72zZO5afgv3LttPuiGGv3/2AasrW5h+fHdezx/J8XeejqNXAa+ceRuvXfMUk44xcl5kL89cOpv79izn4Ecv8ea01xk1OoYe9z9P6bN3kzDydCzpeWy86jISjunKyjf3cdwFnYkrGMX2mf+k36dfYK4rj16rt2QHwSo1R5nBlUzc6Vcil27n7hKRG2o/RQ4GiO03FM/uTer1yRLfPryY1OQISQM7YYyLw3HtY+g3LSVl1Jnse+F+ksadR+Pqzyl+4TWK7v0XE/Z8wjV3v0yi3srJH75Lr5Q4uqW4eG39brJm/IPz+z1H8T092Tzpb3S5+V6smV0INddjcMQhxKdQ+/7c6MBD8nsxOF0E66pJHHU6YpvDQ9jdyOIvl3Hds/8G4N3HH6Sr0oJodyB53BgT00gYOoG/vV7Ko2tvZcYXZdx/SneyL7sFye8htuBv5M/cxuobulBy9al0OutC4gaPxFu6i0j3AsKSjKOulJpPXyVx5Jn4ynYimCxYs7uzfOK1nPj1Iu7qfyJX3D6B1qItbP6kij7jEth7ySNMSDZhdMSxbfoFpJ0xiYo3/0Xxylb2BODYThJD33uXUEMNle/P44uXizj/rXv5dzCJy7JMvDXyH/TsB9n/uIK6Lz/CnJZOxN2MOTUT1/DTQJYIu5uwpGUDqEvErhTKX34Ia3Z35GAAa3Z3TIlp6C12dKKAYDBy0fvbuaP0JbyywsjH3z5sAUm+ur2A1Mw5OgLS2NjINddc0y6Q8Omnn44GEh4aSH/11VcMHz6c5cuXM2LEiJ88V2lpKdnZ2SxevJjbbruN4uJiFEWhc+fOTJkyhcsvv/xHwZz/Tf5SArL5vfmYA618mzyQV045CYDXFs6nesFL1K8rosu/FyMKAtbmKtZcfCGflcNgs0hWfxP3fa2+xE80JONG4e93TeCN+z9lZbiWZL2NMDKTMi2sP6BnRbiWqV3iybvkbOKHjGOZVy2cVHfBaYz8/CsUWaLk2bswOF2knHSRWs4zJ59AOIy89Vv0jjis6XmsrPGSHR9DCgE8JTtw9iqg5J+3E1cwCsFkQQ768e7bgXPAcASTGW/Jjra0FZ0QDAbC7qaosf2QmOgMRnSCiCU5A4M9Bn9dFeedcxZ7m3zR2vGrV6+msLCQMSOG89LjD2OMiSXYUMOu4mJOn3wdHq+XB26/hSsnX0mwuR57eg5FlfVkBBswxiVQI+mJ9zficyThtFkIVZUTdCZjMxkJ7d9DTE53vJXlfHHyRMZ/9Qn7X5lN8kmTqHz3OeRggK9f3U3f/qATBeL698YYn4S3bA/e0gp6zX6JxhWfIgf9RNxNpJz6j+jsUJElHHn5BJvqad27VXVxjk+hYdFr2HsOwuhMIGhVo90PzfJihp5C6f2X0+nvVyHm9SUsSQS+fJuUceex4+5Lad2zH5PLzr5VrThiJY555mHoNoh3+o7D/vY8Jhgaqf3sDbIvv4vaL9WgrqRRZ7Gizk+P3ctIO/ki6r5bjGvwyDZXXD+h+kpa8ocTs3Ex9V99QtK4sxEtdvwVJdRUVTF30XJijCLXTzoPe0Yest+D3hGPr2wna2a8Ss+zelK7cgc7i/UcO8FFoKaJ/IeexZ7VhdqvP8aUnEF5bBautQto2bQKg8OJa/ip+PIGoCyZz46n32LYB2/TuOozTH+7AGHXWlZffQfLGmRu/ue17H1mDoNefItwbBKbKmpx/esWPKWVWNOTSD//ahacfw8Tnr0WU2IaRlcyG4MmjnHq1Xxk7kZ0osi6Kdfj8+g42KLn5IfOoXnTKqxZuUieVqY+8xlvr1pMncnJgWYPaUtfINzcQPL4iTRvXE7VwsUkDT8OS2Zn4oeMI+xuwr9/D4LR0mYPFDCnZFD90cu07t5GzPjz6P/3yYctIM7JH6EzqgKihLw0v3CqlsrkMPlLCYj14o/55wWJFJQuJ/30ywhJMr69W9WdZInqj+cTqK6itbQaa7JqGLfl5tK4fguuwkF0uf5himZcjmi18t1L2xk+dRhKOMzOV7/h+VrVgHbf+M4gCsT27M0bT3zDhLFxWO56mdxEJ5sP1JB7cAuOnoOoiOjpnOwi2FyPwe5Q40b0sOexqXS96XFKm310Tnaxddq5pJwyCUfPQZhdSZz/1iZG5ViY4NmBNbsHjryelL48C9FiJ3HUmQTrKrGm5xFqrseWnqumgq85gNGZoLqZNtVHl7SkoB+9PZY3PviIf77yb2w2Gw888AAXXHABPp+P4wYN4M1nHkVviyXcXMc7365j2h2qp8fI44bw5LkjsWZ3xzpwJKPOeZuK3n14ZeUVdL9+MokjTsdTuhPJ7yW+/1DWHWzgoNuP3ahndF4KXlmHw2KmtXQXgaoyfGW7SBh+OgfffgZjfBIHPvgIRVL4tkhg3GgH1uws4o8bT+DgPpzHjFRnYZld1aVHdxOBtqXCUGM1Or2RcGMN5tRsbBm5RHQilW+okft6R5xqj/K0IHndapBhfDLBmgNYMrvgL9uFo99QNb1GcgZSZg+Esu00JHbmo92VnJ2fgbjyI2JGn4tRL1L7/lxsXfrgze6Ly24ltHcTBkc87u1riBSchGXvOmw53dEnpKFXJLwH9mFOTKNx3ZfRLAeV78+jdU85rsEDUGQJvcNJxN2MThAxJqZgcCZgyeyKaLFFY4TeO2M6o6aNJK7gb9QtfZctr6znhKdvpCS7gO7J8UiblrPv2dmYk10k3P0S6fGxtJaqTicGRxzBxhosufl8tXs/JySasCSmEZIkfLs3EagqY2PKAFLsFnSPTSG21wAEkxn/wVKcg0bgGHYagrse2ZGAUlNOxNOCYLJw8O1/kjDyDGo/e4NO511H5dtzMCV3Qu+Iw7N7Cy07ihnw/HusqPOzd8KFLAzVcI4liZaIDrMO+p9goanYTf/Z96sZnLv0oaHNm0swWUAQkYN+DI54Hj1rGrd+/i90gognGKL70JGHLSD2f3zcTkA8L2n1QA6Xv5SAfH3rRSR2ycc57gKCu9ajt8fi379HtVXs30vZO0vIOms0rhNOpnX7Wpo3rMCUkkbzpi0okswLq1uoiXij540Rjdx9ybHkXHUfU5ZXc3vFG7z6zEomP3QeX9/zNj2HOXDk9+aGRz8EVCOo/aqHifc30rLpWz52DWJiMpizu1P9/lyqPn4fZ5/eZF16OwGzA0tYdbuN6ESU1iYaVy+lad1yzMmdCLc2Ifl8CAYDuVfdz/amAL3izDSu+5Laxe8CkDX5DgyOOAKV5QgWG6LJghT0o0RCGONTMNgdBBtUT5ns4RPa3bPk5GRqamo4Y/RwJp02gb7dutAii2zatYeMrGxMmV3ZUtuCJyRx5bHqjEIwGHhudzP+iEKDTyIvzoDdKDI0M4ntNU1Y9CKxZgM76lqZOKAzYtBPyN2kXqPfo9bMAEINNXh2buDAh5/j7JGGThSI7XMMgar9WLO7YevSJ5q+RQoG0LXVg7AkpkWTDsrhMCF3E6LJjL6tSJBosiCHQwQaagm7G0GWok4IlrRsBL0R0WzGL4Eky5iNBkSdjtI5d2BMTOMO3XH0SzUgyXDz0G5svfk8wjc8Q3Wrn66JsbgDIfrGW2jdu1WNG+pVQPUbT+A8ZiSBqjI8vUZg/PotLJldEQxGdaYw734UScaUmIJgMhNxNxOoqUQwmVQ7m8OJ0RmPMTmdSHMDr8xawl7Zzd/0TqplhVMu7ILuikewr3iHXc+8Ru+7prLvuafR24wMevFTGjd8jXXgSIJFaxHtDg6+8TSdzr+Ohm8+JqbHICzpeYgWGz5LLDVuH95QmEGZycjokDwtFHsi5CY6ca/+HMFkQfJ7CDfWsKPbaHruXELK2PNpXPcl/rZAwlBjDQZngpo5wWKndsnb7P9sC8e+9BxNqxaz6clP6HfdicgnTeHRVfsB2PRmLa8+NRDdm7No3rwZV+FxuIafhhz007RqCebULPTOBJRICNEWy8VnX80Lz96BYLKoNh+Hi64Fxx+2gFgmfdJOQPyvnqwJyGFy1AXk008/5b777mPr1q2YzWaGDRvGggULotv379/PlClT+Oqrr7Db7Vx00UXMnDnzRxGYv8ShB2TXimVUPnU7OVfejsERT8vmb0k//TJeWL2ToZmJxFlNyF+/B4KIe9tqtSZIZlcEk5maz17HkT+YYM0B3EWbCTU0EjeogC3PLeXFxnqemHoK2+ev5Linb+UfF9zM7YOS6DrtTvSOOJRwGKMrmRkjLmRnqJGRhmQu3fY5MRYTVc2tWMu3EWqspq7b8XxZWscpOz8g7cwraN27ldatq0i75DbWllXRX+/DnJiGHAmhE8Ro5lajK5m9D9/Cuq+8nPTIROJP+gd6v5uaxW/i3VeEZ28JMd26knLKxQBULXiRYG0t6edfTbixBsFkJmiwcOEFl7PZo9pTEhMToym5s0Ur6UYzV3eK54R3XiPsbqTq/XlM+9c3pBnszN35HZ/sPshpffIAHRG/BzmsVmYUDAbkcJimTd+SeNw4DjS1YhAFqlq87G5wI+p0DM9LY0dVAyO6ZdKw4RsEk5mGbz4h3NxAy7adyGEJ1+AB+CrKienSQ12ma3Onffqdj7hp8qXY0nOJ+P8juHjdCAYDjzz5NNf+/Qx0BiP6NhENGcwoNeXo9EZ8ZTuxpOehSBImVzJGh5Owp5WakEIgHCHOZkbUCYi15UhBPyZXMgsOtLKkxMuDxs3EDxnHooNeTumcTNUiNQmgwRHPvJJWJneNpfL953EOGok9L5+A2cGCojJcZiMjYmV0yVmIniYCNQeI7d6fyk9eYc7UeVwwKZ/mbbsJtUbIuehcdZS9dzsGhxNLZmcMzgQEkwVnnyFc9XkJ13z3EAZHLMbEFMypWbi3riHt7CujOdQSxp5HcP9epKCf2K59qF+7jEj3guj3I0EPBnsML6zeyZm9sqhs9pCbGEfDh8+TdOaVmA0GdaZYcwAlHKJl4zfEDjgBW14vaha+QuzAYYQa1OdI8rqjxnqd3ohgMBKsOUD1wn/TuqecsDeCIsPsogY6G5wUh5sBmNbTRe6lF3D39Hlc0t1CxunjSRh1Jt6SHQB8cOUzbFA8PPbJXK47eTKz/3UXrdvXkTDqDNZLDs4adQQE5PwfCMgbmoAcLkdVQN5//30uv/xyHnroIUaOHEkkEmH79u2cc845gJr6uV+/fqSkpDB79myqqqqYNGlS9JjfyqEH5MurTyM+M5uUky7CmpyBNxii1u2hJRCiS1I8gru+zcvJHl360Qkivv17VDfZxhq8+4pw9DlWjbVwxBPbdwj7X3mY0tc/4rHdqrvc3cPSSX3qAzJdsVR8+C9Sxp2He9dmwu5GavMKiLWY2Hiwnh5L5+DoXUiovpLyN94hcUhfkidcSMtGNVnhgvsXUfjFm+TrPEhJWZgDbl4efBanPHkZtpHnoG+owJ7ZGW/lfpRIiFBTHb59O7B16UNzchckWaFzsouQJHFg3n3sfPFzOp85ZQKX1wAAOPBJREFUkLiCUQRrKogdOAzRYifcXMfOGTeTOXESNRX7eeSRBTREAsQ7JYqbI7z09VdcedsWmkre5kHpAJu9EH72WU7p4iJp4T8xJafjOuEUKt99llBjLXnXzaIWE7rlb5F64gVc//leBqSY6Z/iJCnGiqV0M45u/Sn3hslMcAJqKndvUJ09JMveaP2Lg289g6dkD76KWpy9u/Hy9oNce+aJOPoNJVRXiRzyY07NRg6HMMYlYnQm4G+LzTA64jjwxpOYO+Vi794f//49mFOzAQi7m5C8LZg75WFyJRO2x+OwmAEFbzCMKOiodXv5dPdBUu0m/tYtE8/KT0kYdiqt/iDhbSuw5+VjdMThlXVqCn1FwRcIUdPqxWY0sLWqgXHd0nl5Qwn9U5x0TorDbDTw1e799ElzkWw3U+n243/tITKueoBtB+voG2/BX3OA1u1r0OmNtGz8FmtuD3SiiBIOEfG6MSakEnfMKMyJaZS9+CCOPseSePwE/LVqGpvW0l3oRJHGFZ/i3r4RgyOWnKvuw5KYSq3bi9mgxxsMkep0UFRZR0iSEHU6Kt0+xnTPpNrtVa+nZAubDSmc0CVDrUPvC3J8pouaz99B70wgWHMAf9keOv39GnTJWTR5/aTHx+KtLEeJhBBMZhRJVuvlhMPoI0HKXp6FThDZMe9zjCaF1MIM1iyoZvg1BQh6A3JETUZV981qHlpfG/0O/2vevRiHnkK9x09OYhytm1fgGjiM5qL1hCwOuhwz5LAFJPasT9AZ2gQk7KXlPU1ADpejJiCRSITs7GzuvfdeLr300p/c57PPPuOkk06isrIymv9q7ty5TJ8+nbq6unYJFn+JQw/Ixtfnkvu306n56kMEo4XE48bR4g+xo7qe7snxWLyNGGyxiGYziqDOcFr3bEFu83KSvGrQ4yGDp8GZgP/A3mj8Qu2SNzF3yiWm5yD0dgdVH/6LTpfeScuKhRgT0/CV7oqmHdEJIub0PKyp2ciREDWL38TWpQ+ByjIMzgQSjxtH/dplXHz21ZxqTOa8L+apCRNFC8aSTTStWUrDypUY4x0kjToF19AJbGgOcUyincZ1X2LL7YUpswt777sM1wkTaFy5lJRTLiLcWIPk9xLxtNC0ZjnOgcepUfO9Cyl64AH6PvYk7556M/1PsLDr6qeZ88g+7iy9n+eqG7j/lO64i+vIvuAsrr79eWZPHkHT+XeSbLdiMxkwlW7F3qeQ6uZWBJ2OOJuFquZWtlQ10jc1no2VDZyWl8iS8kYSbWpgU8/UBMKShCQrSLJMRbMHs16ki0VNw9K4cjH1Xy/GltMZT8lu3qwNc+XfCil9fSGfiDIznnke//69PPHyawBMGTMEa24+RlcyOr2RwMESnlv8HXfcdTeh5npCDTUgCIhWO3pbLKLJjBwJYXImqDU4gPImDwZRpLzJTX5KArtqGpi+pJZJAyyc2K0TSQ47C7aW0Dc1HpvJQFiSMRv0GEWRA01u3MEQGc4Y9tQ1E5Jk0hxWkmOs7GtowROMkOG0YxQFnli9n6xYPTedkM8Zr23mMfe7CFYbyeMvpOr9uUihAPbOvfEf3IdoseHoM0RNR2NR4yokv4e3xlxJijPCcV9/QcnVp5I0cgwJo85CJ4jslUzkx1nRW22qrUjREQhFONDkJrVyB5a+Q3H7AyTbzbiDEp5gCItRj1K0BmfvwTSu/xrjgJEsLzlIit1Mp00LcW9dQ8atzyGX78QYl0CwrhJFkrDn9KAZA4l2C2EFdH4vTbKA2x8kJzGOBo+PUEQixSKiSDLL9jeQHGOh04HNWDK7YnImIJrVfrQufoWMM68gf/BlbF/9Av66ShZW+um77Dnem7OOW4u/oaq5lfiwm5pF/8Zy3ClHxI034bSPEdoERA57qV+g2UAOl6MmIGvXrqWgoICXXnqJp59+murqavr168fs2bPp1asXAHfffTcff/wxmzdvjh5XWlpKbm4uGzdupH///j957p/KxpuRkcFrS5YzoX9PdHJETbeAEW8wjKQoGEWBJIcdvd+Nv/oAlpQM/NUH1Oy6VjVQL9hQg2Cy4C3ZjjWnB83rvsTerT9yMIDBmUD5izOx5nQhVFeNMT4Ja15P4gYOx1dRgjFeTbtS+e6zdPr7NWpOobQs6jx+hF1rseV0p/L953ENP40mVxbxzRU48vJpLlqPmNeX+tceIW7IWKric+nmsiOHw1T4I8RWFGHPy6f2y/fwle1Wi++Mn4g9pztl/3oQ9ynX0VXnIdSgrk2bXMlUh3UYRIGtlQ0MS7YSdjdRs/AVlj+1gvyBAvkPPUvDNx+z96WPSB/Tl5ie/XEOGE7Y3Yg1PY+W7Wuwd+mLYDBgikugosWHJximc3I84YiE1SCiE0QqGluobfXhCYXxhyXWVbfSK0ENgJJkBbtRjyjoyE85NPqHQDiMJCvqC9nXQqCuEt++HTSvWw6A3uHE3CmHSGsTelusGqjXtqQjWmzoDGrSSsnnRWz7f5v9zBwUSUIO+rnxHxeCICJ53ej0BvT2WAKJWSQ5bEiyQliS2LC/mgxnDJUtHg66/fRKiaNzkloyuLSuiZIGN9lxMSS0RfaXNbYQbzWT4oxBbq5HkSVaTY5oQbH9LT6CkkxLMMKgVCd7Gz24LOrgZ1VlK70TLBxoDfKPeB8feB2MzE0mJzGeWrcHu79JrVqZlk2ooRqdIBLxq3Y4OehHaLted9F6vCU7cA4cxj/f+5Swu5GrJgxHpzfy/JdruGKqWoAt0W7BF5b4tKiMs/vmUfnpv7HmdFeX8/btIHXceSzcUcaJ+Tk0rv0SwWDEnJpNwJGAQRSRZYXQxmU4+w5Rl6li4thYXkX/eDMmZwIVjS3YTUb84TCpTge1brW4WigiEWMyoMgSTYEwNpORWreXZDGC32DDHHDTtOFrYvsWYnYls7BgGK/Vu5lXtJ6J3fty3/jO6Ax6LJ06kXvV/YgmM5U+NeOhvrKEHiPGHraApE34sJ2AVH6qFZQ6XI6agLz11lucd955ZGZm8vjjj5Odnc1jjz3G559/zp49e4iPj2fy5MmUl5ezZMmS6HE+nw+bzcaiRYsYP378T557xowZPxk8s2LdRjq5K4jvW9huKh8IRwhJMu5AiB11LfRJcpIaayPVGYOnohR/RQn2vHxEkwWdIBKoq4x6Mimy+mKSwyHi+w6hZe9WQg01qk+7IGJ2JaG32KlbuZiIu4nEYScT0ZsIhCLoKvdSt/hNIt5Wut/6DPvf+ifmTrkYB4zE+8WbIIgUPfocx73zLu8f8HJyjyyE6n1q2m9ZwleyA1NqNo7u/ZHDIcyuJDz7iymdey+9HngFX3UFTWs+J+JuIuvCm2jZtQlnz4FsLK/Ge91ZdLv5dug7jFBEQvft+xhGnEPFbecTP3gozrOupfqZaaSccjE6g5GYnO7U+0KIezcgh0O05AxgWWkNqTYTBVnJVDS1srW2mVynjT5piTz3z6fpOvYMGgJhRJ0OUQCrXsTXFnUfkhQkWcFiEEi1mUlzWMlyxap2B0HGq1PT1sTowVuxj2BdJaLFBrKsLo0cck+22NHbHcjBAIokoW9LVimHw1hT0gk21RPxulXPLHcjMTndce/dijU9LzpQkMMhjG31VGrdXgLhCI2+AKJOR4LdgivGxu4qNRo8EJHo41TtKXJYXW4J1FViTkxDsNrxBkNUtXiiA5JtB2sxiiKN/iDxFhNmg54t1Y2IOh3HpCfw0a5KylvCjMxykOG0UdbkIdZkoNEfopPDQm6CE5ddLUClSKrHmc8SiwOJiN+DJTEVORLmvrvu4JYbrsNgi+XROc8BcOmUq0ly2AmEw+i8LTzy3DwAbp96g1o7prmBiMWBzWTE7Q+wr76ZsiYPfTe8S6dJtxCOSASL1hJqrCZl1Jl49hdjS8+NVtw0h/00yCIJegi2lSIQTWY1fY67Gdlip6q5FZfdisNiotkbwNP2fRMFHfXeAANS4/DXVWFPz0GRZVr8IeyCFE1zEqgooXHV55gSUomfOI3Gtx6jYdQ/iLOY6JzsQo6EcLe2HpFUJllj30MwWNueHx/lS87SBOQw+d0C8luTim3cuJGJEyfy/PPPR1OSBINB0tPTeeCBB7jiiis6LCA/NwM55ckPeXB8bwwbPifx+BPxyCKSIhNnNhBSdDR5/YQlmXqPn/4ZSfjCEoHtq3ANHEawuZ6WbWuI6d4fORxWK8uJIoLeiE4UiLTVBjnkyRNqaYoauy2JqYQkGVmWMYkCQUn9VycIBJsb0DviCUsS+kiQA54QSQ47Br1IIBSm1u2lc3I8W6dPJP++F5H0RvC68VWWIZosWLO6sr+hmZZAiASbBWH1JxicCaoNwKUu+y07fRLD3nqeVlcW3pdnEHf5/ThtFoyiwOp9B4n/6EmCddUYXYkIeiOJY/9OsKoMY2Ia9V9+gPOYEcQfO4ZatweLqOOaa65h9PFDyBw6FqfFxOI3Xmbq5EvBodY7t3gbsSZnUFzTQEiSSffXUmtPUcuwiuo9X1NeQ60viMtsRBR0pDms9EmNJ9TSpKZjt9qRfR41nqUtNb0UDKDIEmZXUjRJpNGZQKDmQDSjrL+mAs/eLZg75RHbtU+0Foq3dBfO3oMR9Eaatq9BCYfbBVka4xKiotAkC9R7/HS26zE6nFQ0ukm1GaO2jmafH5fdFs1wa7A7kCMhwh43osmMp3SXWoe9oRpPek8kWUaSFfzhCJVuH0ZRwKgXqG4NEGs20BJQbS6iToekKAxKT8QbChNnNUdL1yqCmpCzoslNthk1bXtdJY8//xI3XnYxosnMw8+/yE2T/o5gMGBNzlD7YzYTUnRsO1iHw2xEkhXS42KwmdSiZmJTDaLJzKPzXuamSychh8MokZDqnSUI3ysLoNY7DzY3oIuJw6CDQEMNvv17MSWmcdAYR5YrFqmuMircgjOBQCiMw2KmuKaB9HgHoYhEsy+APxzBKArUewPYjAYkWSYQkYizmPCEwmTHx+K0mfEGw5iRaCnagC2nO97yPVjT82hcs5TkUWdSv3opcnL2EXHjzRv1KqK+bYYc8VHy5SRNQA6T3y0gvzWp2HfffcfIkSP59ttvGTp0aHRbQUEBo0eP5sEHH+zwEtYPOfSAFO/ZgzXQqmbwbKrHnJiGaDajE0Ra/CGsUgAp6Ec0Wdjvl7GZ1Op1NkEh4vdGvXhA/fIoshQdhR4aeSmyjBT0E/GpywxqvEc8gbYamW5/kEA4QoozBr0iIYdD6C02QEdFYwtxNguBcBhj3QGCdZUkDB5JUWUd6f5agnWVhOoq22qkq1ULEdQCQ/HHjsFbuhP7seMQ3PWEmusRLXaqDbGUNbUyunt21Lgqh0MYHHFUffgvTMnppE64AHfxDmK79om6vsrhcDTZYq0/zOzHH+f1t99pd19Xf7mULl27MGPajVxz+lg13UrvIapXkyypKVdqDuDsOZD9DS00+gIEwpE2EVFo9AXxRySqPEF2NoRIsolEZIWRWXHkumJJiLFiNhho9voxeurbxFrEX1lGbPf+qnfS/r3YM7vgLtmh2qoEEd++HezOO57hXdIB0AkCYU8r7t2b1IqGa5cx5+MvMLnUhJi3XHctckSdhchGCwcbW3BYTOyqaSTZbiXTIlAbEQhLUjSz8qHElDZBQQoGEE3qEtz9jz6OKAhccfW11Hv8JNgthCISFqMeSVZ44vHHmT5tGg/Pns3Zl1zOS3PnACDodJgNItddfyN2kxGrWV3iavUHiLeakcMhBIMRX3UFeqsN0WQm0FCLJTFVrWsTDKgvf4uNcERib20jXZLUZbBXthzgsgHZpDks3PDZDi7uk0LPtAT21TXTNc6ipnavrcLoiENvtSGHQ4QFPQ/Oepi7br8NUacjLEngbkSRJdxF67FmduWJl1/jur+rkfSWlHR0egOCqMcXlqDhP+fTCQLeYBhD0IvR4SQQjmASBaSgn7seVu9HTauX5Bh1+chpM3PvQzO55IqrEHQ6VUT1Im5/ELc/qA44DDLBhhrsWV0oqapjUP++hy0gXYbNaycge7++XBOQw+SoLWG53W6SkpKYM2dO1IgeDodJT0+P5ro/ZESvqqqK5tB/4YUXmDZtGrW1tb85w+T3c2Gl2UzMfuaf3HDJJHUk6/cSqKskJqc7IUWHQRQRBQE5onoECXpD21kOFWVRUGQ5KhBANA5BJ6hGWNFkRpIVvMEQencdEUciDR4fmS6nOoo3GDjQ5GbuP5/mrEsmYzcZSHXYCEsSoiBwoLkVi15PlsuBWLEHS0qGWnK0pQmdKEZdeJU2IZAjIeRggGBdJXI4RFzfQgSDEUXQc7DJTac4dS06HnVG1LByMa4h44j4PYTdTRidCeq1trm6htxNFAdFrER4dd7zPDnn2Xb3c9iQQm68/lqGFhwLQMig1uQ+lAhSNlqY+fDDTL7qGox6kVBEoskXJCRJxFvNhCIS/nCEem8AUdDhD0t4QhG6JjhYVdFAjFHEF5bJdVqpaPUzIjeVvXXNJNjMZMQ5EGvVNCa2nB6Igk6tZLjpW+xd+1AmmekaZ0E0WfCU70XfVjTJXbQeS2ZXQnWVPLtwGTpBhyLJSHoDd9x4XdtMIgZJVhAFAbc/wKc7yzmrayohg5lwRKLJ58cgiiTEWJn58COMOW8Sx+VlMPPhh7l12s0EJZmq5lYcFhPr99diFAVWlFSx8JOlHNi1G39TIznpVnoMLODsM0+nV7JaDC0kyaw9WI9VL2I36tt+1OeuJRAiw2mP3idR0LGnroV4q5GtNW66u+zIssw/rriRsYM6k3PyJFa/9wp9UuK44PIr+fTdN2ltbeX8f0xmx6YNzP3nU/jDEqecdQ6dBgzhvZ0+ejn9REq2UF68h9aWFjKOOZ5xxw5gnVvgnJ5ppMWY2bptGxEEtta2ECMoyGYrybowIY8bXyDIa6+9xsaNGxh738skpKYwMsNKstVAXkIcVikAbVUz5XCIoDUOSZGxmYyY2zJsu/0Bqlo8OMxGAuEISQ47NpMhen8CoTBhScIgipiNBg42qil5bCYjNQ2NDD1mwOFn4x06B1GvDhCliJ89K67WBOQwOapuvDfccAPvvfceL730EllZWcyePZtPPvmEXbt2ERcXF3XjTUtL45FHHqG6upoLL7yQyy67rENuvEVFO5n/0ovcfss0Zs1+lFun3dz20ldHkaG2LKCKLEW9seSw+tKNzjTaPlMkmYefeJLpN96Awe5AajNo6gSRBx96kFuuuxavzoDZqMeoU6j3hXj0sUeZfNW1iIK6FGIURfzhMEa9KjySrOCyWwlLEo/OfIhbbrguKm7eynI1f5VdrZGuSFJ0ySxQVxmdWQgmCwa7IzpCNTriEAwGBIORyk9ewd5jELb0PASDgZmPzOaW2+9A12aUlUwW1VDq8/Dp1t3ccNH57dJOn3nmmeTl5HDrjddH2/OU7ormKHKjXoc3GMZs0EdH62aDnvKGFsKSTILdwqvz5nLjjVO5474HuOa66zHqRTzBMN5QmP3NXkKyzIoDPkIS5CeqL5HsWDNlLQGcZpGylhCSAoVpMfTvpIpfokkgKKhr+c6wB8FgZObDD3PHHXepo+Zdm4jtOQi/aMRuEHho5iykYIDrLzibmJzuSHojrf4AoYhEnM2CNxgiRg4h2mN5ZPZsJEUhIklcde316Es248nsxce7Kph8bA8CoTC7axpp9AVZvG0f29atY2iczFdfL6do+3ZCoRADBgwgIyOD0tJStm7dSpfuPRgxdAhljjz27aukv81NgzmelOQkOqen0rVHT/wRiT1LPiBvzOmccHA1M77ZRbrDSklZGZFIhMq6ekRZoq6ujvLycmx2O7EOB0lJSYiiiMFgwOFw0NLSQlNTE3v27KFfv34kJCTwxRdfYDKZiEQiSG3FL3JyctDr9ezduxeAlPQMXAmJ7Nu9E7/f/5u+a4IgoNPpouc8hNls5qEHHuCMM89CbquQt6a8hn3NPlpDMlcf2xVddRkmVzKiPTZqJ5FkBYNexBcIRb3EvMEwLf7/LFEb5DAFA/odvoAMeby9gKycqgnIYXJUBSQcDnPbbbfx2muv4ff7KSgo4MknnyQ/Pz+6T3l5OVOmTGH58uXYbDYuuugiZs2a1aFAwuI9e4h3OiGsPnzqC19AkeXoS1n9V0YwGKJpMhRJjhptDXZH9OWtt9jalg5CGOwOwoKe+lYfcTYLxnAAxWLDFwgRkiRsJnVJQpJlZLn9LRUEXfQLA2A2GDAb9G3eYWK7fUNtX0y9IuGvrUJsMygb7A50JiuSpyWarl0J+gh73GpQX9ssSWgL7hIMap12Y9uoUE0tYuLjRYtZ/vVydu7YwcrVq6PtTps2jZkzZ+KuqUIwGAkrYBTVpYl9dU2EJZnOSXGEJYn1+2vpmhiLKAhRR4WcxDhCkkyz109Tm4E6JMkcaPaw6PWXuOTKq0mOsWIxGGjy+cl0OfFXlBCMT+PWex/gnttu5d6Zs5h1z52UN7j5qryeY1JjeeHZf3LsWZM4pXs626sb6ZoQS43Hx466Vio9Ya4YlENYkmnxB0lz2nH7g9y7vIw5p/Ri28Fa4qzq0pM3GEYQdCTHWAlLMgZRQBQEjKLIjup6PMEIb22pJmHvMpYs/JiuvftROLA/Y0eOYNHnn7Pi669Zv34dXq8XnU6Hw+HguOOOo3///lxwwQV07949ei8XLFjA66+/zurVq6moqEAQBNLS0qiqqoq+eGc+/Ah9h/+NrDgHgVCImvpGPv3wPeb+82kkSWLAgAHk5eURGxuLzWYjNzeXqqqq6Mw8Eong8/nwer04nU70ej0TJkzg1FNPRafTsWHDBr799lt0Oh1paWkcc8wxZGdnoygK69ato7S0lNWrV1NbW0unTp0455xzkCSJUCiEzWajtraWtLQ00tPTMRqN2Gw2qqur+fjjj1Vbn8mE2WxGEARaWlqiVS6NRiPpGRmkpKYRGxtLQ10dxw8bRma3nkgWB5mZGbisZpJi1KWkQ4MroyjiDgSjNXXqPX5aAiH8EYlYQebE4489fAEZ/ACiXn0epEiAPWvv1ATkMPlLpTLZX1JMjM0aXX46tG4dbK5HkeRoenQEEWQJ2iKpD81ABIMBvcUeXd4S2wKlDo3wD51XjoQImuyE25Zswm13UPVG+k9mTEmWo7+LgqAaEsORqMhIihx1nTToxWignTr110c9c74/ek51xtDsDSApcrTtVn+A8ga3GjAmCPTNSEZpbcLoiCPi81IfUWc/Ea+bvm12JVEUycvLY/78+Tz44IMsWrSIwYMHs+jjj9GLIpIsI6Dgq67AnJKB7PMgmSy4/UEkWcZhMbO5Qi36ZDMa6JVgRzZakGWZWrcXh9mEIOho8PiiZXXjrBZCkoTbH2TOuv2c3s3F0JwUpECAj4trOKNPLsGmehYfbCXRZmLxvkYWvvwihaNH8d1XCVj8DdQkpmMPhHFnGumVWA+tNbRUFLNn215MjUWYrVb+MX446/eWsW7NGqw2K7Ks0OD2oDcYcLniSe9zDBvLjOh0YQxiBHvdemIMAsXFxYCajnvNmjXt6iyMGDGC448/nl69ejFixAgSEhJ+9bmUZZl9+/YRFxeHy+UiEokwceJE3nnnHc477zyefPJJzj33XJYvXx495vrrr+eKK66ge/fuf2it699LKBRi06ZNrF27luLiYsrKyqirq8NqtbJixYqo00vv3n04YdRoWgwx1AUV+qfE0LlTKgN65xPUmwlJErKs4G8rWl7dGiDk93HlyaMO3wYy8M52ArJ3wwOagBwmfykB+Wj5Sna6wxR0iifBbiHT5Yzuo8gSwab6aIpwICoUh/4+9O+hpapD+x6KEwH1Vqki0P622UxGJFluJxQA4YjEvvpmkmKs2E1G9tU3kx4Xw5I9FSRZTexr9pFqV209Azol4A9HyHLFsq6sko/eeI0lS5aQkpREYmICGZ3SuPSKq1B0OnbWNtMrOQ5BkclKciF5WlBkCUFvVNf628r0SsEAdzz0MF9/+y27d+8GVDfoGTNmIIoimzZtonfv3jz++OPcdNNNvPPqfMaMG49OEKhodLOrtqktnkPAIArR0bs7ECLOYiIQkWgJhLAb9aQ7Y9jX0EK/9CREQUAUVJvTIS8yAItBT5NP9cypdPsY1iWdcEUJyz0Gbn2qBV8GzOpbwa233IzX642+eCwWC3a7PZp+xWw2Ewio12gwGOjatSv5+fk0NTWxdOlSsrKyOOmkk9ixYweFhYXExsbi8/nYs2cPCxYswOl0YjKZEEWRTp060atXLwYPHszAgQPp3bs3iqLgdrtZunQpPXv2pGfPnkfkWT0kChMnTuTTTz8F4P777yctLY2ePXu2m8n8VfD7/ZSVlbFu3To+/PBDPvvss3ZelIcw2WO5bNptTBg3lkZfiFpfiNaQhIMQN54+5rAFpHP/WxBF9bsmSUGKNz2iCchh8pcSkDUbN5ORnITNZESOhKho8WEzGYhpC2KTZRmzwYAU9KseLe1qa5jbopbD0SUsUI3sh8Tih7ML1WvHEJ1pfLFLrawWkmQevHUqkWCQM8/5OwdL9iCgYDUZGTBoEEOGHEckEqG0uJg+fXpjNpnaGRQVSSItM5NI2yjs+/Ts2ZMevftStHUz/foP4L1336H/gIFYbTaOHTiARKeDlPR0vlu1hvWbNnP88OEkxru4dfq0n7x3b7/9Nueccw6KopCens5xxx1HTlYWt98yLSqoIUmitsVDky9AWZOHnQ0+nGaRWl+ErnFmNtT4iDEKuMwiLouBnDg7cRYTqc4YzEg0BKV2y3dKTTmW5HRe21rGk3NaCev19Aw+ScjvY9u2bTQ0NDBs2DCOO+44tm3bRt++fZEkCbPZTEZGBiUlJQiCwMCBA8nOzqZbt27tyn82NzcTGxv7syN4RVH+sNH95MmTmTdPjdc48cQTmT9/PomJiX9IX/4oZFnG5/MRDoeRJIlVq1YxdepUiouLmXz9VFKGnUyMUWRwp3jcgRC1Tc1ceuIRcOPte307ASnZ8pQmIIfJbzc0dIA9e/Ywbdo0vvvuO0KhEH369OH+++9vVzzlSCRTPMSh9fhDS0Sd4hzRmcB/loD0YDChN6hBgvW+EK62l3fE71VjFPQGVK8shYgk88gHizktP5uviyv5ZO0OHEIYa9DN9p27CdXsRwfIskSMzYbeYKSuoYGK/aqYzNq8HlEU2xkddTodOp0OWZZJ6ZROVkYGvXr3IS83l5z0NCqqqtqJR35+PtXV1TQ0NFBRUYHBYMBqtbJm9SqGDh2Kw+HA7Xbz3Asv0NraitwmaDExMWzfthWfzweAyWQiLS2N5ORkOnfuzJgxYzj99NP54osvePrpp6msrKSqsop/PfecOpv63r1NirWTEGMlwW7h+FwDkiLjD0WobfVhaVvH3lDjAWBRcTWdXXreezVATYqZ2EaZU8+Ar1/YS2hoHreOsDPGBY2rv2DHO9NZt24dw4YtAtTsBKNHj2bo0KEdfsk7nc5f3P5HLg298MILPPbYYyiK8v/2xSUIAhaLhYULF3LPPfewZ88eAI4rLGTmtBuQDaaoLc0oith08q+c8behRPzIivo9VKTQETnn/3eO6gyka9eudOnShZkzZ2KxWHjyySeZP38+JSUlpKSkHPFkisV79pDgjKXO4ycQjmAQBQLhCA6LiR1VDext9HJit06s2V/LkOwUJFkm0Wam0eMnzqC+VCr8ER76tpzN77dQawhj2nkvNSW7ftSmw+EgPz+ffv36YTAYMBgMeL1eAoEAcXFx+Hw+Dh48GB05HzJ+nnrqqZhMJgRBwOl0Mn/+fHQ6Hdu2bePAgQMoivIjwbnllluYNWsW4XD4V/ODSZJEeXk5ycnJ2Gw2ysrKeOedd6LLPGazmR07drBixQqKiorYt28ffr+f3r17c+KJJzJt2jRcLhctdbXIba7NAgot/hD+cDiakuTQrOuQG29YknBaLYQlqd3fhIPUNLvZtGE9FQcP8smizxg0aBBVVVUUFRVRVFQUFcvp06cza9as3/z/rvG/y7jx41myeDHDhg3ntDNOZ8SwEYT0RrZXNzGqSzruQDAajCiHggzo2/uwZyDZ3S9CENXvjyyFKNv1ijYDOUyOmoDU19eTmJjIN998w/HHHw9Aa2srDoeDpUuXMnr06COeTLGoaCcpCS6AqIeTNxhCFHQIgoCxzVPpwIEDyKEAcUY919zzAB99/DGulE4Eva0kJSaQlpzE5m3b8XjUEbVOp+ODDz4gLi6OXr16Ybfbf3OMyu+htbUVn8+Hy+VCFEXcbje1tbXk5eV1uGxlbGwsbre73WeiKHLcccfRq1cv8vLyKCgooLCw8FfbWLZ+Cw0V5RRt30aM1crAY44hLycHgIqmVirdXloCYSI1+9m+fTvBxlo++eQT6urqCIfDGAwGUlJSsNlsJCUl0a1bNwYMGMCxxx5L7969EX/gkabx1+TQe6D/MQXces+9lCsWLivoQVFlHVmuWJ544nEAbrxxqlpB0uejZ88eh5/KpOt57QSkfM+bR62k7bXXXtuupO1TTz0VLWn7UwwfPpyvv/663WdXXHEFc+fOjf59JFdrjhRHrWWXy0W3bt149dVXGTBgACaTieeff56kpCQGDhwIwKpVq+jdu3dUPADGjh3LlClT2LFjx2+ORD/Ein2VZHmCOMwmAuEINpOhnbG7R0o8Da1eevUfEP1s8DHHANBQfRAAT6ubffv2ERMTwwUXXMDUqVPp27fvf6XucExMDDExMdG/Y2NjiY2N7fD5FixY0E484uPjWbduHU6nk/j4+B/tHwqFqKuro1OnTj/atmPHDm6efAmbNm1qN0MaN24cBQVq7YkZM2bwzAsvct0Vl7U7tnv37ixcuJCcnJw/tH6zxh9PRUUFzz//PACb1q3h3JPGce1119Pa52bcwRBvbdnHtqpGrrvuOnbXNGIzGqhtbPqVs/42ZCnEIUcYWQofkXP+FBMnTqSqqoqlS5cSDoe55JJLmDx5Mm+88cYvHnf55Zdz3333Rf+2Wq3R3yVJYsKECaSkpLBy5croao3BYPhdqzVHHOUocuDAAWXgwIGKTqdTRFFUUlNTlY0bN0a3X3755cqYMWPaHeP1ehVAWbRo0c+eNxAIKC0tLdGfAwcOKICye9du5drrrlcURVEaa2uVe+6550fH3n333YojNlZBfZKU9PR05f3331cyMzMVvV4f/fz7P1OnTj0i9+O/zRdffBG9hri4OOW0005TbrnlFuXNN99Udu3apQSDQSU3N/dH1/vwww9Hz3H55Ze32zZp0iTlzjvvVE455RQFUAYMGKBIkqT4/X7F4/EoL7/88k/ew27duv2Bd0Ljz8Ch76nJZFJ69uypZGRkKAMGDFC++OKLH+37/or1yoEDFcqOPSXK7l27FUBpaWnpULstLS0KoHTKOVnJyDtDycg7Q+mUc/JhnfPnKCoqUgBl3bp10c8+++wzRafTKQcPHvzZ44YNG6Zcf/31P7t90aJFiiAISnV1dfSz5557TnE4HEowGDwife8Iv1tApk+f/pMviO//7Ny5U5FlWTnllFOU8ePHKytWrFA2bNigTJkyRenUqZNSWVmpKErHBeSee+75yXZ/z8NQV1en7N69W/F4PMoll1zyi9fz1FNP/d7b9KehoaFBmTlzpnL++ecro0ePVjIzM3/1/+/5559XJElSiouLlfz8/B9tNxgMitPpVPLy8pRjjz1WiYmJ+cnz6HQ6JSUlRQGUK6644o++FRp/MIe+t4IgKHPmzFF27NihSJL0q8cdEoDDFZCUjNFKWtZ4JS1rvJKSMfqoCMiLL76oOJ3Odp+Fw2FFFEXlgw8++Nnjhg0bpiQkJCgul0vJz89Xbr31VsXr9Ua333XXXUrfvn3bHbNv3z4FaDco/2/zu5ewbrrpJi6++OJf3Cc3N5dly5axcOFCmpqaomuMzz77LEuXLuWVV17h1ltvJSUlhbVr17Y7tqZGDU5LSUn52fPfdtttTJ06Nfr3oWy8kiRRX18f9UoqLy+npaWF1NTUHwVmJSQkRIPBHnvsMVJSUpg5cyagxhjMmjWLE088kc6dO/9PBXT9kPj4eG699dZ2n9XV1bF161YqKipoampi//79lJWVUVRURHFxMVdccQU33HBDNL1FTEwMGRkZZGdnk5iYiE6no7W1ldraWtxuN9OmTSMnJwdRFElKSiI9PZ20tDTsdvv/9L3TOLLcddddjBgxgpkzZ3L99dcTiURwOBx0796dfv36ccYZZ5CcnEyvXr2IRCKEQqEjap+Qwn5kQV26UuS25Kc/sA+aTKbDsm9WV1dH8/odQq/XEx8fT3V19c8ed/7555OVlUVaWhpbt25l+vTp7N69mw8++CB63u8v9QPRv3/pvEeb3y0giYmJv8lv/ZDr6A/XvAVBiLqZFhYW8uCDD1JbWxu96UuXLsXhcPxi4NbP/SenpqYSDAax2Wz4fD6U7/kHuFwuunfvTnJyMpmZmeTk5OByuYiNjUVRFHJychg+fDjLly8nEAhwww03cMMNN7Q7f35+PsuWLfvRA/K/RmJiIqNGjfrJbR6Ph7lz59La2kphYSF9+/YlJSVFEwKNw0YURYYNG8awYcNobW1l7dq1rFu3jl27dvHmm2/ywgsvAGpgaLgtN118fDzZ2dmH1a7RaCQlJYXq6u/afW6328nIyGj32T333MOMGTN+dI7fWsaioxwqeQHQu3dvUlNTGTVqFCUlJeTl5XX4vEebo2ZELywsJC4ujosuuoi7774bi8XCvHnzKC0tZcKECQCMGTOGnj17cuGFF0aTKd55551cffXVHRoFzJgxg7S0NKqrq3G5XPTr14/Y2Fj27dvHypUr2bdvH9XV1SxatIiysjJCof/4gut0ul9tc8eOHX95TyG73c7NN9/8R3dD4y9OTEwMo0aNig5knnvuOaqrqzlw4AAbNmzA5XJhMBgoLS2ltLSUjRs3drgts9lMaWlpu+87/HRA6c+9A37ryktKSgq1tbXtPo9EIjQ2Nv7iqsoPOeSYUlxcTF5eXodXa446R3N9bN26dcqYMWOU+Ph4JSYmRjn22GN/ZNsoKytTxo8fr1gsFiUhIUG56aablHA4/Lva6cgaqSzLis/nUyorK5WDBw9G24xEIkpzc7Oye/duZenSpcratWuVgwcPKpFI5Hf1SUND48hwuDaQ/yaHjOjr16+PfrZkyZJfNaL/kBUrViiAsmXLFkVR/mNEr6mpie7z/PPPKw6HQwkEAkfuAn4nf6lUJlpQkIbGX4//te/3+PHjqampYe7cuVE33kGDBkXdeA8ePMioUaN49dVXGTx4MCUlJbzxxhuceOKJuFwutm7dyo033kh6eno0NuRIlb440vxxEShHgR8axDQ0NP73+V/7Xr/++utcc801jBo1KhpI+PTTT0e3h8Nhdu/eHbUTG41GvvjiC5588km8Xi8ZGRmceeaZ3HnnndFjRFFk4cKFTJkyhcLCwmjpi+/HjfwR/CVmIIFAgJycnD/UG0FDQ+PokZKSQmlpabukmRp/PH8JAQFVRH5oJDscDrkGHzhw4A+dNv9Z+vFn6ovWjz9vX45WP4xGoyYef0L+MktYZrP5qDxgDofjD385/Jn6AX+evmj9+DF/lr78WfqhcXTREhNpaGhoaHQITUA0NDQ0NDqEJiA/g8lk4p577jkqadv/F/vxZ+qL1o8/b1/+LP3Q+O/wlzGia2hoaGj8d9FmIBoaGhoaHUITEA0NDQ2NDqEJiIaGhoZGh9AERENDQ0OjQ/y/F5AHH3yQIUOGYLVacTqdP7mPTqf70c9bb73Vbp/ly5dHa7937tyZ+fPnH5W+7N+/nwkTJmC1WklKSmLatGlEIpEj3pfvk52d/aPrnzVrVrt9tm7dyvHHH4/ZbCYjI4NHHnnksNr8JebMmUN2djZms5mCgoIfpbk+0syYMeNH19+9e/fo9kAgwNVXX43L5cJut3PmmWdGU20fDt988w0nn3wyaWlp6HQ6FixY0G67oijcfffdpKamYrFYGD16NHv37m23T2NjIxMnTsThcOB0Orn00kvxeDxHvC8XX3zxj+7RuHHjjkpfNP48/L8XkFAoxNlnn82UKVN+cb+XX36Zqqqq6M9pp50W3XaoxsmIESPYvHkzN9xwA5dddhlLliw5on2RJIkJEyYQCoVYuXIlr7zyCvPnz+fuu+8+4n35Iffdd1+767/22muj29xuN2PGjCErK4sNGzYwe/ZsZsyYES0QdCR5++23mTp1Kvfccw8bN26kb9++jB079kc1GI40+fn57a5/xYoV0W033ngjn3zyCe+++y5ff/01lZWVnHHGGYfdptfrpW/fvsyZM+cntz/yyCM8/fTTzJ07lzVr1mCz2Rg7diyBQCC6z8SJE9mxYwdLly5l4cKFfPPNN+2KFx2pvgCMGzeu3T168803220/Un3R+BPxhyWS/5Px8ssvK7GxsT+5DVA+/PDDnz32lltuUfLz89t9du655ypjx449on05VBOguro6+tlzzz2nOBwOJRgMHpW+KIqiZGVlKU888cTPbn/22WeVuLi4aB8URVGmT5+udOvWrcNt/hyDBw9Wrr766ujfkiQpaWlpysyZM494W4e45557flSP+hDNzc2KwWBQ3n333ehnO3fuVABl1apVR6wPP3wGZVlWUlJSlNmzZ7fri8lkUt58801FUf5Tm2LdunXRfT777LPfXZvi1/qiKIpy0UUXKaeeeurPHnO0+qLxx/L/fgbyW7n66qtJSEhg8ODBvPTSS+3K5a5atYrRo0e323/s2LGsWrXqiPZh1apV9O7du11t5LFjx+J2u9mxY8dR7cusWbNwuVz079+f2bNnt1s2W7VqFSeccAJGo7Fdm7t376apqemw2v0+oVCIDRs2tLs+QRAYPXr0Eb/XP2Tv3r2kpaWRm5vLxIkT2b9/PwAbNmwgHA6361P37t3JzMw8qn0qLS2lurq6XbuxsbEUFBRE2121ahVOp5NBgwZF9xk9ejSCILBmzZoj3qfly5eTlJREt27dmDJlCg0NDdFt/+2+aPx3+MskUzya3HfffYwcORKr1crnn3/OVVddhcfj4brrrgN+vuC92+3G7/djsViOSD9+rp1D245WX6677joGDBhAfHw8K1eu5LbbbqOqqorHH3882mZOTs7P9isuLu53t/lT1NfXI0nST17frl27jkgbP0VBQQHz58+nW7duVFVVce+993L88cezfft2qqurMRqNP7JZJScnH9XyAofO/VP34vvPQlJSUrvter2e+Pj4I963cePGccYZZ5CTk0NJSQm3334748ePZ9WqVYii+F/ti8Z/j7+kgNx66608/PDDv7jPzp072xlCf4m77ror+nv//v3xer3Mnj07KiC/duxjjz12xPpypPg992jq1KnRz/r06YPRaOSKK65g5syZ/y9SVowfPz76e58+fSgoKCArK4t33nnniA0O/tf5+9//Hv29d+/e9OnTh7y8PJYvXx6te67x1+MvKSA33XQTF1988S/uk5ub2+HzFxQUcP/99xMMBjGZTKSkpPzI66ampgaHw8H06dO57LLLjkhfUlJSfuRxdKjdlJSU6L8/15fvv+wO5x4VFBQQiUQoKyujW7duP9vm9/t1JEhISEAUxZ9s60i282s4nU66du1KcXExf/vb3wiFQjQ3N7ebhRztPh06d01NDampqe3a7devX3SfHzoXRCIRGhsbj/r9ys3NJSEhgeLiYkaNGvWH9kXj6PGXFJDExEQSExOP2vk3b95MXFxcdPRdWFjIokWL2u2zdOlSCgsLj2hfCgsLefDBB6mtrY0uByxduhSHw0HPnj1/tS/f53D6tXnzZgRBiPahsLCQO+64g3A4jMFgiLbZrVu3I7Z8BWpRoYEDB/Lll19GveBkWebLL7/kmmuuOWLt/Boej4eSkhIuvPBCBg4ciMFg4Msvv+TMM88EYPfu3ezfv/9H9/xIkpOTQ0pKCl9++WVUMNxuN2vWrIl68RUWFtLc3MyGDRsYOHAgAMuWLUOWZQoKCo5a3wAqKipoaGiIitsf2ReNo8gfbcX/oykvL1c2bdqk3HvvvYrdblc2bdqkbNq0SWltbVUURVE+/vhjZd68ecq2bduUvXv3Ks8++6xitVqVu+++O3qOffv2KVarVZk2bZqyc+dOZc6cOYooisrixYuPaF8ikYjSq1cvZcyYMcrmzZuVxYsXK4mJicptt912xPtyiJUrVypPPPGEsnnzZqWkpET597//rSQmJiqTJk2K7tPc3KwkJycrF154obJ9+3blrbfeUqxWq/L88893qM1f4q233lJMJpMyf/58paioSJk8ebLidDrbeaYdaW666SZl+fLlSmlpqfLdd98po0ePVhISEpTa2lpFURTlyiuvVDIzM5Vly5Yp69evVwoLC5XCwsLDbre1tTX6DADK448/rmzatEkpLy9XFEVRZs2apTidTuWjjz5Stm7dqpx66qlKTk6O4vf7o+cYN26c0r9/f2XNmjXKihUrlC5duijnnXfeEe1La2urcvPNNyurVq1SSktLlS+++EIZMGCA0qVLFyUQCBzxvmj8efh/LyAXXXSRAvzo56uvvlIURXU17Nevn2K32xWbzab07dtXmTt3riJJUrvzfPXVV0q/fv0Uo9Go5ObmKi+//PIR74uiKEpZWZkyfvx4xWKxKAkJCcpNN92khMPhI96XQ2zYsEEpKChQYmNjFbPZrPTo0UN56KGH2r0YFEVRtmzZogwdOlQxmUxKp06dlFmzZnW4zV/jmWeeUTIzMxWj0agMHjxYWb169VFrS1FUN+jU1FTFaDQqnTp1Us4991yluLg4ut3v9ytXXXWVEhcXp1itVuX0009XqqqqDrvdr7766iefh4suukhRFNWV96677lKSk5MVk8mkjBo1Stm9e3e7czQ0NCjnnXeeYrfbFYfDoVxyySXRAcmR6ovP51PGjBmjJCYmKgaDQcnKylIuv/zyH4n6keqLxp8HLZ27hoaGhkaH0OJANDQ0NDQ6hCYgGhoaGhodQhMQDQ0NDY0OoQmIhoaGhkaH0AREQ0NDQ6NDaAKioaGhodEhNAHR0NDQ0OgQmoBoaGhoaHQITUA0NDQ0NDqEJiAaGhoaGh1CExANDQ0NjQ6hCYiGhoaGRofQBERDQ0NDo0NoAqKhoaGh0SE0AdHQ0NDQ6BCagGhoaGhodAhNQDQ0NDQ0OoQmIBoaGhoaHUITEA0NDQ2NDqEJiIaGhoZGh9AERENDQ0OjQ2gCoqGhoaHRITQB0dDQ0NDoEJqAaGhoaGh0CE1ANDQ0NDQ6hCYgGhoaGhodQhMQDQ0NDY0OoQmIhoaGhkaH0AREQ0NDQ6NDaAKioaGhodEhNAHR0NDQ0OgQmoBoaGhoaHQITUA0NDQ0NDqEJiAaGhoaGh1CExANDQ0NjQ6hCYiGhoaGRofQBERDQ0NDo0NoAqKhoaGh0SH+D+U26fv0JxOoAAAAAElFTkSuQmCC", + "text/html": [ + "\n", + "
\n", + "
\n", + " Figure\n", + "
\n", + " \n", + "
\n", + " " + ], + "text/plain": [ + "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "\\begin{table}[]\n", + "\\begin{tabular}{lcccc}\n", + "subdomain & latitude range & longitude range & training & validation \\\\\n", + "0 & 35\\degree, 50\\degree & -50\\degree, -20\\degree & X & X \\\\\n", + "1 & -40\\degree, -25\\degree & -180\\degree, -162\\degree & X & X \\\\\n", + "2 & -20\\degree, -5\\degree & -110\\degree, -92\\degree & X & X \\\\\n", + "3 & 0\\degree, 15\\degree & -48\\degree, -30\\degree & X & X \\\\\n", + "\n", + "\\end{tabular}\n", + "\\end{table}\n", + "\n" + ] + } + ], "source": [ - "from data.pangeo_catalog import get_patch\n", - "\n", - "#run = select_run(experiment_ids=('497746281881301089'))\n", - "run_id = run.run_id\n", + "from gz21_ocean_momentum.data.pangeo_catalog import get_patch, get_whole_data\n", + "import gz21_ocean_momentum.analysis.utils as analysisutils\n", "\n", + "import xarray as xr\n", + "from dask.diagnostics import ProgressBar\n", + "import numpy as np\n", + "import cmocean\n", "from cartopy.crs import PlateCarree\n", - "from data.pangeo_catalog import get_patch, get_whole_data\n", "from scipy.ndimage import gaussian_filter\n", "from matplotlib import colors\n", "import matplotlib.pyplot as plt\n", "\n", - "from importlib import reload\n", - "reload(plt)\n", + "#from importlib import reload\n", + "#reload(plt)\n", + "\n", + "data = xr.open_zarr(forcings_path)\n", + "\n", + "plt.rcParams[\"figure.figsize\"] = (4, 4 / 1.618)\n", "\n", "%matplotlib widget\n", "#%matplotlib inline #this option does not work with jupyterlab\n", @@ -324,24 +345,21 @@ "plotter = GlobalPlotter(cbar=True, margin=0)\n", "plotter.x_ticks = np.arange(-150., 151., 50)\n", "plotter.y_ticks = np.arange(-80., 81., 20)\n", - "plot_training_subdomains(plotter, bg_variable=data['usurf'].isel(time=0), facecolor='green', edgecolor='black', linewidth=2, fill=False, vmin=-0.5, vmax=0.5, lon=0., cmap=cmap_balance)\n" + "\n", + "analysisutils.plot_training_subdomains(plotter, bboxes, bg_variable=data['usurf'].isel(time=0), \\\n", + " facecolor='green', edgecolor='black', linewidth=2, \\\n", + " fill=False, vmin=-0.5, vmax=0.5, lon=0., cmap=cmocean.cm.balance)\n", + "print(analysisutils.training_subdomains_latex(bboxes))\n" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "plt.savefig('figure1b.jpg', dpi=250)" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { @@ -360,7 +378,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.4" + "version": "3.11.3" } }, "nbformat": 4, diff --git a/src/gz21_ocean_momentum/analysis/latex_table.txt b/src/gz21_ocean_momentum/analysis/latex_table.txt deleted file mode 100644 index 032785d0..00000000 --- a/src/gz21_ocean_momentum/analysis/latex_table.txt +++ /dev/null @@ -1,8 +0,0 @@ -\begin{table}[] -\begin{tabular}{lcccc} -subdomain & latitude range & longitude range & training & validation \\ - -{} & {} & {} & X & X \\ - -\end{tabular} -\end{table} diff --git a/src/gz21_ocean_momentum/analysis/utils.py b/src/gz21_ocean_momentum/analysis/utils.py index b6bd7c23..47cfcb10 100755 --- a/src/gz21_ocean_momentum/analysis/utils.py +++ b/src/gz21_ocean_momentum/analysis/utils.py @@ -4,6 +4,11 @@ @author: Arthur """ + +from gz21_ocean_momentum.analysis.analysis import TimeSeriesForPoint +from gz21_ocean_momentum.data.pangeo_catalog import get_patch, get_whole_data +from gz21_ocean_momentum.common.bounding_box import BoundingBox + import numpy as np import mlflow from mlflow.tracking import client @@ -12,22 +17,17 @@ import matplotlib.animation as animation from matplotlib.patches import Rectangle import pandas as pd -from gz21_ocean_momentum.analysis.analysis import TimeSeriesForPoint import xarray as xr from typing import Optional from scipy.ndimage import gaussian_filter -from gz21_ocean_momentum.data.pangeo_catalog import get_patch, get_whole_data from cartopy.crs import PlateCarree import yaml -from gz21_ocean_momentum.data.utils import load_training_datasets - from enum import Enum CATALOG_URL = "https://raw.githubusercontent.com/pangeo-data/pangeo-datastore\ /master/intake-catalogs/master.yaml" - def correlation_map(truth: np.ndarray, pred: np.ndarray): """ Return the correlation map. @@ -588,9 +588,9 @@ def apply_complete_mask(array, pred, uv_plotter): array = array.sel(latitude=slice(pred["latitude"][0], pred["latitude"][-1])) return array - def plot_training_subdomains( global_plotter: GlobalPlotter, + bboxes: list[BoundingBox], alpha=0.5, bg_variable=None, facecolor="blue", @@ -601,10 +601,7 @@ def plot_training_subdomains( **plot_kwd_args, ): """ - Plots the training subdomains used for a training run. Retrieves - those subdomains from the training_subdomains.yaml file. Additionally, provide the - latex code of a table with the latitudes and longitudes of each - subdomain. + Plots the training subdomains used for a training run. Parameters ---------- @@ -619,58 +616,56 @@ def plot_training_subdomains( Returns ------- - None. + Plotted map. """ - # retrieve the latex code for the table from file - with open("analysis/latex_table.txt") as f: - lines = f.readlines() - latex_start = "".join(lines[:3]) - latex_line = lines[4] - latex_end = "".join(lines[6:]) - latex_lines = [] - subdomain_names = "ABCDE" # Plot the map ax = global_plotter.plot(bg_variable, *plot_args, **plot_kwd_args) - # Recover the coordinates of the rectangular subdomain - with open("../../training_subdomains.yaml", encoding="utf-8") as config_file: - subdomains = yaml.full_load(config_file) - for i in range(len(subdomains)): - lat_min = dict(subdomains[i][1])["lat_min"] - lat_max = dict(subdomains[i][1])["lat_max"] - lon_min = dict(subdomains[i][1])["lon_min"] - lon_max = dict(subdomains[i][1])["lon_max"] - - lat_min, lat_max = float(lat_min), float(lat_max) - lon_min, lon_max = float(lon_min), float(lon_max) - x, y = lon_min, lat_min - width, height = lon_max - lon_min, lat_max - lat_min - ax.add_patch( - Rectangle( - (x, y), - width, - height, - facecolor=facecolor, - edgecolor=edgecolor, - linewidth=linewidth, - fill=fill, - alpha=alpha, - ) - ) - # Add the table line - lat_range = str(lat_min) + "\\degree, " + str(lat_max) + "\\degree" - lon_range = str(lon_min) + "\\degree, " + str(lon_max) + "\\degree" - latex_lines.append( - latex_line.format(subdomain_names[i], lat_range, lon_range) + for bbox in bboxes: + ax.add_patch( + Rectangle( + (bbox.long_min, bbox.lat_min), + bbox.long_max - bbox.long_min, + bbox.lat_max - bbox.lat_min, + facecolor=facecolor, + edgecolor=edgecolor, + linewidth=linewidth, + fill=fill, + alpha=alpha, ) + ) - latex_lines = "".join(latex_lines) - latex = "".join((latex_start, latex_lines, latex_end)) - print(latex) plt.show() return ax +def training_subdomains_latex(bboxes: list[BoundingBox]) -> str: + """ + Return the LaTeX code of a table with the latitudes and longitudes of each + subdomain. + + Potentially useful in visualizations, Jupyter notebooks. + """ + text_lines = [] + for i in range(len(bboxes)): + bbox = bboxes[i] + text_bbox_range_lat = f"{bbox.lat_min}\\degree, {bbox.lat_max}\\degree" + text_bbox_range_long = f"{bbox.long_min}\\degree, {bbox.long_max}\\degree" + text_line = f"{i} & {text_bbox_range_lat} & {text_bbox_range_long} & X & X \\\\\n" + text_lines.append(text_line) + + text_pre = """ +\\begin{table}[] +\\begin{tabular}{lcccc} +subdomain & latitude range & longitude range & training & validation \\\\ +""" + text_post = """ +\\end{tabular} +\\end{table} +""" + + text = text_pre + "".join(text_lines) + text_post + return text def anomalies(dataset: xr.Dataset, dim: str = "time.month"): """Returns a dataset of the anomalies.""" From c0e2727c73e0ea4fe07bd12fbf47ff140a4f63af Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 18 Oct 2023 14:17:13 +0100 Subject: [PATCH 025/114] cli-configs: add figure configs From examples/jupyter-notebooks/README.md . --- examples/cli-configs/README.md | 4 ++++ examples/cli-configs/data-paper-fig-1.yaml | 11 +++++++++++ examples/cli-configs/data-paper-fig-6-ctrl.yaml | 12 ++++++++++++ examples/cli-configs/data-paper.yaml | 1 + 4 files changed, 28 insertions(+) create mode 100644 examples/cli-configs/README.md create mode 100644 examples/cli-configs/data-paper-fig-1.yaml create mode 100644 examples/cli-configs/data-paper-fig-6-ctrl.yaml diff --git a/examples/cli-configs/README.md b/examples/cli-configs/README.md new file mode 100644 index 00000000..0cc5502b --- /dev/null +++ b/examples/cli-configs/README.md @@ -0,0 +1,4 @@ +# Example run configurations +## General tips +* If the data step (forcing generation) is taking too long, lower `ntimes`. On a + consumer machine, for testing, 100 is good enough. (4000 will take ages.) diff --git a/examples/cli-configs/data-paper-fig-1.yaml b/examples/cli-configs/data-paper-fig-1.yaml new file mode 100644 index 00000000..09f77afb --- /dev/null +++ b/examples/cli-configs/data-paper-fig-1.yaml @@ -0,0 +1,11 @@ +# Forcing generation configuration for Figure 1 in the 2021 paper. + +lat-min: -80 +lat-max: 80 +long-min: -280 +long-max: 80 + +ntimes: 4000 +factor: 4 + +co2-increase: true diff --git a/examples/cli-configs/data-paper-fig-6-ctrl.yaml b/examples/cli-configs/data-paper-fig-6-ctrl.yaml new file mode 100644 index 00000000..f16ecea7 --- /dev/null +++ b/examples/cli-configs/data-paper-fig-6-ctrl.yaml @@ -0,0 +1,12 @@ +# Control forcing generation configuration for Figure 6 in the 2021 paper. +# Same as Figure 1, but using control dataset (no annual 1% CO2 increase). + +lat-min: -80 +lat-max: 80 +long-min: -280 +long-max: 80 + +ntimes: 4000 +factor: 4 + +co2-increase: false diff --git a/examples/cli-configs/data-paper.yaml b/examples/cli-configs/data-paper.yaml index 79d14b83..b9dc56c6 100644 --- a/examples/cli-configs/data-paper.yaml +++ b/examples/cli-configs/data-paper.yaml @@ -1,4 +1,5 @@ # Approximates data step configuration used in the 2021 paper. +# Also generates forcing data used to generate Figure 1 (via Jupyter notebook). lat-min: -80 lat-max: 80 From 1e617634ab5c3a6499725bd13db7bad5cc9c2319 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 18 Oct 2023 15:16:06 +0100 Subject: [PATCH 026/114] fix figure notebook configs --- examples/cli-configs/data-paper-fig-1.yaml | 2 +- .../cli-configs/data-paper-fig-6-1pct.yaml | 12 +++++++++ .../cli-configs/data-paper-fig-6-ctrl.yaml | 12 --------- examples/cli-configs/data-paper.yaml | 1 - examples/jupyter-notebooks/README.md | 25 +++++++++---------- 5 files changed, 25 insertions(+), 27 deletions(-) create mode 100644 examples/cli-configs/data-paper-fig-6-1pct.yaml delete mode 100644 examples/cli-configs/data-paper-fig-6-ctrl.yaml diff --git a/examples/cli-configs/data-paper-fig-1.yaml b/examples/cli-configs/data-paper-fig-1.yaml index 09f77afb..1eb32392 100644 --- a/examples/cli-configs/data-paper-fig-1.yaml +++ b/examples/cli-configs/data-paper-fig-1.yaml @@ -8,4 +8,4 @@ long-max: 80 ntimes: 4000 factor: 4 -co2-increase: true +co2-increase: false diff --git a/examples/cli-configs/data-paper-fig-6-1pct.yaml b/examples/cli-configs/data-paper-fig-6-1pct.yaml new file mode 100644 index 00000000..e6b9d8a5 --- /dev/null +++ b/examples/cli-configs/data-paper-fig-6-1pct.yaml @@ -0,0 +1,12 @@ +# One forcing generation configuration for Figure 6 in the 2021 paper. +# Same as Figure 1, but using 1% annual CO2 increase dataset. + +lat-min: -80 +lat-max: 80 +long-min: -280 +long-max: 80 + +ntimes: 4000 +factor: 4 + +co2-increase: true diff --git a/examples/cli-configs/data-paper-fig-6-ctrl.yaml b/examples/cli-configs/data-paper-fig-6-ctrl.yaml deleted file mode 100644 index f16ecea7..00000000 --- a/examples/cli-configs/data-paper-fig-6-ctrl.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# Control forcing generation configuration for Figure 6 in the 2021 paper. -# Same as Figure 1, but using control dataset (no annual 1% CO2 increase). - -lat-min: -80 -lat-max: 80 -long-min: -280 -long-max: 80 - -ntimes: 4000 -factor: 4 - -co2-increase: false diff --git a/examples/cli-configs/data-paper.yaml b/examples/cli-configs/data-paper.yaml index b9dc56c6..79d14b83 100644 --- a/examples/cli-configs/data-paper.yaml +++ b/examples/cli-configs/data-paper.yaml @@ -1,5 +1,4 @@ # Approximates data step configuration used in the 2021 paper. -# Also generates forcing data used to generate Figure 1 (via Jupyter notebook). lat-min: -80 lat-max: 80 diff --git a/examples/jupyter-notebooks/README.md b/examples/jupyter-notebooks/README.md index bd8d5678..80ceb61a 100644 --- a/examples/jupyter-notebooks/README.md +++ b/examples/jupyter-notebooks/README.md @@ -8,23 +8,22 @@ forcing][gz21-paper-agupubs]. The exact version of the code used to produce said paper can be found on [Zenodo][gz21-paper-code-zenodo]. ## 2021 paper figures -There are several notebooks which were used to generate the figures in the 2021 paper. +There are several notebooks which were used to generate the figures in the 2021 +paper. -The data for `generate-paper-figure-1.ipynb` can be generated by running +`generate-paper-figure-1.ipynb` generates figure 1b. The forcings it uses can be +generated by running the data step with the following configuration: ``` -mlflow run . --experiment-name --env-manager=local \ --P lat_min=-80 -P lat_max=80 -P long_min=-280 -P long_max=80 \ --P factor=4 \ --P CO2=1 -P global=0 \ --P ntimes=4000 \ --P chunk_size=1 +python src/gz21_ocean_momentum/cli/data.py \ +--config-file examples/cli-configs/data-paper-fig-1.yaml \ +--out-dir tmp/generated/forcings/paper-fig-1 ``` -. The notebook generates figure 1b. -For `generate-paper-figure-6.ipynb`, which generates figure 6b, -the same call has to be run with again with `CO2=1`. -The notebook is then asking for the data set with `CO2=0` first and the one with `CO2=1` second. +`generate-paper-figure-6.ipynb`, which generates figure 6b, requires the above +forcing data, plus another set of forcings generated using the 1% annual CO2 +increase CM2.6 dataset. Use `--config-file +examples/cli-configs/data-paper-fig-6-1pct.yaml`. `test-global-control.ipynb` generates figures 4, 5 and 7, as well as D4 and D5. For this, the inference step with the trained neural network has to be run both on the data with `CO2=0` and with `CO2=1`, and then the notebook needs to @@ -33,4 +32,4 @@ with pre-industrial CO2 levels), and the figures referring to _1pctCO2_ are thos year in CO2 levels for the first 70 years, after which they remain constant). The notebook needs to be handed the experiment and run ID of the inference run, which is linked to the data and training runs through `params.data_run_id` (run ID of data run) and `params.model_run_id` (run ID of training run), -respectively. \ No newline at end of file +respectively. From 0d9e5e8cf92d4d2e121b5ee905df410ec893a702 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 18 Oct 2023 14:17:44 +0100 Subject: [PATCH 027/114] rework figure 6 generator notebook --- .../generate-paper-figure-6.ipynb | 556 +++++++++++++++--- 1 file changed, 484 insertions(+), 72 deletions(-) diff --git a/examples/jupyter-notebooks/generate-paper-figure-6.ipynb b/examples/jupyter-notebooks/generate-paper-figure-6.ipynb index c094c212..bdbf33e6 100644 --- a/examples/jupyter-notebooks/generate-paper-figure-6.ipynb +++ b/examples/jupyter-notebooks/generate-paper-figure-6.ipynb @@ -9,86 +9,83 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "To load the net from the paper, use the function load_paper_net().\n" + ] + } + ], "source": [ - "%cd ../../src/gz21_ocean_momentum\n", - "import os\n", - "from utils import select_experiment, select_run\n", - "from analysis.utils import plot_dataset, GlobalPlotter\n", - "import mlflow\n", - "from mlflow.tracking import MlflowClient\n", + "from gz21_ocean_momentum.analysis.utils import plot_dataset, GlobalPlotter\n", "import xarray as xr\n", "from dask.diagnostics import ProgressBar\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", - "from numpy.linalg import norm\n", - "\n", - "import cmocean\n", - "cmap_solar = cmocean.cm.solar\n", - "cmap_balance = cmocean.cm.balance\n", - "\n", - "mlruns_path=os.path.join(os.getcwd(), '../../mlruns')\n", - "%env MLFLOW_TRACKING_URI $mlruns_path\n", - "\n", - "plt.rcParams[\"figure.figsize\"] = (4, 4 / 1.618)" + "import cmocean" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Script parameters" + "## Steps\n", + "### Locate forcing data\n", + "* `forcings_ctrl_path` should point to forcing data generated using the control CM2.6 dataset.\n", + "* `forcings_1pct_path` should point to forcing data generated using the annual 1% CO2 increase CM2.6 dataset.\n", + "\n", + "See the Jupyter notebook README and the example CLI configs for help selecting/generating these." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ - "run_control_id = None\n", - "run_1pct_id = None\n", - "var_name = 'vsurf'\n", - "cmap = cmocean.cm.amp\n" + "forcings_ctrl_path = \"~/sh/gz21/tmp/generated/forcings/paper-fig-1-ctrl-n100\"\n", + "forcings_1pct_path = \"~/sh/gz21/tmp/generated/forcings/paper-n100\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Various parameters" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ - "def select_run_id():\n", - " exp_id, exp_name = select_experiment()\n", - " #experiment_id = mlflow.get_experiment_by_name(exp_name).experiment_id\n", - " cols = ['params.CO2', 'params.factor']\n", - " run = select_run(cols=cols, experiment_ids=(exp_id,))\n", - " return run.run_id" + "plt.rcParams[\"figure.figsize\"] = (4, 4 / 1.618)\n", + "var_name = 'vsurf'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### The rest" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "metadata": {}, "outputs": [], "source": [ - "ml_client = MlflowClient()\n", - "if not run_control_id:\n", - " print(\"Please select run with CO2=0.\")\n", - " run_control_id = select_run_id()\n", - "if not run_1pct_id:\n", - " print(\"Please select run with CO2=1.\")\n", - " run_1pct_id = select_run_id()\n", - "\n", - "run_control = mlflow.get_run(run_control_id)\n", - "run_1pct = mlflow.get_run(run_1pct_id)\n", + "data_control = xr.open_zarr(forcings_ctrl_path)\n", + "data_1pct = xr.open_zarr(forcings_1pct_path)\n", "\n", - "data_control = xr.open_zarr(ml_client.download_artifacts(run_control_id, 'forcing'))\n", - "data_1pct = xr.open_zarr(ml_client.download_artifacts(run_1pct_id, 'forcing'))\n", "data_control = data_control.rename(dict(xu_ocean='longitude', yu_ocean='latitude'))\n", - "data_1pct = data_1pct.rename(dict(xu_ocean='longitude', yu_ocean='latitude'))\n", + "data_1pct = data_1pct.rename(dict(xu_ocean='longitude', yu_ocean='latitude'))\n", "\n", "# Rescale the forcing\n", "for var in ('S_x', 'S_y'):\n", @@ -105,9 +102,33 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[########################################] | 100% Completed | 3.64 sms\n", + "[ ] | 0% Completed | 458.18 us" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/raehik/proj/work/2020-ukc-camfort-iccs/iccs/lib/gz21/venv/lib/python3.11/site-packages/dask/array/numpy_compat.py:43: RuntimeWarning: invalid value encountered in divide\n", + " x = np.divide(x1, x2, out)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[########################################] | 100% Completed | 3.73 sms\n" + ] + } + ], "source": [ "var_control = data_control[var_name].std(dim='time')\n", "var_1pct = data_1pct[var_name].std(dim='time')\n", @@ -118,7 +139,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "metadata": {}, "outputs": [], "source": [ @@ -128,32 +149,49 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "30923a7f991443e8881f206858ad8889", + "version_major": 2, + "version_minor": 0 + }, + "image/png": "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", + "text/html": [ + "\n", + "
\n", + "
\n", + " Figure\n", + "
\n", + " \n", + "
\n", + " " + ], + "text/plain": [ + "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "#%matplotlib notebook #this option does not work with jupyterlab\n", "%matplotlib widget\n", - "from cartopy.crs import PlateCarree\n", - "from data.pangeo_catalog import get_patch, get_whole_data\n", - "from scipy.ndimage import gaussian_filter\n", - "from matplotlib import colors\n", - "\n", - "\n", - "CATALOG_URL = 'https://raw.githubusercontent.com/pangeo-data/pangeo-datastore\\\n", - "/master/intake-catalogs/master.yaml'\n", - "\n", "\n", "plotter = GlobalPlotter(cbar=True, margin=4)\n", "plotter.x_ticks = np.arange(-150., 151., 50)\n", "plotter.y_ticks = np.arange(-80., 81., 20)\n", "\n", - "ax=plotter.plot(diff, vmin=-0.05, vmax=0.05, cmap=cmocean.cm.delta, lon=0., colorbar_label='m/s')\n" + "ax=plotter.plot(diff, vmin=-0.05, vmax=0.05, cmap=cmocean.cm.delta, lon=0., colorbar_label='m/s')" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "metadata": {}, "outputs": [], "source": [ @@ -162,9 +200,390 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 9, + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.DataArray 'vsurf' ()>\n",
+       "array(0.09132938)
" + ], + "text/plain": [ + "\n", + "array(0.09132938)" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "uv_plotter = plotter\n", "def apply_complete_mask(array):\n", @@ -177,13 +596,6 @@ " return array\n", "apply_complete_mask(r_diff).sel(latitude=slice(-60, 60)).mean().compute()" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { @@ -202,7 +614,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.4" + "version": "3.11.3" } }, "nbformat": 4, From 23e3a76c260673322c076f8cbab115e5fbf7c8b5 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 26 Oct 2023 16:29:56 +0100 Subject: [PATCH 028/114] Jupyter notebooks/global ctrl: begin rework --- .../test_global_control.ipynb | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/examples/jupyter-notebooks/test_global_control.ipynb b/examples/jupyter-notebooks/test_global_control.ipynb index ff60f675..4714e9a0 100644 --- a/examples/jupyter-notebooks/test_global_control.ipynb +++ b/examples/jupyter-notebooks/test_global_control.ipynb @@ -21,17 +21,11 @@ "outputs": [], "source": [ "#%matplotlib notebook #This option does not work in Jupyterlab\n", - "%matplotlib widget \n", - "\n", - "%cd ../../src/gz21_ocean_momentum\n", - "\n", - "import os\n", - "mlruns_path=os.path.join(os.getcwd(), '../../mlruns/')\n", - "%env MLFLOW_TRACKING_URI $mlruns_path\n", + "%matplotlib widget\n", "\n", "# See https://github.com/m2lines/gz21_ocean_momentum/blob/main/docs/data.md for an explanation \n", "# The environment variable does not need setting if you place the credentials file at ~/.config/gcloud/application_default_credentials.json .\n", - "%env GOOGLE_APPLICATION_CREDENTIALS /home/marion/.config/gcloud/application_default_credentials.json" + "# %env GOOGLE_APPLICATION_CREDENTIALS /home/marion/.config/gcloud/application_default_credentials.json" ] }, { @@ -40,7 +34,7 @@ "metadata": {}, "outputs": [], "source": [ - "import mlflow\n", + ".import mlflow\n", "from mlflow.tracking import client\n", "import xarray as xr\n", "import numpy as np\n", @@ -57,11 +51,11 @@ "\n", "import cartopy.crs as ccrs\n", "import cmocean\n", - "cmap = cmocean.cm.balance\n", - "cmap_balance = cmocean.cm.balance\n", - "cmap_balance_r=cmocean.cm.balance_r\n", - "cmap_amp = cmocean.cm.amp\n", - "cmap_amp_r = cmocean.cm.amp_r\n", + "#cmap = cmocean.cm.balance\n", + "#cmap_balance = cmocean.cm.balance\n", + "#cmap_balance_r=cmocean.cm.balance_r\n", + "#cmap_amp = cmocean.cm.amp\n", + "#cmap_amp_r = cmocean.cm.amp_r\n", "\n", "plt.rcParams[\"figure.figsize\"] = (4, 4 / 1.618)\n", "\n", @@ -69,9 +63,6 @@ "#uv_plotter.y_ticks = np.arange(-80., 81., 20)\n", "#uv_plotter.margin = 10\n", "\n", - "client_ = client.MlflowClient()\n", - "\n", - "from importlib import reload \n", "import gz21_ocean_momentum.analysis as analysis\n", "GlobalPlotter = analysis.utils.GlobalPlotter\n", "uv_plotter = GlobalPlotter() \n", @@ -1280,7 +1271,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.4" + "version": "3.11.3" } }, "nbformat": 4, From 616e4f23fccd33c104130d07309403888e8515ee Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 26 Oct 2023 16:30:13 +0100 Subject: [PATCH 029/114] begin new inference CLI script --- src/gz21_ocean_momentum/cli/inference.py | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/gz21_ocean_momentum/cli/inference.py diff --git a/src/gz21_ocean_momentum/cli/inference.py b/src/gz21_ocean_momentum/cli/inference.py new file mode 100644 index 00000000..ecd117a2 --- /dev/null +++ b/src/gz21_ocean_momentum/cli/inference.py @@ -0,0 +1,29 @@ +import configargparse + +import logging + +from gz21_ocean_momentum.models import submodels + +DESCRIPTION = "GZ21 inference step." + +p = configargparse.ArgParser(description=DESCRIPTION) +p.add("--config-file", is_config_file=True, help="config file path") +p.add("--model-state-dict-file", type=str, required=True, help="model state dict file (*.pth)") +p.add("--forcing-data-dir", type=str, required=True, help="directory containing zarr-format forcing data") + +p.add("--train-split", required=True) +p.add("--test-split", required=True) +p.add("--test-split", required=True) + +options = p.parse_args() + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +xr_dataset = xr.open_zarr(options.forcing_data_dir) + +# TODO hardcode submodel, transformation +# unlikely for a CLI we need to provide dynamic code loading +# we can enable this in the "library" interface! +submodel = gz21_ocean_momentum.models.submodels +transformation = gz21_ocean_momentum.models.transforms.SoftPlusTransform From d7787116cf78a57a56dbb4334ccda3d8980d4dbd Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 1 Nov 2023 16:19:34 +0000 Subject: [PATCH 030/114] pyproject: clean up dependencies --- pyproject.toml | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bea6c283..f3e12da1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,27 +17,24 @@ classifiers = [ ] dependencies = [ - # general, likely used all over - "scipy", "xarray", + "torch>=1.13.1", "dask", "mlflow-skinny", + "scipy", # data analysis, graphing + "matplotlib>=3.7", # analysis, examples + "cartopy>=0.21", # analysis (plotting data) + # data download "intake", + "intake-xarray", + "gcsfs", # required for downloading CM2.6 via intake (dataset stored on GCP) "requests", "aiohttp", - "intake-xarray", - "gcsfs", - - "matplotlib>=3.7", # analysis, examples - "cartopy>=0.21", # analysis (plotting data) - - "torch>=1.13.1", - "progressbar2>=4.2.0" # inference/utils (but imported by trainScript) - # new CLI - "configargparse>=1.7", + "progressbar2>=4.2.0", # inference/utils (but imported by trainScript) + "configargparse>=1.7", # CLI ] authors = [ From f8c3fd7c5c3cb1d08ad8905b05b61fecad0ed68f Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 1 Nov 2023 16:20:18 +0000 Subject: [PATCH 031/114] various inference step refactoring --- src/gz21_ocean_momentum/cli/data.py | 5 +- src/gz21_ocean_momentum/cli/inference.py | 106 ++++++++++++++++++++-- src/gz21_ocean_momentum/common/cli.py | 16 ++++ src/gz21_ocean_momentum/inference/main.py | 2 - 4 files changed, 117 insertions(+), 12 deletions(-) diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py index 77709c76..380a6c12 100644 --- a/src/gz21_ocean_momentum/cli/data.py +++ b/src/gz21_ocean_momentum/cli/data.py @@ -41,9 +41,8 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -if not cli.path_is_nonexist_or_empty_dir(options.out_dir): - cli.fail(1, "--out-dir output directory is invalid", - "if the directory exists, ensure it is empty") +cli.fail_if_path_is_nonempty_dir( + 1, f"--out-dir \"{options.out_dir}\" invalid", options.out_dir) # store bounding box in a struct-like bbox = BoundingBox( diff --git a/src/gz21_ocean_momentum/cli/inference.py b/src/gz21_ocean_momentum/cli/inference.py index ecd117a2..e3ac90cc 100644 --- a/src/gz21_ocean_momentum/cli/inference.py +++ b/src/gz21_ocean_momentum/cli/inference.py @@ -2,7 +2,18 @@ import logging -from gz21_ocean_momentum.models import submodels +from gz21_ocean_momentum.train.base import Trainer + +# TODO hardcode submodel, transformation +# unlikely for a CLI we need to provide dynamic code loading -- let's just give +# options +# we could enable such "dynamic loading" in the "library" interface!-- but, due +# to the class-based setup, it's a little complicated for a user to come in with +# their own code for some of these, and it needs documentation. so a task for +# later +import gz21_ocean_momentum.models.models1.FullyCNN as model_cls +import gz21_ocean_momentum.models.submodels.transform3 as submodel +import gz21_ocean_momentum.models.transforms.SoftPlusTransform as transformation DESCRIPTION = "GZ21 inference step." @@ -10,20 +21,101 @@ p.add("--config-file", is_config_file=True, help="config file path") p.add("--model-state-dict-file", type=str, required=True, help="model state dict file (*.pth)") p.add("--forcing-data-dir", type=str, required=True, help="directory containing zarr-format forcing data") +p.add("--device", type=str, default="cuda", help="neural net device (e.g. cuda, cuda:0, cpu)") +p.add("--out-dir", type=str, required=True, help="folder to save output dataset to") p.add("--train-split", required=True) p.add("--test-split", required=True) -p.add("--test-split", required=True) +p.add("--batch_size", required=True) options = p.parse_args() logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +cli.fail_if_path_is_nonempty_dir( + 1, f"--out-dir \"{options.out_dir}\" invalid", options.out_dir) + xr_dataset = xr.open_zarr(options.forcing_data_dir) -# TODO hardcode submodel, transformation -# unlikely for a CLI we need to provide dynamic code loading -# we can enable this in the "library" interface! -submodel = gz21_ocean_momentum.models.submodels -transformation = gz21_ocean_momentum.models.transforms.SoftPlusTransform +# convert forcings xarray to PyTorch dataset +# TODO identical loading code in train step (originally trainScript.py) +dataset = RawDataFromXrDataset(xr_dataset) +dataset.index = "time" +dataset.add_input("usurf") +dataset.add_input("vsurf") +dataset.add_output("S_x") +dataset.add_output("S_y") + +# Load some extra parameters of the model. +# TODO allow general time_indices +time_indices = [ + 0, +] +train_split = float(model_run["params.train_split"]) +test_split = float(model_run["params.test_split"]) +batch_size = batch_size if batch_size else int(model_run["params.batchsize"]) +source_data_id = model_run["params.source.run-id"] +loss_cls_name = model_run["params.loss_cls_name"] +learning_rates = learning_rates_from_string(model_run["params.learning_rate"]) +submodel_name = model_run["params.submodel"] + +# Set up training criterion and select parameters to train +try: + n_targets = dataset.n_targets + criterion = getattr(losses, loss_cls_name)(n_targets) +except AttributeError as e: + raise type(e)("Could not find the loss class used for training, ", loss_cls_name) + +# load, prepare pre-trained neural net +net = model_cls(dataset.n_features, criterion.n_required_channels) +net.load_state_dict(torch.load(options.model_state_dict_file)) +print(net) +#net.cpu() # TODO why needed? +dataset.add_transforms_from_model(net) + +print("Size of training data: {}".format(len(train_dataset))) +print("Size of validation data : {}".format(len(test_dataset))) +print("Input height: {}".format(train_dataset.height)) +print("Input width: {}".format(train_dataset.width)) +print(train_dataset[0][0].shape) +print(train_dataset[0][1].shape) +print("Features transform: ", transform.transforms["features"].transforms) +print("Targets transform: ", transform.transforms["targets"].transforms) + +# Net to GPU +with TaskInfo("Put neural network on device"): + net.to(options.device) + +print("width: {}, height: {}".format(dataset.width, dataset.height)) + +# Training itself +if n_epochs > 0: + with TaskInfo("Training"): + trainer = Trainer(net, options.device) + trainer.criterion = criterion + # Register metrics + for metric_name, metric in metrics.items(): + trainer.register_metric(metric_name, metric) + parameters = net.parameters() + optimizer = torch.optim.Adam(parameters, lr=learning_rate) + for i_epoch in range(n_epochs): + train_loss = trainer.train_for_one_epoch(train_dataloader, optimizer) + test_loss, metrics_results = trainer.test(test_dataloader) + print("Epoch {}".format(i_epoch)) + print("Train loss for this epoch is {}".format(train_loss)) + print("Test loss for this epoch is {}".format(test_loss)) + + with TaskInfo("Validation"): + train_loss, train_metrics_results = trainer.test(train_dataloader) + print(f"Final train loss is {train_loss}") + +# Test +with ProgressBar(), TaskInfo("Create output dataset"): + out = create_large_test_dataset(net, criterion, partition, loaders, options.device) + ProgressBar().register() + print("Start of actual computations...") + out = out.chunk(dict(time=32)) + out.to_zarr(file_path) + mlflow.log_artifact(file_path) + print(f"Size of output data is {out.nbytes/1e9} GB") diff --git a/src/gz21_ocean_momentum/common/cli.py b/src/gz21_ocean_momentum/common/cli.py index b16f0efa..761317ce 100644 --- a/src/gz21_ocean_momentum/common/cli.py +++ b/src/gz21_ocean_momentum/common/cli.py @@ -23,6 +23,22 @@ def path_is_nonexist_or_empty_dir(path) -> bool: # path does not exist: all good return True +def fail_if_path_is_nonempty_dir(err_code: int, msg_pre: str, path): + msg = f"{msg_pre}: path should not exist, or be empty directory" + if os.path.exists(path): + # path exists + if os.path.isdir(path): + # path is directory: check contents + with os.scandir(path) as it: + if any(it): + # path is non-empty directory: fail + fail(err_code, msg, "path is non-empty directory") + # else path is empty directory: all good, do nothing + else: + # path is non-directory: fail + fail(err_code, msg, "path is not a directory") + # else path does not exist: all good, do nothing + def fail(err_code: int, msg: str, hint: Optional[str] = None): """Exit the program with the given message and error code. diff --git a/src/gz21_ocean_momentum/inference/main.py b/src/gz21_ocean_momentum/inference/main.py index 25e37b5a..7c26d5ee 100755 --- a/src/gz21_ocean_momentum/inference/main.py +++ b/src/gz21_ocean_momentum/inference/main.py @@ -113,12 +113,10 @@ train_split = float(model_run["params.train_split"]) test_split = float(model_run["params.test_split"]) batch_size = batch_size if batch_size else int(model_run["params.batchsize"]) -source_data_id = model_run["params.source.run-id"] model_module_name = model_run["params.model_module_name"] model_cls_name = model_run["params.model_cls_name"] loss_cls_name = model_run["params.loss_cls_name"] learning_rates = learning_rates_from_string(model_run["params.learning_rate"]) -weight_decay = float(model_run["params.weight_decay"]) submodel_name = model_run["params.submodel"] learning_rate = learning_rates[0] * lr_ratio From 3fc003edd1bf8a6e91d17a9b7b429a0d3f194954 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 2 Nov 2023 16:12:45 +0000 Subject: [PATCH 032/114] CI/linting: install required Python type stub libs --- .github/workflows/linting.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml index aeb7c0a1..a745d8ce 100644 --- a/.github/workflows/linting.yaml +++ b/.github/workflows/linting.yaml @@ -30,6 +30,9 @@ jobs: - run: pip install mypy + - name: Install relevant Python type stub libraries + run: pip install types-requests + - name: mypy run: mypy --strict . From e876ec766a70b40a47c8e01ca9ee28120c19167a Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 2 Nov 2023 16:17:24 +0000 Subject: [PATCH 033/114] analysis/multiscale: fix imports --- src/gz21_ocean_momentum/analysis/multiscale_analysis.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/gz21_ocean_momentum/analysis/multiscale_analysis.py b/src/gz21_ocean_momentum/analysis/multiscale_analysis.py index 481d0b17..23b79db0 100644 --- a/src/gz21_ocean_momentum/analysis/multiscale_analysis.py +++ b/src/gz21_ocean_momentum/analysis/multiscale_analysis.py @@ -7,8 +7,9 @@ to the script inference/multiscale.py. """ -from analysis.loadmlflow import LoadMLFlow -from analysis.utils import select_run, view_predictions, DisplayMode +from gz21_ocean_momentum.analysis.loadmlflow import LoadMLFlow +from gz21_ocean_momentum.utils import select_run +from gz21_ocean_momentum.analysis.utils import view_predictions, DisplayMode import mlflow # We'll run this locally From 7a241713ad2e0f51630eb5d15d95a5a1abb78e39 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 2 Nov 2023 16:18:17 +0000 Subject: [PATCH 034/114] analysis/analysis: move global state to save_fig() Apparently unused, however. --- src/gz21_ocean_momentum/analysis/analysis.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/gz21_ocean_momentum/analysis/analysis.py b/src/gz21_ocean_momentum/analysis/analysis.py index 6b18dfa4..deab15a5 100644 --- a/src/gz21_ocean_momentum/analysis/analysis.py +++ b/src/gz21_ocean_momentum/analysis/analysis.py @@ -12,13 +12,9 @@ only one figure. """ import numpy as np -import matplotlib.pyplot as plt +import matplotlib.pyplot as plt # type: ignore from os.path import join -data_location = "/data/ag7531/" -figures_directory = "figures" - - def allow_hold_on(f): """Decorator that allows to specify a hold_on parameter that makes the plotting use the current figure instead of creating a new one.""" @@ -84,7 +80,7 @@ def plot_pred_vs_true(self): plt.title("Prediction errors for point {}, {}".format(*self.point)) plt.show() - def save_fig(self): + def save_fig(self, figures_directory: str, data_location: str): if not self._fig: self.plot_pred_vs_true() plt.savefig(join(data_location, figures_directory, self.name)) From 231156cb299585ff4c05d090fd444d5dcee9c460 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 2 Nov 2023 16:19:02 +0000 Subject: [PATCH 035/114] add some function type annotations --- .../analysis/multiscale_analysis.py | 2 +- src/gz21_ocean_momentum/train/utils.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/gz21_ocean_momentum/analysis/multiscale_analysis.py b/src/gz21_ocean_momentum/analysis/multiscale_analysis.py index 23b79db0..4db1ffeb 100644 --- a/src/gz21_ocean_momentum/analysis/multiscale_analysis.py +++ b/src/gz21_ocean_momentum/analysis/multiscale_analysis.py @@ -10,7 +10,7 @@ from gz21_ocean_momentum.analysis.loadmlflow import LoadMLFlow from gz21_ocean_momentum.utils import select_run from gz21_ocean_momentum.analysis.utils import view_predictions, DisplayMode -import mlflow +import mlflow # type: ignore # We'll run this locally mlflow.set_tracking_uri("file:///d:\\Data sets\\NYU\\mlruns") diff --git a/src/gz21_ocean_momentum/train/utils.py b/src/gz21_ocean_momentum/train/utils.py index 70f5cece..54002edd 100755 --- a/src/gz21_ocean_momentum/train/utils.py +++ b/src/gz21_ocean_momentum/train/utils.py @@ -41,15 +41,15 @@ def print_every(to_print: str, every: int, n_iter: int) -> bool: class RunningAverage: """Class for online computing of a running average""" - def __init__(self): + def __init__(self) -> None: self.n_items = 0 self.average = 0.0 @property - def value(self): + def value(self) -> float: return self.average - def update(self, value: float, weight: float = 1) -> float: + def update(self, value: float, weight: int = 1) -> float: """Adds some value to be used in the running average. Parameters @@ -78,16 +78,16 @@ def update(self, value: float, weight: float = 1) -> float: self.average = temp / self.n_items return self.average - def reset(self): + def reset(self) -> None: """Resets the running average to zero as well as its number of items""" self.n_items = 0 self.average = 0.0 - def __str__(self): + def __str__(self) -> str: return str(self.average) -def learning_rates_from_string(rates_string: str) -> dict: +def learning_rates_from_string(rates_string: str) -> dict[int, float]: temp = rates_string.split("/") if len(temp) == 1: return {0: float(rates_string)} @@ -99,9 +99,9 @@ def learning_rates_from_string(rates_string: str) -> dict: return rates -def run_ids_from_string(run_ids_str: str) -> list: +def run_ids_from_string(run_ids_str: str) -> list[str]: return run_ids_str.split("/") -def list_from_string(string: str) -> list: +def list_from_string(string: str) -> list[str]: return string.split("/") From 4bd7dcd6eb27cad6781919f6aa85351ad456ecce Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 7 Nov 2023 13:57:51 +0000 Subject: [PATCH 036/114] readme: clean up intro --- README.md | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 4b8013a8..256d7eb7 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,33 @@ -# Stochastic-Deep Learning Parameterization of Ocean Momentum Forcing +# GZ21: stochastic deep learning parameterization of ocean momentum forcing [gz21-paper-code-zenodo]: https://zenodo.org/record/5076046#.ZF4ulezMLy8 [gz21-paper-agupubs]: https://agupubs.onlinelibrary.wiley.com/doi/10.1029/2021MS002534 -This repository provides a subgrid model of ocean momentum forcing, based on a -convolutional neural network (CNN) trained on high-resolution surface velocity -data from CM2.6. This model can then be coupled into larger GCMs, e.g., at -coarser granularity to provide high-fidelity parameterization of ocean momentum -forcing. The parameterization output by the CNN consists of a Gaussian -distribution specified by 2 parameters (mean and standard deviation), which -allows for stochastic implementations in online models. +This repository trains a convolutional neural network (CNN) to parameterize +subgrid ocean momentum forcing, intended for coupling into larger GCMs at to +provide a high-fidelity parameterization in coarser-grain models. -The model is based on the paper [Arthur P. Guillaumin, Laure Zanna (2021). -Stochastic-deep learning parameterization of ocean momentum -forcing][gz21-paper-agupubs]. The exact version of the code used to produce said -paper can be found on [Zenodo][gz21-paper-code-zenodo]. The present repository -provides a version of this model which is designed for others to reproduce, -replicate, and reuse. +High-resolution surface velocity data from the CM2.6 dataset is used to compute +forcings (present in coarser-grain models), then coarsened. These coarse surface +velocities are used to train a CNN to predict forcings. For every grid point, +rather than predicting a single value for the subgrid momentum forcing, the CNN +predicts both the mean and standard deviation of a Gaussian probability +distribution. This allows for stochastic implementations in online models. -_This repository is currently work-in-progress following a process of -refreshing the code and making it available for easy reuse by others._ +The paper +[Arthur P. Guillaumin, Laure Zanna (2021). Stochastic-deep learning +parameterization of ocean momentum forcing][gz21-paper-agupubs] discusses the +original model, and is a useful resource for further reading. +(The exact version of the code used to produce said paper can be found on +[Zenodo][gz21-paper-code-zenodo].) ## Architecture The model is written in Python, using PyTorch for the CNN. We provide 3 separate "steps", which are run using different commands and arguments: -* data processing: downloads part of CM2.6 dataset and processes -* model training: train model on processed data -* model testing: tests the trained model on an unseen region +* data processing: downloads subset of CM2.6 dataset, computes forcings +* model training: train model to predict forcing from (coarse) velocities +* model testing: test trained model on unseen region of data (the subset not + used in previous training step) For more details on each of the steps, see the [`docs`](docs/) directory. From 59694fe67bfe036497c2d4c03d7bc0d891aa0a0c Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 7 Nov 2023 15:20:14 +0000 Subject: [PATCH 037/114] src: various inference, CLI changes --- src/gz21_ocean_momentum/cli/data.py | 2 +- src/gz21_ocean_momentum/cli/inference-test.py | 115 ++++++++++++++++++ src/gz21_ocean_momentum/cli/inference.py | 76 +++--------- src/gz21_ocean_momentum/data/datasets.py | 13 ++ src/gz21_ocean_momentum/inference/main.py | 52 ++------ src/gz21_ocean_momentum/inference/utils.py | 12 +- src/gz21_ocean_momentum/models/submodels.py | 5 + src/gz21_ocean_momentum/step/data/lib.py | 14 ++- src/gz21_ocean_momentum/step/inference/lib.py | 9 ++ tests/step/test_data.py | 6 +- 10 files changed, 191 insertions(+), 113 deletions(-) create mode 100644 src/gz21_ocean_momentum/cli/inference-test.py create mode 100644 src/gz21_ocean_momentum/step/inference/lib.py diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py index 380a6c12..22494a21 100644 --- a/src/gz21_ocean_momentum/cli/data.py +++ b/src/gz21_ocean_momentum/cli/data.py @@ -81,7 +81,7 @@ grid = grid.chunk({"xu_ocean": -1}) logger.info("computing forcings...") -forcings = lib.compute_forcings_cm2_6(surface_fields, grid, options.factor) +forcings = lib.compute_forcings_and_coarsen_cm2_6(surface_fields, grid, options.factor) logger.info("selecting forcing bounding box...") forcings = bounding_box.bound_dataset("yu_ocean", "xu_ocean", forcings, bbox) diff --git a/src/gz21_ocean_momentum/cli/inference-test.py b/src/gz21_ocean_momentum/cli/inference-test.py new file mode 100644 index 00000000..6391a380 --- /dev/null +++ b/src/gz21_ocean_momentum/cli/inference-test.py @@ -0,0 +1,115 @@ +import configargparse + +import logging + +from gz21_ocean_momentum.train.base import Trainer + +# TODO hardcode submodel, transformation, NN loss function +# unlikely for a CLI we need to provide dynamic code loading -- let's just give +# options +# we could enable such "dynamic loading" in the "library" interface!-- but, due +# to the class-based setup, it's a little complicated for a user to come in with +# their own code for some of these, and it needs documentation. so a task for +# later +import gz21_ocean_momentum.models.models1.FullyCNN as model_cls +import gz21_ocean_momentum.models.submodels.transform3 as submodel +import gz21_ocean_momentum.models.transforms.SoftPlusTransform as transformation +import gz21_ocean_momentum.train.losses.HeteroskedasticGaussianLossV2 as loss_cls + +from gz21_ocean_momentum.data.datasets import + pytorch_dataset_from_cm2_6_forcing_dataset + +DESCRIPTION = "GZ21 inference step: predict forcings trained model on " + +p = configargparse.ArgParser(description=DESCRIPTION) +p.add("--config-file", is_config_file=True, help="config file path") + +p.add("--lat-min", type=float, required=True, help="bounding box minimum latitude") +p.add("--lat-max", type=float, required=True, help="bounding box maximum latitude") +p.add("--long-min", type=float, required=True, help="bounding box minimum longitude") +p.add("--long-max", type=float, required=True, help="bounding box maximum longitude") +p.add("--ntimes", type=int, help="number of time points to process, starting from the first. Note that the CM2.6 dataset is daily, so this would be number of days. If unset, uses whole dataset.") +p.add("--co2-increase", action="store_true", help="use 1%% annual CO2 increase CM2.6 dataset. By default, uses control (no increase)") +p.add("--factor", type=int, required=True, help="resolution degradation factor") + +p.add("--model-state-dict-file", type=str, required=True, help="model state dict file (*.pth)") +p.add("--device", type=str, default="cuda", help="neural net device (e.g. cuda, cuda:0, cpu)") +p.add("--out-dir", type=str, required=True, help="folder to save output dataset to") + +p.add("--train-split", required=True) +p.add("--test-split", required=True) +p.add("--batch_size", required=True) + +p.add("--verbose", action="store_true", help="be more verbose (displays progress, debug messages)") + +options = p.parse_args() + +# set up logging immediately after parsing CLI options (need to check verbosity) +# (would like to simplify this, maybe with `basicConfig(force=True)`) +if options.verbose: + logging.basicConfig(level=logging.DEBUG) + dask.diagnostics.ProgressBar().register() + logger = logging.getLogger(__name__) + logger.debug("verbose mode; displaying all debug messages, progress bars)") +else: + logging.basicConfig(level=logging.INFO) + logger = logging.getLogger(__name__) + +# store bounding box in a struct-like +bbox = BoundingBox( + options.lat_min, options.lat_max, + options.long_min, options.long_max) +if not bounding_box.validate_nonempty(bbox): + cli.fail(2, f"provided bounding box describes an empty region: {bbox}") + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +logger.info("retrieving CM2.6 dataset via Pangeo Cloud Datastore...") +surface_fields, _grid = lib.retrieve_cm2_6(options.pangeo_catalog_uri, options.co2_increase) + +logger.debug("dropping irrelevant data variables...") +surface_fields = surface_fields[["usurf", "vsurf"]] + +if options.ntimes is not None: + logger.info(f"slicing {options.ntimes} time points...") + surface_fields = surface_fields.isel(time=slice(options.ntimes)) + +logger.info("selecting input data bounding box...") +surface_fields = bounding_box.bound_dataset("yu_ocean", "xu_ocean", surface_fields, bbox) + +# --- + +# TODO hard-coded loss class +criterion = loss_cls(dataset.n_targets) + +# load, prepare pre-trained neural net +net = model_cls(dataset.n_features, criterion.n_required_channels) +net.load_state_dict(torch.load(options.model_state_dict_file)) +print(net) +#net.cpu() # TODO why needed? +dataset.add_transforms_from_model(net) + +print("Size of training data: {}".format(len(train_dataset))) +print("Size of validation data : {}".format(len(test_dataset))) +print("Input height: {}".format(train_dataset.height)) +print("Input width: {}".format(train_dataset.width)) +print(train_dataset[0][0].shape) +print(train_dataset[0][1].shape) +print("Features transform: ", transform.transforms["features"].transforms) +print("Targets transform: ", transform.transforms["targets"].transforms) + +# Net to GPU +with TaskInfo("Put neural network on device"): + net.to(options.device) + +print("width: {}, height: {}".format(dataset.width, dataset.height)) + +with ProgressBar(), TaskInfo("Predict & save prediction dataset"): + out = predict_lazy_cm2_6(net, criterion, partition, loaders, options.device) + ProgressBar().register() + logger.info(f"chunk predictions to time=32 ...") + out = out.chunk(dict(time=32)) + print(f"Size of output data is {out.nbytes/1e9} GB") + logger.info(f"writing re-chunked predictions zarr to directory: {options.out_dir}") + out.to_zarr(options.out_dir) diff --git a/src/gz21_ocean_momentum/cli/inference.py b/src/gz21_ocean_momentum/cli/inference.py index e3ac90cc..90329246 100644 --- a/src/gz21_ocean_momentum/cli/inference.py +++ b/src/gz21_ocean_momentum/cli/inference.py @@ -4,7 +4,7 @@ from gz21_ocean_momentum.train.base import Trainer -# TODO hardcode submodel, transformation +# TODO hardcode submodel, transformation, NN loss function # unlikely for a CLI we need to provide dynamic code loading -- let's just give # options # we could enable such "dynamic loading" in the "library" interface!-- but, due @@ -14,8 +14,12 @@ import gz21_ocean_momentum.models.models1.FullyCNN as model_cls import gz21_ocean_momentum.models.submodels.transform3 as submodel import gz21_ocean_momentum.models.transforms.SoftPlusTransform as transformation +import gz21_ocean_momentum.train.losses.HeteroskedasticGaussianLossV2 as loss_cls -DESCRIPTION = "GZ21 inference step." +from gz21_ocean_momentum.data.datasets import + pytorch_dataset_from_cm2_6_forcing_dataset + +DESCRIPTION = "GZ21 inference step: predict forcings trained model on " p = configargparse.ArgParser(description=DESCRIPTION) p.add("--config-file", is_config_file=True, help="config file path") @@ -38,34 +42,14 @@ xr_dataset = xr.open_zarr(options.forcing_data_dir) -# convert forcings xarray to PyTorch dataset -# TODO identical loading code in train step (originally trainScript.py) -dataset = RawDataFromXrDataset(xr_dataset) -dataset.index = "time" -dataset.add_input("usurf") -dataset.add_input("vsurf") -dataset.add_output("S_x") -dataset.add_output("S_y") - -# Load some extra parameters of the model. -# TODO allow general time_indices -time_indices = [ - 0, -] -train_split = float(model_run["params.train_split"]) -test_split = float(model_run["params.test_split"]) -batch_size = batch_size if batch_size else int(model_run["params.batchsize"]) -source_data_id = model_run["params.source.run-id"] -loss_cls_name = model_run["params.loss_cls_name"] -learning_rates = learning_rates_from_string(model_run["params.learning_rate"]) -submodel_name = model_run["params.submodel"] - -# Set up training criterion and select parameters to train -try: - n_targets = dataset.n_targets - criterion = getattr(losses, loss_cls_name)(n_targets) -except AttributeError as e: - raise type(e)("Could not find the loss class used for training, ", loss_cls_name) +# TODO: Actually, we shouldn't need this whole snippet, because we shouldn't use +# existing forcings. We do pure inference here now. +dataset = pytorch_dataset_from_cm2_6_forcing_dataset(xr_dataset) + +# --- + +# TODO hard-coded loss class +criterion = loss_cls(dataset.n_targets) # load, prepare pre-trained neural net net = model_cls(dataset.n_features, criterion.n_required_channels) @@ -89,33 +73,11 @@ print("width: {}, height: {}".format(dataset.width, dataset.height)) -# Training itself -if n_epochs > 0: - with TaskInfo("Training"): - trainer = Trainer(net, options.device) - trainer.criterion = criterion - # Register metrics - for metric_name, metric in metrics.items(): - trainer.register_metric(metric_name, metric) - parameters = net.parameters() - optimizer = torch.optim.Adam(parameters, lr=learning_rate) - for i_epoch in range(n_epochs): - train_loss = trainer.train_for_one_epoch(train_dataloader, optimizer) - test_loss, metrics_results = trainer.test(test_dataloader) - print("Epoch {}".format(i_epoch)) - print("Train loss for this epoch is {}".format(train_loss)) - print("Test loss for this epoch is {}".format(test_loss)) - - with TaskInfo("Validation"): - train_loss, train_metrics_results = trainer.test(train_dataloader) - print(f"Final train loss is {train_loss}") - -# Test -with ProgressBar(), TaskInfo("Create output dataset"): - out = create_large_test_dataset(net, criterion, partition, loaders, options.device) +with ProgressBar(), TaskInfo("Predict & save prediction dataset"): + out = predict_lazy_cm2_6(net, criterion, partition, loaders, options.device) ProgressBar().register() - print("Start of actual computations...") + logger.info(f"chunk predictions to time=32 ...") out = out.chunk(dict(time=32)) - out.to_zarr(file_path) - mlflow.log_artifact(file_path) print(f"Size of output data is {out.nbytes/1e9} GB") + logger.info(f"writing re-chunked predictions zarr to directory: {options.out_dir}") + out.to_zarr(options.out_dir) diff --git a/src/gz21_ocean_momentum/data/datasets.py b/src/gz21_ocean_momentum/data/datasets.py index 35612db8..9688d683 100644 --- a/src/gz21_ocean_momentum/data/datasets.py +++ b/src/gz21_ocean_momentum/data/datasets.py @@ -12,6 +12,7 @@ import numpy as np import torch +import torch.utils.data as torch from torch.utils.data import Dataset, ConcatDataset, Subset import xarray as xr @@ -712,6 +713,18 @@ def __getattr__(self, attr_name): return getattr(self.xr_dataset, attr_name) raise AttributeError() +def pytorch_dataset_from_cm2_6_forcing_dataset(ds: xr.Dataset) -> torch.Dataset: + """Obtain a PyTorch `Dataset` view over an xarray dataset, specifically for + CM2.6 data annotated with forcings in `S_x` and `S_y`. + + The same snippet is used for both training and inference.""" + ds_torch = RawDataFromXrDataset(ds) + ds_torch.index = "time" + ds_torch.add_input("usurf") + ds_torch.add_input("vsurf") + ds_torch.add_output("S_x") + ds_torch.add_output("S_y") + return ds_torch class DatasetWithTransform: def __init__(self, dataset, transform: DatasetTransformer): diff --git a/src/gz21_ocean_momentum/inference/main.py b/src/gz21_ocean_momentum/inference/main.py index 7c26d5ee..78550989 100755 --- a/src/gz21_ocean_momentum/inference/main.py +++ b/src/gz21_ocean_momentum/inference/main.py @@ -28,7 +28,7 @@ from gz21_ocean_momentum.utils import select_run, select_experiment, TaskInfo from gz21_ocean_momentum.train.utils import learning_rates_from_string from gz21_ocean_momentum.data.datasets import ( - RawDataFromXrDataset, + pytorch_dataset_from_cm2_6_forcing_dataset DatasetTransformer, Subset_, DatasetWithTransform, @@ -36,7 +36,6 @@ MultipleTimeIndices, DatasetPartitioner, ) -from gz21_ocean_momentum.train.base import Trainer from gz21_ocean_momentum.train import losses from gz21_ocean_momentum.inference.utils import ( create_large_test_dataset, @@ -58,7 +57,6 @@ # Parse arguments parser = argparse.ArgumentParser() -parser.add_argument("--n_epochs", type=int, default=0) parser.add_argument("--lr_ratio", type=float, default=1) parser.add_argument("--train_mode", type=str, default="all") parser.add_argument("--n_test_times", type=int, default=None) @@ -67,7 +65,6 @@ parser.add_argument("--n_splits", type=int, default=1) script_params = parser.parse_args() -n_epochs = script_params.n_epochs lr_ratio = script_params.lr_ratio to_experiment = script_params.to_experiment n_test_times = script_params.n_test_times @@ -150,7 +147,6 @@ mlflow.log_param("model_run_id", model_run.run_id) mlflow.log_param("data_run_id", data_run.run_id) -mlflow.log_param("n_epochs", n_epochs) # Read the dataset file print("loading dataset...") @@ -159,21 +155,11 @@ with ProgressBar(), TaskInfo("Applying transforms to dataset"): xr_dataset = submodel.fit_transform(xr_dataset) -# To PyTorch Dataset -dataset = RawDataFromXrDataset(xr_dataset) -dataset.index = "time" -dataset.add_input("usurf") -dataset.add_input("vsurf") -dataset.add_output("S_x") -dataset.add_output("S_y") - -if n_epochs > 0: - train_index = int(train_split * len(dataset)) - test_index = int(test_split * len(dataset)) -else: - # TODO check this. Right now we have done this to align with chunks. - train_index = batch_size - test_index = batch_size +dataset = pytorch_dataset_from_cm2_6_forcing_dataset(xr_dataset) + +# TODO check this. Right now we have done this to align with chunks. +train_index = batch_size +test_index = batch_size n_test_times = n_test_times if n_test_times else (len(dataset) - test_index) train_dataset = Subset_(dataset, np.arange(train_index)) @@ -246,31 +232,9 @@ print("width: {}, height: {}".format(dataset.width, dataset.height)) - -# Training itself -if n_epochs > 0: - with TaskInfo("Training"): - trainer = Trainer(net, device) - trainer.criterion = criterion - # Register metrics - for metric_name, metric in metrics.items(): - trainer.register_metric(metric_name, metric) - parameters = net.parameters() - optimizer = torch.optim.Adam(parameters, lr=learning_rate) - for i_epoch in range(n_epochs): - train_loss = trainer.train_for_one_epoch(train_dataloader, optimizer) - test_loss, metrics_results = trainer.test(test_dataloader) - print("Epoch {}".format(i_epoch)) - print("Train loss for this epoch is {}".format(train_loss)) - print("Test loss for this epoch is {}".format(test_loss)) - - with TaskInfo("Validation"): - train_loss, train_metrics_results = trainer.test(train_dataloader) - print(f"Final train loss is {train_loss}") - -# Test +# make predictions using neural net with ProgressBar(), TaskInfo("Create output dataset"): - out = create_large_test_dataset(net, criterion, partition, loaders, device) + out = predict_lazy_cm2_6(net, criterion, partition, loaders, device) file_path = os.path.join(data_location, f"test_output_0") ProgressBar().register() print("Start of actual computations...") diff --git a/src/gz21_ocean_momentum/inference/utils.py b/src/gz21_ocean_momentum/inference/utils.py index 6bd83aac..94a40d80 100755 --- a/src/gz21_ocean_momentum/inference/utils.py +++ b/src/gz21_ocean_momentum/inference/utils.py @@ -105,8 +105,9 @@ def _dataset_from_channels(array, channels_names: list, dims, coords): return xr.Dataset(data) -def create_large_test_dataset( - net, criterion, test_datasets, test_loaders, device, save_input: bool = False +def predict_lazy_cm2_6( + net: torch.nn.Module, + criterion, test_datasets, test_loaders, device, save_input: bool = False ): """ Return an xarray dataset with the predictions carried out on the @@ -116,11 +117,16 @@ def create_large_test_dataset( dataset into smaller test datasets, each of which fits in RAM, there should be no issue. + TODO: + + * `xu_ocean`, `yu_ocean` hardcoded + * why do we take `test_datasets`? bad + Parameters ---------- net : torch.nn.Module Neural net used to make predictions - test_datasets : list + test_datasets : list <-- can't go `list[xarray[has "usurf"]]`... List of PytTorch datasets containing the input data. test_loaders : list List of Pytorch DataLoaders corresponding to the datasets diff --git a/src/gz21_ocean_momentum/models/submodels.py b/src/gz21_ocean_momentum/models/submodels.py index b2b9f0d5..5bdf3c1b 100755 --- a/src/gz21_ocean_momentum/models/submodels.py +++ b/src/gz21_ocean_momentum/models/submodels.py @@ -13,7 +13,12 @@ TargetedTransform, ) +# v CM2.6 specific v + +# velocities (usurf, vsurf) are metres/s velocity_vars = ["usurf", "vsurf"] + +# forcing unitless -- common scale is ? forcing_vars = ["S_x", "S_y"] velocity_scaler = TargetedTransform(ScalingTransform(10.0), velocity_vars) diff --git a/src/gz21_ocean_momentum/step/data/lib.py b/src/gz21_ocean_momentum/step/data/lib.py index 97e191fc..5f34d25e 100644 --- a/src/gz21_ocean_momentum/step/data/lib.py +++ b/src/gz21_ocean_momentum/step/data/lib.py @@ -78,7 +78,7 @@ def cyclize(dim_name: str, ds: xr.Dataset, nb_points: int): return xr.concat((left, right), dim_name) -def compute_forcings_cm2_6( +def compute_forcings_and_coarsen_cm2_6( u_v_dataset: xr.Dataset, grid_data: xr.Dataset, scale: int, @@ -90,7 +90,7 @@ def compute_forcings_cm2_6( Parameters ---------- u_v_dataset : xarray Dataset - High-resolution velocity field. + High-resolution velocity field in "usurf" and "vsurf". grid_data : xarray Dataset High-resolution grid details. scale : float @@ -100,13 +100,14 @@ def compute_forcings_cm2_6( nan values in the initial surface velocities array or whether we replace them by zeros before applying the procedure. In the second case, remaining zeros after applying the procedure will - be replaced by nans for consistency. + be replaced by NaNs for consistency. The default is 'zero'. Returns ------- forcing : xarray Dataset - Dataset containing the low-resolution velocity field and forcing. + Dataset containing the low-resolution velocity field in "usurf" and + "vsurf", and forcing in data variables "S_x" and "S_y". """ # Replace nan values with zeros. if nan_or_zero == "zero": @@ -122,10 +123,12 @@ def compute_forcings_cm2_6( adv = _advections(u_v_dataset, grid_data) # Filtered advections filtered_adv = _spatial_filter_dataset(adv, grid_data, scale/2) + # Filtered u,v field and temperature u_v_filtered = _spatial_filter_dataset(u_v_dataset, grid_data, scale/2) # Advection term from filtered velocity field adv_filtered = _advections(u_v_filtered, grid_data) + # Forcing forcing = adv_filtered - filtered_adv forcing = forcing.rename({"adv_x": "S_x", "adv_y": "S_y"}) @@ -161,7 +164,8 @@ def _advections(u_v_field: xr.Dataset, grid_data: xr.Dataset): Parameters ---------- u_v_field : xarray Dataset - Velocity field, must contains variables "usurf" and "vsurf" + Velocity field, must contains variables "usurf" and "vsurf", coordinates + "xu_ocean" and "yu_ocean" grid_data : xarray Dataset grid data, must contain variables "dxu" and "dyu" diff --git a/src/gz21_ocean_momentum/step/inference/lib.py b/src/gz21_ocean_momentum/step/inference/lib.py new file mode 100644 index 00000000..15684272 --- /dev/null +++ b/src/gz21_ocean_momentum/step/inference/lib.py @@ -0,0 +1,9 @@ +def cm2_6_prep_pytorch(_: xr.Dataset, idx_start: float, idx_end) -> _: + """ + Various transformations, subsetting of a CM2.6 dataset. + + Retrieve using data step lib, slice & restrict spatial domain. + + idx_start: 0->1 subset of dataset to use, start + idx_end: 0->1 subset of dataset to use, end (must be > idx_start) + """ diff --git a/tests/step/test_data.py b/tests/step/test_data.py index d3b6c30f..8fce5d6f 100755 --- a/tests/step/test_data.py +++ b/tests/step/test_data.py @@ -87,7 +87,7 @@ def test_eddy_forcing_chunks(self): ), } ) - forcing = lib.compute_forcings_cm2_6(data, grid_info, 4) + forcing = lib.compute_forcings_and_coarsen_cm2_6(data, grid_info, 4) usurf_0, usurf_1 = forcing.usurf.isel(time=0), forcing.usurf.isel(time=1) # remove nan values at the boundaries from the test usurf_0 = usurf_0.data[~np.isnan(usurf_0)] @@ -143,7 +143,7 @@ def test_eddy_forcing_chunking(self): ) # new forcing: apply - forcing_new = lib.compute_forcings_cm2_6(data, grid_info, scale=scale_m) + forcing_new = lib.compute_forcings_and_coarsen_cm2_6(data, grid_info, scale=scale_m) # new forcing: post-chunk for var in forcing_new: @@ -169,7 +169,7 @@ def f(block): - S_x and S_y, the two components of the diagnosed subgrid momentum forcing """ - return lib.compute_forcings_cm2_6(block, grid_info, scale=scale_m) + return lib.compute_forcings_and_coarsen_cm2_6(block, grid_info, scale=scale_m) # old forcing: apply template = data.coarsen( From cc883dd9afdf6718f6684409063a1cc371c97aba Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 7 Nov 2023 16:01:32 +0000 Subject: [PATCH 038/114] train/base: clarify data loading comments --- src/gz21_ocean_momentum/train/base.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/gz21_ocean_momentum/train/base.py b/src/gz21_ocean_momentum/train/base.py index 5bc3cb67..8206418c 100755 --- a/src/gz21_ocean_momentum/train/base.py +++ b/src/gz21_ocean_momentum/train/base.py @@ -10,7 +10,7 @@ from torch.nn.utils import clip_grad_norm_ from torch.utils.data import DataLoader -from .utils import print_every, RunningAverage +from gz21_ocean_momentum.train.utils import print_every, RunningAverage class Trainer: @@ -110,25 +110,28 @@ def train_for_one_epoch( ------- float The average train loss for this epoch. + + Effect: backpropagates loss, editing neural network. """ self.net.train() self._locked = True running_loss = RunningAverage() running_loss_ = RunningAverage() - for i_batch, batch in enumerate(dataloader): + for i, (feature, target) in enumerate(dataloader): # Zero the gradients self.net.zero_grad() # Move batch to the GPU (if possible) - X = batch[0].to(self._device, dtype=torch.float) - Y = batch[1].to(self._device, dtype=torch.float) - Y_hat = self.net(X) + feature_device = feature.to(self._device, dtype=torch.float) + target_device = target.to(self._device, dtype=torch.float) + # predict with input + predict = self.net(feature) # Compute loss loss = self.criterion(Y_hat, Y) running_loss.update(loss.item(), X.size(0)) running_loss_.update(loss.item(), X.size(0)) # Print current loss loss_text = "Loss value {}".format(running_loss_.average) - if print_every(loss_text, self.print_loss_every, i_batch): + if print_every(loss_text, self.print_loss_every, i): # Every time we print we reset the running average running_loss_.reset() # Backpropagate From 421bb5777dbd6c3cd97e15f6f0a8478c1eee7ee7 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 7 Nov 2023 16:03:41 +0000 Subject: [PATCH 039/114] docs/data: link to CM2.6 dataset --- docs/data.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/data.md b/docs/data.md index 1317a0a3..350b1d49 100644 --- a/docs/data.md +++ b/docs/data.md @@ -1,11 +1,14 @@ # Notes on the data processing stage ## Notes on the CM2.6 dataset ### Requester Pays -We use the CM2.6 dataset hosted on the Pangeo Cloud Datastore. Though public, it -is *not* freely available, due to the data being in a Requester Pays bucket on -Google Cloud Platform (GCP). Reading data from the bucket requires you to have -Google Cloud access credentials configured with billing access; bandwidth -charges are then charged to that account i.e. you. +[cm26-pangeo-ds]: https://catalog.pangeo.io/browse/master/ocean/GFDL_CM2_6/ + +We use the [CM2.6 dataset][cm26-pangeo-ds], generated by the CM2.6 model, hosted +on the Pangeo Cloud Datastore. Though public, it is *not* freely available, due +to the data being in a Requester Pays bucket on Google Cloud Platform (GCP). +Reading data from the bucket requires you to have Google Cloud access +credentials configured with billing access; bandwidth charges are then charged +to that account i.e. you. A guide for configuring GCP credentials is over here on the Pangeo Cloud Datastore: [Working with requester pays From 1f74b43aeeb90473aedbed7e189e2f8d338311a5 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 8 Nov 2023 16:01:17 +0000 Subject: [PATCH 040/114] cli/inference-test: +describe points about input, model --- src/gz21_ocean_momentum/cli/inference-test.py | 48 ++++++++----------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/src/gz21_ocean_momentum/cli/inference-test.py b/src/gz21_ocean_momentum/cli/inference-test.py index 6391a380..35b5146c 100644 --- a/src/gz21_ocean_momentum/cli/inference-test.py +++ b/src/gz21_ocean_momentum/cli/inference-test.py @@ -19,26 +19,33 @@ from gz21_ocean_momentum.data.datasets import pytorch_dataset_from_cm2_6_forcing_dataset -DESCRIPTION = "GZ21 inference step: predict forcings trained model on " +DESCRIPTION = """ +Use pre-trained GZ21 neural net to predict forcing for input ocean velocity data. + +This script is intended as example of how use the GZ21 neural net, and for +general tinkering. + +Designed to ingest coarsened CM2.6 data: looks for data variables at certain +names (`xu_ocean`, ...) with certain units. If these do not match up, the neural +net will not operate properly. + +More specifically, this script is designed to ingest coarsened CM2.6 data as +output from the GZ21 data step. This also computes forcings, which are ignored. +(Ideally, we would provide a short script to simply coarsen some data.) + +Note that the neural net has two outputs per grid point. See project +documentation (specifically `README.md` in the project repository), and the +associated paper Guillaumin (2021) for suggestions on how to integrate these +into your GCM of choice. +""" p = configargparse.ArgParser(description=DESCRIPTION) p.add("--config-file", is_config_file=True, help="config file path") -p.add("--lat-min", type=float, required=True, help="bounding box minimum latitude") -p.add("--lat-max", type=float, required=True, help="bounding box maximum latitude") -p.add("--long-min", type=float, required=True, help="bounding box minimum longitude") -p.add("--long-max", type=float, required=True, help="bounding box maximum longitude") -p.add("--ntimes", type=int, help="number of time points to process, starting from the first. Note that the CM2.6 dataset is daily, so this would be number of days. If unset, uses whole dataset.") -p.add("--co2-increase", action="store_true", help="use 1%% annual CO2 increase CM2.6 dataset. By default, uses control (no increase)") -p.add("--factor", type=int, required=True, help="resolution degradation factor") - +p.add("--input-data-dir", type=str, required=True, help="path to input ocean velocity data, in zarr format (folder)") p.add("--model-state-dict-file", type=str, required=True, help="model state dict file (*.pth)") p.add("--device", type=str, default="cuda", help="neural net device (e.g. cuda, cuda:0, cpu)") -p.add("--out-dir", type=str, required=True, help="folder to save output dataset to") - -p.add("--train-split", required=True) -p.add("--test-split", required=True) -p.add("--batch_size", required=True) +p.add("--out-dir", type=str, required=True, help="folder to save forcing predictions dataset to (in zarr format)") p.add("--verbose", action="store_true", help="be more verbose (displays progress, debug messages)") @@ -55,19 +62,6 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# store bounding box in a struct-like -bbox = BoundingBox( - options.lat_min, options.lat_max, - options.long_min, options.long_max) -if not bounding_box.validate_nonempty(bbox): - cli.fail(2, f"provided bounding box describes an empty region: {bbox}") - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -logger.info("retrieving CM2.6 dataset via Pangeo Cloud Datastore...") -surface_fields, _grid = lib.retrieve_cm2_6(options.pangeo_catalog_uri, options.co2_increase) - logger.debug("dropping irrelevant data variables...") surface_fields = surface_fields[["usurf", "vsurf"]] From 9959006f73416b90e3ffebffeeb2a03830c62faf Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 8 Nov 2023 16:23:04 +0000 Subject: [PATCH 041/114] readme: various fixes, changes --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 256d7eb7..5dbfd067 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,14 @@ original model, and is a useful resource for further reading. (The exact version of the code used to produce said paper can be found on [Zenodo][gz21-paper-code-zenodo].) +## Repository layout +TODO + +* `src`: +* `tests`: pytest tests +* `docs` +* `examples`: CLI step configs, Jupyter notebooks for generating figures etc. + ## Architecture The model is written in Python, using PyTorch for the CNN. We provide 3 separate "steps", which are run using different commands and arguments: @@ -31,7 +39,7 @@ The model is written in Python, using PyTorch for the CNN. We provide 3 separate For more details on each of the steps, see the [`docs`](docs/) directory. -## Usage +## Installation ### Dependencies Python 3.9 or newer is required. We primarily test on Python 3.11. @@ -67,7 +75,7 @@ be run in the regular method: pytest -### Running steps +## Running steps Execute these commands from the repository root. See [`docs`](docs/) directory for more details. @@ -144,7 +152,7 @@ For command-line option explanation, append the `--help` flag: python src/gz21_ocean_momentum/cli/data.py --help Some preprocessed data is hosted on HuggingFace at -[datasets/M2LInES/gfdl-cmip26-gz21-ocean-forcing](https://huggingface.co/datasets/M2LInES/gfdl-cmip26-gz21-ocean-forcing). +[datasets/M2LInES/gz21-forcing-cm26](https://huggingface.co/datasets/M2LInES/gz21-forcing-cm26). You may also run the data processing step directly from Python using the functions at [`step/data/lib.py`](src/gz21_ocean_momentum/step/data/lib.py). See From cfe52f8d72d62ca3205fb7a64a8d7dfa82d0ae14 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 9 Nov 2023 10:53:52 +0000 Subject: [PATCH 042/114] MLproject/train: improve default test_split Having test and train splits the same leaves 0% for test. 0.85 is used throughout the paper. --- MLproject | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MLproject b/MLproject index 6c63307c..5666090a 100755 --- a/MLproject +++ b/MLproject @@ -12,7 +12,7 @@ entry_points: learning_rate : {type : string, default : 0\1e-3} n_epochs : {type : float, default : 100} train_split : {type : float, default : 0.8} - test_split : {type : float, default : 0.8} + test_split : {type : float, default : 0.85} time_indices : {type : string, default : 0} print_every : {type : float, default : 20} weight_decay : {type : float, default : 0.01} From 9ab746cbae9d65178d661adb1645ef4a65c1be4e Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 9 Nov 2023 11:01:19 +0000 Subject: [PATCH 043/114] MLproject/train: revert idk --- MLproject | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MLproject b/MLproject index 5666090a..f96ee2e8 100755 --- a/MLproject +++ b/MLproject @@ -12,7 +12,7 @@ entry_points: learning_rate : {type : string, default : 0\1e-3} n_epochs : {type : float, default : 100} train_split : {type : float, default : 0.8} - test_split : {type : float, default : 0.85} + test_split : {type : float, default : 0.8} time_indices : {type : string, default : 0} print_every : {type : float, default : 20} weight_decay : {type : float, default : 0.01} @@ -23,7 +23,7 @@ entry_points: submodel : {type: string, default : transform3} features_transform_cls_name : {type : string, default : None} targets_transform_cls_name : {type : string, default : None} - subdomains_file: {type: string, default: training_subdomains.yaml} + subdomains_file: {type: string, default: examples/cli-configs/training-subdomains-paper.yaml} command: "python src/gz21_ocean_momentum/trainScript.py --run-id {run_id} --forcing-data-path {forcing_data_path} From a1c52b7659dd5390d5a0d5f04261c7158fd3f7fb Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 9 Nov 2023 11:20:38 +0000 Subject: [PATCH 044/114] readme: update training step docs --- README.md | 76 ++++++++++++------- examples/cli-configs/README.md | 4 + .../training-subdomains-paper.yaml | 0 3 files changed, 53 insertions(+), 27 deletions(-) rename training_subdomains.yaml => examples/cli-configs/training-subdomains-paper.yaml (100%) diff --git a/README.md b/README.md index 5dbfd067..56bba2b8 100644 --- a/README.md +++ b/README.md @@ -159,29 +159,37 @@ functions at [`step/data/lib.py`](src/gz21_ocean_momentum/step/data/lib.py). See the CLI script for example usage. #### Training -The [`trainScript.py`](src/gz21_ocean_momentum/trainScript.py) script runs the -model training step. You may configure various training parameters through -command-line arguments, such as number of training epochs, loss functions, and -training data. (You will want to select the output from a data processing step -for the latter.) +[cli-train]: src/gz21_ocean_momentum/trainScript.py + +The [`trainScript.py`][cli-train] script runs the model training step. You may +configure various training parameters through command-line arguments, such as +number of training epochs, loss functions, and training data. (You will want to +select the output from a data processing step for the latter.) MLflow call example: ``` mlflow run . --experiment-name -e train --env-manager=local \ -P run_id= \ --P learning_rate=0/5e-4/15/5e-5/30/5e-6 -P n_epochs=200 -P weight_decay=0.00 -P train_split=0.8 \ --P test_split=0.85 -P model_module_name=models.models1 -P model_cls_name=FullyCNN -P batchsize=4 \ +-P subdomains_file=examples/cli-configs/training-subdomains-paper.yaml \ +-P learning_rate=0/5e-4/15/5e-5/30/5e-6 -P weight_decay=0.00 \ +-P n_epochs=200 -P batchsize=4 \ +-P train_split=0.8 -P test_split=0.85 \ +-P model_module_name=models.models1 -P model_cls_name=FullyCNN \ -P transformation_cls_name=SoftPlusTransform -P submodel=transform3 \ -P loss_cls_name=HeteroskedasticGaussianLossV2 ``` Relevant parameters: -* `exp_id`: id of the experiment containing the run that generated the forcing - data. -* `run_id`: id of the run that generated the forcing data that will be used for - training. +* `run_id`: MLflow run ID of the run that generated the forcing data that will + be used for training. +* `subdomains_file`: path to YAML file storing a list of subdomains to select + from the forcing data, which are then used for training. (Note that at + runtime, domains are be truncated to the size of the smallest domain in terms + of number of points.) +* `train_split`: use `0->N` percent of the dataset for training +* `test_split`: use `N->100` percent of the dataset for testing * `loss_cls_name`: name of the class that defines the loss. This class should be defined in train/losses.py in order for the script to find it. Currently, the main available options are: @@ -192,18 +200,32 @@ Relevant parameters: NN used * `model_cls_name`: name of the class defining the NN used, should be defined in the module specified by `model_module_name` -* `train_split`: use `0->N` percent of the dataset for training -* `test_split`: use `N->100` percent of the dataset for testing -Another important way to modify the way the script runs consists in modifying -the domains used for training. These are defined in -[`training_subdomains.yaml`](training_subdomains.yaml) in terms of their -coordinates. Note that at run time domains will be truncated to the size of the -smallest domain in terms of number of points. +You may also call this script directly instead of going through `mlflow run`. In +such cases, you may replace `--run-id` with `--forcing-data-path`. See +[`trainScript`][cli-train] and [`MLproject`](MLproject) for more details. + +##### Subdomains +The `subdomains_file` format is a list of bounding boxes, each defined using +four floats: + +```yaml +- lat-min: 35 + lat-max: 50 + long-min: -50 + long-max: -20 +- lat-min: -40 + lat-max: -25 + long-min: -180 + long-max: -162 +# - ... +``` + +`lat-min` must be smaller than `lat-max`, likewise for `long-min`. -*Note:* Ensure that the spatial subdomains defined in `training_subdomains.yaml` -are contained in the domain of the forcing data you use. If they aren't, you may -get a Python error along the lines of: +*Note:* Ensure that the subdomains you use are contained in the domain of the +forcing data you use. If they aren't, you may get a confusing Python error along +the lines of: ``` RuntimeError: Calculated padded input size per channel: . @@ -243,7 +265,7 @@ The inference step should then start. ### Jupyter Notebooks The [examples/jupyter-notebooks](examples/jupyter-notebooks/) folder stores notebooks developed during early project development, some of which were used to -generate figures used in the 2021 paper. See the readme in the folder for +generate figures used in the 2021 paper. See the readme in the above folder for details. ### Dev Branch @@ -253,11 +275,11 @@ use through a command line interface for the data step, and the training step is in progress. Further work is needed for the inference step, and to adapt the Jupyter notebooks. -## Data on Huggingface -There is GZ21 Ocean Momentum data available on [Huggingface](https://huggingface.co/): -- [the output of the data step](https://huggingface.co/datasets/M2LInES/gfdl-cmip26-gz21-ocean-forcing) -and -- [the trained model](https://huggingface.co/M2LInES/gz21-ocean-momentum) +## Data on HuggingFace +There is GZ21 Ocean Momentum data available on [HuggingFace](https://huggingface.co/) + +* [the output of the data step][datasets/M2LInES/gz21-forcing-cm26] and +* [the trained model](https://huggingface.co/M2LInES/gz21-ocean-momentum). ## Contributing We are not currently accepting contributions outside of the M2LInES and ICCS diff --git a/examples/cli-configs/README.md b/examples/cli-configs/README.md index 0cc5502b..50a49023 100644 --- a/examples/cli-configs/README.md +++ b/examples/cli-configs/README.md @@ -2,3 +2,7 @@ ## General tips * If the data step (forcing generation) is taking too long, lower `ntimes`. On a consumer machine, for testing, 100 is good enough. (4000 will take ages.) + +## Details +### `training-subdomains-paper.yaml` +Four spatial bounding boxes, used for training in the original paper. diff --git a/training_subdomains.yaml b/examples/cli-configs/training-subdomains-paper.yaml similarity index 100% rename from training_subdomains.yaml rename to examples/cli-configs/training-subdomains-paper.yaml From a3c7ee1ac65ffe345393d76d461f8c7b7488a7c3 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 9 Nov 2023 11:21:26 +0000 Subject: [PATCH 045/114] whitespace nit --- examples/jupyter-notebooks/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/jupyter-notebooks/README.md b/examples/jupyter-notebooks/README.md index 80ceb61a..6f73e431 100644 --- a/examples/jupyter-notebooks/README.md +++ b/examples/jupyter-notebooks/README.md @@ -5,7 +5,7 @@ These Jupyter notebooks were created & used during original development of the code and associated paper: [Arthur P. Guillaumin, Laure Zanna (2021). Stochastic-deep learning parameterization of ocean momentum forcing][gz21-paper-agupubs]. The exact version of the code used to produce said -paper can be found on [Zenodo][gz21-paper-code-zenodo]. +paper can be found on [Zenodo][gz21-paper-code-zenodo]. ## 2021 paper figures There are several notebooks which were used to generate the figures in the 2021 From c5901a12fffe496e6195ab446d118abd6d63e7be Mon Sep 17 00:00:00 2001 From: Dominic Orchard Date: Thu, 9 Nov 2023 14:36:15 +0000 Subject: [PATCH 046/114] tidy up start of test_global_control notebook --- .../test_global_control.ipynb | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/examples/jupyter-notebooks/test_global_control.ipynb b/examples/jupyter-notebooks/test_global_control.ipynb index 4714e9a0..54befba2 100644 --- a/examples/jupyter-notebooks/test_global_control.ipynb +++ b/examples/jupyter-notebooks/test_global_control.ipynb @@ -7,6 +7,15 @@ "# Test on Global scale " ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Dependencies\n", + "* `ipympl` (`pip install ipympl`)\n", + "* `cmocean` (`pip install cmocean`)\n" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -23,7 +32,7 @@ "#%matplotlib notebook #This option does not work in Jupyterlab\n", "%matplotlib widget\n", "\n", - "# See https://github.com/m2lines/gz21_ocean_momentum/blob/main/docs/data.md for an explanation \n", + "# See https://github.com/m2lines/gz21_ocean_momentum/blob/main/docs/data.md for an explanation\n", "# The environment variable does not need setting if you place the credentials file at ~/.config/gcloud/application_default_credentials.json .\n", "# %env GOOGLE_APPLICATION_CREDENTIALS /home/marion/.config/gcloud/application_default_credentials.json" ] @@ -34,7 +43,8 @@ "metadata": {}, "outputs": [], "source": [ - ".import mlflow\n", + "# Imports\n", + "import mlflow\n", "from mlflow.tracking import client\n", "import xarray as xr\n", "import numpy as np\n", @@ -50,23 +60,7 @@ "from models.submodels import transform3\n", "\n", "import cartopy.crs as ccrs\n", - "import cmocean\n", - "#cmap = cmocean.cm.balance\n", - "#cmap_balance = cmocean.cm.balance\n", - "#cmap_balance_r=cmocean.cm.balance_r\n", - "#cmap_amp = cmocean.cm.amp\n", - "#cmap_amp_r = cmocean.cm.amp_r\n", - "\n", - "plt.rcParams[\"figure.figsize\"] = (4, 4 / 1.618)\n", - "\n", - "#uv_plotter.x_ticks = np.arange(-150., 151., 50)\n", - "#uv_plotter.y_ticks = np.arange(-80., 81., 20)\n", - "#uv_plotter.margin = 10\n", - "\n", - "import gz21_ocean_momentum.analysis as analysis\n", - "GlobalPlotter = analysis.utils.GlobalPlotter\n", - "uv_plotter = GlobalPlotter() \n", - "\n" + "import cmocean" ] }, { @@ -75,7 +69,13 @@ "metadata": {}, "outputs": [], "source": [ - "plt.rcParams[\"figure.figsize\"] = (4, 4 / 1.618)" + "# Setup the plotter\n", + "plt.rcParams[\"figure.figsize\"] = (4, 4 / 1.618)\n", + "\n", + "import gz21_ocean_momentum.analysis as analysis\n", + "GlobalPlotter = analysis.utils.GlobalPlotter\n", + "uv_plotter = GlobalPlotter()\n", + "\n" ] }, { @@ -88,9 +88,7 @@ "\n", "def new_plot_func(self, name: str, *args, **kargs):\n", " data = xr.Dataset({name: args[0]})\n", - " old_plot(self, *args, **kargs)\n", - "\n", - "#GlobalPlotter.plot = new_plot_func" + " old_plot(self, *args, **kargs)" ] }, { From 1db29decb595f42bb6fdad52f49eb8dafceebf9e Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 9 Nov 2023 17:06:07 +0000 Subject: [PATCH 047/114] notebooks/readme: +note on common deps --- examples/jupyter-notebooks/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/jupyter-notebooks/README.md b/examples/jupyter-notebooks/README.md index 6f73e431..6033d9a3 100644 --- a/examples/jupyter-notebooks/README.md +++ b/examples/jupyter-notebooks/README.md @@ -7,6 +7,11 @@ Stochastic-deep learning parameterization of ocean momentum forcing][gz21-paper-agupubs]. The exact version of the code used to produce said paper can be found on [Zenodo][gz21-paper-code-zenodo]. +Some of these notebooks require the following dependencies (obtain using `pip`): + + * `ipympl` + * `cmocean` + ## 2021 paper figures There are several notebooks which were used to generate the figures in the 2021 paper. From fcd6dea27da714e51a763704d5bec82436da617a Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Thu, 9 Nov 2023 17:06:21 +0000 Subject: [PATCH 048/114] readme: start new training step cmd example --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 56bba2b8..36c19bee 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,15 @@ mlflow run . --experiment-name -e train --env-manager=local \ -P loss_cls_name=HeteroskedasticGaussianLossV2 ``` +Plain Python call example: + +``` +python src/gz21_ocean_momentum/trainScript.py +--subdomains-file examples/cli-configs/training-subdomains-paper.yaml \ +--forcing-data-path \ +TODO +``` + Relevant parameters: * `run_id`: MLflow run ID of the run that generated the forcing data that will From 0d70488f6bec89b306afa679b25775e4ea673ac5 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 10 Nov 2023 12:16:59 +0000 Subject: [PATCH 049/114] various readme, docs updates --- README.md | 62 ++++++++++++++++++++++++++++--------------- docs/common-errors.md | 2 +- docs/data.md | 16 ++++++----- 3 files changed, 51 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 36c19bee..46bf24d1 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,51 @@ # GZ21: stochastic deep learning parameterization of ocean momentum forcing [gz21-paper-code-zenodo]: https://zenodo.org/record/5076046#.ZF4ulezMLy8 [gz21-paper-agupubs]: https://agupubs.onlinelibrary.wiley.com/doi/10.1029/2021MS002534 +[cm26-ds]: https://catalog.pangeo.io/browse/master/ocean/GFDL_CM2_6/ -This repository trains a convolutional neural network (CNN) to parameterize -subgrid ocean momentum forcing, intended for coupling into larger GCMs at to -provide a high-fidelity parameterization in coarser-grain models. +This repository trains a PyTorch convolutional neural network (CNN) to predict +subgrid ocean momentum forcing from ocean surface velocity, intended for +coupling with larger GCMs to provide a performant, high-fidelity +parameterization in coarse-resolution climate models. -High-resolution surface velocity data from the CM2.6 dataset is used to compute -forcings (present in coarser-grain models), then coarsened. These coarse surface -velocities are used to train a CNN to predict forcings. For every grid point, -rather than predicting a single value for the subgrid momentum forcing, the CNN -predicts both the mean and standard deviation of a Gaussian probability -distribution. This allows for stochastic implementations in online models. +Scripts for preparing training data, training up a model, and using the model to +make predictions (inference mode) are provided. These are run from the +command-line, and accept various configuration options (e.g. hyperparameters for +NN training). -The paper +For further detail and discussion, please read the original paper [Arthur P. Guillaumin, Laure Zanna (2021). Stochastic-deep learning -parameterization of ocean momentum forcing][gz21-paper-agupubs] discusses the -original model, and is a useful resource for further reading. -(The exact version of the code used to produce said paper can be found on +parameterization of ocean momentum forcing][gz21-paper-agupubs]. +Documentation in this repository will refer back to sections from the paper e.g. +*Guillaumin (2021) 2.1* to provide context and further reading. +(A snapshot of the code used in the paper can be found on [Zenodo][gz21-paper-code-zenodo].) -## Repository layout -TODO +## Overview +Most of this repository is concerned with preparing training data, and training +a NN. Each of these is handled with a standalone Python script, and data is +saved and loaded between via disk. + +In the "data" step, we generate training data using +[simulation data from the CM2.6 climate model][cm26-ds] +(which we refer to as the CM2.6 dataset, or just CM2.6). +We calculate the subgrid forcing needed for coarse-resolution models using the +high-resolution ocean velocity data in the CM2.6 dataset, then coarsen. This +coarsened, with-forcings dataset is saved to disk. You may generate training +data using either the "control" CM2.6 simulation, or the "1-percent annual CO2 +increase" one. *(See Guillaumin (2021) 2.1.)* + +In the "training" step, we train a NN to predict the true forcing from the +coarse velocity data generated above. This forcing term tends to have a large +amount of uncertainty. Rather than a single value, we predict both the mean and +standard deviation of a Gaussian probability distribution for the forcing. This +allows for stochastic implementations in online models. *(See Guillaumin (2021) +2.3 for a more in-depth explanation and how to interpret the NN output.)* -* `src`: +## Repository layout +* `src`: source code (both library functions and command-line scripts) * `tests`: pytest tests -* `docs` +* `docs`: detailed project documentation, implementation notes * `examples`: CLI step configs, Jupyter notebooks for generating figures etc. ## Architecture @@ -159,9 +179,9 @@ functions at [`step/data/lib.py`](src/gz21_ocean_momentum/step/data/lib.py). See the CLI script for example usage. #### Training -[cli-train]: src/gz21_ocean_momentum/trainScript.py +[cli-train]: src/gz21_ocean_momentum/cli/train.py -The [`trainScript.py`][cli-train] script runs the model training step. You may +The [`cli/train.py`][cli-train] script runs the model training step. You may configure various training parameters through command-line arguments, such as number of training epochs, loss functions, and training data. (You will want to select the output from a data processing step for the latter.) @@ -183,7 +203,7 @@ mlflow run . --experiment-name -e train --env-manager=local \ Plain Python call example: ``` -python src/gz21_ocean_momentum/trainScript.py +python src/gz21_ocean_momentum/cli/train.py --subdomains-file examples/cli-configs/training-subdomains-paper.yaml \ --forcing-data-path \ TODO @@ -212,7 +232,7 @@ Relevant parameters: You may also call this script directly instead of going through `mlflow run`. In such cases, you may replace `--run-id` with `--forcing-data-path`. See -[`trainScript`][cli-train] and [`MLproject`](MLproject) for more details. +[`cli/train.py`][cli-train] and [`MLproject`](MLproject) for more details. ##### Subdomains The `subdomains_file` format is a list of bounding boxes, each defined using diff --git a/docs/common-errors.md b/docs/common-errors.md index 42188a03..594514af 100644 --- a/docs/common-errors.md +++ b/docs/common-errors.md @@ -1,4 +1,4 @@ -# Common errors +# Troubleshooting common errors ## `User project specified in the request is invalid.` If when running the data processing step, you see an error message like this: diff --git a/docs/data.md b/docs/data.md index 350b1d49..4b793b06 100644 --- a/docs/data.md +++ b/docs/data.md @@ -1,14 +1,16 @@ -# Notes on the data processing stage +# GZ21: Forcing generation +See Guillaumin (2021) 2.2, 2.3. + ## Notes on the CM2.6 dataset ### Requester Pays [cm26-pangeo-ds]: https://catalog.pangeo.io/browse/master/ocean/GFDL_CM2_6/ -We use the [CM2.6 dataset][cm26-pangeo-ds], generated by the CM2.6 model, hosted -on the Pangeo Cloud Datastore. Though public, it is *not* freely available, due -to the data being in a Requester Pays bucket on Google Cloud Platform (GCP). -Reading data from the bucket requires you to have Google Cloud access -credentials configured with billing access; bandwidth charges are then charged -to that account i.e. you. +We use the [CM2.6 dataset][cm26-pangeo-ds] hosted on the Pangeo Cloud Datastore, +which is simulation output from the CM2.6 climate model. Though public, the data +is *not* freely available, due to the data being in a Requester Pays bucket on +Google Cloud Platform (GCP). Reading data from the bucket requires you to have +Google Cloud access credentials configured with billing access; bandwidth +charges are then charged to that account i.e. you. A guide for configuring GCP credentials is over here on the Pangeo Cloud Datastore: [Working with requester pays From 1887b399e31876a7203833230a36a4c462d51e01 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 10 Nov 2023 12:17:12 +0000 Subject: [PATCH 050/114] examples/cli: +quick data run config --- examples/cli-configs/data-quick.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 examples/cli-configs/data-quick.yaml diff --git a/examples/cli-configs/data-quick.yaml b/examples/cli-configs/data-quick.yaml new file mode 100644 index 00000000..b41b5a3e --- /dev/null +++ b/examples/cli-configs/data-quick.yaml @@ -0,0 +1,11 @@ +# "Quick" settings for the data step: small subdomain, low ntimes, high factor. + +lat-min: -40 +lat-max: 40 +long-min: -140 +long-max: 40 + +ntimes: 10 +factor: 8 + +co2-increase: false From 89ecf35a3c7004350a9d0e482c2aa03a3ab3dc8d Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 10 Nov 2023 12:17:51 +0000 Subject: [PATCH 051/114] move trainScript -> cli/train --- pyproject.toml | 5 +++-- src/gz21_ocean_momentum/{trainScript.py => cli/train.py} | 0 src/gz21_ocean_momentum/inference/utils.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) rename src/gz21_ocean_momentum/{trainScript.py => cli/train.py} (100%) diff --git a/pyproject.toml b/pyproject.toml index f3e12da1..0cfebaef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,8 +33,9 @@ dependencies = [ "requests", "aiohttp", - "progressbar2>=4.2.0", # inference/utils (but imported by trainScript) - "configargparse>=1.7", # CLI + # common CLI packages + "progressbar2>=4.2.0", + "configargparse>=1.7", ] authors = [ diff --git a/src/gz21_ocean_momentum/trainScript.py b/src/gz21_ocean_momentum/cli/train.py similarity index 100% rename from src/gz21_ocean_momentum/trainScript.py rename to src/gz21_ocean_momentum/cli/train.py diff --git a/src/gz21_ocean_momentum/inference/utils.py b/src/gz21_ocean_momentum/inference/utils.py index 94a40d80..4965f5a8 100755 --- a/src/gz21_ocean_momentum/inference/utils.py +++ b/src/gz21_ocean_momentum/inference/utils.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# TODO 2023-05-12 raehik: in `inference/`, but also used by `trainScript.py` +# TODO 2023-05-12 raehik: in `inference/`, but also used by `cli/train.py` # -*- coding: utf-8 -*- """ Created on Tue Jun 9 17:58:33 2020 From 499f18103ca8259516b3ae410554ed612441c9a9 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 10 Nov 2023 12:19:04 +0000 Subject: [PATCH 052/114] step/data: rename vars to clarify dataflow --- src/gz21_ocean_momentum/step/data/lib.py | 51 ++++++++++++++---------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/src/gz21_ocean_momentum/step/data/lib.py b/src/gz21_ocean_momentum/step/data/lib.py index 5f34d25e..f8b407e4 100644 --- a/src/gz21_ocean_momentum/step/data/lib.py +++ b/src/gz21_ocean_momentum/step/data/lib.py @@ -43,7 +43,7 @@ def retrieve_cm2_6( return surface_fields, grid -def cyclize(dim_name: str, ds: xr.Dataset, nb_points: int): +def cyclize(dim_name: str, ds: xr.Dataset, nb_points: int) -> xr.Dataset: """ Generate a cyclic dataset from non-cyclic input. @@ -63,7 +63,7 @@ def cyclize(dim_name: str, ds: xr.Dataset, nb_points: int): ------- New extended dataset. """ - # TODO 2023-09-20: old note from original import: "make this flexible" + # 2023-09-20 raehik: old note from original import: "make this flexible" cycle_length = 360.0 left = ds.roll({dim_name: nb_points}, roll_coords=True) right = left.isel({dim_name: slice(0, 2 * nb_points)}) @@ -85,7 +85,19 @@ def compute_forcings_and_coarsen_cm2_6( nan_or_zero: str = "zero", ) -> xr.Dataset: """ - Compute the sub-grid forcing terms using mean coarse-graining. + Coarsen and compute subgrid forcings for the given ocean surface velocities. + Takes in high-resolution data, outputs low-resolution with associated + subgrid forcings. + + Designed for CM2.6 simulation data. + + Rough outline: + + * apply a Gaussian filter + * compute subgrid forcing from filtered data, save in filtered dataset + * coarsen this amended filtered dataset + + See Guillaumin (2021) 2.2 for further details. Parameters ---------- @@ -130,34 +142,34 @@ def compute_forcings_and_coarsen_cm2_6( adv_filtered = _advections(u_v_filtered, grid_data) # Forcing - forcing = adv_filtered - filtered_adv - forcing = forcing.rename({"adv_x": "S_x", "adv_y": "S_y"}) + ds_forcing = adv_filtered - filtered_adv + ds_forcing = ds_forcing.rename({"adv_x": "S_x", "adv_y": "S_y"}) # Merge filtered u,v, temperature and forcing terms - forcing = forcing.merge(u_v_filtered) + ds_filtered_with_forcing = ds_forcing.merge(u_v_filtered) logger.debug("uncoarsened forcings follow below:") - logger.debug(forcing) + logger.debug(ds_filtered_with_forcing) # Coarsen - forcing_coarse = forcing.coarsen( + ds_filtered_with_forcing_coarse = ds_filtered_with_forcing.coarsen( {"xu_ocean": int(scale), "yu_ocean": int(scale)}, boundary="trim" ) - forcing_coarse = forcing_coarse.mean() + ds_filtered_with_forcing_coarse = ds_filtered_with_forcing.mean() if nan_or_zero == "zero": # Replace zeros with nans for consistency - forcing_coarse = forcing_coarse.where(forcing_coarse["usurf"] != 0) + ds_filtered_with_forcing_coarse = ds_filtered_with_forcing_coarse.where(ds_filtered_with_forcing_coarse["usurf"] != 0) # Specify input vs output type for each variable of the dataset. Might # be used later on for training or testing. - forcing_coarse["S_x"].attrs["type"] = "output" - forcing_coarse["S_y"].attrs["type"] = "output" - forcing_coarse["usurf"].attrs["type"] = "input" - forcing_coarse["vsurf"].attrs["type"] = "input" + ds_filtered_with_forcing_coarse["S_x"].attrs["type"] = "output" + ds_filtered_with_forcing_coarse["S_y"].attrs["type"] = "output" + ds_filtered_with_forcing_coarse["usurf"].attrs["type"] = "input" + ds_filtered_with_forcing_coarse["vsurf"].attrs["type"] = "input" - return forcing_coarse + return ds_filtered_with_forcing_coarse -def _advections(u_v_field: xr.Dataset, grid_data: xr.Dataset): +def _advections(u_v_field: xr.Dataset, grid_data: xr.Dataset) -> xr.Dataset: """ Compute advection terms corresponding to the passed velocity field. @@ -189,7 +201,6 @@ def _advections(u_v_field: xr.Dataset, grid_data: xr.Dataset): adv_x = u * gradient_x["usurf"] + v * gradient_y["usurf"] adv_y = u * gradient_x["vsurf"] + v * gradient_y["vsurf"] result = xr.Dataset({"adv_x": adv_x, "adv_y": adv_y}) - # TODO 2023-09-20: old note from original import: v # check if we can simply prevent the previous operation from adding chunks # result = result.chunk(dict(xu_ocean=-1, yu_ocean=-1)) return result @@ -236,11 +247,11 @@ def _spatial_filter_dataset( ) return filtered / norm -def _spatial_filter(data: np.ndarray, sigma: float): +def _spatial_filter(data: np.ndarray, sigma: float) -> np.ndarray: """ - Apply a gaussian filter to spatial data. + Apply a Gaussian filter to spatial data. - Apply scipy gaussian filter to along all dimensions except first one, which + Apply scipy Gaussian filter to along all dimensions except first one, which corresponds to time. Parameters From 95b48b3707240cc07c9bbe54bfaec5df52967a9e Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 10 Nov 2023 12:51:20 +0000 Subject: [PATCH 053/114] add example Slurm jobs --- examples/slurm-jobs/README.md | 2 + examples/slurm-jobs/data.sh | 125 ++++++++++++++++++++++++++++++++++ examples/slurm-jobs/train.sh | 117 +++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 examples/slurm-jobs/README.md create mode 100755 examples/slurm-jobs/data.sh create mode 100755 examples/slurm-jobs/train.sh diff --git a/examples/slurm-jobs/README.md b/examples/slurm-jobs/README.md new file mode 100644 index 00000000..f56b1170 --- /dev/null +++ b/examples/slurm-jobs/README.md @@ -0,0 +1,2 @@ +# Example Slurm jobs +Run on CSD3. diff --git a/examples/slurm-jobs/data.sh b/examples/slurm-jobs/data.sh new file mode 100755 index 00000000..0e2229d0 --- /dev/null +++ b/examples/slurm-jobs/data.sh @@ -0,0 +1,125 @@ +#!/bin/bash +#! +#! Example SLURM job script for Peta4-IceLake (Ice Lake CPUs, HDR200 IB) +#! Last updated: Sat Jul 31 15:39:45 BST 2021 +#! + +#!############################################################# +#!#### Modify the options in this section as appropriate ###### +#!############################################################# + +#! sbatch directives begin here ############################### +#! Name of the job: +#SBATCH -J cpujob +#! Which project should be charged: +#SBATCH -A ICCS-SL3-CPU +#SBATCH -p icelake +#! How many whole nodes should be allocated? +#SBATCH --nodes=1 +#! How many (MPI) tasks will there be in total? (<= nodes*76) +#! The Ice Lake (icelake) nodes have 76 CPUs (cores) each and +#! 3380 MiB of memory per CPU. +#SBATCH --ntasks=4 +##SBATCH --mem=16G +#SBATCH --time=00:02:00 +#! What types of email messages do you wish to receive? +#SBATCH --mail-type=NONE +#! Uncomment this to prevent the job from being requeued (e.g. if +#! interrupted by node failure or system downtime): +##SBATCH --no-requeue + +#! sbatch directives end here (put any additional directives above this line) + +#! Notes: +#! Charging is determined by cpu number*walltime. +#! The --ntasks value refers to the number of tasks to be launched by SLURM only. This +#! usually equates to the number of MPI tasks launched. Reduce this from nodes*76 if +#! demanded by memory requirements, or if OMP_NUM_THREADS>1. +#! Each task is allocated 1 CPU by default, and each CPU is allocated 3380 MiB +#! of memory. If this is insufficient, also specify +#! --cpus-per-task and/or --mem (the latter specifies MiB per node). + +#! Number of nodes and tasks per node allocated by SLURM (do not change): +numnodes=$SLURM_JOB_NUM_NODES +numtasks=$SLURM_NTASKS +mpi_tasks_per_node=$(echo "$SLURM_TASKS_PER_NODE" | sed -e 's/^\([0-9][0-9]*\).*$/\1/') +#! ############################################################ +#! Modify the settings below to specify the application's environment, location +#! and launch method: + +workdir=~/sh/gz21 + +#! Optionally modify the environment seen by the application +#! (note that SLURM reproduces the environment at submission irrespective of ~/.bashrc): +. /etc/profile.d/modules.sh # Leave this line (enables the module command) +module purge # Removes all modules still loaded +module load rhel8/default-icl # REQUIRED - loads the basic environment +module load python/3.11.0-icl +source $workdir/venv/bin/activate + +#! Insert additional module load commands after this line if needed: + +#! Full path to application executable: +application=python + +#! Run options for the application: +options="src/gz21_ocean_momentum/new/data/cli.py --config-file ~/sh/slurm/jobs/gz21/data-config.yaml" + +#! Are you using OpenMP (NB this is unrelated to OpenMPI)? If so increase this +#! safe value to no more than 76: +export OMP_NUM_THREADS=1 + +#! Number of MPI tasks to be started by the application per node and in total (do not change): +np=$[${numnodes}*${mpi_tasks_per_node}] + +#! The following variables define a sensible pinning strategy for Intel MPI tasks - +#! this should be suitable for both pure MPI and hybrid MPI/OpenMP jobs: +export I_MPI_PIN_DOMAIN=omp:compact # Domains are $OMP_NUM_THREADS cores in size +export I_MPI_PIN_ORDER=scatter # Adjacent domains have minimal sharing of caches/sockets +#! Notes: +#! 1. These variables influence Intel MPI only. +#! 2. Domains are non-overlapping sets of cores which map 1-1 to MPI tasks. +#! 3. I_MPI_PIN_PROCESSOR_LIST is ignored if I_MPI_PIN_DOMAIN is set. +#! 4. If MPI tasks perform better when sharing caches/sockets, try I_MPI_PIN_ORDER=compact. + + +#! Uncomment one choice for CMD below (add mpirun/mpiexec options if necessary): + +#! Choose this for a MPI code (possibly using OpenMP) using Intel MPI. +#CMD="mpirun -ppn $mpi_tasks_per_node -np $np $application $options" + +#! Choose this for a pure shared-memory OpenMP parallel program on a single node: +#! (OMP_NUM_THREADS threads will be created): +CMD="$application $options" + +#! Choose this for a MPI code (possibly using OpenMP) using OpenMPI: +#CMD="mpirun -npernode $mpi_tasks_per_node -np $np $application $options" + + +############################################################### +### You should not have to change anything below this line #### +############################################################### + +cd $workdir +echo -e "Changed directory to `pwd`.\n" + +JOBID=$SLURM_JOB_ID + +echo -e "JobID: $JOBID\n======" +echo "Time: `date`" +echo "Running on master node: `hostname`" +echo "Current directory: `pwd`" + +if [ "$SLURM_JOB_NODELIST" ]; then + #! Create a machine file: + export NODEFILE=`generate_pbs_nodefile` + cat $NODEFILE | uniq > machine.file.$JOBID + echo -e "\nNodes allocated:\n================" + echo `cat machine.file.$JOBID | sed -e 's/\..*$//g'` +fi + +echo -e "\nnumtasks=$numtasks, numnodes=$numnodes, mpi_tasks_per_node=$mpi_tasks_per_node (OMP_NUM_THREADS=$OMP_NUM_THREADS)" + +echo -e "\nExecuting command:\n==================\n$CMD\n" + +eval $CMD diff --git a/examples/slurm-jobs/train.sh b/examples/slurm-jobs/train.sh new file mode 100755 index 00000000..d38e3f9c --- /dev/null +++ b/examples/slurm-jobs/train.sh @@ -0,0 +1,117 @@ +#!/bin/bash +#! +#! Example SLURM job script for Wilkes3 (AMD EPYC 7763, ConnectX-6, A100) +#! Last updated: Fri 30 Jul 11:07:58 BST 2021 +#! + +#!############################################################# +#!#### Modify the options in this section as appropriate ###### +#!############################################################# + +#! sbatch directives begin here ############################### +#! Name of the job: +#SBATCH -J gpujob +#! Which project should be charged (NB Wilkes2 projects end in '-GPU'): +#SBATCH -A ICCS-SL3-GPU +#! How many whole nodes should be allocated? +#SBATCH --nodes=1 +#! How many (MPI) tasks will there be in total? +#! Note probably this should not exceed the total number of GPUs in use. +#SBATCH --ntasks=1 +#! Specify the number of GPUs per node (between 1 and 4; must be 4 if nodes>1). +#! Note that the job submission script will enforce no more than 32 cpus per GPU. +#SBATCH --gres=gpu:1 +#! How much wallclock time will be required? +#SBATCH --time=01:00:00 +#! What types of email messages do you wish to receive? +#SBATCH --mail-type=ALL +#! Uncomment this to prevent the job from being requeued (e.g. if +#! interrupted by node failure or system downtime): +##SBATCH --no-requeue + +#! Do not change: +#SBATCH -p ampere + +#! sbatch directives end here (put any additional directives above this line) + +#! Notes: +#! Charging is determined by GPU number*walltime. + +#! Number of nodes and tasks per node allocated by SLURM (do not change): +numnodes=$SLURM_JOB_NUM_NODES +numtasks=$SLURM_NTASKS +mpi_tasks_per_node=$(echo "$SLURM_TASKS_PER_NODE" | sed -e 's/^\([0-9][0-9]*\).*$/\1/') +#! ############################################################ +#! Modify the settings below to specify the application's environment, location +#! and launch method: + +workdir=~/sh/gz21 + +#! Optionally modify the environment seen by the application +#! (note that SLURM reproduces the environment at submission irrespective of ~/.bashrc): +. /etc/profile.d/modules.sh # Leave this line (enables the module command) +module purge # Removes all modules still loaded +module load rhel8/default-icl # REQUIRED - loads the basic environment +module load python/3.11.0-icl +source $workdir/venv/bin/activate + +#! Insert additional module load commands after this line if needed: + +#! Full path to application executable: +application="mlflow" + +#! Run options for the application: +options="run . --experiment-name raehik -e train --env-manager=local \ +-P forcing_data_path=/rds/user/bhgo2/hpc-work/generated/gz21/forcing/default-ish \ +-P learning_rate=0/5e-4/10/5e-5/20/5e-6 -P n_epochs=10000 -P weight_decay=0.00 -P train_split=0.8 \ +-P test_split=0.85 -P model_module_name=models.models1 -P model_cls_name=FullyCNN -P batchsize=4 \ +-P transformation_cls_name=SoftPlusTransform -P submodel=transform3 \ +-P loss_cls_name=HeteroskedasticGaussianLossV2 \ +" + +#! Work directory (i.e. where the job will run): +#workdir="$SLURM_SUBMIT_DIR" # The value of SLURM_SUBMIT_DIR sets workdir to the directory + # in which sbatch is run. + +#! Are you using OpenMP (NB this is unrelated to OpenMPI)? If so increase this +#! safe value to no more than 128: +export OMP_NUM_THREADS=1 + +#! Number of MPI tasks to be started by the application per node and in total (do not change): +np=$[${numnodes}*${mpi_tasks_per_node}] + +#! Choose this for a pure shared-memory OpenMP parallel program on a single node: +#! (OMP_NUM_THREADS threads will be created): +CMD="$application $options" + +#! Choose this for a MPI code using OpenMPI: +#CMD="mpirun -npernode $mpi_tasks_per_node -np $np $application $options" + + +############################################################### +### You should not have to change anything below this line #### +############################################################### + +cd $workdir +echo -e "Changed directory to `pwd`.\n" + +JOBID=$SLURM_JOB_ID + +echo -e "JobID: $JOBID\n======" +echo "Time: `date`" +echo "Running on master node: `hostname`" +echo "Current directory: `pwd`" + +if [ "$SLURM_JOB_NODELIST" ]; then + #! Create a machine file: + export NODEFILE=`generate_pbs_nodefile` + cat $NODEFILE | uniq > machine.file.$JOBID + echo -e "\nNodes allocated:\n================" + echo `cat machine.file.$JOBID | sed -e 's/\..*$//g'` +fi + +echo -e "\nnumtasks=$numtasks, numnodes=$numnodes, mpi_tasks_per_node=$mpi_tasks_per_node (OMP_NUM_THREADS=$OMP_NUM_THREADS)" + +echo -e "\nExecuting command:\n==================\n$CMD\n" + +eval $CMD From f5c466799cc0149d03761e2b35ed2fb695ef2268 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 10 Nov 2023 13:10:21 +0000 Subject: [PATCH 054/114] readme: clean up --- README.md | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 46bf24d1..3363fb9b 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ Documentation in this repository will refer back to sections from the paper e.g. ## Overview Most of this repository is concerned with preparing training data, and training -a NN. Each of these is handled with a standalone Python script, and data is -saved and loaded between via disk. +a NN. Each of these is handled with a standalone command-line interface (CLI) +Python script, and data is saved and loaded between via disk. In the "data" step, we generate training data using [simulation data from the CM2.6 climate model][cm26-ds] @@ -42,23 +42,15 @@ standard deviation of a Gaussian probability distribution for the forcing. This allows for stochastic implementations in online models. *(See Guillaumin (2021) 2.3 for a more in-depth explanation and how to interpret the NN output.)* -## Repository layout -* `src`: source code (both library functions and command-line scripts) +In the "testing" step, we test a trained model on an unseen region of data (the +subset not used in the previous training step). + +### Repository layout +* `src`: source code (both library functions and CLI scripts) * `tests`: pytest tests * `docs`: detailed project documentation, implementation notes * `examples`: CLI step configs, Jupyter notebooks for generating figures etc. -## Architecture -The model is written in Python, using PyTorch for the CNN. We provide 3 separate -"steps", which are run using different commands and arguments: - -* data processing: downloads subset of CM2.6 dataset, computes forcings -* model training: train model to predict forcing from (coarse) velocities -* model testing: test trained model on unseen region of data (the subset not - used in previous training step) - -For more details on each of the steps, see the [`docs`](docs/) directory. - ## Installation ### Dependencies Python 3.9 or newer is required. We primarily test on Python 3.11. @@ -103,6 +95,9 @@ See [`docs`](docs/) directory for more details. For command-line option explanation, run the appropriate step with `--help` e.g. `python src/gz21_ocean_momentum/cli/data.py --help`. +For CLI scripts which support reading in options from a file, various examples +are stored in [`examples/cli-configs`](examples/cli-configs/). + #### MLflow specifics MLflow parameters: From 2a33a3b68b3967bef0c0d13bca2291d6ffcd0d3f Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 10 Nov 2023 13:10:35 +0000 Subject: [PATCH 055/114] notebooks/readme: +note on common deps --- examples/jupyter-notebooks/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/jupyter-notebooks/README.md b/examples/jupyter-notebooks/README.md index 6033d9a3..940a24e3 100644 --- a/examples/jupyter-notebooks/README.md +++ b/examples/jupyter-notebooks/README.md @@ -7,7 +7,10 @@ Stochastic-deep learning parameterization of ocean momentum forcing][gz21-paper-agupubs]. The exact version of the code used to produce said paper can be found on [Zenodo][gz21-paper-code-zenodo]. -Some of these notebooks require the following dependencies (obtain using `pip`): +Notebooks require the same dependencies as gz21. Some have extra dependencies, +mostly for visualization. These should be displayed at the top of the notebook. +In particular, the following packages are common between multiple notebooks +(`pip install `): * `ipympl` * `cmocean` From 4b63e62cebb67fd07d0aa46e2b18920b0f3e0fb1 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 10 Nov 2023 13:11:15 +0000 Subject: [PATCH 056/114] cli/train: fix some bad CLI option defaults --- src/gz21_ocean_momentum/cli/train.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/gz21_ocean_momentum/cli/train.py b/src/gz21_ocean_momentum/cli/train.py index 8a2f7e58..b71e7ede 100755 --- a/src/gz21_ocean_momentum/cli/train.py +++ b/src/gz21_ocean_momentum/cli/train.py @@ -120,9 +120,12 @@ def check_str_is_None(string_in: str): parser.add_argument("--batchsize", type=int, default=8) parser.add_argument("--n_epochs", type=int, default=100) + +# TODO: borked parser.add_argument( "--learning_rate", type=learning_rates_from_string, default="0/1e-3" ) + parser.add_argument("--train_split", type=float, default=0.8, help="Between 0 and 1") parser.add_argument( "--test_split", @@ -141,7 +144,7 @@ def check_str_is_None(string_in: str): parser.add_argument( "--model_module_name", type=str, - default="models.fully_conv_net", + default="models.models1", help="Name of the module containing the nn model", ) parser.add_argument( @@ -176,6 +179,7 @@ def check_str_is_None(string_in: str): ) params = parser.parse_args() +print(params.learning_rate) def argparse_get_mlflow_artifact_path_or_direct_or_fail( mlflow_artifact_name: str, params: dict[str, Any] From 01af5cc65d70b2b84039ee80024b63bdf10dac86 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 10 Nov 2023 13:23:34 +0000 Subject: [PATCH 057/114] readme: tweak --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3363fb9b..0dc8d1a7 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,8 @@ os.environ['MLFLOW_TRACKING_URI'] = '/path/to/data/dir' The CLI into the data processing step is at [`cli/data.py`](src/gz21_ocean_momentum/cli/data.py). It generates coarse surface velocities and diagnosed forcings from the CM2.6 dataset and saves them -to disk. You may configure certain parameters such as bounds (lat/lon) and CO2 -level. +to disk. This is used as training data for our NN. You may configure certain +parameters such as bounds (lat/lon) and CO2 level. **You must configure GCP credentials to download the CM2.6 dataset used.** See [`docs/data.md`](docs/data.md) for more details. From de1695403d3dda543b369fa098229af0f028f6ce Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 10 Nov 2023 13:58:27 +0000 Subject: [PATCH 058/114] notebooks: more work on test-global --- examples/jupyter-notebooks/README.md | 17 +- examples/jupyter-notebooks/test-global.ipynb | 2362 +++++++++++++++++ .../test_global_control.ipynb | 1277 --------- 3 files changed, 2371 insertions(+), 1285 deletions(-) create mode 100644 examples/jupyter-notebooks/test-global.ipynb delete mode 100644 examples/jupyter-notebooks/test_global_control.ipynb diff --git a/examples/jupyter-notebooks/README.md b/examples/jupyter-notebooks/README.md index 940a24e3..18ff7638 100644 --- a/examples/jupyter-notebooks/README.md +++ b/examples/jupyter-notebooks/README.md @@ -33,11 +33,12 @@ forcing data, plus another set of forcings generated using the 1% annual CO2 increase CM2.6 dataset. Use `--config-file examples/cli-configs/data-paper-fig-6-1pct.yaml`. -`test-global-control.ipynb` generates figures 4, 5 and 7, as well as D4 and D5. For this, the inference step with -the trained neural network has to be run both on the data with `CO2=0` and with `CO2=1`, and then the notebook needs to -be run once with each set. The paper figures referring to _piControl_ are those with `CO2=0` (the control simulation -with pre-industrial CO2 levels), and the figures referring to _1pctCO2_ are those with `CO2=1` (a 1% increase per -year in CO2 levels for the first 70 years, after which they remain constant). -The notebook needs to be handed the experiment and run ID of the inference run, which is linked to the data and training -runs through `params.data_run_id` (run ID of data run) and `params.model_run_id` (run ID of training run), -respectively. +`test-global.ipynb` generates figures 4, 5 and 7, as well as D4 and D5. For +this, the inference step with the trained neural network has to be run both on +the data with and without `--co2-increase`, and then the notebook needs to be +run once with each set. *(The neural net may be trained only once, on data +without `--co2-increase`.)* The paper figures referring to _piControl_ are those +without `--co2-increase` (the control simulation with pre-industrial CO2 +levels), and the figures referring to _1pctCO2_ are those with `--co2-increase` +(a 1% increase per year in CO2 levels for the first 70 years, after which they +remain constant). diff --git a/examples/jupyter-notebooks/test-global.ipynb b/examples/jupyter-notebooks/test-global.ipynb new file mode 100644 index 00000000..e5e823ad --- /dev/null +++ b/examples/jupyter-notebooks/test-global.ipynb @@ -0,0 +1,2362 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Test on Global scale (Code to generate figures 3, 4, 6, 7)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Dependencies\n", + "* `ipympl` (`pip install ipympl`)\n", + "* `cmocean` (`pip install cmocean`)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that this notebook is intended to be run with both the \"control\" CM2.6 simulation run dataset, and the 1% annual CO2 increase one. To produce the paper figures:\n", + "\n", + "* run the data step to generate two sets of training data, one with `--co2-increase` (1ptCO2) and one without (control)\n", + "* train your model with the control dataset\n", + "* for each set of training data, predict with your trained model. The predictions should override the forcings we calculated (clumsy, but it's fine).\n", + "* load the two training data and prediction pairs in this notebook, making edits to the configuration part below as required" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## To-dos\n", + "* Clean up grid data downloading -- should be a utility in our library for this." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Configuration" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# Descriptive name for the CM2.6 data set being used in this notebook execution.\n", + "# This is used to create file names for exporting figures.\n", + "# For example, you may use `control` and `1pct`.\n", + "cm26_sim_run = \"unspecified\"\n", + "\n", + "# path to zarr (folder) holding the computed forcings output from a data step invocation\n", + "forcings_computed_path = \"~/sh/gz21/gz21/tmp/generated/forcings/paper-n100\"\n", + "\n", + "# path to zarr (folder) holding the predicted forcings output from an inference step invocation\n", + "forcings_predicted_path = \"~/sh/gz21/gz21/tmp/generated/forcings/paper-n100\"" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "#%matplotlib notebook #This option does not work in Jupyterlab\n", + "%matplotlib widget\n", + "\n", + "# See https://github.com/m2lines/gz21_ocean_momentum/blob/main/docs/data.md for an explanation\n", + "# The environment variable does not need setting if you place the credentials file at ~/.config/gcloud/application_default_credentials.json .\n", + "# %env GOOGLE_APPLICATION_CREDENTIALS /home/marion/.config/gcloud/application_default_credentials.json" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "To load the net from the paper, use the function load_paper_net().\n" + ] + } + ], + "source": [ + "import xarray as xr\n", + "import numpy as np\n", + "import dask.array as da\n", + "import matplotlib\n", + "import matplotlib.pyplot as plt\n", + "from gz21_ocean_momentum.utils import select_experiment, select_run\n", + "from gz21_ocean_momentum.analysis.utils import (plot_dataset, GlobalPlotter, anomalies,\n", + " download_data_pred, plot_time_series, apply_complete_mask)\n", + "from gz21_ocean_momentum.data.pangeo_catalog import get_whole_data\n", + "from gz21_ocean_momentum.data.xrtransforms import SeasonalStdizer, TargetedTransform, ScalingTransform\n", + "from dask.diagnostics import ProgressBar\n", + "from models.submodels import transform3\n", + "\n", + "import cartopy.crs as ccrs\n", + "import cmocean" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "# Setup the plotter\n", + "plt.rcParams[\"figure.figsize\"] = (4, 4 / 1.618)\n", + "\n", + "import gz21_ocean_momentum.analysis as analysis\n", + "GlobalPlotter = analysis.utils.GlobalPlotter\n", + "uv_plotter = GlobalPlotter()" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "old_plot = GlobalPlotter.plot\n", + "\n", + "def new_plot_func(self, name: str, *args, **kargs):\n", + " data = xr.Dataset({name: args[0]})\n", + " old_plot(self, *args, **kargs)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This downloads some information about the grid, used later on" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "CATALOG_URL = 'https://raw.githubusercontent.com/pangeo-data/pangeo-datastore\\\n", + "/master/intake-catalogs/master.yaml'\n", + "data = get_whole_data(CATALOG_URL, 0)\n", + "grid_info = data[1]" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "ename": "KeyError", + "evalue": "'S_xscale'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mKeyError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/venv/lib/python3.11/site-packages/xarray/core/dataset.py\u001b[0m in \u001b[0;36m?\u001b[0;34m(self, name)\u001b[0m\n\u001b[1;32m 1446\u001b[0m \u001b[0mvariable\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_variables\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1447\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0mKeyError\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1448\u001b[0;31m \u001b[0m_\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mname\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvariable\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0m_get_virtual_variable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_variables\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mname\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdims\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1449\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31mKeyError\u001b[0m: 'S_xscale'", + "\nDuring handling of the above exception, another exception occurred:\n", + "\u001b[0;31mKeyError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m/tmp/nix-shell.ifovvA/ipykernel_153458/2186194141.py\u001b[0m in \u001b[0;36m?\u001b[0;34m()\u001b[0m\n\u001b[1;32m 10\u001b[0m \u001b[0;31m# various transforms\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 11\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0;34m\"S_xpred\"\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mpred\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mkeys\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 12\u001b[0m \u001b[0;31m# \"for compatibility with old version\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 13\u001b[0m \u001b[0mpred\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mpred\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mrename\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mS_xpred\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m\"S_x\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mS_ypred\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m\"S_y\"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 14\u001b[0;31m \u001b[0mpred\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"S_xscale\"\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m1\u001b[0m \u001b[0;34m/\u001b[0m \u001b[0mpred\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"S_xscale\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 15\u001b[0m \u001b[0mpred\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"S_yscale\"\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m1\u001b[0m \u001b[0;34m/\u001b[0m \u001b[0mpred\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"S_yscale\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/venv/lib/python3.11/site-packages/xarray/core/dataset.py\u001b[0m in \u001b[0;36m?\u001b[0;34m(self, key)\u001b[0m\n\u001b[1;32m 1540\u001b[0m \"\"\"\n\u001b[1;32m 1541\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mutils\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mis_dict_like\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1542\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0misel\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m**\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1543\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mutils\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mhashable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1544\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_construct_dataarray\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1545\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mutils\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0miterable_of_hashable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1546\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_copy_listed\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1547\u001b[0m \u001b[0;32mraise\u001b[0m \u001b[0mValueError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34mf\"Unsupported key-type {type(key)}\"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/venv/lib/python3.11/site-packages/xarray/core/dataset.py\u001b[0m in \u001b[0;36m?\u001b[0;34m(self, name)\u001b[0m\n\u001b[1;32m 1444\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1445\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1446\u001b[0m \u001b[0mvariable\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_variables\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1447\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0mKeyError\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1448\u001b[0;31m \u001b[0m_\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mname\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvariable\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0m_get_virtual_variable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_variables\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mname\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdims\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1449\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1450\u001b[0m \u001b[0mneeded_dims\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mvariable\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdims\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1451\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/venv/lib/python3.11/site-packages/xarray/core/dataset.py\u001b[0m in \u001b[0;36m?\u001b[0;34m(variables, key, dim_sizes)\u001b[0m\n\u001b[1;32m 210\u001b[0m \u001b[0;32mraise\u001b[0m \u001b[0mKeyError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 211\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 212\u001b[0m \u001b[0msplit_key\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mkey\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msplit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\".\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;36m1\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 213\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mlen\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0msplit_key\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m!=\u001b[0m \u001b[0;36m2\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 214\u001b[0;31m \u001b[0;32mraise\u001b[0m \u001b[0mKeyError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 215\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 216\u001b[0m \u001b[0mref_name\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvar_name\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0msplit_key\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 217\u001b[0m \u001b[0mref_var\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mvariables\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mref_name\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31mKeyError\u001b[0m: 'S_xscale'" + ] + } + ], + "source": [ + "data = xr.open_zarr(forcings_computed_path)\n", + "\n", + "# various transforms\n", + "data = data.rename(xu_ocean=\"longitude\", yu_ocean=\"latitude\")\n", + "data[\"S_x\"] = data[\"S_x\"] * 1e7\n", + "data[\"S_y\"] = data[\"S_y\"] * 1e7\n", + "\n", + "pred = xr.open_zarr(forcings_predicted_path)\n", + "\n", + "# various transforms\n", + "if \"S_xpred\" in pred.keys():\n", + " # \"for compatibility with old version\"\n", + " pred = pred.rename(S_xpred=\"S_x\", S_ypred=\"S_y\")\n", + "pred[\"S_xscale\"] = 1 / pred[\"S_xscale\"]\n", + "pred[\"S_yscale\"] = 1 / pred[\"S_yscale\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "

Menu

MSE and R²   Correlation  Variance of forcing  Comparison of distributions  QQ-plot  Bias analysis  Time series plots" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# generate a nice little in-line menu for navigating through the rest of this notebook\n", + "from IPython.core.display import HTML\n", + "html = '

Menu

'\n", + "html += 'MSE and R²  '\n", + "html += ' Correlation  '\n", + "html += 'Variance of forcing  '\n", + "html += 'Comparison of distributions  '\n", + "html += 'QQ-plot  '\n", + "html += 'Bias analysis  '\n", + "html += 'Time series plots'\n", + "HTML(html)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.Dataset>\n",
+       "Dimensions:   (time: 100, yu_ocean: 609, xu_ocean: 900)\n",
+       "Coordinates:\n",
+       "  * time      (time) object 0181-01-01 12:00:00 ... 0181-04-10 12:00:00\n",
+       "  * xu_ocean  (xu_ocean) float64 -279.7 -279.3 -278.9 ... 79.05 79.45 79.85\n",
+       "  * yu_ocean  (yu_ocean) float64 -79.93 -79.76 -79.59 ... 79.55 79.71 79.88\n",
+       "Data variables:\n",
+       "    S_x       (time, yu_ocean, xu_ocean) float64 dask.array<chunksize=(1, 609, 900), meta=np.ndarray>\n",
+       "    S_y       (time, yu_ocean, xu_ocean) float64 dask.array<chunksize=(1, 609, 900), meta=np.ndarray>\n",
+       "    usurf     (time, yu_ocean, xu_ocean) float64 dask.array<chunksize=(1, 609, 900), meta=np.ndarray>\n",
+       "    vsurf     (time, yu_ocean, xu_ocean) float64 dask.array<chunksize=(1, 609, 900), meta=np.ndarray>
" + ], + "text/plain": [ + "\n", + "Dimensions: (time: 100, yu_ocean: 609, xu_ocean: 900)\n", + "Coordinates:\n", + " * time (time) object 0181-01-01 12:00:00 ... 0181-04-10 12:00:00\n", + " * xu_ocean (xu_ocean) float64 -279.7 -279.3 -278.9 ... 79.05 79.45 79.85\n", + " * yu_ocean (yu_ocean) float64 -79.93 -79.76 -79.59 ... 79.55 79.71 79.88\n", + "Data variables:\n", + " S_x (time, yu_ocean, xu_ocean) float64 dask.array\n", + " S_y (time, yu_ocean, xu_ocean) float64 dask.array\n", + " usurf (time, yu_ocean, xu_ocean) float64 dask.array\n", + " vsurf (time, yu_ocean, xu_ocean) float64 dask.array" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "ename": "KeyError", + "evalue": "'longitude'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mKeyError\u001b[0m Traceback (most recent call last)", + "File \u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/venv/lib/python3.11/site-packages/xarray/core/dataarray.py:839\u001b[0m, in \u001b[0;36mDataArray._getitem_coord\u001b[0;34m(self, key)\u001b[0m\n\u001b[1;32m 838\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[0;32m--> 839\u001b[0m var \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_coords\u001b[49m\u001b[43m[\u001b[49m\u001b[43mkey\u001b[49m\u001b[43m]\u001b[49m\n\u001b[1;32m 840\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mKeyError\u001b[39;00m:\n", + "\u001b[0;31mKeyError\u001b[0m: 'longitude'", + "\nDuring handling of the above exception, another exception occurred:\n", + "\u001b[0;31mKeyError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m/tmp/nix-shell.ifovvA/ipykernel_153458/666010548.py\u001b[0m in \u001b[0;36m?\u001b[0;34m()\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m uv_plotter.plot(data['S_x'].isel(time=70), lon=0., projection_cls = ccrs.PlateCarree,\n\u001b[0m\u001b[1;32m 2\u001b[0m colorbar_label='m/s', cmap=cmocean.cm.delta, vmin=-1, vmax=1)\n", + "\u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/src/gz21_ocean_momentum/analysis/utils.py\u001b[0m in \u001b[0;36m?\u001b[0;34m(self, u, projection_cls, lon, lat, ax, animated, borders_color, borders_alpha, colorbar_label, **plot_func_kw)\u001b[0m\n\u001b[1;32m 427\u001b[0m \u001b[0mfig\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mplt\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfigure\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 428\u001b[0m \u001b[0mprojection\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mprojection_cls\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mlon\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 429\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0max\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 430\u001b[0m \u001b[0max\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mplt\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0maxes\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mprojection\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mprojection\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 431\u001b[0;31m \u001b[0mmesh_x\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mmesh_y\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mnp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mmeshgrid\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mu\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"longitude\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mu\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"latitude\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 432\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mu\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 433\u001b[0m \u001b[0mextra\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mmask\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0misel\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mlongitude\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mslice\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;36m0\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;36m10\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 434\u001b[0m \u001b[0mextra\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"longitude\"\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mextra\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"longitude\"\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m+\u001b[0m \u001b[0;36m360\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/venv/lib/python3.11/site-packages/xarray/core/dataarray.py\u001b[0m in \u001b[0;36m?\u001b[0;34m(self, key)\u001b[0m\n\u001b[1;32m 846\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m__getitem__\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mkey\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mAny\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m->\u001b[0m \u001b[0mSelf\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 847\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0misinstance\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mstr\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 848\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_getitem_coord\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 849\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 850\u001b[0m \u001b[0;31m# xarray-style array indexing\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 851\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0misel\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mindexers\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_item_key_to_dict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/venv/lib/python3.11/site-packages/xarray/core/dataarray.py\u001b[0m in \u001b[0;36m?\u001b[0;34m(self, key)\u001b[0m\n\u001b[1;32m 838\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 839\u001b[0m \u001b[0mvar\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_coords\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 840\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0mKeyError\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 841\u001b[0m \u001b[0mdim_sizes\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mdict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mzip\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdims\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mshape\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 842\u001b[0;31m \u001b[0m_\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mkey\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvar\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0m_get_virtual_variable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_coords\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mkey\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdim_sizes\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 843\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 844\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_replace_maybe_drop_dims\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mvar\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mname\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/venv/lib/python3.11/site-packages/xarray/core/dataset.py\u001b[0m in \u001b[0;36m?\u001b[0;34m(variables, key, dim_sizes)\u001b[0m\n\u001b[1;32m 210\u001b[0m \u001b[0;32mraise\u001b[0m \u001b[0mKeyError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 211\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 212\u001b[0m \u001b[0msplit_key\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mkey\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msplit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\".\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;36m1\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 213\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mlen\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0msplit_key\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m!=\u001b[0m \u001b[0;36m2\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 214\u001b[0;31m \u001b[0;32mraise\u001b[0m \u001b[0mKeyError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 215\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 216\u001b[0m \u001b[0mref_name\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvar_name\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0msplit_key\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 217\u001b[0m \u001b[0mref_var\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mvariables\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mref_name\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31mKeyError\u001b[0m: 'longitude'" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "ff8fe7a9c61540a3b88ab96ad03eb055", + "version_major": 2, + "version_minor": 0 + }, + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAZAAAAD3CAYAAAAzOQKaAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/SrBM8AAAACXBIWXMAAA9hAAAPYQGoP6dpAAAEHElEQVR4nO3ZIW7jUBSG0ddRZQUFmwR0/4sqKAkOskzcFQTMN1bfNDpnBT+6H7hvx3EcAwD+0p/ZAwD4nQQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEjeZw84y7ZtY9/32TMATrMsy7hcLrNnPPUSAdm2bXx8fIz7/T57CsBp1nUdn5+f/21EXiIg+76P+/0+vr6+xvV6nT0H4J89Ho9xu93Gvu8C8hOu16uAAPwQT3QAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAkvfZA870eDxmTwA4xW+4Zy8RkGVZxrqu43a7zZ4CcJp1XceyLLNnPPV2HMcxe8QZtm0b+77PngFwmmVZxuVymT3jqZcJCAA/yxMdgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASAQEgERAAEgEBIBEQABIBASAREAASL4BysE4oyl+7gQAAAAASUVORK5CYII=", + "text/html": [ + "\n", + "
\n", + "
\n", + " Figure\n", + "
\n", + " \n", + "
\n", + " " + ], + "text/plain": [ + "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "uv_plotter.plot(data['S_x'].isel(time=70), lon=0., projection_cls = ccrs.PlateCarree,\n", + " colorbar_label='m/s', cmap=cmocean.cm.delta, vmin=-1, vmax=1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Below is the plot shown in Figure 5 of the paper" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def plot_time_series(data, pred, longitude: float, latitude: float, time: slice,\n", + " std: bool = True, true: bool = True):\n", + " plt.figure()\n", + " xs = np.arange(time.start, time.stop, time.step)\n", + " truth = data['S_x'].sel(longitude=longitude, latitude=latitude,\n", + " method='nearest').isel(time=time)\n", + " pred_mean = pred['S_x'].sel(longitude=longitude, latitude=latitude,\n", + " method='nearest').isel(time=time)\n", + " pred_std = pred['S_xscale'].sel(longitude=longitude, latitude=latitude,\n", + " method='nearest').isel(time=time)\n", + " if true:\n", + " plt.plot(xs, truth, 'darkblue')\n", + " plt.plot(xs, pred_mean, 'darkorange')\n", + " if std:\n", + " plt.plot(xs, pred_mean + 1.96 * pred_std, 'g--', linewidth=1)\n", + " plt.plot(xs, pred_mean - 1.96 * pred_std, 'g--', linewidth=1)\n", + " plt.ylabel(r'$1e^{-7}m/s^2$')\n", + " _ = plt.xlabel('days')\n", + " \n", + "time_slice=slice(0, 300)\n", + "plt.rcParams[\"figure.figsize\"] = (4 * 2, 4 * 2 / 1.618)\n", + "\n", + "plot_time_series(data, pred, longitude=-60, latitude=30, time=time_slice, std=True, true=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.savefig(f\"timeseries-cm26-{cm26_sim_run}.jpg\", dpi=400)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## MSE and R²" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "First we compute the seasonal (monthly) means of the data. This will be used later in some of the metrics." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "forcing_vars = ['S_x', 'S_y']\n", + "errors = pred[forcing_vars] - data[forcing_vars]\n", + "errors_cycle = anomalies(data[forcing_vars])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Below, mse is the time-mean MSE of the mean component of our predicted forcing, mse_month is the variance of the residuals of the data after removing monthly variation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mse = (errors**2).mean(dim='time')\n", + "mse_cycle = (errors_cycle**2).mean(dim='time')\n", + "amplitudes = (data[forcing_vars]**2).mean(dim='time')\n", + "\n", + "with ProgressBar():\n", + " mse = mse.compute()\n", + " mse_cycle = mse_cycle.compute()\n", + " amplitudes = amplitudes.compute()\n", + "mse['total'] = mse['S_x'] + mse['S_y']\n", + "mse_cycle['total'] = mse_cycle['S_x'] + mse_cycle['S_y']\n", + "amplitudes['total'] = amplitudes['S_x'] + amplitudes['S_y']" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### MSE plot" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Below is Figure 4a of the paper" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.rcParams[\"figure.figsize\"] = (4*2, 4 * 2 / 1.618)\n", + "x = uv_plotter.plot(mse['total'], lon=0., cmap=cmocean.cm.dense,\n", + " colorbar_label=r'$1e^{-14}m^2/s^4$', norm=matplotlib.colors.LogNorm(vmin=0.01, vmax=10))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.savefig(f\"mse-cm26-{cm26_sim_run}.jpg\", dpi=400)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### R² plot" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Below is Figure 4b of the paper" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib\n", + "rsquared = 1 - mse / amplitudes\n", + "rsquared_cycles = 1 - mse / mse_cycle\n", + "mse_ratio_2 = 1 - mse_cycle / amplitudes\n", + "uv_plotter.plot(rsquared_cycles['total'], cmap=cmocean.cm.delta, lon=0., norm=matplotlib.colors.LogNorm(vmin=0.5, vmax=1))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.savefig(f\"r2-month-cm26-{cm26_sim_run}.jpg\", dpi=400)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Scalar R²" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We limit the range to latitudes -60 to 60, and we apply a mask that discards points near continents according to the mask used in the plotter (the points shown in gray on the maps in the paper). This is why we define these quantities \"to_scalar\", in order to not account for points near continents in the computation of the scalar R²." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "latitudes = slice(-60, 60)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mse_to_scalar = apply_complete_mask(mse, pred, uv_plotter)\n", + "mse_cycle_to_scalar = apply_complete_mask(mse_cycle, pred, uv_plotter)\n", + "amplitudes_to_scalar = apply_complete_mask(amplitudes, pred, uv_plotter)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with ProgressBar():\n", + " mse_scalar = mse_to_scalar.sel(latitude=latitudes).sum().compute()\n", + " mse_cycle_scalar = mse_cycle_to_scalar.sel(latitude=latitudes).sum().compute()\n", + " amplitudes_scalar = amplitudes_to_scalar.sel(latitude=latitudes).sum().compute()\n", + " rsquared_scalar_cycle = 1 - mse_scalar / mse_cycle_scalar\n", + " rsquared_scalar = 1 - mse_scalar / amplitudes_scalar\n", + "print(rsquared_scalar)\n", + "print(rsquared_scalar_cycle)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Correlation between true forcing and mean component of the prediction " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "forcing_vars = ['S_x', 'S_y']\n", + "data_anomaly = anomalies(data[forcing_vars])\n", + "pred_anomaly = anomalies(pred[forcing_vars])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "std_data = data_anomaly.std(dim='time')\n", + "std_pred = pred_anomaly.std(dim='time')\n", + "corr = ((data_anomaly * pred_anomaly).mean(dim='time') - data_anomaly.mean(dim='time') * pred_anomaly.mean(dim='time')) / (std_data * std_pred)\n", + "# corr_s_y = xr.corr(data.S_y, pred.S_y, dim='time')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with ProgressBar():\n", + " corr = corr.compute()\n", + " # corr_s_y = corr_s_y.compute()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "uv_plotter.plot(corr['S_x'], vmin=0.7, vmax=1., lon=0., cmap=cmocean.cm.balance_r)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.savefig(f\"corr-x-cm26-{cm26_sim_run}.jpg\", dpi=400)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Variance of norm of subgrid momentum forcing " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "norm_S = np.sqrt(data['S_x']**2 + data['S_y']**2)\n", + "norm_Spred = np.sqrt(pred['S_x']**2 + pred['S_y']**2)\n", + "var_norm_S = norm_S.var(dim='time')\n", + "var_norm_Spred = norm_Spred.var(dim='time')\n", + "with ProgressBar():\n", + " var_norm_S = var_norm_S.compute()\n", + " var_norm_Spred = var_norm_Spred.compute()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "uv_plotter.plot(var_norm_S, cmap=cmocean.cm.dense, lon=0., colorbar_label=r'$1e^{-14}m^2s^{-4}$', norm=matplotlib.colors.LogNorm(vmin=0.01, vmax=10,))\n", + "uv_plotter.plot(var_norm_Spred, cmap=cmocean.cm.dense, lon=0., colorbar_label=r'$1e^{-14}m^2s^{-4}$', norm=matplotlib.colors.LogNorm(vmin=0.01, vmax=10,))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.savefig(f\"variance-forcing-control-cm26-{cm26_sim_run}.jpg\", dpi=400)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Compare distributions of true and stochastic simulated forcing" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "forcing_vars = ['S_x', 'S_y']\n", + "scale_vars = ['S_xscale', 'S_yscale']\n", + "\n", + "pred_ = apply_complete_mask(pred, pred, uv_plotter)\n", + "pred_scale = pred_[scale_vars].rename(dict(S_xscale='S_x', S_yscale='S_y'))\n", + "pred_ = pred_[forcing_vars]\n", + "data_ = apply_complete_mask(data[forcing_vars], pred, uv_plotter)\n", + "\n", + "# Subsample the data\n", + "time_slice = slice(None, None, 1)\n", + "lon_slice = slice(None, None, 2)\n", + "lat_slice = slice(-60, 60, 2)\n", + "pred_ = pred_.sel(longitude=lon_slice, latitude=lat_slice).isel(time=time_slice)\n", + "pred_scale = pred_scale.sel(longitude=lon_slice, latitude=lat_slice).isel(time=time_slice)\n", + "data_ = data_.sel(longitude=lon_slice, latitude=lat_slice).isel(time=time_slice)\n", + "\n", + "# Standardized residuals\n", + "residuals = (data_ - pred_) / pred_scale" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "assert np.all(np.isnan(data_['S_x']) == np.isnan(pred_['S_x'])), \"Not the same number of points!\"\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here we do a stochastic simulation of the forcing given the parameters of the Gaussian distribution at each location and each time point" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "shape = tuple(pred_.dims.values())\n", + "epsilons = dict(x=np.random.randn(*shape), y=np.random.randn(*shape))\n", + "epsilons = xr.Dataset(dict(S_x=(pred_.dims, epsilons['x']), S_y=(pred_.dims, epsilons['y'])))\n", + "pred_stochastic = pred_ + pred_scale * epsilons" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "data_" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pred_" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "bins = np.arange(-20, 21, 1)\n", + "\n", + "# assert np.all(np.isnan(data_['S_x']) == np.isnan(pred_stochastic['S_x'])), \"Not the same number of points!\"\n", + "\n", + "plt.figure()\n", + "plt.subplot(121)\n", + "with ProgressBar():\n", + " plt.hist(np.ravel(data_['S_x']), bins=bins, density=True, log=True, alpha=0.5, color='purple')\n", + " plt.hist(np.ravel(pred_stochastic['S_x']), bins=bins, density=True, log=True, alpha=0.5, color='green')\n", + "plt.title('Zonal component')\n", + "plt.xlabel(r'$1e^{-7}m/s^2$')\n", + "plt.ylabel('log density')\n", + "plt.subplot(122)\n", + "with ProgressBar():\n", + " plt.hist(np.ravel(data_['S_y']), bins=bins, density=True, log=True, alpha=0.5, color='purple')\n", + " plt.hist(np.ravel(pred_stochastic['S_y']), bins=bins, density=True, log=True, alpha=0.5, color='green')\n", + "plt.title('Meridional component')\n", + "plt.xlabel(r'$1e^{-7}m/s^2$')\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.savefig(f\"forcing-dist-cm26-{cm26_sim_run}.jpg\", dpi=400)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.figure()\n", + "bins=np.arange(-6, 6, 0.025)\n", + "from scipy.stats import norm\n", + "with ProgressBar():\n", + " plt.subplot(121)\n", + " plt.hist(np.ravel(residuals['S_x'].compute()), bins=bins, density=True, color='orange')\n", + " plt.plot(bins, (norm.pdf(bins)), 'r')\n", + " plt.title('Meridional component')\n", + " plt.subplot(122)\n", + " plt.hist(np.ravel(residuals['S_y'].compute()), bins=bins, density=True, color='orange')\n", + " plt.plot(bins, (norm.pdf(bins)), 'r')\n", + " plt.title('Zonal component')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.savefig('normalized_residuals_ditribution.jpg', dpi=300)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### QQ plot" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "quantiles = np.exp(np.linspace(-5, 5, 100)) / (1 + np.exp(np.linspace(-5, 5, 100)))\n", + "quantiles = np.linspace(0.01, 0.99, 99)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "quantiles" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with ProgressBar():\n", + " quantiles_x = np.nanquantile(residuals['S_x'].compute(), quantiles)\n", + " quantiles_y = np.nanquantile(residuals['S_y'].compute(), quantiles)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from scipy.stats import norm, cauchy, t\n", + "quantiles_norm = norm.ppf(quantiles)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.figure()\n", + "reference = quantiles_norm\n", + "plt.subplot(121)\n", + "plt.plot(reference, quantiles_x, 'x')\n", + "plt.plot(reference, reference, 'g')\n", + "plt.axis([None, None, -5, 5])\n", + "plt.title('Zonal component')\n", + "plt.subplot(122)\n", + "plt.plot(reference, quantiles_y, 'x')\n", + "plt.plot(reference, reference, 'g')\n", + "plt.axis([None, None, -5, 5])\n", + "plt.title('Meridional component')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.savefig(f\"normalized-residuals-qq-cm26-{cm26_sim_run}.jpg\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### (IGNORE THIS) Another way to do it" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from scipy.stats import norm\n", + "lon = slice(None, None, 1)\n", + "lat= slice(-40, 40, 1)\n", + "time_slice = slice(None, 1000, 1)\n", + "\n", + "true = apply_complete_mask(data['S_x'])\n", + "pred_mean = apply_complete_mask(pred['S_x'])\n", + "pred_std = apply_complete_mask(pred['S_xscale'])\n", + "\n", + "def my_transform(x , mean, precision):\n", + " cdf = lambda x: norm.cdf((x - mean) * precision) \n", + " return cdf(x)\n", + "\n", + "v = xr.apply_ufunc(my_transform, true, pred_mean, 1 / pred_std,\n", + " dask='parallelized', output_dtypes=[np.float64, ])\n", + "residuals = (true - pred_mean) / pred_std\n", + "residuals = residuals.sel(longitude=lon, latitude=lat).isel(time=time_slice)\n", + "v = v.sel(longitude=lon, latitude=lat).isel(time=time_slice)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with ProgressBar():\n", + " q2 = np.nanquantile(residuals, quantiles)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "norm_quantiles = norm.ppf(quantiles)\n", + "plt.figure()\n", + "plt.plot(norm_quantiles, q2)\n", + "plt.plot(norm_quantiles, norm_quantiles)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "q = np.nanquantile(v, quantiles)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.figure()\n", + "plt.plot(quantiles, q, 'x')\n", + "plt.plot(quantiles, quantiles)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## (IGNORE THIS) Likelihood plot" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from scipy.stats import norm\n", + "lon = slice(None, None, 1)\n", + "lat= slice(-80, 80, 1)\n", + "time_slice = slice(None, None, 1)\n", + "\n", + "true = data['S_x'].isel(time=time_slice)\n", + "pred_mean = pred['S_x'].isel(time=time_slice)\n", + "pred_std = pred['S_xscale'].isel(time=time_slice)\n", + "\n", + "residuals = (true - pred_mean) / pred_std\n", + "log_lkh = xr.apply_ufunc(lambda x: np.log(norm.pdf(x)), residuals, dask='parallelized', output_dtypes=[np.float64,])\n", + "with ProgressBar():\n", + " log_lkh = log_lkh.compute()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "true" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "uv_plotter.margin=10\n", + "uv_plotter.plot(-log_lkh.mean(dim='time'), vmin=0, vmax=2.5)\n", + "apply_complete_mask(-log_lkh, pred, uv_plotter).mean()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "lat= slice(-40, 40, 1)\n", + "\n", + "with ProgressBar():\n", + " lkh_mean = lkh.sel(latitude=lat).isel(time=time_slice).mean().compute()\n", + "lkh_mean" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Bias analysis" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "forcing_vars = ['S_x', 'S_y']\n", + "errors = pred[forcing_vars] - data[forcing_vars]\n", + "map_errors = errors.mean(dim='time')\n", + "with ProgressBar():\n", + " map_errors = map_errors.compute()\n", + " absolute = (abs(data[forcing_vars])).mean(dim='time').compute()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "relative_bias = (map_errors / absolute).compute()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with ProgressBar():\n", + " uv_plotter.plot(abs(relative_bias['S_x']), cmap=cmocean.cm.delta, lon=0., vmin=0.01, vmax=1, norm=matplotlib.colors.LogNorm())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.savefig(f\"relative-bias-cm26-{cm26_sim_run}.jpg\", dpi=400)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Time series plots" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.rcParams[\"figure.figsize\"] = (4*2, 4*2 / 1.618)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "points = [(-60, 30), (-104, -20), (-129, 29)]\n", + "\n", + "plot_time_series(data, pred, *points[1], slice(0, 300), std=True)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.savefig(f\"timeseries-quescient-cm26-{cm26_sim_run}.jpg\", dpi=400)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# (IGNORE THIS) Comparison of quantiles" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pred" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from analysis.base import QuantileCompare\n", + "\n", + "with ProgressBar():\n", + " qq = QuantileCompare()\n", + " qq.quantiles = [0.5, 0.25, 0.5, 0.75, 0.95]\n", + " qq.data = ((pred['S_x']-data['S_x']) / pred['S_xscale']).isel(time=slice(None, None, 1)).compute()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with ProgressBar():\n", + " q_0_75 = qq.data_quantiles[0.75]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from scipy.stats import norm\n", + "norm.ppf(0.75)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import cartopy.crs as ccrs\n", + "import cmocean\n", + "cmap = cmocean.cm.balance\n", + "uv_plotter.plot(np.abs(q_0_75 - 0.6745) < 0.05, vmin=0, vmax=1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "uv_plotter.plot(data['S_x'].isel(time=0), cmap=cmap_balance, vmin=-2, vmax=2)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.plot((data['S_x'].sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800))).data)\n", + "plt.plot((pred['S_xpred'].sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800))).data)\n", + "plt.plot((pred['S_xpred'].sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800)) + 1.96 * pred['S_xscale'].sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800))).data, '--')\n", + "plt.plot((pred['S_xpred'].sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800)) - 1.96 * pred['S_xscale'].sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800))).data, '--')\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "r = qq.data.sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800)).compute()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "np.quantile(r, [0.25, 0.5, 0.75])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.hist(np.ravel(q_0_25.data), bins=np.arange(-2, 2, 0.1))\n", + "plt.title('Histogram of 0.25 quantiles of normalized residuals')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "np.quantile(r.data, [0.25, 0.5, 0.75])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "np.max(q_0_5).compute()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Snapshot of the forcing" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import cartopy.crs as ccrs\n", + "cmap = cmocean.cm.balance\n", + "s_x = v\n", + "\n", + "ax = plt.axes(projection=ccrs.PlateCarree(-100.))\n", + "mesh_x, mesh_y = np.meshgrid(s_x['longitude'], s_x['latitude'])\n", + "mesh_x = mesh_x + 360\n", + "ax.pcolormesh(mesh_x, mesh_y, s_x.values, vmin=-4, vmax=4, transform = ccrs.PlateCarree(), cmap=cmap, alpha=1)\n", + "mesh_x, mesh_y = np.meshgrid(borders['longitude'], borders['latitude'])\n", + "mesh_x = mesh_x + 360\n", + "ax.pcolormesh(mesh_x, mesh_y, borders * 1., transform=ccrs.PlateCarree(), alpha=0.1)\n", + "ax.set_global()\n", + "ax.coastlines()\n", + "ax.set_xticks(np.arange(-180, 181, 20))\n", + "ax.set_yticks(np.arange(-80,81, 20))\n", + "#ax.set_extent([-20, 20, -20, 20])\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib notebook" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from matplotlib import animation\n", + "cmap = cmocean.cm.amp\n", + "import cartopy.crs as ccrs\n", + "\n", + "fig = plt.figure()\n", + "\n", + "try:\n", + " del video\n", + "except:\n", + " pass\n", + "\n", + "uv_plotter.x_ticks = None\n", + "uv_plotter.y_ticks = None\n", + "\n", + "def animate(i):\n", + " print(i)\n", + " v = pred['S_xscale'].isel(time=i)\n", + " uv_plotter.plot(v, projection_cls = ccrs.Orthographic, lon=(i/5)%360, cmap=cmap, vmin=0, vmax=2, animated=True)\n", + " \n", + "ani = animation.FuncAnimation(fig, animate, frames = 500, interval = 50)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib\n", + "matplotlib.rcParams['animation.embed_limit'] = 100" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ani.save('forcing_pred_mean.mp4', fps=60, dpi=300)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from IPython.display import HTML\n", + "video = ani.to_html5_video()\n", + "HTML(video)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "e = (merged['S_xpred'] - merged['S_x']) / merged['S_xscale']\n", + "d = (e**2).mean(dim='time').compute()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d_ = abs(d-1)\n", + "d_ = d_.interp(mask_.coords)\n", + "d_ = xr.where(borders, -1000, d_)\n", + "d_ = xr.where(mask__, d_, np.nan)\n", + "d_ = d_.interp(latitude = np.arange(-80, 80, 0.1), longitude = np.arange(-279.9, 80.1, 0.1))\n", + "d_['longitude'] = d_['longitude'] + 100.\n", + "\n", + "ax = plt.axes(projection=ccrs.PlateCarree())\n", + "d_.plot.imshow(x='longitude', y='latitude', ax=ax, vmin=0, vmax=2, cmap=cmap,\n", + " transform = ccrs.PlateCarree(-100.))\n", + "ax.set_global()\n", + "ax.coastlines()\n", + "x_ticks = plt.xticks(np.arange(-180, 181, 20))\n", + "y_ticks = plt.yticks(np.arange(-80, 81, 20))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/jupyter-notebooks/test_global_control.ipynb b/examples/jupyter-notebooks/test_global_control.ipynb deleted file mode 100644 index 54befba2..00000000 --- a/examples/jupyter-notebooks/test_global_control.ipynb +++ /dev/null @@ -1,1277 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Test on Global scale " - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Dependencies\n", - "* `ipympl` (`pip install ipympl`)\n", - "* `cmocean` (`pip install cmocean`)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To produce the paper figures, this notebook has to be run twice: once with CO2=1 settings and once with CO2=0." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "#%matplotlib notebook #This option does not work in Jupyterlab\n", - "%matplotlib widget\n", - "\n", - "# See https://github.com/m2lines/gz21_ocean_momentum/blob/main/docs/data.md for an explanation\n", - "# The environment variable does not need setting if you place the credentials file at ~/.config/gcloud/application_default_credentials.json .\n", - "# %env GOOGLE_APPLICATION_CREDENTIALS /home/marion/.config/gcloud/application_default_credentials.json" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Imports\n", - "import mlflow\n", - "from mlflow.tracking import client\n", - "import xarray as xr\n", - "import numpy as np\n", - "import dask.array as da\n", - "import matplotlib\n", - "import matplotlib.pyplot as plt\n", - "from gz21_ocean_momentum.utils import select_experiment, select_run\n", - "from gz21_ocean_momentum.analysis.utils import (plot_dataset, GlobalPlotter, anomalies,\n", - " download_data_pred, plot_time_series, apply_complete_mask)\n", - "from gz21_ocean_momentum.data.pangeo_catalog import get_whole_data\n", - "from gz21_ocean_momentum.data.xrtransforms import SeasonalStdizer, TargetedTransform, ScalingTransform\n", - "from dask.diagnostics import ProgressBar\n", - "from models.submodels import transform3\n", - "\n", - "import cartopy.crs as ccrs\n", - "import cmocean" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Setup the plotter\n", - "plt.rcParams[\"figure.figsize\"] = (4, 4 / 1.618)\n", - "\n", - "import gz21_ocean_momentum.analysis as analysis\n", - "GlobalPlotter = analysis.utils.GlobalPlotter\n", - "uv_plotter = GlobalPlotter()\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "old_plot = GlobalPlotter.plot\n", - "\n", - "def new_plot_func(self, name: str, *args, **kargs):\n", - " data = xr.Dataset({name: args[0]})\n", - " old_plot(self, *args, **kargs)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "This downloads some information about the grid, used later on" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "CATALOG_URL = 'https://raw.githubusercontent.com/pangeo-data/pangeo-datastore\\\n", - "/master/intake-catalogs/master.yaml'\n", - "data = get_whole_data(CATALOG_URL, 0)\n", - "grid_info = data[1]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "print(\"Select the experiment of the inference run.\")\n", - "\n", - "exp_id, test_exp_name = select_experiment()\n", - "cols=['status', 'start_time', 'params.CO2', 'params.factor',\n", - " 'params.submodel', 'params.loss_cls_name']\n", - "# In the following merge parameter, the first strings in the tupels need to be the experiment names for the data run and the training run, respectively.\n", - "# If these are in the same experiment they can either be duplicated, or you can set merge=[].\n", - "# This is used to show the available runs.\n", - "merge=[('data', 'params.data_run_id', 'run_id'),\n", - " ('train', 'params.model_run_id', 'run_id')]\n", - "run = select_run(experiment_ids=exp_id, cols=cols, merge=merge)\n", - "data, pred = download_data_pred(run['params.data_run_id'], run.run_id)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from IPython.core.display import HTML\n", - "html = '

Menu

'\n", - "html += 'MSE and R²  '\n", - "html += ' Correlation  '\n", - "html += 'Variance of forcing  '\n", - "html += 'Comparison of distributions  '\n", - "html += 'QQ-plot  '\n", - "html += 'Bias analysis  '\n", - "html += 'Time series plots'\n", - "HTML(html)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "data" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "uv_plotter.plot(data['S_x'].isel(time=70), lon=0., projection_cls = ccrs.PlateCarree,\n", - " colorbar_label='m/s', cmap=cmocean.cm.delta, vmin=-1, vmax=1)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Below is the plot shown in Figure 5 of the paper" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def plot_time_series(data, pred, longitude: float, latitude: float, time: slice,\n", - " std: bool = True, true: bool = True):\n", - " plt.figure()\n", - " xs = np.arange(time.start, time.stop, time.step)\n", - " truth = data['S_x'].sel(longitude=longitude, latitude=latitude,\n", - " method='nearest').isel(time=time)\n", - " pred_mean = pred['S_x'].sel(longitude=longitude, latitude=latitude,\n", - " method='nearest').isel(time=time)\n", - " pred_std = pred['S_xscale'].sel(longitude=longitude, latitude=latitude,\n", - " method='nearest').isel(time=time)\n", - " if true:\n", - " plt.plot(xs, truth, 'darkblue')\n", - " plt.plot(xs, pred_mean, 'darkorange')\n", - " if std:\n", - " plt.plot(xs, pred_mean + 1.96 * pred_std, 'g--', linewidth=1)\n", - " plt.plot(xs, pred_mean - 1.96 * pred_std, 'g--', linewidth=1)\n", - " plt.ylabel(r'$1e^{-7}m/s^2$')\n", - " _ = plt.xlabel('days')\n", - " \n", - "time_slice=slice(0, 300)\n", - "plt.rcParams[\"figure.figsize\"] = (4 * 2, 4 * 2 / 1.618)\n", - "\n", - "plot_time_series(data, pred, longitude=-60, latitude=30, time=time_slice, std=True, true=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('timeseries3.jpg', dpi=400)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('timeseriespredcontrol.jpg', dpi=400)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## MSE and R²" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "First we compute the seasonal (monthly) means of the data. This will be used later in some of the metrics." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "forcing_vars = ['S_x', 'S_y']\n", - "errors = pred[forcing_vars] - data[forcing_vars]\n", - "errors_cycle = anomalies(data[forcing_vars])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Below, mse is the time-mean MSE of the mean component of our predicted forcing, mse_month is the variance of the residuals of the data after removing monthly variation." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "mse = (errors**2).mean(dim='time')\n", - "mse_cycle = (errors_cycle**2).mean(dim='time')\n", - "amplitudes = (data[forcing_vars]**2).mean(dim='time')\n", - "\n", - "with ProgressBar():\n", - " mse = mse.compute()\n", - " mse_cycle = mse_cycle.compute()\n", - " amplitudes = amplitudes.compute()\n", - "mse['total'] = mse['S_x'] + mse['S_y']\n", - "mse_cycle['total'] = mse_cycle['S_x'] + mse_cycle['S_y']\n", - "amplitudes['total'] = amplitudes['S_x'] + amplitudes['S_y']" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### MSE plot" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Below is Figure 4a of the paper" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.rcParams[\"figure.figsize\"] = (4*2, 4 * 2 / 1.618)\n", - "x = uv_plotter.plot(mse['total'], lon=0., cmap=cmocean.cm.dense,\n", - " colorbar_label=r'$1e^{-14}m^2/s^4$', norm=matplotlib.colors.LogNorm(vmin=0.01, vmax=10))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('msecontrol.jpg', dpi=400)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('mse1pct.jpg', dpi=400)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### R² plot" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Below is Figure 4b of the paper" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib\n", - "rsquared = 1 - mse / amplitudes\n", - "rsquared_cycles = 1 - mse / mse_cycle\n", - "mse_ratio_2 = 1 - mse_cycle / amplitudes\n", - "uv_plotter.plot(rsquared_cycles['total'], cmap=cmocean.cm.delta, lon=0., norm=matplotlib.colors.LogNorm(vmin=0.5, vmax=1))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('r2_control_month.jpg', dpi=400)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('r2_1pctC02_month.jpg', dpi=400)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Scalar R²" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We limit the range to latitudes -60 to 60, and we apply a mask that discards points near continents according to the mask used in the plotter (the points shown in gray on the maps in the paper). This is why we define these quantities \"to_scalar\", in order to not account for points near continents in the computation of the scalar R²." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "latitudes = slice(-60, 60)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "mse_to_scalar = apply_complete_mask(mse, pred, uv_plotter)\n", - "mse_cycle_to_scalar = apply_complete_mask(mse_cycle, pred, uv_plotter)\n", - "amplitudes_to_scalar = apply_complete_mask(amplitudes, pred, uv_plotter)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "with ProgressBar():\n", - " mse_scalar = mse_to_scalar.sel(latitude=latitudes).sum().compute()\n", - " mse_cycle_scalar = mse_cycle_to_scalar.sel(latitude=latitudes).sum().compute()\n", - " amplitudes_scalar = amplitudes_to_scalar.sel(latitude=latitudes).sum().compute()\n", - " rsquared_scalar_cycle = 1 - mse_scalar / mse_cycle_scalar\n", - " rsquared_scalar = 1 - mse_scalar / amplitudes_scalar\n", - "print(rsquared_scalar)\n", - "print(rsquared_scalar_cycle)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Correlation between true forcing and mean component of the prediction " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "forcing_vars = ['S_x', 'S_y']\n", - "data_anomaly = anomalies(data[forcing_vars])\n", - "pred_anomaly = anomalies(pred[forcing_vars])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "std_data = data_anomaly.std(dim='time')\n", - "std_pred = pred_anomaly.std(dim='time')\n", - "corr = ((data_anomaly * pred_anomaly).mean(dim='time') - data_anomaly.mean(dim='time') * pred_anomaly.mean(dim='time')) / (std_data * std_pred)\n", - "# corr_s_y = xr.corr(data.S_y, pred.S_y, dim='time')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "with ProgressBar():\n", - " corr = corr.compute()\n", - " # corr_s_y = corr_s_y.compute()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "uv_plotter.plot(corr['S_x'], vmin=0.7, vmax=1., lon=0., cmap=cmocean.cm.balance_r)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('corr_X_1pct.jpg', dpi=400)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Variance of norm of subgrid momentum forcing " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "norm_S = np.sqrt(data['S_x']**2 + data['S_y']**2)\n", - "norm_Spred = np.sqrt(pred['S_x']**2 + pred['S_y']**2)\n", - "var_norm_S = norm_S.var(dim='time')\n", - "var_norm_Spred = norm_Spred.var(dim='time')\n", - "with ProgressBar():\n", - " var_norm_S = var_norm_S.compute()\n", - " var_norm_Spred = var_norm_Spred.compute()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "uv_plotter.plot(var_norm_S, cmap=cmocean.cm.dense, lon=0., colorbar_label=r'$1e^{-14}m^2s^{-4}$', norm=matplotlib.colors.LogNorm(vmin=0.01, vmax=10,))\n", - "uv_plotter.plot(var_norm_Spred, cmap=cmocean.cm.dense, lon=0., colorbar_label=r'$1e^{-14}m^2s^{-4}$', norm=matplotlib.colors.LogNorm(vmin=0.01, vmax=10,))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('variance_forcing_control.jpg', dpi=400)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('variance_forcing_control_pred.jpg', dpi=400)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('variance_forcing_control_pred.jpg', dpi=400)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Compare distributions of true and stochastic simulated forcing" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "forcing_vars = ['S_x', 'S_y']\n", - "scale_vars = ['S_xscale', 'S_yscale']\n", - "\n", - "pred_ = apply_complete_mask(pred, pred, uv_plotter)\n", - "pred_scale = pred_[scale_vars].rename(dict(S_xscale='S_x', S_yscale='S_y'))\n", - "pred_ = pred_[forcing_vars]\n", - "data_ = apply_complete_mask(data[forcing_vars], pred, uv_plotter)\n", - "\n", - "# Subsample the data\n", - "time_slice = slice(None, None, 1)\n", - "lon_slice = slice(None, None, 2)\n", - "lat_slice = slice(-60, 60, 2)\n", - "pred_ = pred_.sel(longitude=lon_slice, latitude=lat_slice).isel(time=time_slice)\n", - "pred_scale = pred_scale.sel(longitude=lon_slice, latitude=lat_slice).isel(time=time_slice)\n", - "data_ = data_.sel(longitude=lon_slice, latitude=lat_slice).isel(time=time_slice)\n", - "\n", - "# Standardized residuals\n", - "residuals = (data_ - pred_) / pred_scale" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "assert np.all(np.isnan(data_['S_x']) == np.isnan(pred_['S_x'])), \"Not the same number of points!\"\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Here we do a stochastic simulation of the forcing given the parameters of the Gaussian distribution at each location and each time point" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "shape = tuple(pred_.dims.values())\n", - "epsilons = dict(x=np.random.randn(*shape), y=np.random.randn(*shape))\n", - "epsilons = xr.Dataset(dict(S_x=(pred_.dims, epsilons['x']), S_y=(pred_.dims, epsilons['y'])))\n", - "pred_stochastic = pred_ + pred_scale * epsilons" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "data_" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pred_" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "bins = np.arange(-20, 21, 1)\n", - "\n", - "# assert np.all(np.isnan(data_['S_x']) == np.isnan(pred_stochastic['S_x'])), \"Not the same number of points!\"\n", - "\n", - "plt.figure()\n", - "plt.subplot(121)\n", - "with ProgressBar():\n", - " plt.hist(np.ravel(data_['S_x']), bins=bins, density=True, log=True, alpha=0.5, color='purple')\n", - " plt.hist(np.ravel(pred_stochastic['S_x']), bins=bins, density=True, log=True, alpha=0.5, color='green')\n", - "plt.title('Zonal component')\n", - "plt.xlabel(r'$1e^{-7}m/s^2$')\n", - "plt.ylabel('log density')\n", - "plt.subplot(122)\n", - "with ProgressBar():\n", - " plt.hist(np.ravel(data_['S_y']), bins=bins, density=True, log=True, alpha=0.5, color='purple')\n", - " plt.hist(np.ravel(pred_stochastic['S_y']), bins=bins, density=True, log=True, alpha=0.5, color='green')\n", - "plt.title('Meridional component')\n", - "plt.xlabel(r'$1e^{-7}m/s^2$')\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('forcing_dist_control.jpg', dpi=400)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.figure()\n", - "bins=np.arange(-6, 6, 0.025)\n", - "from scipy.stats import norm\n", - "with ProgressBar():\n", - " plt.subplot(121)\n", - " plt.hist(np.ravel(residuals['S_x'].compute()), bins=bins, density=True, color='orange')\n", - " plt.plot(bins, (norm.pdf(bins)), 'r')\n", - " plt.title('Meridional component')\n", - " plt.subplot(122)\n", - " plt.hist(np.ravel(residuals['S_y'].compute()), bins=bins, density=True, color='orange')\n", - " plt.plot(bins, (norm.pdf(bins)), 'r')\n", - " plt.title('Zonal component')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('normalized_residuals_ditribution.jpg', dpi=300)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### QQ plot" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "quantiles = np.exp(np.linspace(-5, 5, 100)) / (1 + np.exp(np.linspace(-5, 5, 100)))\n", - "quantiles = np.linspace(0.01, 0.99, 99)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "quantiles" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "with ProgressBar():\n", - " quantiles_x = np.nanquantile(residuals['S_x'].compute(), quantiles)\n", - " quantiles_y = np.nanquantile(residuals['S_y'].compute(), quantiles)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from scipy.stats import norm, cauchy, t\n", - "quantiles_norm = norm.ppf(quantiles)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.figure()\n", - "reference = quantiles_norm\n", - "plt.subplot(121)\n", - "plt.plot(reference, quantiles_x, 'x')\n", - "plt.plot(reference, reference, 'g')\n", - "plt.axis([None, None, -5, 5])\n", - "plt.title('Zonal component')\n", - "plt.subplot(122)\n", - "plt.plot(reference, quantiles_y, 'x')\n", - "plt.plot(reference, reference, 'g')\n", - "plt.axis([None, None, -5, 5])\n", - "plt.title('Meridional component')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('normalized_residuals_qq.jpg')" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### (IGNORE THIS) Another way to do it" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from scipy.stats import norm\n", - "lon = slice(None, None, 1)\n", - "lat= slice(-40, 40, 1)\n", - "time_slice = slice(None, 1000, 1)\n", - "\n", - "true = apply_complete_mask(data['S_x'])\n", - "pred_mean = apply_complete_mask(pred['S_x'])\n", - "pred_std = apply_complete_mask(pred['S_xscale'])\n", - "\n", - "def my_transform(x , mean, precision):\n", - " cdf = lambda x: norm.cdf((x - mean) * precision) \n", - " return cdf(x)\n", - "\n", - "v = xr.apply_ufunc(my_transform, true, pred_mean, 1 / pred_std,\n", - " dask='parallelized', output_dtypes=[np.float64, ])\n", - "residuals = (true - pred_mean) / pred_std\n", - "residuals = residuals.sel(longitude=lon, latitude=lat).isel(time=time_slice)\n", - "v = v.sel(longitude=lon, latitude=lat).isel(time=time_slice)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "with ProgressBar():\n", - " q2 = np.nanquantile(residuals, quantiles)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "norm_quantiles = norm.ppf(quantiles)\n", - "plt.figure()\n", - "plt.plot(norm_quantiles, q2)\n", - "plt.plot(norm_quantiles, norm_quantiles)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "q = np.nanquantile(v, quantiles)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.figure()\n", - "plt.plot(quantiles, q, 'x')\n", - "plt.plot(quantiles, quantiles)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## (IGNORE THIS) Likelihood plot" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from scipy.stats import norm\n", - "lon = slice(None, None, 1)\n", - "lat= slice(-80, 80, 1)\n", - "time_slice = slice(None, None, 1)\n", - "\n", - "true = data['S_x'].isel(time=time_slice)\n", - "pred_mean = pred['S_x'].isel(time=time_slice)\n", - "pred_std = pred['S_xscale'].isel(time=time_slice)\n", - "\n", - "residuals = (true - pred_mean) / pred_std\n", - "log_lkh = xr.apply_ufunc(lambda x: np.log(norm.pdf(x)), residuals, dask='parallelized', output_dtypes=[np.float64,])\n", - "with ProgressBar():\n", - " log_lkh = log_lkh.compute()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "true" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "uv_plotter.margin=10\n", - "uv_plotter.plot(-log_lkh.mean(dim='time'), vmin=0, vmax=2.5)\n", - "apply_complete_mask(-log_lkh, pred, uv_plotter).mean()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "lat= slice(-40, 40, 1)\n", - "\n", - "with ProgressBar():\n", - " lkh_mean = lkh.sel(latitude=lat).isel(time=time_slice).mean().compute()\n", - "lkh_mean" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Bias analysis" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "forcing_vars = ['S_x', 'S_y']\n", - "errors = pred[forcing_vars] - data[forcing_vars]\n", - "map_errors = errors.mean(dim='time')\n", - "with ProgressBar():\n", - " map_errors = map_errors.compute()\n", - " absolute = (abs(data[forcing_vars])).mean(dim='time').compute()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "relative_bias = (map_errors / absolute).compute()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "with ProgressBar():\n", - " uv_plotter.plot(abs(relative_bias['S_x']), cmap=cmocean.cm.delta, lon=0., vmin=0.01, vmax=1, norm=matplotlib.colors.LogNorm())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('relative_bias_control.jpg', dpi=400)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Time series plots" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.rcParams[\"figure.figsize\"] = (4*2, 4*2 / 1.618)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "points = [(-60, 30), (-104, -20), (-129, 29)]\n", - "\n", - "plot_time_series(data, pred, *points[1], slice(0, 300), std=True)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.savefig('timeseries_quescient.jpg', dpi=400)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# (IGNORE THIS) Comparison of quantiles" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pred" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "data" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from analysis.base import QuantileCompare\n", - "\n", - "with ProgressBar():\n", - " qq = QuantileCompare()\n", - " qq.quantiles = [0.5, 0.25, 0.5, 0.75, 0.95]\n", - " qq.data = ((pred['S_x']-data['S_x']) / pred['S_xscale']).isel(time=slice(None, None, 1)).compute()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "with ProgressBar():\n", - " q_0_75 = qq.data_quantiles[0.75]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from scipy.stats import norm\n", - "norm.ppf(0.75)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import cartopy.crs as ccrs\n", - "import cmocean\n", - "cmap = cmocean.cm.balance\n", - "uv_plotter.plot(np.abs(q_0_75 - 0.6745) < 0.05, vmin=0, vmax=1)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "uv_plotter.plot(data['S_x'].isel(time=0), cmap=cmap_balance, vmin=-2, vmax=2)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.plot((data['S_x'].sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800))).data)\n", - "plt.plot((pred['S_xpred'].sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800))).data)\n", - "plt.plot((pred['S_xpred'].sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800)) + 1.96 * pred['S_xscale'].sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800))).data, '--')\n", - "plt.plot((pred['S_xpred'].sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800)) - 1.96 * pred['S_xscale'].sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800))).data, '--')\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "r = qq.data.sel(longitude=-140, latitude=30, method='nearest').isel(time=slice(0, 800)).compute()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "np.quantile(r, [0.25, 0.5, 0.75])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plt.hist(np.ravel(q_0_25.data), bins=np.arange(-2, 2, 0.1))\n", - "plt.title('Histogram of 0.25 quantiles of normalized residuals')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "np.quantile(r.data, [0.25, 0.5, 0.75])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "np.max(q_0_5).compute()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Snapshot of the forcing" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import cartopy.crs as ccrs\n", - "cmap = cmocean.cm.balance\n", - "s_x = v\n", - "\n", - "ax = plt.axes(projection=ccrs.PlateCarree(-100.))\n", - "mesh_x, mesh_y = np.meshgrid(s_x['longitude'], s_x['latitude'])\n", - "mesh_x = mesh_x + 360\n", - "ax.pcolormesh(mesh_x, mesh_y, s_x.values, vmin=-4, vmax=4, transform = ccrs.PlateCarree(), cmap=cmap, alpha=1)\n", - "mesh_x, mesh_y = np.meshgrid(borders['longitude'], borders['latitude'])\n", - "mesh_x = mesh_x + 360\n", - "ax.pcolormesh(mesh_x, mesh_y, borders * 1., transform=ccrs.PlateCarree(), alpha=0.1)\n", - "ax.set_global()\n", - "ax.coastlines()\n", - "ax.set_xticks(np.arange(-180, 181, 20))\n", - "ax.set_yticks(np.arange(-80,81, 20))\n", - "#ax.set_extent([-20, 20, -20, 20])\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%matplotlib notebook" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from matplotlib import animation\n", - "cmap = cmocean.cm.amp\n", - "import cartopy.crs as ccrs\n", - "\n", - "fig = plt.figure()\n", - "\n", - "try:\n", - " del video\n", - "except:\n", - " pass\n", - "\n", - "uv_plotter.x_ticks = None\n", - "uv_plotter.y_ticks = None\n", - "\n", - "def animate(i):\n", - " print(i)\n", - " v = pred['S_xscale'].isel(time=i)\n", - " uv_plotter.plot(v, projection_cls = ccrs.Orthographic, lon=(i/5)%360, cmap=cmap, vmin=0, vmax=2, animated=True)\n", - " \n", - "ani = animation.FuncAnimation(fig, animate, frames = 500, interval = 50)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib\n", - "matplotlib.rcParams['animation.embed_limit'] = 100" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ani.save('forcing_pred_mean.mp4', fps=60, dpi=300)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from IPython.display import HTML\n", - "video = ani.to_html5_video()\n", - "HTML(video)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "e = (merged['S_xpred'] - merged['S_x']) / merged['S_xscale']\n", - "d = (e**2).mean(dim='time').compute()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d_ = abs(d-1)\n", - "d_ = d_.interp(mask_.coords)\n", - "d_ = xr.where(borders, -1000, d_)\n", - "d_ = xr.where(mask__, d_, np.nan)\n", - "d_ = d_.interp(latitude = np.arange(-80, 80, 0.1), longitude = np.arange(-279.9, 80.1, 0.1))\n", - "d_['longitude'] = d_['longitude'] + 100.\n", - "\n", - "ax = plt.axes(projection=ccrs.PlateCarree())\n", - "d_.plot.imshow(x='longitude', y='latitude', ax=ax, vmin=0, vmax=2, cmap=cmap,\n", - " transform = ccrs.PlateCarree(-100.))\n", - "ax.set_global()\n", - "ax.coastlines()\n", - "x_ticks = plt.xticks(np.arange(-180, 181, 20))\n", - "y_ticks = plt.yticks(np.arange(-80, 81, 20))" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.3" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} From f43afe41ff53adba08203b977b47824e056e1d00 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 10 Nov 2023 13:59:15 +0000 Subject: [PATCH 059/114] cli: more inference work, cleanup --- src/gz21_ocean_momentum/cli/data.py | 0 src/gz21_ocean_momentum/cli/infer.py | 110 ++++++++++++++++++ src/gz21_ocean_momentum/cli/inference-test.py | 109 ----------------- .../cli/{inference.py => remove/test.py} | 0 src/gz21_ocean_momentum/inference/utils.py | 18 ++- 5 files changed, 118 insertions(+), 119 deletions(-) mode change 100644 => 100755 src/gz21_ocean_momentum/cli/data.py create mode 100755 src/gz21_ocean_momentum/cli/infer.py delete mode 100644 src/gz21_ocean_momentum/cli/inference-test.py rename src/gz21_ocean_momentum/cli/{inference.py => remove/test.py} (100%) diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py old mode 100644 new mode 100755 diff --git a/src/gz21_ocean_momentum/cli/infer.py b/src/gz21_ocean_momentum/cli/infer.py new file mode 100755 index 00000000..c4efb531 --- /dev/null +++ b/src/gz21_ocean_momentum/cli/infer.py @@ -0,0 +1,110 @@ +import configargparse + +import logging +from dask.diagnostics import ProgressBar +from gz21_ocean_momentum.utils import TaskInfo + +from gz21_ocean_momentum.data.datasets import ( + pytorch_dataset_from_cm2_6_forcing_dataset, + #DatasetPartitioner, + DatasetTransformer, + DatasetWithTransform, + ComposeTransforms, +) + +import xarray as xr +import torch + +# TODO hardcode submodel, transformation, NN loss function +# unlikely for a CLI we need to provide dynamic code loading -- let's just give +# options +# we could enable such "dynamic loading" in the "library" interface!-- but, due +# to the class-based setup, it's a little complicated for a user to come in with +# their own code for some of these, and it needs documentation. so a task for +# later +import gz21_ocean_momentum.models.models1 as model +import gz21_ocean_momentum.models.submodels as submodels +import gz21_ocean_momentum.models.transforms as transforms +import gz21_ocean_momentum.train.losses as loss_funcs +#from gz21_ocean_momentum.train.base import Trainer + +submodel = submodels.transform3 +#criterion = loss_funcs.HeteroskedasticGaussianLossV2(dataset.n_targets) + +DESCRIPTION = """ +Use a trained GZ21 neural net to predict forcing for input ocean velocity data. + +This script is intended as example of how use the GZ21 neural net, generating +data for analyzing and visualizing model behaviour, and for general tinkering. + +Designed to ingest coarsened CM2.6 data: looks for data variables at certain +names (`xu_ocean`, ...) with certain units. If these do not match up, the neural +net will not operate properly. + +More specifically, this script is designed to ingest coarsened CM2.6 data as +output from the GZ21 data step. This also computes forcings, which are ignored. +(Ideally, we would provide a short script to simply coarsen some data, without +computing the associated forcings.) + +Note that the neural net has two outputs per grid point. See project +documentation (specifically `README.md` in the project repository), and the +associated paper Guillaumin (2021) for suggestions on how to integrate these +into your GCM of choice. +""" + +p = configargparse.ArgParser(description=DESCRIPTION) +p.add("--config-file", is_config_file=True, help="config file path") + +p.add("--input-data-dir", type=str, required=True, help="path to input ocean velocity data, in zarr format (folder)") +p.add("--model-state-dict-file", type=str, required=True, help="model state dict file (*.pth)") +p.add("--out-dir", type=str, required=True, help="folder to save forcing predictions dataset to (in zarr format)") + +p.add("--device", type=str, default="cuda", help="neural net device (e.g. cuda, cuda:0, cpu)") +p.add("--splits", type=int) + +options = p.parse_args() + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# --- + +logger.info("loading input (coarse) ocean momentum data...") +ds_computed_xr = xr.open_zarr(options.input_data_dir) + +with ProgressBar(), TaskInfo("Applying transforms to dataset"): + ds_computed_xr = submodel.fit_transform(ds_computed_xr) + +ds_computed_torch = pytorch_dataset_from_cm2_6_forcing_dataset(ds_computed_xr) + +logger.info("performing various dataset transforms...") +features_transform_ = ComposeTransforms() +targets_transform_ = ComposeTransforms() +transform = DatasetTransformer(features_transform_, targets_transform_) +transform.fit(ds_computed_torch) +dataset = DatasetWithTransform(ds_computed_torch, transform) + +# load trained neural net +#net = model.FullyCNN(dataset.n_features, criterion.n_required_channels) +net = model.FullyCNN(dataset.n_features, 4) +net.final_transformation = transforms.SoftPlusTransform() # TODO +net.load_state_dict(torch.load(options.model_state_dict_file)) + +dataset.add_transforms_from_model(net) + +with TaskInfo(f"moving neural network to requested device: {options.device}"): + net.to(options.device) + +with ProgressBar(), TaskInfo("Predict & save prediction dataset"): + out = predict_lazy_cm2_6(net, + #criterion.n_required_channels, + #criterion.channel_names, + 4, + ["S_x"] + dataset, loaders, options.device) + ProgressBar().register() + logger.info(f"chunk predictions to time=32 ...") + out = out.chunk(dict(time=32)) + print(f"Size of output data is {out.nbytes/1e9} GB") + logger.info(f"writing re-chunked predictions zarr to directory: {options.out_dir}") + out.to_zarr(options.out_dir) diff --git a/src/gz21_ocean_momentum/cli/inference-test.py b/src/gz21_ocean_momentum/cli/inference-test.py deleted file mode 100644 index 35b5146c..00000000 --- a/src/gz21_ocean_momentum/cli/inference-test.py +++ /dev/null @@ -1,109 +0,0 @@ -import configargparse - -import logging - -from gz21_ocean_momentum.train.base import Trainer - -# TODO hardcode submodel, transformation, NN loss function -# unlikely for a CLI we need to provide dynamic code loading -- let's just give -# options -# we could enable such "dynamic loading" in the "library" interface!-- but, due -# to the class-based setup, it's a little complicated for a user to come in with -# their own code for some of these, and it needs documentation. so a task for -# later -import gz21_ocean_momentum.models.models1.FullyCNN as model_cls -import gz21_ocean_momentum.models.submodels.transform3 as submodel -import gz21_ocean_momentum.models.transforms.SoftPlusTransform as transformation -import gz21_ocean_momentum.train.losses.HeteroskedasticGaussianLossV2 as loss_cls - -from gz21_ocean_momentum.data.datasets import - pytorch_dataset_from_cm2_6_forcing_dataset - -DESCRIPTION = """ -Use pre-trained GZ21 neural net to predict forcing for input ocean velocity data. - -This script is intended as example of how use the GZ21 neural net, and for -general tinkering. - -Designed to ingest coarsened CM2.6 data: looks for data variables at certain -names (`xu_ocean`, ...) with certain units. If these do not match up, the neural -net will not operate properly. - -More specifically, this script is designed to ingest coarsened CM2.6 data as -output from the GZ21 data step. This also computes forcings, which are ignored. -(Ideally, we would provide a short script to simply coarsen some data.) - -Note that the neural net has two outputs per grid point. See project -documentation (specifically `README.md` in the project repository), and the -associated paper Guillaumin (2021) for suggestions on how to integrate these -into your GCM of choice. -""" - -p = configargparse.ArgParser(description=DESCRIPTION) -p.add("--config-file", is_config_file=True, help="config file path") - -p.add("--input-data-dir", type=str, required=True, help="path to input ocean velocity data, in zarr format (folder)") -p.add("--model-state-dict-file", type=str, required=True, help="model state dict file (*.pth)") -p.add("--device", type=str, default="cuda", help="neural net device (e.g. cuda, cuda:0, cpu)") -p.add("--out-dir", type=str, required=True, help="folder to save forcing predictions dataset to (in zarr format)") - -p.add("--verbose", action="store_true", help="be more verbose (displays progress, debug messages)") - -options = p.parse_args() - -# set up logging immediately after parsing CLI options (need to check verbosity) -# (would like to simplify this, maybe with `basicConfig(force=True)`) -if options.verbose: - logging.basicConfig(level=logging.DEBUG) - dask.diagnostics.ProgressBar().register() - logger = logging.getLogger(__name__) - logger.debug("verbose mode; displaying all debug messages, progress bars)") -else: - logging.basicConfig(level=logging.INFO) - logger = logging.getLogger(__name__) - -logger.debug("dropping irrelevant data variables...") -surface_fields = surface_fields[["usurf", "vsurf"]] - -if options.ntimes is not None: - logger.info(f"slicing {options.ntimes} time points...") - surface_fields = surface_fields.isel(time=slice(options.ntimes)) - -logger.info("selecting input data bounding box...") -surface_fields = bounding_box.bound_dataset("yu_ocean", "xu_ocean", surface_fields, bbox) - -# --- - -# TODO hard-coded loss class -criterion = loss_cls(dataset.n_targets) - -# load, prepare pre-trained neural net -net = model_cls(dataset.n_features, criterion.n_required_channels) -net.load_state_dict(torch.load(options.model_state_dict_file)) -print(net) -#net.cpu() # TODO why needed? -dataset.add_transforms_from_model(net) - -print("Size of training data: {}".format(len(train_dataset))) -print("Size of validation data : {}".format(len(test_dataset))) -print("Input height: {}".format(train_dataset.height)) -print("Input width: {}".format(train_dataset.width)) -print(train_dataset[0][0].shape) -print(train_dataset[0][1].shape) -print("Features transform: ", transform.transforms["features"].transforms) -print("Targets transform: ", transform.transforms["targets"].transforms) - -# Net to GPU -with TaskInfo("Put neural network on device"): - net.to(options.device) - -print("width: {}, height: {}".format(dataset.width, dataset.height)) - -with ProgressBar(), TaskInfo("Predict & save prediction dataset"): - out = predict_lazy_cm2_6(net, criterion, partition, loaders, options.device) - ProgressBar().register() - logger.info(f"chunk predictions to time=32 ...") - out = out.chunk(dict(time=32)) - print(f"Size of output data is {out.nbytes/1e9} GB") - logger.info(f"writing re-chunked predictions zarr to directory: {options.out_dir}") - out.to_zarr(options.out_dir) diff --git a/src/gz21_ocean_momentum/cli/inference.py b/src/gz21_ocean_momentum/cli/remove/test.py similarity index 100% rename from src/gz21_ocean_momentum/cli/inference.py rename to src/gz21_ocean_momentum/cli/remove/test.py diff --git a/src/gz21_ocean_momentum/inference/utils.py b/src/gz21_ocean_momentum/inference/utils.py index 4965f5a8..9a9c6909 100755 --- a/src/gz21_ocean_momentum/inference/utils.py +++ b/src/gz21_ocean_momentum/inference/utils.py @@ -107,7 +107,8 @@ def _dataset_from_channels(array, channels_names: list, dims, coords): def predict_lazy_cm2_6( net: torch.nn.Module, - criterion, test_datasets, test_loaders, device, save_input: bool = False + n_required_channels, channel_names, + test_datasets, test_loaders, device, save_input: bool = False ): """ Return an xarray dataset with the predictions carried out on the @@ -117,17 +118,14 @@ def predict_lazy_cm2_6( dataset into smaller test datasets, each of which fits in RAM, there should be no issue. - TODO: - - * `xu_ocean`, `yu_ocean` hardcoded - * why do we take `test_datasets`? bad - Parameters ---------- net : torch.nn.Module Neural net used to make predictions - test_datasets : list <-- can't go `list[xarray[has "usurf"]]`... - List of PytTorch datasets containing the input data. + test_datasets : list + List of PyTorch datasets containing the input data. + TODO 2023-11-10 raehik: we shouldn't really need this. Should be a + better way to obtain what we need from each dataset in this list. test_loaders : list List of Pytorch DataLoaders corresponding to the datasets device : torch.device @@ -149,7 +147,7 @@ def predict_lazy_cm2_6( temp = delayed_apply(net, loader, device) shape = ( len(test_dataset), - criterion.n_required_channels, + n_required_channels, test_dataset.output_height, test_dataset.output_width, ) @@ -163,7 +161,7 @@ def predict_lazy_cm2_6( coords_s = test_dataset.output_coords coords_s["latitude"] = coords_s.pop("yu_ocean") coords_s["longitude"] = coords_s.pop("xu_ocean") - var_names = criterion.channel_names + var_names = channel_names output_dataset = _dataset_from_channels(output, var_names, new_dims, coords_s) outputs.append(output_dataset) # same for input From 9edc753a1ba028263192f486543e60ba39095fa2 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 10 Nov 2023 14:52:53 +0000 Subject: [PATCH 060/114] notebooks: cleanup; prefix paper notebooks --- examples/jupyter-notebooks/README.md | 2 +- .../{ => paper}/generate-paper-figure-1.ipynb | 0 .../{ => paper}/generate-paper-figure-6.ipynb | 0 .../test-global-fig-4-5-7.ipynb} | 2 +- examples/jupyter-notebooks/test_global.ipynb | 136 ------------------ 5 files changed, 2 insertions(+), 138 deletions(-) rename examples/jupyter-notebooks/{ => paper}/generate-paper-figure-1.ipynb (100%) rename examples/jupyter-notebooks/{ => paper}/generate-paper-figure-6.ipynb (100%) rename examples/jupyter-notebooks/{test-global.ipynb => paper/test-global-fig-4-5-7.ipynb} (99%) delete mode 100644 examples/jupyter-notebooks/test_global.ipynb diff --git a/examples/jupyter-notebooks/README.md b/examples/jupyter-notebooks/README.md index 18ff7638..8dab39c3 100644 --- a/examples/jupyter-notebooks/README.md +++ b/examples/jupyter-notebooks/README.md @@ -17,7 +17,7 @@ In particular, the following packages are common between multiple notebooks ## 2021 paper figures There are several notebooks which were used to generate the figures in the 2021 -paper. +paper. These are stored in `paper/`. `generate-paper-figure-1.ipynb` generates figure 1b. The forcings it uses can be generated by running the data step with the following configuration: diff --git a/examples/jupyter-notebooks/generate-paper-figure-1.ipynb b/examples/jupyter-notebooks/paper/generate-paper-figure-1.ipynb similarity index 100% rename from examples/jupyter-notebooks/generate-paper-figure-1.ipynb rename to examples/jupyter-notebooks/paper/generate-paper-figure-1.ipynb diff --git a/examples/jupyter-notebooks/generate-paper-figure-6.ipynb b/examples/jupyter-notebooks/paper/generate-paper-figure-6.ipynb similarity index 100% rename from examples/jupyter-notebooks/generate-paper-figure-6.ipynb rename to examples/jupyter-notebooks/paper/generate-paper-figure-6.ipynb diff --git a/examples/jupyter-notebooks/test-global.ipynb b/examples/jupyter-notebooks/paper/test-global-fig-4-5-7.ipynb similarity index 99% rename from examples/jupyter-notebooks/test-global.ipynb rename to examples/jupyter-notebooks/paper/test-global-fig-4-5-7.ipynb index e5e823ad..863e0a31 100644 --- a/examples/jupyter-notebooks/test-global.ipynb +++ b/examples/jupyter-notebooks/paper/test-global-fig-4-5-7.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Test on Global scale (Code to generate figures 3, 4, 6, 7)" + "# Test on Global scale (Code to generate figures 4, 5, 7)" ] }, { diff --git a/examples/jupyter-notebooks/test_global.ipynb b/examples/jupyter-notebooks/test_global.ipynb deleted file mode 100644 index 91455134..00000000 --- a/examples/jupyter-notebooks/test_global.ipynb +++ /dev/null @@ -1,136 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import mlflow\n", - "from mlflow.tracking import client\n", - "import xarray as xr\n", - "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "import os,sys\n", - "sys.path.insert(1, os.path.join(os.getcwd() , '../../src/gz21_ocean_momentum'))\n", - "from utils import select_experiment, select_run\n", - "from analysis.utils import plot_dataset" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "test_exp_name = select_experiment()\n", - "test_exp = mlflow.get_experiment_by_name(test_exp_name)\n", - "test_exp_id = test_exp.experiment_id" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "run = select_run(experiment_ids=test_exp_id)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "run" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "client_ = client.MlflowClient()\n", - "data_file_name = client_.download_artifacts(run['params.data_run_id'], 'forcing')\n", - "print(data_file_name)\n", - "data = xr.open_zarr(data_file)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "data" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pred_file_name = client_.download_artifacts(run.run_id, 'test_output_0')\n", - "pred = xr.open_zarr(pred_file_name)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "merged = xr.merge((data, pred))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "merged" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "plot_dataset(merged.isel(time=10), vmin=-1e-7, vmax=1e-7)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.2" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} From 3c33204919f71d49fc4ca22cacf7148af92b7a6d Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Mon, 13 Nov 2023 18:40:05 +0000 Subject: [PATCH 061/114] begin overhauling train step --- examples/cli-configs/train-wip.yaml | 12 + src/gz21_ocean_momentum/cli/data.py | 7 +- src/gz21_ocean_momentum/cli/remove/train.py | 480 ++++++++++++++++++ src/gz21_ocean_momentum/cli/train.py | 394 ++++---------- src/gz21_ocean_momentum/common/assorted.py | 3 +- src/gz21_ocean_momentum/step/train/lib.py | 10 - .../unsorted/train_data_xr_to_pytorch.py | 80 +++ 7 files changed, 666 insertions(+), 320 deletions(-) create mode 100644 examples/cli-configs/train-wip.yaml create mode 100755 src/gz21_ocean_momentum/cli/remove/train.py delete mode 100644 src/gz21_ocean_momentum/step/train/lib.py create mode 100644 src/gz21_ocean_momentum/unsorted/train_data_xr_to_pytorch.py diff --git a/examples/cli-configs/train-wip.yaml b/examples/cli-configs/train-wip.yaml new file mode 100644 index 00000000..a5ae4191 --- /dev/null +++ b/examples/cli-configs/train-wip.yaml @@ -0,0 +1,12 @@ +in-train-data-dir: /home/raehik/sh/gz21/gz21/tmp/generated/forcings/paper-n100 +subdomains-file: /home/raehik/sh/gz21/gz21/examples/cli-configs/training-subdomains-paper.yaml + +batch-size: 8 + +epochs: 200 +initial-learning-rate: 5.0e-4 +decay-factor: 0.0 +decay-at-epoch-milestones: [15, 30] # TODO is 0 implicit? I forget + +train-split: 0.8 +test-split: 0.85 diff --git a/src/gz21_ocean_momentum/cli/data.py b/src/gz21_ocean_momentum/cli/data.py index 22494a21..6187a298 100755 --- a/src/gz21_ocean_momentum/cli/data.py +++ b/src/gz21_ocean_momentum/cli/data.py @@ -1,3 +1,6 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + import gz21_ocean_momentum.step.data.lib as lib import gz21_ocean_momentum.common.cli as cli from gz21_ocean_momentum.common.bounding_box import BoundingBox @@ -11,10 +14,10 @@ # up to date as of 2023-09-01 DEF_CATALOG_URI = "https://raw.githubusercontent.com/pangeo-data/pangeo-datastore/d684158e92fb3f3ad3b34e7dc5bba52b22a3ba80/intake-catalogs/ocean.yaml" -DESCRIPTION = "GZ21 data step: download CM2.6 dataset, apply coarse graining \ +_cli_desc = "GZ21 data step: download CM2.6 dataset, apply coarse graining \ and generate forcings. Saves result to disk in zarr format." -p = configargparse.ArgParser(description=DESCRIPTION) +p = configargparse.ArgParser(description=_cli_desc) p.add("--config-file", is_config_file=True, help="config file path") p.add("--out-dir", type=str, required=True, help="folder to save generated forcings to (in zarr format)" ) p.add("--lat-min", type=float, required=True, help="bounding box minimum latitude") diff --git a/src/gz21_ocean_momentum/cli/remove/train.py b/src/gz21_ocean_momentum/cli/remove/train.py new file mode 100755 index 00000000..b71e7ede --- /dev/null +++ b/src/gz21_ocean_momentum/cli/remove/train.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Script that performs training of a model on data.""" +import os +import os.path +import copy +import argparse +import importlib +import pickle +import tempfile +from dask.diagnostics import ProgressBar +import numpy as np +import mlflow +import xarray as xr + +import torch +from torch.utils.data import DataLoader +from torch import optim +from torch.optim.lr_scheduler import MultiStepLR + +# These imports are used to create the training datasets +from data.datasets import ( + DatasetWithTransform, + DatasetTransformer, + RawDataFromXrDataset, + ConcatDataset_, + Subset_, + ComposeTransforms, +) + +# Some utils functions +from train.utils import ( + DEVICE_TYPE, + learning_rates_from_string, +) +from train.base import Trainer +from inference.utils import create_test_dataset +from inference.metrics import MSEMetric, MaxMetric +import train.losses +from models import transforms, submodels + + +from utils import TaskInfo + +import gz21_ocean_momentum.step.train.lib as lib +from gz21_ocean_momentum.common.bounding_box import load_bounding_boxes_yaml + +from typing import Any + +torch.autograd.set_detect_anomaly(True) + + +def _check_dir(dir_path): + """ + Create directory if it does not already exist. + + Parameters + ---------- + dir_path : str + string of directory to check/make + """ + if not os.path.exists(dir_path): + os.mkdir(dir_path) + + +def negative_int(value: str): + """ + Convert string input to negative integer. + + Parameters + ---------- + value : str + string to convert + + Returns + ------- + : int + negative integer of input string + """ + return -int(value) + + +def check_str_is_None(string_in: str): + """ + Return None if string is "none". + + Parameters + ---------- + string_in : str + string to check + + Returns + ------- + string_in or None : str or None + returns None if string_in is none, else returns string_in + """ + return None if string_in.lower() == "none" else string_in + + +# -------------------- +# READ IN DATA FOR RUN +# -------------------- +description = ( + "Trains a model on a chosen dataset from the store." + "Allows to set training parameters via the CLI." + "Use one of either --run-id or --forcing-data-path." +) +parser = argparse.ArgumentParser(description=description) + +parser.add_argument( + "--run-id", + type=str, + help="MLflow run ID of data step containing forcing data to use", +) + +# access input forcing data via absolute filepath +parser.add_argument( + "--forcing-data-path", type=str, help="Filepath of the forcing data" +) + +parser.add_argument("--batchsize", type=int, default=8) +parser.add_argument("--n_epochs", type=int, default=100) + +# TODO: borked +parser.add_argument( + "--learning_rate", type=learning_rates_from_string, default="0/1e-3" +) + +parser.add_argument("--train_split", type=float, default=0.8, help="Between 0 and 1") +parser.add_argument( + "--test_split", + type=float, + default=0.8, + help="Between 0 and 1, greater than train_split.", +) +parser.add_argument("--time_indices", type=negative_int, nargs="*") +parser.add_argument("--printevery", type=int, default=20) +parser.add_argument( + "--weight_decay", + type=float, + default=0.05, + help="Depreciated. Controls the weight decay on the linear " "layer", +) +parser.add_argument( + "--model_module_name", + type=str, + default="models.models1", + help="Name of the module containing the nn model", +) +parser.add_argument( + "--model_cls_name", + type=str, + default="FullyCNN", + help="Name of the class defining the nn model", +) +parser.add_argument( + "--loss_cls_name", + type=str, + default="HeteroskedasticGaussianLossV2", + help="Name of the loss function used for training.", +) +parser.add_argument( + "--transformation_cls_name", + type=str, + default="SquareTransform", + help="Name of the transformation applied to outputs " + "required to be positive. Should be defined in " + "models.transforms.", +) +parser.add_argument("--submodel", type=str, default="transform1") +parser.add_argument( + "--features_transform_cls_name", type=str, default="None", help="Depreciated" +) +parser.add_argument( + "--targets_transform_cls_name", type=str, default="None", help="Depreciated" +) +parser.add_argument( + "--subdomains-file", type=str, required=True, help="YAML file describing subdomains to use (bounding boxes. TODO format" +) +params = parser.parse_args() + +print(params.learning_rate) + +def argparse_get_mlflow_artifact_path_or_direct_or_fail( + mlflow_artifact_name: str, params: dict[str, Any] +) -> str: + """Obtain a filepath either from an MLflow run ID and artifact name, or a + direct path if provided. + + params must have keys run_id and forcing_data_path. + + Only one of run_id and path should be non-None. + + Note that the filepath is not checked for validity (but for run_id, MLflow + probably will assert that it exists). + + Effectful: errors result in immediate program exit. + """ + if params.run_id is not None and params.run_id != "None": + if params.forcing_data_path is not None and params.forcing_data_path != "None": + # got run ID and direct path: bad + raise TypeError( + "overlapping options provided (--forcing-data-path and --exp-id)" + ) + + # got only run ID: obtain path via MLflow + mlflow.log_param("source.run-id", params.run_id) + mlflow_client = mlflow.tracking.MlflowClient() + return mlflow_client.download_artifacts(params.run_id, mlflow_artifact_name) + + if params.forcing_data_path is not None and params.forcing_data_path != "None": + # got only direct path: use + return params.forcing_data_path + + # if we get here, neither options were provided + raise TypeError("require one of --run-id or --forcing-data-path") + + +forcings_path = argparse_get_mlflow_artifact_path_or_direct_or_fail("forcing", params) + +# -------------------------- +# SET UP TRAINING PARAMETERS +# -------------------------- +# Note that we use two indices for the train/test split. This is because we +# want to avoid the time correlation to play in our favour during test. +batch_size = params.batchsize +learning_rates = params.learning_rate +weight_decay = params.weight_decay +n_epochs = params.n_epochs +train_split = params.train_split +test_split = params.test_split +model_module_name = params.model_module_name +model_cls_name = params.model_cls_name +loss_cls_name = params.loss_cls_name +transformation_cls_name = params.transformation_cls_name +# Transforms applied to the features and targets +temp = params.features_transform_cls_name +features_transform_cls_name = check_str_is_None(temp) +temp = params.targets_transform_cls_name +targets_transform_cls_name = check_str_is_None(temp) +# Submodel (for instance monthly means) +submodel = params.submodel + + +# -------------------------- +# SET UP INPUT PARAMETERS +# -------------------------- +# Parameters specific to the input data +# past specifies the indices from the past that are used for prediction +indices = params.time_indices + +# Other parameters +print_loss_every = params.printevery +MODEL_NAME = "trained_model.pth" + +# Directories where temporary data will be saved +data_location = tempfile.mkdtemp() +print("Created temporary dir at ", data_location) + +FIGURES_DIRECTORY = "figures" +MODELS_DIRECTORY = "models" +MODEL_OUTPUT_DIR = "model_output" + +for directory in [FIGURES_DIRECTORY, MODELS_DIRECTORY, MODEL_OUTPUT_DIR]: + _check_dir(os.path.join(data_location, directory)) + +# Device selection. If available we use the GPU. +# TODO Allow CLI argument to select the GPU +device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") +device_type = DEVICE_TYPE.GPU if torch.cuda.is_available() else DEVICE_TYPE.CPU +print("Selected device type: ", device_type.value) + + +# ------------------ +# LOAD TRAINING DATA +# ------------------ +global_ds = xr.open_zarr(forcings_path) +subdomains = load_bounding_boxes_yaml(params.subdomains_file) +xr_datasets = lib.select_subdomains(global_ds, subdomains) +# Split into train and test datasets +datasets, train_datasets, test_datasets = [], [], [] + + +for xr_dataset in xr_datasets: + # TODO this is a temporary fix to implement seasonal patterns + submodel_transform = copy.deepcopy(getattr(submodels, submodel)) + print(submodel_transform) + xr_dataset = submodel_transform.fit_transform(xr_dataset) + with ProgressBar(), TaskInfo("Computing dataset"): + # Below line only for speeding up debugging + # xr_dataset = xr_dataset.isel(time=slice(0, 1000)) + xr_dataset = xr_dataset.compute() + print(xr_dataset) + dataset = RawDataFromXrDataset(xr_dataset) + dataset.index = "time" + dataset.add_input("usurf") + dataset.add_input("vsurf") + dataset.add_output("S_x") + dataset.add_output("S_y") + # TODO temporary addition, should be made more general + if submodel == "transform2": + dataset.add_output("S_x_d") + dataset.add_output("S_y_d") + if submodel == "transform4": + dataset.add_input("s_x_formula") + dataset.add_input("s_y_formula") + train_index = int(train_split * len(dataset)) + test_index = int(test_split * len(dataset)) + features_transform = ComposeTransforms() + targets_transform = ComposeTransforms() + transform = DatasetTransformer(features_transform, targets_transform) + dataset = DatasetWithTransform(dataset, transform) + # dataset = MultipleTimeIndices(dataset) + # dataset.time_indices = [0, ] + train_dataset = Subset_(dataset, np.arange(train_index)) + test_dataset = Subset_(dataset, np.arange(test_index, len(dataset))) + train_datasets.append(train_dataset) + test_datasets.append(test_dataset) + datasets.append(dataset) + +# Concatenate datasets. This adds shape transforms to ensure that all regions +# produce fields of the same shape, hence should be called after saving +# the transformation so that when we're going to test on another region +# this does not occur. +train_dataset = ConcatDataset_(train_datasets) +test_dataset = ConcatDataset_(test_datasets) + +# Dataloaders +train_dataloader = DataLoader( + train_dataset, batch_size=batch_size, shuffle=True, drop_last=True, num_workers=4 +) +test_dataloader = DataLoader( + test_dataset, batch_size=batch_size, shuffle=False, drop_last=True +) + +print(f"Size of training data: {len(train_dataset)}") +print(f"Size of validation data : {len(test_dataset)}") + + +# ------------------- +# LOAD NEURAL NETWORK +# ------------------- +# Load the loss class required in the script parameters +n_target_channels = datasets[0].n_targets +criterion = getattr(train.losses, loss_cls_name)(n_target_channels) + +# Recover the model's class, based on the corresponding CLI parameters +try: + models_module = importlib.import_module(model_module_name) + model_cls = getattr(models_module, model_cls_name) +except ModuleNotFoundError as e: + raise type(e)("Could not find the specified module for : " + str(e)) +except AttributeError as e: + raise type(e)("Could not find the specified model class: " + str(e)) +net = model_cls(datasets[0].n_features, criterion.n_required_channels) +try: + transformation_cls = getattr(transforms, transformation_cls_name) + transformation = transformation_cls() + transformation.indices = criterion.precision_indices + net.final_transformation = transformation +except AttributeError as e: + raise type(e)("Could not find the specified transformation class: " + str(e)) + +print("--------------------") +print(net) +print("--------------------") +print("***") + + +# Log the text representation of the net into a txt artifact +with open( + os.path.join(data_location, MODELS_DIRECTORY, "nn_architecture.txt"), + "w", + encoding="utf-8", +) as f: + print("Writing neural net architecture into txt file.") + f.write(str(net)) + +# Add transforms required by the model. +for dataset in datasets: + dataset.add_transforms_from_model(net) + + +# ------------------- +# TRAINING OF NETWORK +# ------------------- +# Adam optimizer +# To GPU +net.to(device) + +# Optimizer and learning rate scheduler +params = list(net.parameters()) +optimizer = optim.Adam(params, lr=learning_rates[0], weight_decay=weight_decay) +lr_scheduler = MultiStepLR(optimizer, list(learning_rates.keys())[1:], gamma=0.1) + +trainer = Trainer(net, device) +trainer.criterion = criterion +trainer.print_loss_every = print_loss_every + +# metrics saved independently of the training criterion. +metrics = {"R2": MSEMetric(), "Inf Norm": MaxMetric()} +for metric_name, metric in metrics.items(): + metric.inv_transform = lambda x: test_dataset.inverse_transform_target(x) + trainer.register_metric(metric_name, metric) + +for i_epoch in range(n_epochs): + print(f"Epoch number {i_epoch}.") + # TODO remove clipping? + train_loss = trainer.train_for_one_epoch( + train_dataloader, optimizer, lr_scheduler, clip=1.0 + ) + test = trainer.test(test_dataloader) + if test == "EARLY_STOPPING": + print(test) + break + test_loss, metrics_results = test + # Log the training loss + print("Train loss for this epoch is ", train_loss) + print("Test loss for this epoch is ", test_loss) + + for metric_name, metric_value in metrics_results.items(): + print(f"Test {metric_name} for this epoch is {metric_value}") + mlflow.log_metric("train loss", train_loss, i_epoch) + mlflow.log_metric("test loss", test_loss, i_epoch) + mlflow.log_metrics(metrics_results) +# Update the logged number of actual training epochs +mlflow.log_param("n_epochs_actual", i_epoch + 1) + + +# ------------------------------ +# SAVE THE TRAINED MODEL TO DISK +# ------------------------------ +net.cpu() +full_path = os.path.join(data_location, MODELS_DIRECTORY, MODEL_NAME) +torch.save(net.state_dict(), full_path) +net.to(device=device) + +# Save other parts of the model +# TODO this should not be necessary +print("Saving other parts of the model") +full_path = os.path.join(data_location, MODELS_DIRECTORY, "transformation") +with open(full_path, "wb") as f: + pickle.dump(transformation, f) + +with TaskInfo("Saving trained model"): + mlflow.log_artifact(os.path.join(data_location, MODELS_DIRECTORY)) + + +# ---------- +# DEBUT TEST +# ---------- +for i_dataset, dataset, test_dataset, xr_dataset in zip( + range(len(datasets)), datasets, test_datasets, xr_datasets +): + test_dataloader = DataLoader( + test_dataset, batch_size=batch_size, shuffle=False, drop_last=True + ) + output_dataset = create_test_dataset( + net, + criterion.n_required_channels, + xr_dataset, + test_dataset, + test_dataloader, + test_index, + device, + ) + + # Save model output on the test dataset + output_dataset.to_zarr( + os.path.join(data_location, MODEL_OUTPUT_DIR, f"test_output{i_dataset}") + ) + + +# ----------------------- +# LOG ARTIFACTS IN MLFLOW +# ----------------------- +print("Logging artifacts...") +mlflow.log_artifact(os.path.join(data_location, FIGURES_DIRECTORY)) +mlflow.log_artifact(os.path.join(data_location, MODEL_OUTPUT_DIR)) +print("Done...") diff --git a/src/gz21_ocean_momentum/cli/train.py b/src/gz21_ocean_momentum/cli/train.py index b71e7ede..c4b33f9a 100755 --- a/src/gz21_ocean_momentum/cli/train.py +++ b/src/gz21_ocean_momentum/cli/train.py @@ -1,13 +1,30 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""Script that performs training of a model on data.""" + +import gz21_ocean_momentum.common.cli as cli +import gz21_ocean_momentum.common.assorted as common +import gz21_ocean_momentum.common.bounding_box as bounding_box +import gz21_ocean_momentum.unsorted.train_data_xr_to_pytorch as lib +from gz21_ocean_momentum.models import submodels + +import configargparse + import os + +import xarray as xr +import torch + +# TODO probably temporary +import tempfile + +# --- + +old_imports = """ import os.path import copy import argparse import importlib import pickle -import tempfile from dask.diagnostics import ProgressBar import numpy as np import mlflow @@ -18,38 +35,47 @@ from torch import optim from torch.optim.lr_scheduler import MultiStepLR -# These imports are used to create the training datasets -from data.datasets import ( - DatasetWithTransform, - DatasetTransformer, - RawDataFromXrDataset, - ConcatDataset_, - Subset_, - ComposeTransforms, -) - -# Some utils functions -from train.utils import ( - DEVICE_TYPE, - learning_rates_from_string, -) -from train.base import Trainer -from inference.utils import create_test_dataset -from inference.metrics import MSEMetric, MaxMetric -import train.losses -from models import transforms, submodels - - -from utils import TaskInfo +from gz21_ocean_momentum.train.base import Trainer +from gz21_ocean_momentum.inference.utils import create_test_dataset +from gz21_ocean_momentum.inference.metrics import MSEMetric, MaxMetric +import gz21_ocean_momentum.train.losses import gz21_ocean_momentum.step.train.lib as lib from gz21_ocean_momentum.common.bounding_box import load_bounding_boxes_yaml - -from typing import Any +""" + +# --- + +_cli_desc = """ +Train a Pytorch neural net to predict subgrid ocean momentum forcing from +ocean surface velocity. + +Uses data generated by the GZ21 data step script. +""" + +p = configargparse.ArgParser(description=_cli_desc) +p.add("--config-file", is_config_file=True, help="config file path") +p.add("--in-train-data-dir", type=str, required=True, help="training data in zarr format, containing ocean velocities and forcings") +p.add("--subdomains-file", type=str, required=True, help="YAML file describing subdomains to split input data into (see readme for format)") +p.add("--batch-size", type=int, required=True, help="TODO") +p.add("--epochs", type=int, required=True, help="number of epochs to train for") +p.add("--out-model", type=str, required=True, help="save trained model to this path") +p.add("--initial-learning-rate", type=float, required=True, help="initial learning rate for optimization algorithm") +p.add("--decay-factor", type=float, required=True, help="learning rate decay factor, applied each time an epoch milestone is reached") +p.add("--decay-at-epoch-milestones", type=int, action="append", required=True, help="milestones to decay at. May specify multiple times. Must be strictly increasing with no duplicates") +p.add("--device", type=str, default="cuda", help="neural net device (e.g. cuda, cuda:0, cpu)") +p.add("--weight-decay", type=float, default=0.0, help="Weight decay parameter for Adam loss function. Deprecated, default 0.") +p.add("--train-split", type=float, required=True, help="0>=x>=1. Use 0->x of input dataset for training") +p.add("--test-split", type=float, required=True, help="0>=x>=1. Use x->end of input dataset for training. Must be greater than --train-split") +p.add("--printevery", type=int, default=20) +options = p.parse_args() + +# TODO raehik 2023-11-13: parse, don't validate +if not common.list_is_strictly_increasing(options.decay_at_epoch_milestones): + cli.fail(2, "epoch milestones list is not strictly increasing") torch.autograd.set_detect_anomaly(True) - def _check_dir(dir_path): """ Create directory if it does not already exist. @@ -62,196 +88,17 @@ def _check_dir(dir_path): if not os.path.exists(dir_path): os.mkdir(dir_path) - -def negative_int(value: str): - """ - Convert string input to negative integer. - - Parameters - ---------- - value : str - string to convert - - Returns - ------- - : int - negative integer of input string - """ - return -int(value) - - -def check_str_is_None(string_in: str): - """ - Return None if string is "none". - - Parameters - ---------- - string_in : str - string to check - - Returns - ------- - string_in or None : str or None - returns None if string_in is none, else returns string_in - """ - return None if string_in.lower() == "none" else string_in - - -# -------------------- -# READ IN DATA FOR RUN -# -------------------- -description = ( - "Trains a model on a chosen dataset from the store." - "Allows to set training parameters via the CLI." - "Use one of either --run-id or --forcing-data-path." -) -parser = argparse.ArgumentParser(description=description) - -parser.add_argument( - "--run-id", - type=str, - help="MLflow run ID of data step containing forcing data to use", -) - -# access input forcing data via absolute filepath -parser.add_argument( - "--forcing-data-path", type=str, help="Filepath of the forcing data" -) - -parser.add_argument("--batchsize", type=int, default=8) -parser.add_argument("--n_epochs", type=int, default=100) - -# TODO: borked -parser.add_argument( - "--learning_rate", type=learning_rates_from_string, default="0/1e-3" -) - -parser.add_argument("--train_split", type=float, default=0.8, help="Between 0 and 1") -parser.add_argument( - "--test_split", - type=float, - default=0.8, - help="Between 0 and 1, greater than train_split.", -) -parser.add_argument("--time_indices", type=negative_int, nargs="*") -parser.add_argument("--printevery", type=int, default=20) -parser.add_argument( - "--weight_decay", - type=float, - default=0.05, - help="Depreciated. Controls the weight decay on the linear " "layer", -) -parser.add_argument( - "--model_module_name", - type=str, - default="models.models1", - help="Name of the module containing the nn model", -) -parser.add_argument( - "--model_cls_name", - type=str, - default="FullyCNN", - help="Name of the class defining the nn model", -) -parser.add_argument( - "--loss_cls_name", - type=str, - default="HeteroskedasticGaussianLossV2", - help="Name of the loss function used for training.", -) -parser.add_argument( - "--transformation_cls_name", - type=str, - default="SquareTransform", - help="Name of the transformation applied to outputs " - "required to be positive. Should be defined in " - "models.transforms.", -) -parser.add_argument("--submodel", type=str, default="transform1") -parser.add_argument( - "--features_transform_cls_name", type=str, default="None", help="Depreciated" -) -parser.add_argument( - "--targets_transform_cls_name", type=str, default="None", help="Depreciated" -) -parser.add_argument( - "--subdomains-file", type=str, required=True, help="YAML file describing subdomains to use (bounding boxes. TODO format" -) -params = parser.parse_args() - -print(params.learning_rate) - -def argparse_get_mlflow_artifact_path_or_direct_or_fail( - mlflow_artifact_name: str, params: dict[str, Any] -) -> str: - """Obtain a filepath either from an MLflow run ID and artifact name, or a - direct path if provided. - - params must have keys run_id and forcing_data_path. - - Only one of run_id and path should be non-None. - - Note that the filepath is not checked for validity (but for run_id, MLflow - probably will assert that it exists). - - Effectful: errors result in immediate program exit. - """ - if params.run_id is not None and params.run_id != "None": - if params.forcing_data_path is not None and params.forcing_data_path != "None": - # got run ID and direct path: bad - raise TypeError( - "overlapping options provided (--forcing-data-path and --exp-id)" - ) - - # got only run ID: obtain path via MLflow - mlflow.log_param("source.run-id", params.run_id) - mlflow_client = mlflow.tracking.MlflowClient() - return mlflow_client.download_artifacts(params.run_id, mlflow_artifact_name) - - if params.forcing_data_path is not None and params.forcing_data_path != "None": - # got only direct path: use - return params.forcing_data_path - - # if we get here, neither options were provided - raise TypeError("require one of --run-id or --forcing-data-path") - - -forcings_path = argparse_get_mlflow_artifact_path_or_direct_or_fail("forcing", params) - # -------------------------- # SET UP TRAINING PARAMETERS # -------------------------- # Note that we use two indices for the train/test split. This is because we # want to avoid the time correlation to play in our favour during test. -batch_size = params.batchsize -learning_rates = params.learning_rate -weight_decay = params.weight_decay -n_epochs = params.n_epochs -train_split = params.train_split -test_split = params.test_split -model_module_name = params.model_module_name -model_cls_name = params.model_cls_name -loss_cls_name = params.loss_cls_name -transformation_cls_name = params.transformation_cls_name -# Transforms applied to the features and targets -temp = params.features_transform_cls_name -features_transform_cls_name = check_str_is_None(temp) -temp = params.targets_transform_cls_name -targets_transform_cls_name = check_str_is_None(temp) +model_module_name = "models.models1" +model_cls_name = "FullyCNN" +loss_cls_name = "HeteroskedasticGaussianLossV2" +transformation_cls_name = "SoftPlusTransform" # Submodel (for instance monthly means) -submodel = params.submodel - - -# -------------------------- -# SET UP INPUT PARAMETERS -# -------------------------- -# Parameters specific to the input data -# past specifies the indices from the past that are used for prediction -indices = params.time_indices - -# Other parameters -print_loss_every = params.printevery -MODEL_NAME = "trained_model.pth" +submodel = "transform3" # Directories where temporary data will be saved data_location = tempfile.mkdtemp() @@ -264,78 +111,21 @@ def argparse_get_mlflow_artifact_path_or_direct_or_fail( for directory in [FIGURES_DIRECTORY, MODELS_DIRECTORY, MODEL_OUTPUT_DIR]: _check_dir(os.path.join(data_location, directory)) -# Device selection. If available we use the GPU. -# TODO Allow CLI argument to select the GPU -device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") -device_type = DEVICE_TYPE.GPU if torch.cuda.is_available() else DEVICE_TYPE.CPU -print("Selected device type: ", device_type.value) - - -# ------------------ -# LOAD TRAINING DATA -# ------------------ -global_ds = xr.open_zarr(forcings_path) -subdomains = load_bounding_boxes_yaml(params.subdomains_file) -xr_datasets = lib.select_subdomains(global_ds, subdomains) -# Split into train and test datasets -datasets, train_datasets, test_datasets = [], [], [] - - -for xr_dataset in xr_datasets: - # TODO this is a temporary fix to implement seasonal patterns - submodel_transform = copy.deepcopy(getattr(submodels, submodel)) - print(submodel_transform) - xr_dataset = submodel_transform.fit_transform(xr_dataset) - with ProgressBar(), TaskInfo("Computing dataset"): - # Below line only for speeding up debugging - # xr_dataset = xr_dataset.isel(time=slice(0, 1000)) - xr_dataset = xr_dataset.compute() - print(xr_dataset) - dataset = RawDataFromXrDataset(xr_dataset) - dataset.index = "time" - dataset.add_input("usurf") - dataset.add_input("vsurf") - dataset.add_output("S_x") - dataset.add_output("S_y") - # TODO temporary addition, should be made more general - if submodel == "transform2": - dataset.add_output("S_x_d") - dataset.add_output("S_y_d") - if submodel == "transform4": - dataset.add_input("s_x_formula") - dataset.add_input("s_y_formula") - train_index = int(train_split * len(dataset)) - test_index = int(test_split * len(dataset)) - features_transform = ComposeTransforms() - targets_transform = ComposeTransforms() - transform = DatasetTransformer(features_transform, targets_transform) - dataset = DatasetWithTransform(dataset, transform) - # dataset = MultipleTimeIndices(dataset) - # dataset.time_indices = [0, ] - train_dataset = Subset_(dataset, np.arange(train_index)) - test_dataset = Subset_(dataset, np.arange(test_index, len(dataset))) - train_datasets.append(train_dataset) - test_datasets.append(test_dataset) - datasets.append(dataset) - -# Concatenate datasets. This adds shape transforms to ensure that all regions -# produce fields of the same shape, hence should be called after saving -# the transformation so that when we're going to test on another region -# this does not occur. -train_dataset = ConcatDataset_(train_datasets) -test_dataset = ConcatDataset_(test_datasets) - -# Dataloaders -train_dataloader = DataLoader( - train_dataset, batch_size=batch_size, shuffle=True, drop_last=True, num_workers=4 -) -test_dataloader = DataLoader( - test_dataset, batch_size=batch_size, shuffle=False, drop_last=True -) - -print(f"Size of training data: {len(train_dataset)}") -print(f"Size of validation data : {len(test_dataset)}") +submodel_transform_func = lambda x: submodels.transform3.fit_transform(x) + +# load input training data, split into spatial domains via provided bounding +# boxes +ds = xr.open_zarr(options.in_train_data_dir) +f_bound_cm26 = lambda x: bounding_box.bound_dataset("yu_ocean", "xu_ocean", ds, x) +sd_dss_xr = map(f_bound_cm26, bounding_box.load_bounding_boxes_yaml(options.subdomains_file)) +# transform shorthand +submodel_transform_and_to_torch = lambda x: lib.gz21_train_data_subdomain_xr_to_torch(submodel_transform_func(x)) + +datasets = map(submodel_transform_and_to_torch, sd_dss_xr) + +train_dataloader, test_dataloader = lib.prep_train_test_dataloaders( + datasets, options.train_split, options.test_split, options.batch_size) # ------------------- # LOAD NEURAL NETWORK @@ -389,13 +179,16 @@ def argparse_get_mlflow_artifact_path_or_direct_or_fail( net.to(device) # Optimizer and learning rate scheduler -params = list(net.parameters()) -optimizer = optim.Adam(params, lr=learning_rates[0], weight_decay=weight_decay) -lr_scheduler = MultiStepLR(optimizer, list(learning_rates.keys())[1:], gamma=0.1) +optimizer = optim.Adam( + list(net.parameters()), + lr=options.initial_learning_rate, weight_decay=options.weight_decay) +lr_scheduler = MultiStepLR( + optimizer, options.decay_at_epoch_milestones, + gamma=options.decay_factor) trainer = Trainer(net, device) trainer.criterion = criterion -trainer.print_loss_every = print_loss_every +trainer.print_loss_every = options.printevery # metrics saved independently of the training criterion. metrics = {"R2": MSEMetric(), "Inf Norm": MaxMetric()} @@ -403,7 +196,7 @@ def argparse_get_mlflow_artifact_path_or_direct_or_fail( metric.inv_transform = lambda x: test_dataset.inverse_transform_target(x) trainer.register_metric(metric_name, metric) -for i_epoch in range(n_epochs): +for i_epoch in range(options.epochs): print(f"Epoch number {i_epoch}.") # TODO remove clipping? train_loss = trainer.train_for_one_epoch( @@ -431,19 +224,15 @@ def argparse_get_mlflow_artifact_path_or_direct_or_fail( # SAVE THE TRAINED MODEL TO DISK # ------------------------------ net.cpu() -full_path = os.path.join(data_location, MODELS_DIRECTORY, MODEL_NAME) -torch.save(net.state_dict(), full_path) +torch.save(net.state_dict(), options.out_model) net.to(device=device) # Save other parts of the model # TODO this should not be necessary -print("Saving other parts of the model") -full_path = os.path.join(data_location, MODELS_DIRECTORY, "transformation") -with open(full_path, "wb") as f: - pickle.dump(transformation, f) - -with TaskInfo("Saving trained model"): - mlflow.log_artifact(os.path.join(data_location, MODELS_DIRECTORY)) +#print("Saving other parts of the model") +#full_path = os.path.join(data_location, MODELS_DIRECTORY, "transformation") +#with open(full_path, "wb") as f: +# pickle.dump(transformation, f) # ---------- @@ -453,7 +242,7 @@ def argparse_get_mlflow_artifact_path_or_direct_or_fail( range(len(datasets)), datasets, test_datasets, xr_datasets ): test_dataloader = DataLoader( - test_dataset, batch_size=batch_size, shuffle=False, drop_last=True + test_dataset, batch_size=options.batch_size, shuffle=False, drop_last=True ) output_dataset = create_test_dataset( net, @@ -469,12 +258,3 @@ def argparse_get_mlflow_artifact_path_or_direct_or_fail( output_dataset.to_zarr( os.path.join(data_location, MODEL_OUTPUT_DIR, f"test_output{i_dataset}") ) - - -# ----------------------- -# LOG ARTIFACTS IN MLFLOW -# ----------------------- -print("Logging artifacts...") -mlflow.log_artifact(os.path.join(data_location, FIGURES_DIRECTORY)) -mlflow.log_artifact(os.path.join(data_location, MODEL_OUTPUT_DIR)) -print("Done...") diff --git a/src/gz21_ocean_momentum/common/assorted.py b/src/gz21_ocean_momentum/common/assorted.py index 80e1a72c..ad5c796b 100644 --- a/src/gz21_ocean_momentum/common/assorted.py +++ b/src/gz21_ocean_momentum/common/assorted.py @@ -1,6 +1,7 @@ -def list_is_strictly_increasing(xs: list[float]) -> bool: +def list_is_strictly_increasing(xs: list) -> bool: """ Is this list monotonically increasing? Does not permit repeated elements. + List elements must be orderable. Asserts that a list is in the correct format to be consumed by the `milestones` parameter in `torch.optim.MultiStepLR(optimizer: list, ...)`. diff --git a/src/gz21_ocean_momentum/step/train/lib.py b/src/gz21_ocean_momentum/step/train/lib.py deleted file mode 100644 index 4092f1b8..00000000 --- a/src/gz21_ocean_momentum/step/train/lib.py +++ /dev/null @@ -1,10 +0,0 @@ -import torch - -import gz21_ocean_momentum.models.transforms - -from gz21_ocean_momentum.common.bounding_box import BoundingBox - -def select_subdomains(ds: torch.utils.data.Dataset, sds: list[BoundingBox]) -> list[torch.utils.data.Dataset]: - """TODO requires xu_ocean, yu_ocean""" - return [ ds.sel(xu_ocean=slice(sd.long_min, sd.long_max), - yu_ocean=slice(sd.lat_min, sd.lat_max)) for sd in sds ] diff --git a/src/gz21_ocean_momentum/unsorted/train_data_xr_to_pytorch.py b/src/gz21_ocean_momentum/unsorted/train_data_xr_to_pytorch.py new file mode 100644 index 00000000..01451c80 --- /dev/null +++ b/src/gz21_ocean_momentum/unsorted/train_data_xr_to_pytorch.py @@ -0,0 +1,80 @@ +import xarray as xr +import numpy as np + +from gz21_ocean_momentum.data.datasets import ( + DatasetWithTransform, + DatasetTransformer, + RawDataFromXrDataset, + ConcatDataset_, + Subset_, + ComposeTransforms, +) + +def at_idx_pct(pct: float, a) -> int: + """ + Obtain the index into the given list-like to the given percent. + + e.g. `at_idx_pct(0.5, [0,1,2]) == 1` + + Must be able to `len(a)`. + + Invariant: `0<=pct<=1`. + + Returns a valid index into `a`. + """ + return int(pct * len(a)) + +def gz21_train_data_subdomain_xr_to_torch(ds_xr: xr.Dataset): + """ + Convert GZ21 training data (coarsened CM2.6 data with diagnosed forcings) + into a PyTorch dataset. + + Intended to take in a single spatial subdomain of the "main" dataset. + Apply submodel transforms first. + Perform dataset splits after. + """ + # TODO disabled + #with ProgressBar(), TaskInfo("Computing dataset"): + # Below line only for speeding up debugging + # xr_dataset = xr_dataset.isel(time=slice(0, 1000)) + #xr_dataset = xr_dataset.compute() + ds_torch = RawDataFromXrDataset(ds_xr) + ds_torch.index = "time" + ds_torch.add_input("usurf") + ds_torch.add_input("vsurf") + ds_torch.add_output("S_x") + ds_torch.add_output("S_y") + features_transform = ComposeTransforms() + targets_transform = ComposeTransforms() + transform = DatasetTransformer(features_transform, targets_transform) + ds_torch_with_transform = DatasetWithTransform(ds_torch, transform) + # dataset = MultipleTimeIndices(dataset) + # dataset.time_indices = [0, ] + return ds_torch_with_transform + +def prep_train_test_dataloaders( + dss: list, + pct_train_end: float, + pct_test_start: float, + batch_size: int): + + # split dataset according to requested lengths + train_datasets = [ Subset_(x, np.arange(0, at_idx_pct(pct_train_end, x))) for x in dss ] + test_datasets = [ Subset_(x, np.arange(at_idx_pct(pct_test_start, x), len(x))) for x in dss ] + + # Concatenate datasets. This adds shape transforms to ensure that all + # regions produce fields of the same shape, hence should be called after + # saving the transformation so that when we're going to test on another + # region this does not occur. + train_dataset = ConcatDataset_(train_datasets) + test_dataset = ConcatDataset_(test_datasets) + + # Dataloaders + train_dataloader = DataLoader( + train_dataset, batch_size=batch_size, shuffle=True, drop_last=True, num_workers=4 + ) + test_dataloader = DataLoader( + test_dataset, batch_size=batch_size, shuffle=False, drop_last=True + ) + + return train_dataloader, test_dataloader From b5354580d35b670c62c30835907e22e07bb83c54 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Mon, 13 Nov 2023 18:56:16 +0000 Subject: [PATCH 062/114] cli/train: executes (but freezes) --- src/gz21_ocean_momentum/cli/train.py | 82 +++++++------------ .../unsorted/train_data_xr_to_pytorch.py | 5 +- 2 files changed, 33 insertions(+), 54 deletions(-) diff --git a/src/gz21_ocean_momentum/cli/train.py b/src/gz21_ocean_momentum/cli/train.py index c4b33f9a..7922bf69 100755 --- a/src/gz21_ocean_momentum/cli/train.py +++ b/src/gz21_ocean_momentum/cli/train.py @@ -1,27 +1,41 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +# TODO: +# * probably remove the map usage. seems non-Pythonic, clumsy over list comps + import gz21_ocean_momentum.common.cli as cli import gz21_ocean_momentum.common.assorted as common import gz21_ocean_momentum.common.bounding_box as bounding_box import gz21_ocean_momentum.unsorted.train_data_xr_to_pytorch as lib -from gz21_ocean_momentum.models import submodels +import gz21_ocean_momentum.models.submodels as submodels +import gz21_ocean_momentum.models.transforms as transforms +import gz21_ocean_momentum.models.models1 as model +import gz21_ocean_momentum.train.losses as loss +from gz21_ocean_momentum.train.base import Trainer +from gz21_ocean_momentum.inference.metrics import MSEMetric, MaxMetric import configargparse import os import xarray as xr + import torch +from torch.utils.data import DataLoader +from torch import optim +from torch.optim.lr_scheduler import MultiStepLR # TODO probably temporary import tempfile +# TODO ideally temporary but probably not +import copy + # --- old_imports = """ import os.path -import copy import argparse import importlib import pickle @@ -30,14 +44,7 @@ import mlflow import xarray as xr -import torch -from torch.utils.data import DataLoader -from torch import optim -from torch.optim.lr_scheduler import MultiStepLR - -from gz21_ocean_momentum.train.base import Trainer from gz21_ocean_momentum.inference.utils import create_test_dataset -from gz21_ocean_momentum.inference.metrics import MSEMetric, MaxMetric import gz21_ocean_momentum.train.losses import gz21_ocean_momentum.step.train.lib as lib @@ -88,18 +95,6 @@ def _check_dir(dir_path): if not os.path.exists(dir_path): os.mkdir(dir_path) -# -------------------------- -# SET UP TRAINING PARAMETERS -# -------------------------- -# Note that we use two indices for the train/test split. This is because we -# want to avoid the time correlation to play in our favour during test. -model_module_name = "models.models1" -model_cls_name = "FullyCNN" -loss_cls_name = "HeteroskedasticGaussianLossV2" -transformation_cls_name = "SoftPlusTransform" -# Submodel (for instance monthly means) -submodel = "transform3" - # Directories where temporary data will be saved data_location = tempfile.mkdtemp() print("Created temporary dir at ", data_location) @@ -111,18 +106,20 @@ def _check_dir(dir_path): for directory in [FIGURES_DIRECTORY, MODELS_DIRECTORY, MODEL_OUTPUT_DIR]: _check_dir(os.path.join(data_location, directory)) -submodel_transform_func = lambda x: submodels.transform3.fit_transform(x) +# TODO: we apparently have to deepcopy because it tracks if it's already fitted +# the transform. ok I guess +submodel_transform_func = lambda x: copy.deepcopy(submodels.transform3).fit_transform(x) # load input training data, split into spatial domains via provided bounding # boxes ds = xr.open_zarr(options.in_train_data_dir) f_bound_cm26 = lambda x: bounding_box.bound_dataset("yu_ocean", "xu_ocean", ds, x) -sd_dss_xr = map(f_bound_cm26, bounding_box.load_bounding_boxes_yaml(options.subdomains_file)) +sd_dss_xr = list(map(f_bound_cm26, bounding_box.load_bounding_boxes_yaml(options.subdomains_file))) # transform shorthand submodel_transform_and_to_torch = lambda x: lib.gz21_train_data_subdomain_xr_to_torch(submodel_transform_func(x)) -datasets = map(submodel_transform_and_to_torch, sd_dss_xr) +datasets = list(map(submodel_transform_and_to_torch, sd_dss_xr)) train_dataloader, test_dataloader = lib.prep_train_test_dataloaders( datasets, options.train_split, options.test_split, options.batch_size) @@ -132,30 +129,11 @@ def _check_dir(dir_path): # ------------------- # Load the loss class required in the script parameters n_target_channels = datasets[0].n_targets -criterion = getattr(train.losses, loss_cls_name)(n_target_channels) - -# Recover the model's class, based on the corresponding CLI parameters -try: - models_module = importlib.import_module(model_module_name) - model_cls = getattr(models_module, model_cls_name) -except ModuleNotFoundError as e: - raise type(e)("Could not find the specified module for : " + str(e)) -except AttributeError as e: - raise type(e)("Could not find the specified model class: " + str(e)) -net = model_cls(datasets[0].n_features, criterion.n_required_channels) -try: - transformation_cls = getattr(transforms, transformation_cls_name) - transformation = transformation_cls() - transformation.indices = criterion.precision_indices - net.final_transformation = transformation -except AttributeError as e: - raise type(e)("Could not find the specified transformation class: " + str(e)) - -print("--------------------") -print(net) -print("--------------------") -print("***") - +criterion = loss.HeteroskedasticGaussianLossV2(n_target_channels) +net = model.FullyCNN(criterion.n_required_channels) +transformation = transforms.SoftPlusTransform() +transformation.indices = criterion.precision_indices +net.final_transformation = transformation # Log the text representation of the net into a txt artifact with open( @@ -176,7 +154,7 @@ def _check_dir(dir_path): # ------------------- # Adam optimizer # To GPU -net.to(device) +net.to(options.device) # Optimizer and learning rate scheduler optimizer = optim.Adam( @@ -186,7 +164,7 @@ def _check_dir(dir_path): optimizer, options.decay_at_epoch_milestones, gamma=options.decay_factor) -trainer = Trainer(net, device) +trainer = Trainer(net, options.device) trainer.criterion = criterion trainer.print_loss_every = options.printevery @@ -225,7 +203,7 @@ def _check_dir(dir_path): # ------------------------------ net.cpu() torch.save(net.state_dict(), options.out_model) -net.to(device=device) +net.to(options.device) # Save other parts of the model # TODO this should not be necessary @@ -241,7 +219,7 @@ def _check_dir(dir_path): for i_dataset, dataset, test_dataset, xr_dataset in zip( range(len(datasets)), datasets, test_datasets, xr_datasets ): - test_dataloader = DataLoader( + test_dataloader = torch.DataLoader( test_dataset, batch_size=options.batch_size, shuffle=False, drop_last=True ) output_dataset = create_test_dataset( diff --git a/src/gz21_ocean_momentum/unsorted/train_data_xr_to_pytorch.py b/src/gz21_ocean_momentum/unsorted/train_data_xr_to_pytorch.py index 01451c80..1221db94 100644 --- a/src/gz21_ocean_momentum/unsorted/train_data_xr_to_pytorch.py +++ b/src/gz21_ocean_momentum/unsorted/train_data_xr_to_pytorch.py @@ -1,5 +1,6 @@ import xarray as xr import numpy as np +import torch.utils.data as torch from gz21_ocean_momentum.data.datasets import ( DatasetWithTransform, @@ -70,10 +71,10 @@ def prep_train_test_dataloaders( test_dataset = ConcatDataset_(test_datasets) # Dataloaders - train_dataloader = DataLoader( + train_dataloader = torch.DataLoader( train_dataset, batch_size=batch_size, shuffle=True, drop_last=True, num_workers=4 ) - test_dataloader = DataLoader( + test_dataloader = torch.DataLoader( test_dataset, batch_size=batch_size, shuffle=False, drop_last=True ) From e3161a1454b2455eda13f798318e7eabbacdb946 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 14 Nov 2023 09:44:22 +0000 Subject: [PATCH 063/114] cli/train: fix default device --- src/gz21_ocean_momentum/cli/train.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gz21_ocean_momentum/cli/train.py b/src/gz21_ocean_momentum/cli/train.py index 7922bf69..da8dda45 100755 --- a/src/gz21_ocean_momentum/cli/train.py +++ b/src/gz21_ocean_momentum/cli/train.py @@ -70,7 +70,7 @@ p.add("--initial-learning-rate", type=float, required=True, help="initial learning rate for optimization algorithm") p.add("--decay-factor", type=float, required=True, help="learning rate decay factor, applied each time an epoch milestone is reached") p.add("--decay-at-epoch-milestones", type=int, action="append", required=True, help="milestones to decay at. May specify multiple times. Must be strictly increasing with no duplicates") -p.add("--device", type=str, default="cuda", help="neural net device (e.g. cuda, cuda:0, cpu)") +p.add("--device", type=str, default="cuda:0", help="neural net device (e.g. cuda:0, cpu)") p.add("--weight-decay", type=float, default=0.0, help="Weight decay parameter for Adam loss function. Deprecated, default 0.") p.add("--train-split", type=float, required=True, help="0>=x>=1. Use 0->x of input dataset for training") p.add("--test-split", type=float, required=True, help="0>=x>=1. Use x->end of input dataset for training. Must be greater than --train-split") From 1a5486489960714ee88e4c0975fda3b0a720dd0e Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 14 Nov 2023 10:28:03 +0000 Subject: [PATCH 064/114] step/data/lib: fix erroneous variable usage --- src/gz21_ocean_momentum/step/data/lib.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/gz21_ocean_momentum/step/data/lib.py b/src/gz21_ocean_momentum/step/data/lib.py index f8b407e4..8a7b49b3 100644 --- a/src/gz21_ocean_momentum/step/data/lib.py +++ b/src/gz21_ocean_momentum/step/data/lib.py @@ -145,28 +145,27 @@ def compute_forcings_and_coarsen_cm2_6( ds_forcing = adv_filtered - filtered_adv ds_forcing = ds_forcing.rename({"adv_x": "S_x", "adv_y": "S_y"}) # Merge filtered u,v, temperature and forcing terms - ds_filtered_with_forcing = ds_forcing.merge(u_v_filtered) + ds_merged = ds_forcing.merge(u_v_filtered) logger.debug("uncoarsened forcings follow below:") - logger.debug(ds_filtered_with_forcing) + logger.debug(ds_merged) # Coarsen - ds_filtered_with_forcing_coarse = ds_filtered_with_forcing.coarsen( + ds_merged_coarse = ds_merged.coarsen( {"xu_ocean": int(scale), "yu_ocean": int(scale)}, boundary="trim" - ) - ds_filtered_with_forcing_coarse = ds_filtered_with_forcing.mean() + ).mean() if nan_or_zero == "zero": # Replace zeros with nans for consistency - ds_filtered_with_forcing_coarse = ds_filtered_with_forcing_coarse.where(ds_filtered_with_forcing_coarse["usurf"] != 0) + ds_merged_coarse = ds_merged_coarse.where(ds_merged_coarse["usurf"] != 0) # Specify input vs output type for each variable of the dataset. Might # be used later on for training or testing. - ds_filtered_with_forcing_coarse["S_x"].attrs["type"] = "output" - ds_filtered_with_forcing_coarse["S_y"].attrs["type"] = "output" - ds_filtered_with_forcing_coarse["usurf"].attrs["type"] = "input" - ds_filtered_with_forcing_coarse["vsurf"].attrs["type"] = "input" + ds_merged_coarse["S_x"].attrs["type"] = "output" + ds_merged_coarse["S_y"].attrs["type"] = "output" + ds_merged_coarse["usurf"].attrs["type"] = "input" + ds_merged_coarse["vsurf"].attrs["type"] = "input" - return ds_filtered_with_forcing_coarse + return ds_merged_coarse def _advections(u_v_field: xr.Dataset, grid_data: xr.Dataset) -> xr.Dataset: From e664357050d0ec9fe555e8191fef18e3dd1326ec Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 17 Nov 2023 13:23:09 +0000 Subject: [PATCH 065/114] readme: update; remove mlflow notes --- README.md | 85 +++++++++++++++++++------------------------------------ 1 file changed, 29 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 0dc8d1a7..c157ed4a 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,9 @@ subset not used in the previous training step). * `tests`: pytest tests * `docs`: detailed project documentation, implementation notes * `examples`: CLI step configs, Jupyter notebooks for generating figures etc. +* `flake.nix`, `flake.lock`: helper files for building on Nix (ignore) ## Installation -### Dependencies Python 3.9 or newer is required. We primarily test on Python 3.11. To avoid any conflicts with local packages, we recommend using a virtual @@ -80,14 +80,7 @@ Note that if you are running Python 3.9 or older, you may also need to install the [GEOS](https://libgeos.org/) library, due to `cartopy` requiring it. (Newer versions moved away from the C dependency.) -### Running unit tests -There are a handful of unit tests using pytest, in the [`tests`](tests/) -directory. These assert some operations and methods used in the steps. They may -be run in the regular method: - - pytest - -## Running steps +## Usage Execute these commands from the repository root. See [`docs`](docs/) directory for more details. @@ -98,45 +91,21 @@ For command-line option explanation, run the appropriate step with `--help` e.g. For CLI scripts which support reading in options from a file, various examples are stored in [`examples/cli-configs`](examples/cli-configs/). -#### MLflow specifics -MLflow parameters: - -* `experiment-name`: "tag" to use for MLflow experiment. Used to share artifacts - between steps, i.e. you should run the training step with a name you used to - run the data processing step. -* `exp_id`: TODO: one way MLflow distinguishes runs. May need to set to share - artifacts between steps...? -* `run_id`: TODO: one way MLflow distinguishes runs. May need to set to share - artifacts between steps...? - -For MLflow versions older than 1.25.0, replace the `--env-manager=local` flag -with `--no-conda`. - -When invoking steps with `mlflow run`, you may need to control the path used for -`mlruns` (which stores outputs of runs). You may set the `MLFLOW_TRACKING_URI` -environment variable to achieve this. In Linux: - - export MLFLOW_TRACKING_URI="/path/to/data/dir" - -In a Jupyter Notebook: - - %env MLFLOW_TRACKING_URI /path/to/data/dir - -In Python: +### Unit tests +There are a handful of unit tests using pytest, in the [`tests`](tests/) +directory. These assert some operations and methods used in the steps. They may +be run in the regular method: -```python -import os -os.environ['MLFLOW_TRACKING_URI'] = '/path/to/data/dir' -``` + pytest -#### Data processing -The CLI into the data processing step is at -[`cli/data.py`](src/gz21_ocean_momentum/cli/data.py). It generates coarse +### Training data generation +[`cli/data.py`](src/gz21_ocean_momentum/cli/data.py) calculates coarse surface velocities and diagnosed forcings from the CM2.6 dataset and saves them -to disk. This is used as training data for our NN. You may configure certain -parameters such as bounds (lat/lon) and CO2 level. +to disk. This is used as training data for the neural net. -**You must configure GCP credentials to download the CM2.6 dataset used.** +You may configure certain parameters such as bounds (lat/lon) and CO2 level. + +**You must configure GCP credentials in order to download the CM2.6 dataset.** See [`docs/data.md`](docs/data.md) for more details. Example invocation: @@ -147,7 +116,7 @@ Example invocation: Alternatively, you may write (all or part of) these options into a YAML file: -``` +```yaml lat-min: -80 lat-max: 80 long-min: -280 @@ -181,19 +150,23 @@ configure various training parameters through command-line arguments, such as number of training epochs, loss functions, and training data. (You will want to select the output from a data processing step for the latter.) -MLflow call example: +Example invocation: + python src/gz21_ocean_momentum/cli/train.py \ + --lat-min -80 --lat-max 80 --long-min -280 --long-max 80 \ + --factor 4 --ntimes 100 --co2-increase --out-dir forcings + +Alternatively, you may write (all or part of) these options into a YAML file: + +```yaml +TODO ``` -mlflow run . --experiment-name -e train --env-manager=local \ --P run_id= \ --P subdomains_file=examples/cli-configs/training-subdomains-paper.yaml \ --P learning_rate=0/5e-4/15/5e-5/30/5e-6 -P weight_decay=0.00 \ --P n_epochs=200 -P batchsize=4 \ --P train_split=0.8 -P test_split=0.85 \ --P model_module_name=models.models1 -P model_cls_name=FullyCNN \ --P transformation_cls_name=SoftPlusTransform -P submodel=transform3 \ --P loss_cls_name=HeteroskedasticGaussianLossV2 -``` + +and use this file in an invocation with the `--config-file` option: + + python src/gz21_ocean_momentum/cli/data.py \ + --config-file examples/cli-configs/data-paper.yaml --out-dir forcings + Plain Python call example: From 3a51f52390ea9be5254ba3b5dc83212c8d36e56c Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 17 Nov 2023 15:09:50 +0000 Subject: [PATCH 066/114] various training code fixes --- examples/cli-configs/data-quick-2.yaml | 11 ++++ examples/cli-configs/train-wip.yaml | 2 +- .../training-subdomains-small.yaml | 26 ++++++++ src/gz21_ocean_momentum/cli/train.py | 27 ++++---- src/gz21_ocean_momentum/data/datasets.py | 2 + src/gz21_ocean_momentum/train/base.py | 15 ++--- .../unsorted/train_data_xr_to_pytorch.py | 63 ++++++++++--------- 7 files changed, 96 insertions(+), 50 deletions(-) create mode 100644 examples/cli-configs/data-quick-2.yaml create mode 100644 examples/cli-configs/training-subdomains-small.yaml diff --git a/examples/cli-configs/data-quick-2.yaml b/examples/cli-configs/data-quick-2.yaml new file mode 100644 index 00000000..470f20c1 --- /dev/null +++ b/examples/cli-configs/data-quick-2.yaml @@ -0,0 +1,11 @@ +# Quick, but retain original spatial bounds because I'm getting weird issues. + +lat-min: -80 +lat-max: 80 +long-min: -280 +long-max: 80 + +ntimes: 10 +factor: 8 + +co2-increase: false diff --git a/examples/cli-configs/train-wip.yaml b/examples/cli-configs/train-wip.yaml index a5ae4191..0bb7d001 100644 --- a/examples/cli-configs/train-wip.yaml +++ b/examples/cli-configs/train-wip.yaml @@ -3,7 +3,7 @@ subdomains-file: /home/raehik/sh/gz21/gz21/examples/cli-configs/training-subdoma batch-size: 8 -epochs: 200 +epochs: 20 initial-learning-rate: 5.0e-4 decay-factor: 0.0 decay-at-epoch-milestones: [15, 30] # TODO is 0 implicit? I forget diff --git a/examples/cli-configs/training-subdomains-small.yaml b/examples/cli-configs/training-subdomains-small.yaml new file mode 100644 index 00000000..995d1727 --- /dev/null +++ b/examples/cli-configs/training-subdomains-small.yaml @@ -0,0 +1,26 @@ +#Configuration file for the subdomains used in the training and validation phase +!!python/tuple +- !!python/tuple + - training + - lat_min: 0. + lat_max: 20. + lon_min: -50. + lon_max: -20. +- !!python/tuple + - training + - lat_min: -20. + lat_max: 0. + lon_min: -180. + lon_max: -162. +- !!python/tuple + - training + - lat_min: -20. + lat_max: -5. + lon_min: -110. + lon_max: -92. +- !!python/tuple + - training + - lat_min: -0. + lat_max: 15. + lon_min: -48 + lon_max: -30 diff --git a/src/gz21_ocean_momentum/cli/train.py b/src/gz21_ocean_momentum/cli/train.py index da8dda45..859d3c69 100755 --- a/src/gz21_ocean_momentum/cli/train.py +++ b/src/gz21_ocean_momentum/cli/train.py @@ -106,18 +106,20 @@ def _check_dir(dir_path): for directory in [FIGURES_DIRECTORY, MODELS_DIRECTORY, MODEL_OUTPUT_DIR]: _check_dir(os.path.join(data_location, directory)) -# TODO: we apparently have to deepcopy because it tracks if it's already fitted -# the transform. ok I guess -submodel_transform_func = lambda x: copy.deepcopy(submodels.transform3).fit_transform(x) - # load input training data, split into spatial domains via provided bounding # boxes ds = xr.open_zarr(options.in_train_data_dir) f_bound_cm26 = lambda x: bounding_box.bound_dataset("yu_ocean", "xu_ocean", ds, x) sd_dss_xr = list(map(f_bound_cm26, bounding_box.load_bounding_boxes_yaml(options.subdomains_file))) -# transform shorthand -submodel_transform_and_to_torch = lambda x: lib.gz21_train_data_subdomain_xr_to_torch(submodel_transform_func(x)) +# transform wrapper +def submodel_transform_and_to_torch(ds_xr): + # TODO: we apparently have to deepcopy because it tracks if it's already + # fitted the transform. ok I guess + ds_xr = copy.deepcopy(submodels.transform3).fit_transform(ds_xr) + ds_xr = ds_xr.compute() + ds_torch = lib.gz21_train_data_subdomain_xr_to_torch(ds_xr) + return ds_torch datasets = list(map(submodel_transform_and_to_torch, sd_dss_xr)) @@ -128,9 +130,8 @@ def _check_dir(dir_path): # LOAD NEURAL NETWORK # ------------------- # Load the loss class required in the script parameters -n_target_channels = datasets[0].n_targets -criterion = loss.HeteroskedasticGaussianLossV2(n_target_channels) -net = model.FullyCNN(criterion.n_required_channels) +criterion = loss.HeteroskedasticGaussianLossV2(datasets[0].n_targets) +net = model.FullyCNN(datasets[0].n_features, criterion.n_required_channels) transformation = transforms.SoftPlusTransform() transformation.indices = criterion.precision_indices net.final_transformation = transformation @@ -191,11 +192,11 @@ def _check_dir(dir_path): for metric_name, metric_value in metrics_results.items(): print(f"Test {metric_name} for this epoch is {metric_value}") - mlflow.log_metric("train loss", train_loss, i_epoch) - mlflow.log_metric("test loss", test_loss, i_epoch) - mlflow.log_metrics(metrics_results) + #mlflow.log_metric("train loss", train_loss, i_epoch) + #mlflow.log_metric("test loss", test_loss, i_epoch) + #mlflow.log_metrics(metrics_results) # Update the logged number of actual training epochs -mlflow.log_param("n_epochs_actual", i_epoch + 1) +#mlflow.log_param("n_epochs_actual", i_epoch + 1) # ------------------------------ diff --git a/src/gz21_ocean_momentum/data/datasets.py b/src/gz21_ocean_momentum/data/datasets.py index 9688d683..32d68873 100644 --- a/src/gz21_ocean_momentum/data/datasets.py +++ b/src/gz21_ocean_momentum/data/datasets.py @@ -920,6 +920,8 @@ def __init__(self, datasets, enforce_same_dims=True): if enforce_same_dims: heights = [dataset.height for dataset in self.datasets] widths = [dataset.width for dataset in self.datasets] + # TODO broken: fails if enforce_same_dims=False + # probably remove option altogether self.height = min(heights) self.width = min(widths) for dataset in self.datasets: diff --git a/src/gz21_ocean_momentum/train/base.py b/src/gz21_ocean_momentum/train/base.py index 8206418c..ff1be0b6 100755 --- a/src/gz21_ocean_momentum/train/base.py +++ b/src/gz21_ocean_momentum/train/base.py @@ -103,8 +103,8 @@ def train_for_one_epoch( forward-backward pass. clip : float, - Value used to clipp gradients. Default is None, in which case - no clipping of gradients. + Value used to clip gradients. Default is None, in which case no + clipping of gradients. Returns ------- @@ -121,14 +121,14 @@ def train_for_one_epoch( # Zero the gradients self.net.zero_grad() # Move batch to the GPU (if possible) - feature_device = feature.to(self._device, dtype=torch.float) - target_device = target.to(self._device, dtype=torch.float) + feature = feature.to(self._device, dtype=torch.float) + target = target.to(self._device, dtype=torch.float) # predict with input predict = self.net(feature) # Compute loss - loss = self.criterion(Y_hat, Y) - running_loss.update(loss.item(), X.size(0)) - running_loss_.update(loss.item(), X.size(0)) + loss = self.criterion(predict, target) + running_loss.update(loss.item(), feature.size(0)) + running_loss_.update(loss.item(), feature.size(0)) # Print current loss loss_text = "Loss value {}".format(running_loss_.average) if print_every(loss_text, self.print_loss_every, i): @@ -143,6 +143,7 @@ def train_for_one_epoch( # Update the learning rate via the scheduler if scheduler is not None: scheduler.step() + print("end loop") return running_loss.value def test(self, dataloader) -> float: diff --git a/src/gz21_ocean_momentum/unsorted/train_data_xr_to_pytorch.py b/src/gz21_ocean_momentum/unsorted/train_data_xr_to_pytorch.py index 1221db94..22f82bd6 100644 --- a/src/gz21_ocean_momentum/unsorted/train_data_xr_to_pytorch.py +++ b/src/gz21_ocean_momentum/unsorted/train_data_xr_to_pytorch.py @@ -11,19 +11,14 @@ ComposeTransforms, ) -def at_idx_pct(pct: float, a) -> int: - """ - Obtain the index into the given list-like to the given percent. - - e.g. `at_idx_pct(0.5, [0,1,2]) == 1` - - Must be able to `len(a)`. - - Invariant: `0<=pct<=1`. - - Returns a valid index into `a`. - """ - return int(pct * len(a)) +def cm26_xarray_to_torch(ds_xr: xr.Dataset): + ds_torch = RawDataFromXrDataset(ds_xr) + ds_torch.index = "time" + ds_torch.add_input("usurf") + ds_torch.add_input("vsurf") + ds_torch.add_output("S_x") + ds_torch.add_output("S_y") + return ds_torch def gz21_train_data_subdomain_xr_to_torch(ds_xr: xr.Dataset): """ @@ -34,31 +29,40 @@ def gz21_train_data_subdomain_xr_to_torch(ds_xr: xr.Dataset): Apply submodel transforms first. Perform dataset splits after. """ - # TODO disabled - #with ProgressBar(), TaskInfo("Computing dataset"): - # Below line only for speeding up debugging - # xr_dataset = xr_dataset.isel(time=slice(0, 1000)) - #xr_dataset = xr_dataset.compute() - ds_torch = RawDataFromXrDataset(ds_xr) - ds_torch.index = "time" - ds_torch.add_input("usurf") - ds_torch.add_input("vsurf") - ds_torch.add_output("S_x") - ds_torch.add_output("S_y") + ds_torch = cm26_xarray_to_torch(ds_xr) + + # prep empty transform, filled in later by custom torch DataLoaders features_transform = ComposeTransforms() targets_transform = ComposeTransforms() transform = DatasetTransformer(features_transform, targets_transform) ds_torch_with_transform = DatasetWithTransform(ds_torch, transform) - # dataset = MultipleTimeIndices(dataset) - # dataset.time_indices = [0, ] + return ds_torch_with_transform +def at_idx_pct(pct: float, a) -> int: + """ + Obtain the index into the given list-like to the given percent. + No interpolation is performed: we choose the leftmost closest index i.e. the + result is floored. + + e.g. `at_idx_pct(0.5, [0,1,2]) == 1` + + Must be able to `len(a)`. + + Invariant: `0<=pct<=1`. + + Returns a valid index into `a`. + """ + return int(pct * len(a)) + def prep_train_test_dataloaders( dss: list, pct_train_end: float, pct_test_start: float, batch_size: int): - + """Split a list of PyTorch datasets into two dataloaders: one for training, + one for testing. + """ # split dataset according to requested lengths train_datasets = [ Subset_(x, np.arange(0, at_idx_pct(pct_train_end, x))) for x in dss ] test_datasets = [ Subset_(x, np.arange(at_idx_pct(pct_test_start, x), len(x))) for x in dss ] @@ -67,8 +71,9 @@ def prep_train_test_dataloaders( # regions produce fields of the same shape, hence should be called after # saving the transformation so that when we're going to test on another # region this does not occur. - train_dataset = ConcatDataset_(train_datasets) - test_dataset = ConcatDataset_(test_datasets) + # TODO ignoring subsetting because it makes things go weird + train_dataset = ConcatDataset_(dss) + test_dataset = ConcatDataset_(dss) # Dataloaders train_dataloader = torch.DataLoader( From 415f628b286bc8bf8f5b3ff499600fec09eab77f Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Fri, 17 Nov 2023 15:11:14 +0000 Subject: [PATCH 067/114] datasets/ConcatDataset_: always enforce same dims The constructor fails on False, it seems most sensible to remove. --- src/gz21_ocean_momentum/data/datasets.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/gz21_ocean_momentum/data/datasets.py b/src/gz21_ocean_momentum/data/datasets.py index 32d68873..92af8661 100644 --- a/src/gz21_ocean_momentum/data/datasets.py +++ b/src/gz21_ocean_momentum/data/datasets.py @@ -908,20 +908,15 @@ def get_partition(self, dataset): class ConcatDataset_(ConcatDataset): """Extends the Pytorch Concat Dataset in two ways: - - enforces (by default) the concatenated dataset to have the same - shapes + - enforces the concatenated dataset to have the same shapes - passes on attributes (from the first dataset, assuming they are equal accross concatenated datasets) """ - def __init__(self, datasets, enforce_same_dims=True): + def __init__(self, datasets): super(ConcatDataset_, self).__init__(datasets) - self.enforce_same_dims = enforce_same_dims - if enforce_same_dims: - heights = [dataset.height for dataset in self.datasets] - widths = [dataset.width for dataset in self.datasets] - # TODO broken: fails if enforce_same_dims=False - # probably remove option altogether + heights = [dataset.height for dataset in self.datasets] + widths = [dataset.width for dataset in self.datasets] self.height = min(heights) self.width = min(widths) for dataset in self.datasets: From 9138fe41b938582f2b5812d0ef3925447386f000 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 21 Nov 2023 11:35:56 +0000 Subject: [PATCH 068/114] cli/train: simplify to get running --- src/gz21_ocean_momentum/cli/train.py | 75 ++++++++-------------- src/gz21_ocean_momentum/common/assorted.py | 16 +++++ 2 files changed, 44 insertions(+), 47 deletions(-) diff --git a/src/gz21_ocean_momentum/cli/train.py b/src/gz21_ocean_momentum/cli/train.py index 859d3c69..eafdbb42 100755 --- a/src/gz21_ocean_momentum/cli/train.py +++ b/src/gz21_ocean_momentum/cli/train.py @@ -14,12 +14,14 @@ import gz21_ocean_momentum.train.losses as loss from gz21_ocean_momentum.train.base import Trainer from gz21_ocean_momentum.inference.metrics import MSEMetric, MaxMetric +from gz21_ocean_momentum.data.datasets import Subset_, ConcatDataset_ import configargparse import os import xarray as xr +import numpy as np import torch from torch.utils.data import DataLoader @@ -72,8 +74,8 @@ p.add("--decay-at-epoch-milestones", type=int, action="append", required=True, help="milestones to decay at. May specify multiple times. Must be strictly increasing with no duplicates") p.add("--device", type=str, default="cuda:0", help="neural net device (e.g. cuda:0, cpu)") p.add("--weight-decay", type=float, default=0.0, help="Weight decay parameter for Adam loss function. Deprecated, default 0.") -p.add("--train-split", type=float, required=True, help="0>=x>=1. Use 0->x of input dataset for training") -p.add("--test-split", type=float, required=True, help="0>=x>=1. Use x->end of input dataset for training. Must be greater than --train-split") +p.add("--train-split-end", type=float, required=True, help="0>=x>=1. Use 0->x of input dataset for training") +p.add("--test-split-start", type=float, required=True, help="0>=x>=1. Use x->end of input dataset for training. Must be greater than --train-split-start") p.add("--printevery", type=int, default=20) options = p.parse_args() @@ -123,8 +125,29 @@ def submodel_transform_and_to_torch(ds_xr): datasets = list(map(submodel_transform_and_to_torch, sd_dss_xr)) -train_dataloader, test_dataloader = lib.prep_train_test_dataloaders( - datasets, options.train_split, options.test_split, options.batch_size) +# split dataset according to requested lengths +train_range = lambda x: np.arange(0, common.at_idx_pct(options.train_split_end,x)) +test_range = lambda x: np.arange(common.at_idx_pct(options.test_split_start, x), len(x)) +#train_datasets = [ Subset_(x, train_range(x)) for x in datasets ] +#test_datasets = [ Subset_(x, test_range(x)) for x in datasets ] +train_datasets = datasets +test_datasets = datasets + +# Concatenate datasets. This adds shape transforms to ensure that all +# regions produce fields of the same shape, hence should be called after +# saving the transformation so that when we're going to test on another +# region this does not occur. +# TODO ignoring subsetting because it makes things go weird +train_dataset = ConcatDataset_(train_datasets) +test_dataset = ConcatDataset_(test_datasets) + +# Dataloaders +train_dataloader = DataLoader( + train_dataset, batch_size=options.batch_size, shuffle=True, drop_last=True, num_workers=4 +) +test_dataloader = DataLoader( + test_dataset, batch_size=options.batch_size, shuffle=False, drop_last=True +) # ------------------- # LOAD NEURAL NETWORK @@ -192,48 +215,6 @@ def submodel_transform_and_to_torch(ds_xr): for metric_name, metric_value in metrics_results.items(): print(f"Test {metric_name} for this epoch is {metric_value}") - #mlflow.log_metric("train loss", train_loss, i_epoch) - #mlflow.log_metric("test loss", test_loss, i_epoch) - #mlflow.log_metrics(metrics_results) -# Update the logged number of actual training epochs -#mlflow.log_param("n_epochs_actual", i_epoch + 1) - -# ------------------------------ -# SAVE THE TRAINED MODEL TO DISK -# ------------------------------ -net.cpu() +#net.cpu() torch.save(net.state_dict(), options.out_model) -net.to(options.device) - -# Save other parts of the model -# TODO this should not be necessary -#print("Saving other parts of the model") -#full_path = os.path.join(data_location, MODELS_DIRECTORY, "transformation") -#with open(full_path, "wb") as f: -# pickle.dump(transformation, f) - - -# ---------- -# DEBUT TEST -# ---------- -for i_dataset, dataset, test_dataset, xr_dataset in zip( - range(len(datasets)), datasets, test_datasets, xr_datasets -): - test_dataloader = torch.DataLoader( - test_dataset, batch_size=options.batch_size, shuffle=False, drop_last=True - ) - output_dataset = create_test_dataset( - net, - criterion.n_required_channels, - xr_dataset, - test_dataset, - test_dataloader, - test_index, - device, - ) - - # Save model output on the test dataset - output_dataset.to_zarr( - os.path.join(data_location, MODEL_OUTPUT_DIR, f"test_output{i_dataset}") - ) diff --git a/src/gz21_ocean_momentum/common/assorted.py b/src/gz21_ocean_momentum/common/assorted.py index ad5c796b..14e1af34 100644 --- a/src/gz21_ocean_momentum/common/assorted.py +++ b/src/gz21_ocean_momentum/common/assorted.py @@ -7,3 +7,19 @@ def list_is_strictly_increasing(xs: list) -> bool: `milestones` parameter in `torch.optim.MultiStepLR(optimizer: list, ...)`. """ return all(xl int: + """ + Obtain the index into the given list-like to the given percent. + No interpolation is performed: we choose the leftmost closest index i.e. the + result is floored. + + e.g. `at_idx_pct(0.5, [0,1,2]) == 1` + + Must be able to `len(a)`. + + Invariant: `0<=pct<=1`. + + Returns a valid index into `a`. + """ + return int(pct * len(a)) From 4e705e90c927c2a0f73758e46576f2e04c8f499b Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 21 Nov 2023 12:35:09 +0000 Subject: [PATCH 069/114] readme: simplify data, training notes --- README.md | 67 ++++++++++++++++--------------------------------------- 1 file changed, 19 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index c157ed4a..14c665b1 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,12 @@ See [`docs`](docs/) directory for more details. For command-line option explanation, run the appropriate step with `--help` e.g. `python src/gz21_ocean_momentum/cli/data.py --help`. -For CLI scripts which support reading in options from a file, various examples -are stored in [`examples/cli-configs`](examples/cli-configs/). +Most CLI scripts support reading in options from a YAML file using a +`--config-file` flag. In general, a flag `--name value` will be converted to a +top-level `name: value` line. Examples are provided in +[`examples/cli-configs`](examples/cli-configs/). CLI options override file +options, so you may provide partial configuration in a file and fill out the +rest (e.g. file paths) on the command line. ### Unit tests There are a handful of unit tests using pytest, in the [`tests`](tests/) @@ -103,8 +107,6 @@ be run in the regular method: surface velocities and diagnosed forcings from the CM2.6 dataset and saves them to disk. This is used as training data for the neural net. -You may configure certain parameters such as bounds (lat/lon) and CO2 level. - **You must configure GCP credentials in order to download the CM2.6 dataset.** See [`docs/data.md`](docs/data.md) for more details. @@ -131,10 +133,6 @@ and use this file in an invocation with the `--config-file` option: python src/gz21_ocean_momentum/cli/data.py \ --config-file examples/cli-configs/data-paper.yaml --out-dir forcings -For command-line option explanation, append the `--help` flag: - - python src/gz21_ocean_momentum/cli/data.py --help - Some preprocessed data is hosted on HuggingFace at [datasets/M2LInES/gz21-forcing-cm26](https://huggingface.co/datasets/M2LInES/gz21-forcing-cm26). @@ -152,59 +150,32 @@ select the output from a data processing step for the latter.) Example invocation: - python src/gz21_ocean_momentum/cli/train.py \ - --lat-min -80 --lat-max 80 --long-min -280 --long-max 80 \ - --factor 4 --ntimes 100 --co2-increase --out-dir forcings - -Alternatively, you may write (all or part of) these options into a YAML file: - -```yaml -TODO +``` +python src/gz21_ocean_momentum/cli/train.py \ +--lat-min -80 --lat-max 80 --long-min -280 --long-max 80 \ +--factor 4 --ntimes 100 --co2-increase --out-dir forcings \ +--train-split-end 0.8 --test-split-start 0.85 \ +--subdomains-file examples/cli-configs/training-subdomains-paper.yaml \ +--forcing-data-path ``` -and use this file in an invocation with the `--config-file` option: - - python src/gz21_ocean_momentum/cli/data.py \ - --config-file examples/cli-configs/data-paper.yaml --out-dir forcings +You may place options into a YAML file and load with the `--config-file` option. Plain Python call example: -``` -python src/gz21_ocean_momentum/cli/train.py ---subdomains-file examples/cli-configs/training-subdomains-paper.yaml \ ---forcing-data-path \ -TODO -``` - Relevant parameters: -* `run_id`: MLflow run ID of the run that generated the forcing data that will - be used for training. -* `subdomains_file`: path to YAML file storing a list of subdomains to select +* `--subdomains-file`: path to YAML file storing a list of subdomains to select from the forcing data, which are then used for training. (Note that at runtime, domains are be truncated to the size of the smallest domain in terms of number of points.) -* `train_split`: use `0->N` percent of the dataset for training -* `test_split`: use `N->100` percent of the dataset for testing -* `loss_cls_name`: name of the class that defines the loss. This class should be - defined in train/losses.py in order for the script to find it. Currently, the - main available options are: - * `HeteroskedasticGaussianLossV2`: this corresponds to the loss used in the - 2021 paper - * `BimodalGaussianLoss`: a Gaussian loss defined using two Gaussian modes -* `model_module_name`: name of the module that contains the class defining the - NN used -* `model_cls_name`: name of the class defining the NN used, should be defined in - the module specified by `model_module_name` - -You may also call this script directly instead of going through `mlflow run`. In -such cases, you may replace `--run-id` with `--forcing-data-path`. See -[`cli/train.py`][cli-train] and [`MLproject`](MLproject) for more details. +* `--train-split-end`: use `0->N` percent of the dataset for training +* `--test-split-start`: use `N->100` percent of the dataset for testing ##### Subdomains -The `subdomains_file` format is a list of bounding boxes, each defined using -four floats: +The `--subdomains-file` format is a YAML list of bounding boxes, each defined +using four labelled floats: ```yaml - lat-min: 35 From fa7eaa727d5e2b2fb30b9f8998326101e8c68c44 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 21 Nov 2023 15:40:31 +0000 Subject: [PATCH 070/114] tinker --- src/gz21_ocean_momentum/cli/train.py | 9 ++++----- src/gz21_ocean_momentum/common/bounding_box.py | 2 ++ src/gz21_ocean_momentum/data/datasets.py | 3 +++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/gz21_ocean_momentum/cli/train.py b/src/gz21_ocean_momentum/cli/train.py index eafdbb42..f15b15c3 100755 --- a/src/gz21_ocean_momentum/cli/train.py +++ b/src/gz21_ocean_momentum/cli/train.py @@ -128,16 +128,15 @@ def submodel_transform_and_to_torch(ds_xr): # split dataset according to requested lengths train_range = lambda x: np.arange(0, common.at_idx_pct(options.train_split_end,x)) test_range = lambda x: np.arange(common.at_idx_pct(options.test_split_start, x), len(x)) -#train_datasets = [ Subset_(x, train_range(x)) for x in datasets ] -#test_datasets = [ Subset_(x, test_range(x)) for x in datasets ] -train_datasets = datasets -test_datasets = datasets +train_datasets = [ Subset_(x, train_range(x)) for x in datasets ] +test_datasets = [ Subset_(x, test_range(x)) for x in datasets ] +#train_datasets = datasets +#test_datasets = datasets # Concatenate datasets. This adds shape transforms to ensure that all # regions produce fields of the same shape, hence should be called after # saving the transformation so that when we're going to test on another # region this does not occur. -# TODO ignoring subsetting because it makes things go weird train_dataset = ConcatDataset_(train_datasets) test_dataset = ConcatDataset_(test_datasets) diff --git a/src/gz21_ocean_momentum/common/bounding_box.py b/src/gz21_ocean_momentum/common/bounding_box.py index 6bb40e3b..a587485d 100644 --- a/src/gz21_ocean_momentum/common/bounding_box.py +++ b/src/gz21_ocean_momentum/common/bounding_box.py @@ -32,6 +32,8 @@ def bound_dataset( The spatial dimensions should be `float`s. Argument order is latitude (y) followed by longitude (x). + + Pure function -- does not alter the input dataset or bounding box. """ return data.sel({ dim_lat: slice(bbox.lat_min, bbox.lat_max), diff --git a/src/gz21_ocean_momentum/data/datasets.py b/src/gz21_ocean_momentum/data/datasets.py index 92af8661..3f18a02a 100644 --- a/src/gz21_ocean_momentum/data/datasets.py +++ b/src/gz21_ocean_momentum/data/datasets.py @@ -697,6 +697,8 @@ def __len__(self): Number of samples of the dataset. """ + print("xrrawdataset len called") + print(len(self.xr_dataset[self._index])) try: return len(self.xr_dataset[self._index]) except KeyError as e: @@ -795,6 +797,7 @@ def __getattr__(self, attr): raise AttributeError() def __len__(self): + print("len on datasetwithtransform") return len(self.dataset) def add_transforms_from_model(self, model): From 29efba2d16114964ca875aa341c748abf678ab7d Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 28 Nov 2023 12:11:22 +0000 Subject: [PATCH 071/114] examples/cfg/data-paper: generalize --- examples/cli-configs/data-paper.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/cli-configs/data-paper.yaml b/examples/cli-configs/data-paper.yaml index 79d14b83..9cd14c4a 100644 --- a/examples/cli-configs/data-paper.yaml +++ b/examples/cli-configs/data-paper.yaml @@ -1,11 +1,9 @@ -# Approximates data step configuration used in the 2021 paper. +# Shared configuration showing up in the 2021 paper. +# You need to provide --ntimes and optionally --co2-increase . lat-min: -80 lat-max: 80 long-min: -280 long-max: 80 -ntimes: 100 factor: 4 - -co2-increase: true From bb50ab26d5346a275db4ae31f9f93e37ed17ceba Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 28 Nov 2023 13:25:14 +0000 Subject: [PATCH 072/114] exaples/cfg: tweak --- examples/cli-configs/data-paper.yaml | 2 +- examples/cli-configs/train-wip.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/cli-configs/data-paper.yaml b/examples/cli-configs/data-paper.yaml index 9cd14c4a..dbe2f3ab 100644 --- a/examples/cli-configs/data-paper.yaml +++ b/examples/cli-configs/data-paper.yaml @@ -1,5 +1,5 @@ # Shared configuration showing up in the 2021 paper. -# You need to provide --ntimes and optionally --co2-increase . +# You may pass --ntimes and --co2-increase on the CLI. lat-min: -80 lat-max: 80 diff --git a/examples/cli-configs/train-wip.yaml b/examples/cli-configs/train-wip.yaml index 0bb7d001..989a7fc5 100644 --- a/examples/cli-configs/train-wip.yaml +++ b/examples/cli-configs/train-wip.yaml @@ -1,7 +1,7 @@ in-train-data-dir: /home/raehik/sh/gz21/gz21/tmp/generated/forcings/paper-n100 subdomains-file: /home/raehik/sh/gz21/gz21/examples/cli-configs/training-subdomains-paper.yaml -batch-size: 8 +batch-size: 4 epochs: 20 initial-learning-rate: 5.0e-4 From bd8772b94a5b5857047cfd311debfce785843c7b Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 28 Nov 2023 13:25:49 +0000 Subject: [PATCH 073/114] cli/infer: initial working script --- src/gz21_ocean_momentum/cli/infer.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/gz21_ocean_momentum/cli/infer.py b/src/gz21_ocean_momentum/cli/infer.py index c4efb531..987efe4d 100755 --- a/src/gz21_ocean_momentum/cli/infer.py +++ b/src/gz21_ocean_momentum/cli/infer.py @@ -13,7 +13,9 @@ ) import xarray as xr + import torch +from torch.utils.data import DataLoader # TODO hardcode submodel, transformation, NN loss function # unlikely for a CLI we need to provide dynamic code loading -- let's just give @@ -26,10 +28,10 @@ import gz21_ocean_momentum.models.submodels as submodels import gz21_ocean_momentum.models.transforms as transforms import gz21_ocean_momentum.train.losses as loss_funcs +from gz21_ocean_momentum.inference.utils import predict_lazy_cm2_6 #from gz21_ocean_momentum.train.base import Trainer submodel = submodels.transform3 -#criterion = loss_funcs.HeteroskedasticGaussianLossV2(dataset.n_targets) DESCRIPTION = """ Use a trained GZ21 neural net to predict forcing for input ocean velocity data. @@ -81,13 +83,19 @@ features_transform_ = ComposeTransforms() targets_transform_ = ComposeTransforms() transform = DatasetTransformer(features_transform_, targets_transform_) -transform.fit(ds_computed_torch) +transform.fit(ds_computed_torch) # TODO this line not in training. idk why, empty transform dataset = DatasetWithTransform(ds_computed_torch, transform) -# load trained neural net -#net = model.FullyCNN(dataset.n_features, criterion.n_required_channels) -net = model.FullyCNN(dataset.n_features, 4) -net.final_transformation = transforms.SoftPlusTransform() # TODO +loader = DataLoader(dataset) + +criterion = loss_funcs.HeteroskedasticGaussianLossV2(dataset.n_targets) +net = model.FullyCNN(dataset.n_features, criterion.n_required_channels) + +# TODO does the transformation file store state? if so, this is bad +transformation = transforms.SoftPlusTransform() +transformation.indices = criterion.precision_indices +net.final_transformation = transformation + net.load_state_dict(torch.load(options.model_state_dict_file)) dataset.add_transforms_from_model(net) @@ -97,11 +105,9 @@ with ProgressBar(), TaskInfo("Predict & save prediction dataset"): out = predict_lazy_cm2_6(net, - #criterion.n_required_channels, - #criterion.channel_names, - 4, - ["S_x"] - dataset, loaders, options.device) + criterion.n_required_channels, + ["S_x"], + [dataset], [loader], options.device) ProgressBar().register() logger.info(f"chunk predictions to time=32 ...") out = out.chunk(dict(time=32)) From 22c097bcc2dc61902801f755133e84063c607c5c Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Tue, 28 Nov 2023 13:26:24 +0000 Subject: [PATCH 074/114] cli/train: fiddle with dataset splitting --- src/gz21_ocean_momentum/cli/train.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/gz21_ocean_momentum/cli/train.py b/src/gz21_ocean_momentum/cli/train.py index f15b15c3..93de42ac 100755 --- a/src/gz21_ocean_momentum/cli/train.py +++ b/src/gz21_ocean_momentum/cli/train.py @@ -24,7 +24,7 @@ import numpy as np import torch -from torch.utils.data import DataLoader +from torch.utils.data import DataLoader, ConcatDataset from torch import optim from torch.optim.lr_scheduler import MultiStepLR @@ -125,20 +125,27 @@ def submodel_transform_and_to_torch(ds_xr): datasets = list(map(submodel_transform_and_to_torch, sd_dss_xr)) +train_dataset, test_dataset = prep_train_test( + datasets, + options.train_split_end, options.test_split_start, + options.batchsize) # split dataset according to requested lengths -train_range = lambda x: np.arange(0, common.at_idx_pct(options.train_split_end,x)) -test_range = lambda x: np.arange(common.at_idx_pct(options.test_split_start, x), len(x)) -train_datasets = [ Subset_(x, train_range(x)) for x in datasets ] -test_datasets = [ Subset_(x, test_range(x)) for x in datasets ] -#train_datasets = datasets -#test_datasets = datasets +train_range = lambda x: range(0, common.at_idx_pct(options.train_split_end,x)) +test_range = lambda x: range(common.at_idx_pct(options.test_split_start, x), len(x)) +#train_datasets = [ Subset_(x, train_range(x)) for x in datasets ] +#test_datasets = [ Subset_(x, test_range(x)) for x in datasets ] +train_datasets = datasets +test_datasets = datasets # Concatenate datasets. This adds shape transforms to ensure that all # regions produce fields of the same shape, hence should be called after # saving the transformation so that when we're going to test on another # region this does not occur. -train_dataset = ConcatDataset_(train_datasets) -test_dataset = ConcatDataset_(test_datasets) +print(f"len(train_datasets[0]): {len(train_datasets[0])}") +print(f"len(train_datasets[0][0]): {len(train_datasets[0][0])}") +print(f"len(train_datasets[0][0][0]): {len(train_datasets[0][0][0])}") +train_dataset = ConcatDataset(train_datasets) +test_dataset = ConcatDataset(test_datasets) # Dataloaders train_dataloader = DataLoader( From a2f567b1d68cec6f0ec0b9e9f75f75cd6965acd4 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 29 Nov 2023 17:01:04 +0000 Subject: [PATCH 075/114] cli/infer: fix passed channel names --- src/gz21_ocean_momentum/cli/infer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gz21_ocean_momentum/cli/infer.py b/src/gz21_ocean_momentum/cli/infer.py index 987efe4d..5f857b36 100755 --- a/src/gz21_ocean_momentum/cli/infer.py +++ b/src/gz21_ocean_momentum/cli/infer.py @@ -106,7 +106,7 @@ with ProgressBar(), TaskInfo("Predict & save prediction dataset"): out = predict_lazy_cm2_6(net, criterion.n_required_channels, - ["S_x"], + criterion.channel_names, [dataset], [loader], options.device) ProgressBar().register() logger.info(f"chunk predictions to time=32 ...") From 58a71504536007fdd5e5bb20f24013a7fb0b1a82 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 29 Nov 2023 17:01:35 +0000 Subject: [PATCH 076/114] inference/utils: +arg doc, todo note --- src/gz21_ocean_momentum/inference/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gz21_ocean_momentum/inference/utils.py b/src/gz21_ocean_momentum/inference/utils.py index 9a9c6909..2cf5f513 100755 --- a/src/gz21_ocean_momentum/inference/utils.py +++ b/src/gz21_ocean_momentum/inference/utils.py @@ -122,6 +122,10 @@ def predict_lazy_cm2_6( ---------- net : torch.nn.Module Neural net used to make predictions + n_required_channels: int + TODO 2023-11-29 raehik + channel_names: list[string] + TODO 2023-11-29 raehik test_datasets : list List of PyTorch datasets containing the input data. TODO 2023-11-10 raehik: we shouldn't really need this. Should be a From ecfc72f8b5f467a008fc6033775f3ab4b2c95238 Mon Sep 17 00:00:00 2001 From: Ben Orchard Date: Wed, 29 Nov 2023 17:02:31 +0000 Subject: [PATCH 077/114] fix last paper Jupyter notebook --- .../paper/test-global-fig-4-5-7.ipynb | 2307 +++++++++++++++-- 1 file changed, 2140 insertions(+), 167 deletions(-) diff --git a/examples/jupyter-notebooks/paper/test-global-fig-4-5-7.ipynb b/examples/jupyter-notebooks/paper/test-global-fig-4-5-7.ipynb index 863e0a31..68bfd930 100644 --- a/examples/jupyter-notebooks/paper/test-global-fig-4-5-7.ipynb +++ b/examples/jupyter-notebooks/paper/test-global-fig-4-5-7.ipynb @@ -52,13 +52,17 @@ "# Descriptive name for the CM2.6 data set being used in this notebook execution.\n", "# This is used to create file names for exporting figures.\n", "# For example, you may use `control` and `1pct`.\n", - "cm26_sim_run = \"unspecified\"\n", + "cm26_sim_run = \"ctrl\"\n", "\n", "# path to zarr (folder) holding the computed forcings output from a data step invocation\n", "forcings_computed_path = \"~/sh/gz21/gz21/tmp/generated/forcings/paper-n100\"\n", "\n", "# path to zarr (folder) holding the predicted forcings output from an inference step invocation\n", - "forcings_predicted_path = \"~/sh/gz21/gz21/tmp/generated/forcings/paper-n100\"" + "forcings_predicted_path = \"~/sh/gz21/gz21/tmp/generated/predictions/1701268939\"\n", + "\n", + "# Slice 0-x from time dimension from both computing and predicted forcings.\n", + "# Ensure that both of your datasets are long enough!\n", + "time_slice_to = 100" ] }, { @@ -154,28 +158,9 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 7, "metadata": {}, - "outputs": [ - { - "ename": "KeyError", - "evalue": "'S_xscale'", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mKeyError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/venv/lib/python3.11/site-packages/xarray/core/dataset.py\u001b[0m in \u001b[0;36m?\u001b[0;34m(self, name)\u001b[0m\n\u001b[1;32m 1446\u001b[0m \u001b[0mvariable\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_variables\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1447\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0mKeyError\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1448\u001b[0;31m \u001b[0m_\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mname\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvariable\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0m_get_virtual_variable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_variables\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mname\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdims\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1449\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;31mKeyError\u001b[0m: 'S_xscale'", - "\nDuring handling of the above exception, another exception occurred:\n", - "\u001b[0;31mKeyError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m/tmp/nix-shell.ifovvA/ipykernel_153458/2186194141.py\u001b[0m in \u001b[0;36m?\u001b[0;34m()\u001b[0m\n\u001b[1;32m 10\u001b[0m \u001b[0;31m# various transforms\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 11\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0;34m\"S_xpred\"\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mpred\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mkeys\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 12\u001b[0m \u001b[0;31m# \"for compatibility with old version\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 13\u001b[0m \u001b[0mpred\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mpred\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mrename\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mS_xpred\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m\"S_x\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mS_ypred\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m\"S_y\"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 14\u001b[0;31m \u001b[0mpred\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"S_xscale\"\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m1\u001b[0m \u001b[0;34m/\u001b[0m \u001b[0mpred\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"S_xscale\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 15\u001b[0m \u001b[0mpred\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"S_yscale\"\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m1\u001b[0m \u001b[0;34m/\u001b[0m \u001b[0mpred\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"S_yscale\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/venv/lib/python3.11/site-packages/xarray/core/dataset.py\u001b[0m in \u001b[0;36m?\u001b[0;34m(self, key)\u001b[0m\n\u001b[1;32m 1540\u001b[0m \"\"\"\n\u001b[1;32m 1541\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mutils\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mis_dict_like\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1542\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0misel\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m**\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1543\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mutils\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mhashable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1544\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_construct_dataarray\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1545\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mutils\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0miterable_of_hashable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1546\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_copy_listed\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1547\u001b[0m \u001b[0;32mraise\u001b[0m \u001b[0mValueError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34mf\"Unsupported key-type {type(key)}\"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/venv/lib/python3.11/site-packages/xarray/core/dataset.py\u001b[0m in \u001b[0;36m?\u001b[0;34m(self, name)\u001b[0m\n\u001b[1;32m 1444\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1445\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1446\u001b[0m \u001b[0mvariable\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_variables\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1447\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0mKeyError\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1448\u001b[0;31m \u001b[0m_\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mname\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvariable\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0m_get_virtual_variable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_variables\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mname\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdims\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1449\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1450\u001b[0m \u001b[0mneeded_dims\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mvariable\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdims\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1451\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/proj/work/2020-ukc-camfort-iccs/iccs/proj/gz21/gz21/venv/lib/python3.11/site-packages/xarray/core/dataset.py\u001b[0m in \u001b[0;36m?\u001b[0;34m(variables, key, dim_sizes)\u001b[0m\n\u001b[1;32m 210\u001b[0m \u001b[0;32mraise\u001b[0m \u001b[0mKeyError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 211\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 212\u001b[0m \u001b[0msplit_key\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mkey\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msplit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\".\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;36m1\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 213\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mlen\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0msplit_key\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m!=\u001b[0m \u001b[0;36m2\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 214\u001b[0;31m \u001b[0;32mraise\u001b[0m \u001b[0mKeyError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 215\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 216\u001b[0m \u001b[0mref_name\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvar_name\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0msplit_key\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 217\u001b[0m \u001b[0mref_var\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mvariables\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mref_name\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;31mKeyError\u001b[0m: 'S_xscale'" - ] - } - ], + "outputs": [], "source": [ "data = xr.open_zarr(forcings_computed_path)\n", "\n", @@ -599,16 +584,16 @@ " fill: currentColor;\n", "}\n", "
<xarray.Dataset>\n",
-       "Dimensions:   (time: 100, yu_ocean: 609, xu_ocean: 900)\n",
+       "Dimensions:    (time: 100, latitude: 609, longitude: 900)\n",
        "Coordinates:\n",
-       "  * time      (time) object 0181-01-01 12:00:00 ... 0181-04-10 12:00:00\n",
-       "  * xu_ocean  (xu_ocean) float64 -279.7 -279.3 -278.9 ... 79.05 79.45 79.85\n",
-       "  * yu_ocean  (yu_ocean) float64 -79.93 -79.76 -79.59 ... 79.55 79.71 79.88\n",
+       "  * time       (time) object 0181-01-01 12:00:00 ... 0181-04-10 12:00:00\n",
+       "  * longitude  (longitude) float64 -279.7 -279.3 -278.9 ... 79.05 79.45 79.85\n",
+       "  * latitude   (latitude) float64 -79.93 -79.76 -79.59 ... 79.55 79.71 79.88\n",
        "Data variables:\n",
-       "    S_x       (time, yu_ocean, xu_ocean) float64 dask.array<chunksize=(1, 609, 900), meta=np.ndarray>\n",
-       "    S_y       (time, yu_ocean, xu_ocean) float64 dask.array<chunksize=(1, 609, 900), meta=np.ndarray>\n",
-       "    usurf     (time, yu_ocean, xu_ocean) float64 dask.array<chunksize=(1, 609, 900), meta=np.ndarray>\n",
-       "    vsurf     (time, yu_ocean, xu_ocean) float64 dask.array<chunksize=(1, 609, 900), meta=np.ndarray>