diff --git a/.gitignore b/.gitignore
index 4efdfe45..deb0167c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ __pycache__/
*.jpg
*.gif
!docs/**/*.gif
+**/Presentation_files
# Data files created
/results
diff --git a/docs/conf.py b/docs/conf.py
index 07661eb0..2b8edd6a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -14,7 +14,7 @@
"sphinx.ext.intersphinx",
"sphinx.ext.mathjax",
"sphinx.ext.napoleon",
- # "sphinx_autodoc_typehints",# https://github.com/OceanParcels/virtualship/pull/125#issuecomment-2668766302
+ # "sphinx_autodoc_typehints",# https://github.com/Parcels-code/virtualship/pull/125#issuecomment-2668766302
"sphinx_copybutton",
]
@@ -36,7 +36,7 @@
"image_dark": "virtual_ship_logo_inverted.png",
},
"use_edit_page_button": True,
- "github_url": "https://github.com/OceanParcels/virtualship",
+ "github_url": "https://github.com/Parcels-code/virtualship",
"icon_links": [
{
"name": "Conda Forge",
@@ -47,7 +47,7 @@
],
}
html_context = {
- "github_user": "OceanParcels",
+ "github_user": "Parcels-code",
"github_repo": "virtualship",
"github_version": "main",
"doc_path": "docs",
diff --git a/docs/user-guide/teacher-content/UU-ocean-of-future/CTD_transects.ipynb b/docs/user-guide/teacher-content/UU-ocean-of-future/CTD_transects.ipynb
new file mode 100644
index 00000000..570c5501
--- /dev/null
+++ b/docs/user-guide/teacher-content/UU-ocean-of-future/CTD_transects.ipynb
@@ -0,0 +1,505 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "cca80169",
+ "metadata": {},
+ "source": [
+ "# CTD Transect Plotting\n",
+ "\n",
+ "This notebook demonstrates a simple plotting exercise for CTD data across a transect, using the output of a VirtualShip expedition. There are example plots embedded at the end, but these will ultimately be replaced by your own versions as you work through the notebook.\n",
+ "\n",
+ "We can plot physical (temperature, salinity) or biogeochemical data (oxygen, chlorophyll, primary production, phytoplankton, nutrients, pH) as measured by the VirtualShip `CTD` and `CTD_BGC` instruments, respectively.\n",
+ "\n",
+ "The plot(s) we will produce are simple plots which follow the trajectory of the expedition as a function of distance from the first waypoint, and are intended to be a starting point for your analysis. \n",
+ "\n",
+ "
\n",
+ "Note: This notebook assumes that each waypoint in the expedition is further from the start than the last waypoint. The code will still work if not, but the resultant plots might not be very intuitive.\n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "aad20bd7",
+ "metadata": {},
+ "source": [
+ "## Set up\n",
+ "\n",
+ "#### Imports\n",
+ "\n",
+ "The first step is to import the Python packages required for post-processing the data and plotting. \n",
+ "\n",
+ "
\n",
+ "Tip: You may need to set the Kernel to the relevant (Conda) environment in the top right of this notebook to access the required packages! \n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 19,
+ "id": "c7f9f2ee",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import cmocean.cm as cmo\n",
+ "import matplotlib.colors as mcolors\n",
+ "import matplotlib.patches as mpatches\n",
+ "import numpy as np\n",
+ "import xarray as xr\n",
+ "from matplotlib import pyplot as plt"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4f387780",
+ "metadata": {},
+ "source": [
+ "\n",
+ "#### Data directory\n",
+ "\n",
+ "Next, you should set `data_dir` to be the path to your expedition results in the code block below. You should replace `\"/path/to/EXPEDITION/results/\"` with the path for your machine.\n",
+ "\n",
+ "
\n",
+ "Tip: You can get the path to your expedition results by navigating to the `results` folder in Terminal (using `cd`) and then using the `pwd` command. This will print your working directory which you can copy to the `data_dir` variable in this notebook. Don't forget to keep it as a string (in \"quotation\" marks)!\n",
+ "
\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 20,
+ "id": "cf497101",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "data_dir = \"GRP1993/results/\" # set this to be where your expedition output data is located on your (virtual) machine"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a499ebe2",
+ "metadata": {},
+ "source": [
+ "#### Variable choice\n",
+ "\n",
+ "You should now consider which variable from your CTD casts you would like to plot. Which ones are available to you will depend on whether you have used the `CTD` (physical variables) or `CTD_BGC` (biogeochemical) instrument, or both. Below is a list of all valid variable choices for both instruments...\n",
+ "\n",
+ "`CTD` (physical):\n",
+ "- \"temperature\"\n",
+ "- \"salinity\"\n",
+ "\n",
+ "`CTD_BGC` (biogeochemical):\n",
+ "- \"oxygen\"\n",
+ "- \"nitrate\"\n",
+ "- \"phosphate\"\n",
+ "- \"ph\"\n",
+ "- \"phytoplankton\"\n",
+ "- \"primary_production\"\n",
+ "- \"chlorophyll\"\n",
+ "\n",
+ "Copy one of the above to `plot_variable` below:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 21,
+ "id": "8de8b4ae",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "plot_variable = \"temperature\" # change this to your chosen variable"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a05fad14",
+ "metadata": {},
+ "source": [
+ "\n",
+ "We also define the `VARIABLES` dictionary here, which we use to store some parameters for the plots related to each variable choice (e.g. labels, what units each is in, and which colour map we should use for the plots).\n",
+ "\n",
+ "
\n",
+ "Tip: You don't need to change anything here, but should you wish to change the colour scheme (`cmap`) for any CTD variable you can do so. At the moment it's set to use relevant cmaps from the cmocean Python package, which has developed specialist colour schemes for oceanographic data applications.\n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 22,
+ "id": "b32d2730",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "VARIABLES = {\n",
+ " \"temperature\": {\n",
+ " \"cmap\": cmo.thermal,\n",
+ " \"label\": \"Temperature (°C)\",\n",
+ " \"ds_name\": \"temperature\",\n",
+ " },\n",
+ " \"salinity\": {\n",
+ " \"cmap\": cmo.haline,\n",
+ " \"label\": \"Salinity (psu)\",\n",
+ " \"ds_name\": \"salinity\",\n",
+ " },\n",
+ " \"oxygen\": {\n",
+ " \"cmap\": cmo.oxy,\n",
+ " \"label\": r\"Dissolved oxygen (mmol m$^{-3}$)\",\n",
+ " \"ds_name\": \"o2\",\n",
+ " },\n",
+ " \"nitrate\": {\n",
+ " \"cmap\": cmo.matter,\n",
+ " \"label\": r\"Nitrate (mmol m$^{-3}$)\",\n",
+ " \"ds_name\": \"no3\",\n",
+ " },\n",
+ " \"phosphate\": {\n",
+ " \"cmap\": cmo.matter,\n",
+ " \"label\": r\"Phosphate (mmol m$^{-3}$)\",\n",
+ " \"ds_name\": \"po4\",\n",
+ " },\n",
+ " \"ph\": {\n",
+ " \"cmap\": cmo.balance,\n",
+ " \"label\": \"pH\",\n",
+ " \"ds_name\": \"ph\",\n",
+ " },\n",
+ " \"phytoplankton\": {\n",
+ " \"cmap\": cmo.algae,\n",
+ " \"label\": r\"Total phytoplankton (mmol m$^{-3}$)\",\n",
+ " \"ds_name\": \"phyc\",\n",
+ " },\n",
+ " \"primary_production\": {\n",
+ " \"cmap\": cmo.matter,\n",
+ " \"label\": r\"Total primary production of phytoplankton (mg m$^{-3}$ day$^{-1}$)\",\n",
+ " \"ds_name\": \"nppv\",\n",
+ " },\n",
+ " \"chlorophyll\": {\n",
+ " \"cmap\": cmo.algae,\n",
+ " \"label\": r\"Chlorophyll (mg m$^{-3}$)\",\n",
+ " \"ds_name\": \"chl\",\n",
+ " },\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6f9a5afb",
+ "metadata": {},
+ "source": [
+ "## Load data\n",
+ "\n",
+ "We are now ready to read in the data. You can carry on executing the next cells without making changes to the code..."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 23,
+ "id": "13f4664b",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# load CTD data\n",
+ "filename = (\n",
+ " \"ctd.zarr\" if plot_variable in [\"temperature\", \"salinity\"] else \"ctd_bgc.zarr\"\n",
+ ")\n",
+ "ctd_ds = xr.open_dataset(f\"{data_dir}/{filename}\")\n",
+ "if ctd_ds[\"trajectory\"].size <= 1:\n",
+ " raise ValueError(\"Number of waypoints must be > 1\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a8201b14",
+ "metadata": {},
+ "source": [
+ "## Data post-processing\n",
+ "\n",
+ "Before we can continue, we need to do some post-processing to get it ready for plotting. Below are various helper functions which perform tasks such as calculating the distance of each waypoint from the start, capturing only the downcasts of the CTD casts, as well as some other utility methods. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 24,
+ "id": "785b2b35",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# utility functions\n",
+ "\n",
+ "\n",
+ "def haversine(lon1, lat1, lon2, lat2):\n",
+ " \"\"\"Great-circle distance (meters) between two points.\"\"\"\n",
+ " lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])\n",
+ " dlon, dlat = lon2 - lon1, lat2 - lat1\n",
+ " a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2\n",
+ " c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))\n",
+ " return 6371000 * c\n",
+ "\n",
+ "\n",
+ "def distance_from_start(ds):\n",
+ " \"\"\"Add 'distance' variable: meters from first waypoint.\"\"\"\n",
+ " lon0, lat0 = (\n",
+ " ds.isel(trajectory=0)[\"lon\"].values[0],\n",
+ " ds.isel(trajectory=0)[\"lat\"].values[0],\n",
+ " )\n",
+ " d = np.zeros_like(ds[\"lon\"].values, dtype=float)\n",
+ " for ob, (lon, lat) in enumerate(zip(ds[\"lon\"], ds[\"lat\"], strict=False)):\n",
+ " d[ob] = haversine(lon, lat, lon0, lat0)\n",
+ " ds[\"distance\"] = xr.DataArray(\n",
+ " d,\n",
+ " dims=ds[\"lon\"].dims,\n",
+ " attrs={\"long_name\": \"distance from first waypoint\", \"units\": \"m\"},\n",
+ " )\n",
+ " return ds\n",
+ "\n",
+ "\n",
+ "def descent_only(ds, variable):\n",
+ " \"\"\"Extract descending CTD data (downcast), pad with NaNs for alignment.\"\"\"\n",
+ " min_z_idx = ds[\"z\"].argmin(\"obs\")\n",
+ " da_clean = []\n",
+ " for i, traj in enumerate(ds[\"trajectory\"].values):\n",
+ " idx = min_z_idx.sel(trajectory=traj).item()\n",
+ " descent_vals = ds[variable][\n",
+ " i, : idx + 1\n",
+ " ] # take values from surface to min_z_idx (inclusive)\n",
+ " da_clean.append(descent_vals)\n",
+ " max_len = max(len(arr[~np.isnan(arr)]) for arr in da_clean)\n",
+ " da_padded = np.full((ds[\"trajectory\"].size, max_len), np.nan)\n",
+ " for i, arr in enumerate(da_clean):\n",
+ " da_dropna = arr[~np.isnan(arr)]\n",
+ " da_padded[i, : len(da_dropna)] = da_dropna\n",
+ " return xr.DataArray(\n",
+ " da_padded,\n",
+ " dims=[\"trajectory\", \"obs\"],\n",
+ " coords={\"trajectory\": ds[\"trajectory\"], \"obs\": np.arange(max_len)},\n",
+ " )\n",
+ "\n",
+ "\n",
+ "def build_masked_array(data_up, profile_indices, n_profiles):\n",
+ " arr = np.full((n_profiles, data_up.shape[1]), np.nan)\n",
+ " for i, idx in enumerate(profile_indices):\n",
+ " if idx is not None:\n",
+ " arr[i, :] = data_up.values[idx, :]\n",
+ " return arr\n",
+ "\n",
+ "\n",
+ "def get_profile_indices(distance_1d):\n",
+ " \"\"\"\n",
+ " Returns regular distance bins and profile indices for CTD transect plotting.\n",
+ "\n",
+ " Bin size is set to one order of magnitude lower than max distance.\n",
+ " \"\"\"\n",
+ " dist_min, dist_max = float(distance_1d.min()), float(distance_1d.max())\n",
+ " if dist_max > 1e6:\n",
+ " dist_step = 1e5\n",
+ " elif dist_max > 1e5:\n",
+ " dist_step = 1e4\n",
+ " elif dist_max > 1e4:\n",
+ " dist_step = 1e3\n",
+ " else:\n",
+ " dist_step = 1e2 # fallback for very short transects\n",
+ "\n",
+ " distance_regular = np.arange(dist_min, dist_max + dist_step, dist_step)\n",
+ " threshold = dist_step / 2\n",
+ " profile_indices = [\n",
+ " np.argmin(np.abs(distance_1d.values - d))\n",
+ " if np.min(np.abs(distance_1d.values - d)) < threshold\n",
+ " else None\n",
+ " for d in distance_regular\n",
+ " ]\n",
+ " return profile_indices, distance_regular"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2bdf98e6",
+ "metadata": {},
+ "source": [
+ "\n",
+ "Now we will execute the utility functions, plus define some extra useful arrays to be used for the plotting..."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 25,
+ "id": "f59824a1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# add distance from start\n",
+ "ctd_distance = distance_from_start(ctd_ds)\n",
+ "\n",
+ "# exract descent-only data\n",
+ "z_up = descent_only(ctd_distance, \"z\")\n",
+ "d_up = descent_only(ctd_distance, \"distance\")\n",
+ "var_up = descent_only(ctd_distance, VARIABLES[plot_variable][\"ds_name\"])\n",
+ "\n",
+ "# 1d array of depth dimension (from deepest trajectory)\n",
+ "traj_idx, obs_idx = np.where(z_up == np.nanmin(z_up))\n",
+ "z1d = z_up.values[traj_idx[0], :]\n",
+ "\n",
+ "# distance as 1d array\n",
+ "distance_1d = d_up.isel(obs=0)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "17745cf1",
+ "metadata": {},
+ "source": [
+ "## Plotting\n",
+ "\n",
+ "
\n",
+ "Note: The plots produced next are a starting point for your analysis. You are encouraged to make adjustments, for example axis limits and scaling if the defaults not best suited to your specific data. Use your preferred AI coding assistant for help!\n",
+ "
\n",
+ "\n",
+ "We are now ready to plot our transect data. We will use distance from the first waypoint/CTD cast for the x-axis, and water column depth for the y-axis. The data for the chosen variable will then be plotted according to the colour map. The CTD casts are likely to be different depths because some parts of the ocean are of course shallower than others.\n",
+ "\n",
+ "There are a few extra steps below which arrange the CTD casts into regular distance bins, so as to clearly demonstrate where along the transect we made CTD casts and indeed where there are gaps.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 31,
+ "id": "ce83c3b9",
+ "metadata": {
+ "tags": [
+ "test"
+ ]
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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Soi+/PJLffDBB4qK4jxXAAAAIJQ5ecrcs88+q4KCAk2YMEGvvPKKJKlTp06aOXOm1q5d61csvzKbSy+9VH/729/8egMAAAAAaEvz5s3TypUrNWfOHF+xpn///vrss8/8juVXhWjhwoU677zzNGDAAKWnpzdpe/jhh/1+83BnbNoJzsjYFhsAAACRwclT5vbt26f+/ftLklwul+//H/3YH35ViO666y7961//ksfjUWVlZZMLAAAAAIIhKytLr732WpN7S5cu1eDBg/2O5VeF6PXXX9fnn3+u7t27+/1GAAAAAILHa9q7B/aZP3++LrnkEi1evFgHDx7UuHHjtGrVKq1YscLvWH4lRL169VJMTIzfb+JcHpviem2MDQAAAIS3wYMHa9u2bVq0aJG6d++unj17av78+crIyPA7ll8J0eTJk3XNNdfoF7/4hdLS0pq0DRo0yO83BwAAAGAPp64h8ng86tGjh0pLS3XvvfcGHM+vhCg/P1+S9M477zS573K55PFQ0QAAAABgrw4dOiguLk719fX63ve+F3A8vzZV8Hq9zV4kQwAAAEBo8RqXbVd7u//++3XzzTdr69at2r9/v6qrq32Xv/yqEAEAAABAe/vpT38qSSoqKvJttW2MOaGZa61WiMaOHatPP/30uK/59NNPNXbsWL/eGAAAAIB9jFy2Xe2ttLTUd/3zn//UP//5T9/H/mq1QnTFFVdo9OjR6tq1qy644AL17dtXCQkJqq6u1t///netWrVK+/bt09y5c0/oYcKaXYenGmNfbAAAACDM9e7du81itZoQXXfddRozZozefPNNLV26VAsXLlRlZaWSk5Pldrs1c+ZMXXbZZYqK8ms5EgAAAAAbOfmf149OmWvOc88951csS2uIXC6XRo0apVGjRvkVHAAAAED7MCGw+YFdEhMTm3xeVlamN99884SW8YRtWefZZ5/VD3/4Q8XHx6tv375atGhRk/Z169YpKytLsbGxcrvdWr9+vV/tAAAAAELTI4880uT605/+pDfeeEM1NTV+xwrLhGjz5s2aNGmSnnnmGVVXV2vBggWaOHGib/OHiooK5eTkKD8/X5WVlZo8ebJycnK0f/9+S+0AAABAuPPKZdsVikaMGKHly5f7/XVhmRCVlpaqT58+Ov/88+VyuXThhReqV69evoSoqKhImZmZysvLU3R0tPLy8pSenq6ioiJL7QAAAABC13fPHaqurtaePXs0b948paen+x0rLBOiH//4x4qPj9ef//xneb1erVixQpWVlRo2bJgkacuWLXK73U2+xu12a8uWLZbaAQAAgHBnjH1Xe0tKSlJycrLvysjI0NNPP62FCxf6HSvkDmZtaGg47mFK0dHRio2N1fXXX6/LL79cDQ0N6tChg55//nl1795dklRbW6ukpKQmX5eUlOSbU9hae3MKCgo0e/bsJvfGXjrMjyezbnC/k2yJK0nl5eW2xQ5XBw4caO8uRBTGO7gY7+BivIOHsQ4uxhuhprS0tMnncXFx6tq16wnF8ish2rZtm26//XZ99NFHqq2tlXTiJ8K2JDc397hz/0pLS/XOO+/ooYce0t/+9jcNHDhQW7du1ejRo5WcnKxLL71UcXFxqqioaPJ1VVVV6tatmyS12t6cgoICFRQU+D53uVx6+a11J/CE1tgV+/nf32dL3HCXmpra3l2IKIx3cDHewcV4Bw9jHVyMd/gJ1bU+beHBBx/UU089dcz9/Px8Pfnkk37F8mvK3E033aRTTz1Vb731ljZt2qRNmzZp8+bN2rRpk19vejzLli2TMabFq0+fPtq8ebMuvfRSZWVlKSoqSllZWbr44ot9idSgQYNUUlLSJG5JSYkGDhxoqd0qI69Nl7EtNgAAABDuXnzxxWbvv/zyy37H8qtCtH37dm3cuLHdD2E955xz9Mtf/lLbtm3T6aefrm3btmnFihW+KW25ubm69957VVhYqPHjx2vRokUqKytTbm6upXYAAAAg3DnxHKKlS5dKkjwej4qLi2W+s6DpH//4xzHnE1nhV0I0bNgwffLJJxo0aJDfb9SWrr/+eu3cuVOjR4/Wv/71L3Xt2lU//elPfSfWpqSkqLi4WJMmTVJ+fr5++MMfqri4WMnJyZbaAQAAAISeO++8U5JUV1enO+64w3c/KipKaWlpevzxx/2O2WpC9N2ggwcP1qhRozR+/PhjtrT7boeC4b777tN997W8HiY7O/u4u8a11g4AAACEMyculji6mcK1116rV155pU1itpoQ/efZPKeccorWr1/f5J7L5Qp6QgQAAACgZU6cMndUWyVDkoWEaPXq1W32ZgAAAAAQqMOHD+vhhx/WmjVrVF5e3mQtkb8bvvm1O0JLu7D95yGnAAAAANqX18arvU2ZMkUvvviiLrvsMm3fvl033XSTDh48qCuuuMLvWH4lRF999VWz93fu3On3GwMAAADAiViyZImWL1+uO++8Ux07dtSdd96poqIirVmzxu9YlnaZmzJliiSpoaHB9/FR//znP/WDH/zA7zd2BGNXfmxsjA0AAIBI4OQ1RAcOHFCfPn0kSd/73vdUV1enfv366aOPPvI7lqWEqLKyUpLk9Xp9H0tHtrfr37+/Hn74Yb/fGAAAAABOxKmnnqqPP/5YWVlZGjhwoB555BElJSUpNTXV71iWEqLnn39e0pFtt2+//Xa/3wQAAABAcDl5vtHcuXNVW1srSfrtb3+rsWPHqqamRs8884zfsfw6mPX2229XaWmpXn75Ze3evVuZmZm67rrrInfKHAAAAICg8ng8qq+v1/DhwyVJQ4YM0RdffHHC8fzaVGHJkiXq37+/3n//fXm9Xq1bt04DBgw45qwiAAAAAO3LyGXb1Z46dOig6667TtHR0W0Sz68K0bRp0/Tqq68qJyfHd2/58uW6++67lZub2yYdAgAAAIDj+dGPfqSPPvpIZ555ZsCx/EqI9uzZo8suu6zJvZEjR2rcuHEBdyQs2bUTnGGXOQAAAATG6+Bd5txut0aNGqVx48apV69eior698S3O+64w69YfiVE11xzjZ577jlNnDjRd++FF17Qtdde69ebAgAAAMCJ2rhxo/r166fNmzdr8+bNvvsul8vehOjbb7/VpEmT9Oijj6p3797asWOHPv/8c40cOVJXXXWV73Wvv/66X50AAAAA0LZMe3fARqtXr26zWH4lREOGDNGQIUN8nw8dOrTNOgIAAACg7XidnBHpyFmpb775pr755htNnTpV33zzjbxer3r06OFXHL8SolmzZvkVHAAAAADa2vr16zV69Gj17dtXH3/8saZOnarPPvtMjz/+uN544w2/Yvm17bZ0pDyVl5en0aNHS5I+/PDDNi1ZAQAAAAicU7fdlqS77rpLzz77rN5//3117HikxnPOOedo48aNfsfyKyF69tlnNX78eKWlpem9996TJHXq1EkzZ870+40BAAAA4ER8/vnnuvLKKyUd2UhBkmJjY3X48GG/Y/mVEM2bN08rV67UnDlzfFvb9e/fX5999pnfbwwAAADAPl5j39XeevXqpY8//rjJvU2bNumkk07yO5ZfCdG+ffvUv39/Sf/OxFwul+9jAAAAALDbfffdp9GjR+uJJ55QQ0OD/vu//1tjxozRr371K79j+bWpQlZWll577TX95Cc/8d1bunSpBg8e7PcbO4GRXYenGhtjAwAAIBKEwlofu1x33XVKSEjQU089pd69e6uoqEiPPvqoRo0a5XcsvxKi+fPn65JLLtHixYt18OBBjRs3TqtWrdKKFSv8fmMAAAAAOFGXXXaZLrvssoDj+JUQDR48WJ988olefPFFde/eXT179tT8+fOVkZERcEcAAAAAtJ1QWOtjp/fff19//OMftXv3bmVmZuqGG27Q8OHD/Y7j97bb6enpuvfee/Xkk09q2rRpJEMAAAAAgmrhwoUaNWqUOnbsqOHDh6tTp0664oortHDhQr9jWa4Q7d27Vw8//LDWrFmjiooKpaSk6Pzzz9fdd9+tbt26+f3GAAAAAOwTSmuIDh06pIEDB6q8vFz79+8PON6DDz6oFStW6Oyzz/bdu/HGG3Xttdfqtttu8yuWpYSovLxcQ4YMUWJioq644gplZmZq9+7deuONN7R48WJ9+OGHSk1N9e8pAAAAAESEmTNnqkePHiovL2+TeLW1tRoyZEiTe4MHD9aBAwf8jmVpytzvfvc7/ehHP9LmzZv1wAMP6NZbb9UDDzygzZs3Kzs7Ww8++KDfbwwAAADAPqFyDtGmTZv05ptv6r777muzZ7vlllv061//Wh6PR5Lk8Xj0m9/8RrfeeqvfsSxViFauXKlXXnlFHTp0aHK/Q4cOmj59uq6++mr9/ve/9/vNAQAAANgjFPZUaGxsVF5enhYsWNCmcd9880198skneuKJJ5SRkaFvvvlGtbW1GjhwoN58803f6zZt2tRqLEsJ0ddff62+ffs229a3b1/t3r3bYtcBAAAAOIHL9e81SrNmzVJBQcExr3nooYc0aNAgjRgxQmvWrGmz977rrrvaLJalhMjrPf4hoa21O5ax6bmNsS82AAAAIoLX2LupgjHHr0H94x//0IIFC7R58+Y2f++bbrqpzWJZSogOHz6sJ554osWHrq+vb7MOAQAAAAh/a9eu1d69e3X66adLOpIzVFdXKz09XUuXLtXQoUMDil9SUqJNmzaptra2yf077rjDrziWEqKzzz5br7/++nHbAQAAAISO9l5DNGbMGI0cOdL3+V//+lfdfPPNKikpUdeuXQOKPX36dM2fP19ZWVmKjY313Xe5XPYkRG053w8AAACA88XExCgmJsb3eUpKilwul9LT0wOOvXDhQm3atEn9+/cPOJblg1kBAAAAhI9WlvgE3YgRI9rkUFbpSHL1gx/8oE1iWTqHCAAAAABCxe9//3vddttt+uKLL1RdXd3k8hcVIgAAAMCBvLJ3l7n2lJSUpD//+c/64x//6LtnjJHL5fId1moVCREAAACAsPKzn/1MN998s8aNG9dkU4UTQUIEAAAAOFCorSFqS/v27dOvf/3rJofDnijWEAEAAAAO5LXxam9jx47VkiVL2iQWFSIAAAAAYWX37t0aO3asBg8efMw23sc7P7U5JEQAAACAAxnj3E0VzjzzTJ155pltEouEKBDGroKh18bYAAAAQHibNWtWm8ViDREAAADgQMbGKxSsXr1aeXl5Gj16tCTpww8/1OrVq/2OQ0IEAAAAIKw8++yzGj9+vNLS0vTee+9Jkjp16qSZM2f6HYuECAAAAHAgr7Hvam/z5s3TypUrNWfOHEVFHUlp+vfvr88++8zvWCREAAAAAMLKvn371L9/f0nynUXkcrlO6FwiEiIAAADAgYxctl3tLSsrS6+99lqTe0uXLtXgwYP9jsUucwAAAADCwqhRo7R8+XLNnz9fl1xyiRYvXqyDBw9q3LhxWrVqlVasWOF3TBIiAAAAwIFCYa1PW1u7dq0kafDgwfrkk0/04osvqnv37urZs6fmz5+vjIwMv2OG5JS5srIyXX755crIyJDL5VJJSckxr1m3bp2ysrIUGxsrt9ut9evXt2k7AAAAgNCVnp6ue++9V08++aSmTZt2QsmQFKIVoqioKI0cOVLTp0/XWWeddUx7RUWFcnJyNG/ePN1444364x//qJycHP3jH/9QUlJSwO0AAABAuHNggUj19fV64oknZEzLT3fHHXf4FTMkK0RpaWmaNGmShg4d2mx7UVGRMjMzlZeXp+joaOXl5Sk9PV1FRUVt0g4AAACEO2Nctl3tpbGxUa+//rqKioqavZYsWeJ3zJCsELVmy5YtcrvdTe653W5t2bKlTdoBAAAAhJ7Y2FitXr26TWMGPSFqaGiQx+NpsT06OrrV/cNra2uPmdqWlJSkmpqaNmlvTkFBgWbPnt3k3rhRI47bzxM1uP8ptsSVpPLycttih6sDBw60dxciCuMdXIx3cDHewcNYBxfjHZ687d2BMBH0hCg3N1fLly9vsb20tFR9+vQ5boy4uDhVVFQ0uVdVVaVu3bq1SXtzCgoKVFBQ4Pvc5XJp8fI1x+1nIOyK/cJDBbbEDXepqant3YWIwngHF+MdXIx38DDWwcV4IxQcb+3QiQr6GqJly5bJGNPi1VoyJEmDBg06Zue5kpISDRw4sE3arfPadBkbYwMAACASGGPf1V6ON6PrRIXkpgqSVFdXp7q6OklHdpOoq6uT13vkD/rc3Fzt2rVLhYWFqq+vV2FhocrKypSbm9sm7QAAAAAiQ8gmRDExMYqJiZEknXXWWYqJidF7770nSUpJSVFxcbEee+wxJSYm6vHHH1dxcbGSk5PbpB0AAAAId0Yu2y4nCdld5lqbH5idnX3cXeECbQcAAADgfCGbEAEAAAA4cV4nnsxqg5CdMgcAAAAAdqNCBAAAADhQe+4GF05IiAAAAAAH8jps8wO7MGUOAAAAQMSiQgQAAAA4EFPmrKFCBAAAACBiUSECAAAAHIgCkTUkRIEwXpviGvtiAwAAAPAhIQIAAAAcyGvYZc4K1hABAAAAiFhUiAAAAAAHYpc5a6gQAQAAAIhYVIgAAAAAB6JAZA0VIgAAAAARiwoRAAAA4EDsMmcNCREAAADgQEyZs4YpcwAAAAAiFhUiAAAAwIHYdtsaKkQAAAAAIhYVIgAAAMCB2FTBGipEAAAAACIWFSIAAADAgVhCZA0VIgAAAAARiwoRAAAA4ECGNUSWkBAFwBivPXFlbIsNAAAA4N9IiAAAAAAH4p/XrWENEQAAAICIRYUIAAAAcCDDNnOWkBABAAAADsSmCtYwZQ4AAABAxKJCBAAAADgQmypYQ4UIAAAAQMSiQgQAAAA4EGuIrKFCBAAAACBiUSECAAAAHMjLttuWUCECAAAAELGoEAEAAAAOZMQaIiuoEAEAAACIWFSIAAAAAAcyrCGyhIQIAAAAcCAv225bwpQ5AAAAABGLChEAAADgQMyYs4aEKCBem+IaG2MDAAAAOIqECAAAAHAg1hBZwxoiAAAAABGLChEAAADgQGy7bQ0VIgAAAAARiwoRAAAA4EBGrCGyIiQrRGVlZbr88suVkZEhl8ulkpKSJu3Lly/X8OHDlZycrO9///u6+uqrtWvXriavWbdunbKyshQbGyu3263169f71Q4AAADA+UIyIYqKitLIkSO1ZMmSZturqqo0bdo0ff311yotLVVCQoKuvfZaX3tFRYVycnKUn5+vyspKTZ48WTk5Odq/f7+ldgAAACDceY19l5OEZEKUlpamSZMmaejQoc22jxs3TqNGjVJcXJy6dOmiu+66Sxs2bFBjY6MkqaioSJmZmcrLy1N0dLTy8vKUnp6uoqIiS+0AAAAAIkNIJkT+evfdd9WvXz917HhkSdSWLVvkdrubvMbtdmvLli2W2gEAAIBwZ4zLtsuqw4cPKy8vTyeddJLi4+PVt29fPffcczY+tf+CvqlCQ0ODPB5Pi+3R0dFyuawP8ubNmzVjxgy9+uqrvnu1tbVKSkpq8rqkpCTV1NRYam9OQUGBZs+e3eTeuJyLLPfTH2f2/6EtcSWpvLzcttjh6sCBA+3dhYjCeAcX4x1cjHfwMNbBxXiHJ297d0BSY2Ojunfvrr/85S/6wQ9+oA0bNujSSy9Vjx49dMkll7R39yS1Q0KUm5ur5cuXt9heWlqqPn36WIq1detWjRw5Uk8++aQuvvhi3/24uDhVVFQ0eW1VVZW6detmqb05BQUFKigo8H3ucrm0eNlfLPXzRNgV+38enmNL3HCXmpra3l2IKIx3cDHewcV4Bw9jHVyMN05Ely5d9Otf/9r3+dlnn63zzz9f77//fsgkREGfMrds2TIZY1q8rCZDn3zyiS666CL97ne/0w033NCkbdCgQcfsTFdSUqKBAwdaagcAAADCXShMmftPdXV12rhxowYNGtSGTxqYkF1DVFdXp7q6OklSfX296urq5PUeKfxt27ZNF154oR544AHdfPPNx3xtbm6udu3apcLCQtXX16uwsFBlZWXKzc211A4AAADg+Fwul+/67kyqlhhjNHHiRJ166qm66qqr7O+gRSGbEMXExCgmJkaSdNZZZykmJkbvvfeeJGn+/Pnau3evpkyZori4ON+1c+dOSVJKSoqKi4v12GOPKTExUY8//riKi4uVnJxsqR0AAAAId3Zvu/3dWV6tJUTGGN12223avn27lixZoqio0ElDgr6GyCpjWt7g/Pnnn9fzzz9/3K/Pzs4+7q5xrbUDAAAACJwxRpMnT9bGjRv1zjvvKDExsb271ETIJkQAAAAATpzRia/1aUv5+flat26dVq1aFZIzskiIAmFs2szQGPtiAwAAAEGyY8cOPfXUU4qOjlbv3r1992+44QY9/fTT7dizfyMhAgAAABzI2/IKlKDp3bv3cZfChILQWc0EAAAAAEFGhQgAAABwoBAvzIQMEiIAAADAgbwhsqlCqGPKHAAAAICIRYUIAAAAcKBQ2FQhHFAhAgAAABCxqBABAAAADmQMa4isoEIEAAAAIGJRIQIAAAAcyNveHQgTVIgAAAAARCwqRAAAAIADscucNVSIAAAAAEQsKkQAAACAA3nFLnNWUCECAAAAELGoEAEAAAAOZFhDZAkJEQAAAOBAbLttDVPmAAAAAEQsKkQAAACAA7HttjVUiAAAAABELCpEAAAAgAN5RYnICipEAAAAACIWFSIAAADAgdhlzhoqRAAAAAAiFhUiAAAAwIHYZc4aKkQAAAAAIhYVIgAAAMCBPOwyZwkVIgAAAAARiwoRAAAA4ECcQ2QNCVEgjF2bGRobYwMAACASkBBZw5Q5AAAAABGLChEAAADgQFSIrKFCBAAAACBiUSECAAAAHMgj1qRbQYUIAAAAQMSiQgQAAAA4kJcKkSVUiAAAAABELCpEAAAAgANRIbKGChEAAACAiEWFCAAAAHAgKkTWkBABAAAADuRxkRBZwZQ5AAAAABGLChEAAADgQEyZs4YKEQAAAICIRYUIAAAAcCCvPO3dhbBAhQgAAABAxArJhKisrEyXX365MjIy5HK5VFJS0uJrn3nmGblcLj366KNN7q9bt05ZWVmKjY2V2+3W+vXr/WoHAAAAwpnXxv9zkpBMiKKiojRy5EgtWbLkuK8rKyvTvHnzNGDAgCb3KyoqlJOTo/z8fFVWVmry5MnKycnR/v37LbUDAAAAiAwhmRClpaVp0qRJGjp06HFfN3nyZM2YMUNdu3Ztcr+oqEiZmZnKy8tTdHS08vLylJ6erqKiIkvtAAAAQLjzymPb5SQhmRBZ8dprr6myslITJkw4pm3Lli1yu91N7rndbm3ZssVSOwAAAIDIEPRd5hoaGuTxtJxVRkdHy+VyHTfG/v37de+99+rtt99utr22tlZJSUlN7iUlJammpsZSe3MKCgo0e/bsJvfGjb7kuP08UWeefpotcSWpvLzcttjh6sCBA+3dhYjCeAcX4x1cjHfwMNbBxXiHJ+OwSo5dgp4Q5ebmavny5S22l5aWqk+fPseN8Ytf/EITJkzQaac1nzjExcWpoqKiyb2qqip169bNUntzCgoKVFBQ4Pvc5XJpcfHK4/YzEHbF/p9H5toSN9ylpqa2dxciCuMdXIx3cDHewcNYBxfjDacK+pS5ZcuWyRjT4tVaMiRJK1eu1JNPPqn09HSlp6frr3/9q2bOnKlrr71WkjRo0KBjdqYrKSnRwIEDLbUDAAAA4Y5d5qwJ2TVEdXV1qqurkyTV19errq5OXu+Rwf/ggw+0detWlZSUqKSkREOGDNHUqVP1zDPPSDpShdq1a5cKCwtVX1+vwsJClZWVKTc311I7AAAAEO7YVMGakE2IYmJiFBMTI0k666yzFBMTo/fee0+S1K1bN191KD09XZ07d1Z8fLySk5MlSSkpKSouLtZjjz2mxMREPf744youLrbcDgAAACAyBH0NkVXGGMuvXbNmzTH3srOzj7trXGvtAAAAQDgzxlmVHLuEbIUIAAAAAOwWshUiAAAAACfOaZsf2IUKEQAAAICIRYUIAAAAcCAOZrWGChEAAACAiEWFCAAAAHAgp50XZBcqRAAAAAAiFhUiAAAAwIEMu8xZQkIEAAAAOBAHs1rDlDkAAAAAEYsKEQAAAOBAbKpgDRUiAAAAABGLChEAAADgQGyqYA0VIgAAAAARiwoRAAAA4EDsMmcNFSIAAAAAEYsKEQAAAOBArCGyhgoRAAAAgIhFhQgAAABwINYQWUOFCAAAAEDEokIEAAAAOBJriKygQgQAAAA4kJHHtssfDQ0Nys/PV0pKilJSUnT77bersbHRpqf2HwkRAAAAANvMmTNH77//vrZt26Zt27Zp7dq1mjt3bnt3y4eECAAAAHAgY7y2Xf547rnnNH36dHXv3l3du3fX/fffr8LCQpue2n8kRAAAAABsUVlZqV27dsntdvvuud1u7dy5U1VVVe3Xse8gIQIAAAAcyMhr2yVJLpfLdxUUFDTbh9raWklSUlKS797Rj2tqaux8fMvYZQ4AAACA34wxrb4mLi5OklRVVaXU1FTfx5IUHx9vX+f8QIUIAAAAcCLjse+yKDk5WT169FBJSYnvXklJiXr27KnExEQbHtp/JEQAAAAAbHPzzTfrN7/5jfbs2aM9e/Zo7ty5mjhxYnt3y4cpcwAAAIADmRA5mHXGjBnat2+f+vXrJ0m6/vrr9atf/aqde/VvJEQAAAAAbNOpUyctWLBACxYsaO+uNIuECAAAAHAiP88LilSsIQIAAAAQsagQAQAAAA5kqBBZQkIEAAAAOJL17bEjGVPmAAAAAEQsKkQAAACAEzFlzhIqRAAAAAAiFhUiAAAAwIFC5WDWUEeFCAAAAEDEokIEAAAAOBFriCyhQgQAAAAgYlEhAgAAAJyICpElVIgAAAAARCwqRAAAAIADscucNSREAAAAgBMxZc4SpswBAAAAiFhUiAAAAAAnokJkCRUiAAAAABErJBOisrIyXX755crIyJDL5VJJSckxr9m/f78mTpyo1NRUJSQkaMiQITp48KCvfd26dcrKylJsbKzcbrfWr1/f5OtbawcAAADCm9fGyzlCMiGKiorSyJEjtWTJkmbbvV6vcnJy1KlTJ33++efav3+//vCHP6hTp06SpIqKCuXk5Cg/P1+VlZWaPHmycnJytH//fkvtAAAAACJDSCZEaWlpmjRpkoYOHdps+1tvvaWdO3fqiSeeUEpKiqKionTGGWf4EqKioiJlZmYqLy9P0dHRysvLU3p6uoqKiiy1AwAAAGHPeO27HCQkE6LWvPvuu+rXr59uueUWde3aVQMGDNCiRYt87Vu2bJHb7W7yNW63W1u2bLHUDgAAACAyBH2XuYaGBnk8nhbbo6Oj5XK5jhujoqJCK1eu1BNPPKGFCxfqgw8+0MiRI3XSSScpOztbtbW1SkpKavI1SUlJqqmpkaRW25tTUFCg2bNnN7k3bvQlx+3niTrz9NNsiStJ5eXltsUOVwcOHGjvLkQUxju4GO/gYryDh7EOLsY7PBmHVXLsEvSEKDc3V8uXL2+xvbS0VH369DlujLi4OPXo0UP5+fmSpGHDhunKK6/U0qVLlZ2drbi4OFVUVDT5mqqqKnXr1s339cdrb05BQYEKCgp8n7tcLi0uXnncfgbCrtj/88hcW+KGu9TU1PbuQkRhvIOL8Q4uxjt4GOvgYrzhVEGfMrds2TIZY1q8WkuGJCkrK+u4VaRBgwYdszNdSUmJBg4caKkdAAAACH/sMmdFyK4hqqurU11dnSSpvr5edXV18nqPDH5ubq4OHTqkp59+Wh6PRxs2bNAbb7yhyy+/3Ne+a9cuFRYWqr6+XoWFhSorK1Nubq6ldgAAAACRIWQTopiYGMXExEiSzjrrLMXExOi9996TdGS9z/Lly1VYWKiEhATdeOONWrBggbKzsyVJKSkpKi4u1mOPPabExEQ9/vjjKi4uVnJysqV2AAAAIOyxy5wlQV9DZJUx5rjtQ4cO1QcffNBie3Z29nF3jWutHQAAAAhrDktc7BKyFSIAAAAAsJvLtFaKQbNa2xocAAAAzhbKf0b36dNHO3bssC1+79699dVXX9kWP5hIiEKQy+UK6f+BOQ3jHVyMd3Ax3sHFeAcPYx1cjDecjClzAAAAACIWCREAAACAiEVCFIJmzZrV3l2IKIx3cDHewcV4BxfjHTyMdXAx3nAy1hABAAAAiFhUiAAAAABELBIiAAAAABGLhAgAAABAxCIhAgAAABCxSIhCSENDg/Lz85WSkqKUlBTdfvvtamxsbO9uhaXDhw8rLy9PJ510kuLj49W3b18999xzvvbWxprvxYk7dOiQTjnlFCUlJfnuMd72WLp0qdxut7p06aKMjAw9/fTTkhhvO+zevVtXXnmlunbtqtTUVF1zzTX69ttvJTHegXryySc1ZMgQRUdH68orr2zSFujYMvbHamm8W/u9KTHecC4SohAyZ84cvf/++9q2bZu2bdumtWvXau7cue3drbDU2Nio7t276y9/+Yuqq6v1wgsv6J577tHKlSsltT7WfC9O3MyZM9WjR48m9xjvtvf2229r0qRJevTRR1VdXa1t27ZpxIgRkhhvO0yaNEmStGPHDpWWlurw4cO68847JTHegcrIyND06dOVl5d3TFugY8vYH6ul8W7t96bEeMPBDEJGjx49zKuvvur7/JVXXjG9evVqxx45S25urpkxY4YxpvWx5ntxYj766CPTv39/8/bbb5vExETffca77Q0ZMsQ888wzzbYx3m1v4MCB5qWXXvJ9/uKLL5rTTz/dGMN4t5VZs2aZK664osm9QMeWsW9Zc+P9n777e9MYxhvORYUoRFRWVmrXrl1yu92+e263Wzt37lRVVVX7dcwh6urqtHHjRg0aNKjVseZ7cWIaGxuVl5enBQsWKDo62nef8W57Bw4c0EcffaTq6mr17dtX6enpGjNmjPbs2cN422TKlCl69dVXVVVVpf379+vll1/WqFGjGG8bBTq2jH1gvvt7U+JnOZyNhChE1NbWSlKTdRdHP66pqWmHHjmHMUYTJ07UqaeeqquuuqrVseZ7cWIeeughDRo0yDdt6yjGu+1VVlbKGKNFixZpxYoV+vLLL9WpUyeNHz+e8bbJsGHD9K9//UvJyclKSUlRRUWFpk+fznjbKNCxZexP3H/+3pT4WQ5nIyEKEXFxcZLU5F9Rjn4cHx/fLn1yAmOMbrvtNm3fvl1LlixRVFRUq2PN98J///jHP7RgwQLNnz//mDbGu+0dHbM77rhDvXv3VlxcnGbPnq133nlHUVFHfqwz3m3H6/Xq4osv1rBhw1RbW6va2lplZ2frxz/+Mf992yjQsWXsT0xzvzclfpbD2UiIQkRycrJ69OihkpIS372SkhL17NlTiYmJ7dexMGaM0eTJk7Vx40atXLnSN46tjTXfC/+tXbtWe/fu1emnn6709HRdddVVqq6uVnp6ur744gvGu40lJSWpV69ecrlcx7QlJiYy3m2soqJCO3bs0B133KHY2FjFxsbq9ttv1/r16+XxeBhvmwT6s5qx919LvzclfnfC4dpx/RL+w4wZM8wZZ5xhysrKTFlZmTnjjDPM7Nmz27tbYWvSpElm0KBBpry8/Ji21saa74V/Dh486BursrIy89prr5mEhARTVlZm6uvrGW8bzJkzx2RlZZldu3aZgwcPmhtvvNFcdNFFxhj++7bDKaecYn75y1+aQ4cOmUOHDplp06aZHj16GGMY70A1NDSYQ4cOmfvvv9+MHj3aHDp0yBw+fNgYE/jYMvbHOt54H+/3pjGMN5yLhCiE1NfXm0mTJpmkpCSTlJRkJk+ebBoaGtq7W2Hpq6++MpJMdHS06dKli++65ZZbjDGtjzXfi8CsXr26yS5zjHfba2xsNFOmTDFdu3Y1Xbt2NVdffbUpKyszxjDedti2bZu55JJLTEpKiklKSjLnn3++2bRpkzGG8Q7UrFmzjKQm13nnnWeMCXxsGftjtTTerf3eNIbxhnO5jDGmvapTAAAAANCeWEMEAAAAIGKREAEAAACIWCREAAAAACIWCREAAACAiEVCBAAAACBikRABAAAAiFgkRAAAAAAiFgkRAPhh7dq16tGjR3t3I2BffPGF/uu//kvx8fG655572rs77epvf/ubzj77bN/nffr00ZIlS9ok9ksvvaQbbrihTWIBAOxBQgQA/9+IESMUHR2t+Ph4JSYmasCAAbrnnnu0d+9e32vOPfdc7dq1q9VYa9asUVJSko29Dcy8efM0aNAg1dTU6KGHHmrv7jTxwgsvyO12BxxnwoQJuuuuu1p93bRp03T//fcH/H7NGTt2rDZs2KDNmzfbEh8AEDgSIgD4jgcffFA1NTXav3+/XnnlFe3evVtnnnmmvv322/buWpsqLS3VwIEDW2xvbGwMYm/antX+f/LJJ9q+fbsuu+wyW/oRFRWl66+/Xk899ZQt8QEAgSMhAoBmuFwu9e/fXy+++KISExP18MMPSzq28vPSSy/p1FNPVXx8vDIzM/XAAw9o3759uvTSS1VVVaW4uDjFxcVp7dq12rlzpy6++GJ169ZNycnJGjVqlL766itfrAkTJigvL0/XXXed4uPjddppp2nNmjW+9vr6es2cOVMnn3yy4uPjNXDgQG3atEmS1NDQ4Gvr2rWrLr/8cn3zzTfNPtvQoUO1evVqTZs2TXFxcfrLX/6igoIC5eTk6LbbblNKSoqmTZumhoYG3XffferVq5e6deumMWPGNKmWuVwuLViwQP3791eXLl00fvx4VVRUaMyYMUpISNAZZ5yhv//97y2O8cMPP6xevXopPj5effr00bPPPqvNmzfr1ltv1datW31jt3PnTm3evFnZ2dlKSUlRt27dNHbsWO3bt88Xa8SIEfrFL36hSy65RF26dNGCBQv00ksv6amnnlJcXJxOP/30ZvuwdOlSDR8+XB06dGi2/dtvv9XgwYP1i1/8wvc9mjhxoq6++mpf3E8++URPP/20evTooW7duh2T/Fx44YUqLi5ucRwAAO3MAACMMcacd9555pFHHjnm/v3332+GDh1qjDFm9erVJjEx0RhjTG1trenYsaN59913jTHGVFZWmo0bNx7zuqNKS0vNm2++aQ4dOmSqqqrM1VdfbS666CJf+0033WTi4uLMO++8YxobG80DDzxgevfu7Wu/++67zZlnnmk+//xz4/V6zd///nfz1VdfGWOMmTp1qrngggvMN998Yw4fPmzuuecec+6551p+1lmzZpkOHTqY559/3jQ0NJgDBw6Y2bNnmwEDBpgdO3aYmpoaM2bMGHPxxRf7vkaSueCCC0x5ebnZtWuX+f73v29OP/10895775mGhgZz4403mtGjRzf7/tu3bzcxMTHms88+M8YYs2fPHvPxxx8bY4x5/vnnTVZWVpPXl5SUmLVr15r6+nqzZ88ec+6555qJEyc2eZ5u3bqZDRs2GK/Xaw4ePGhuuukmc+edd7Y4BsYYc80115gZM2Y0ude7d29TVFRkvvjiC3PKKaeYhx9+2Nd20003mfj4+CbPeNJJJ5kpU6aYw4cPm5UrV5rOnTubPXv2+L6mvLzcSDLffPPNcfsCAGgfHds7IQOAUJeZmamKiopm2zp16qTPPvtMbrdbSUlJ+q//+q8W4/Tp00d9+vSRJH3ve9/T/fffr7POOkter1dRUUcK9qNGjdIFF1wgSbr55ps1Y8YM7du3TykpKXrmmWf01ltv6dRTT5UknXbaaZIkY4yeeuoprVu3Tt27d5ckzZkzR126dNHXX3+tnj17WnrOAQMGaMKECZKkjh07atGiRZozZ4569eol6UhFJzMzU998840yMjIkSVOnTlXXrl0lSeedd56ioqJ07rnnSpLGjBmjn//8582+V4cOHWSM0bZt29S7d2+lpaUpLS2txb5lZWX5Pk5LS9OUKVM0derUJq8ZN26chg4dKkmKiYmx9MyVlZVKSEg45v5HH32k/Px8zZs3T+PGjWvSdtlllzV5xhdffFEPPPCAOnfurIsvvliJiYnaunWr73mOxq+srPR9fwAAoYMpcwDQit27dyslJeWY+126dFFxcbHeeOMN9ezZU9nZ2Vq9enWLcfbu3atx48apZ8+eSkhI0PDhw1VfX6+amhrfa9LT05vEl6Samhrt3btXBw8e9CVD31VeXq4DBw5o+PDhSkpKUlJSktLT09W5c2d9/fXXlp/zaOJz1K5du3wJnCRlZGQoOjq6yaYS3+1vbGzsMZ/X1tY2+14nn3yy/ud//kdPPvmk0tLSdMkll6ikpKTFvn355Ze64oorlJGRoYSEBN1www0qLy8/bv+tSE5OVnV19TH3//CHP+jkk0/Wtddee0zbfz5jfHy8YmNjm9z77nMfjZ+cnOx3/wAA9iMhAoDjaGxs1BtvvKERI0Y0237hhRfqzTffVHl5ua655hrl5uY2qfh813333aeDBw9q06ZNqq6u1nvvvSfpSIWnNd26dVNsbKy+/PLLY9q6du2q2NhYbdiwQfv37/ddhw4d0o9+9CPLz/qffe7Ro0eTNU579uzR4cOH22zb8WuvvVarV6/Wt99+q6ysLI0fP77ZfkjSrbfeqszMTH366aeqrq7Wiy++eMy4/efXNRfnP7nd7mbXOT366KOKiYnRNddco4aGBn8e6xiffvqp0tLSqA4BQIgiIQKAFvz973/XTTfdpKqqKk2ZMuWY9m+//VZFRUWqqalRx44dlZCQ4Fucn5aW5qvsHFVdXa3Y2FglJSVp3759mj17tuW+uFwu5eXl6Z577tGXX34pY4y2b9+uHTt2KCoqSrfeeqvuueceX0Vo3759+t///d+Anv+GG27Q3Llz9fXXX6u2tlZTpkzRRRdd5JsuF4jt27frz3/+sw4dOqTOnTsrLi5OHTsemcWdlpamsrIyHTp0yPf66upqxcfHKyEhQV9//bV+//vft/oeaWlp+uc//3nc14wePVpr166Vx+Npcv973/ue3njjDR0+fFg/+clPVF9ffwJPecSqVas0atSoE/56AIC9SIgA4DumTZvmO4foqquuUnp6uj788MNm17d4vV499thj6tmzpxITE7VgwQL93//9n6KionTaaafpZz/7mfr166ekpCS9//77mj17tr788kslJydr2LBhuvTSS/3q24MPPqgLL7xQF110kRISEnTNNdf41jb99re/1TnnnKMLLrhA8fHxOvPMM7Vy5cqAxuK+++7Tj3/8Y51zzjnq06ePGhoa9OKLLwYU86j6+nrNmDFDaWlp6tq1q1atWqUXXnhBknTBBRfo7LPPVmZmppKSkrRz5049/PDDWrZsmRISEnTFFVfoJz/5SavvMXHiRO3evVvJyckaNGhQs68ZOHCgTj31VL311lvHtEVHR2vJkiUyxig3N1eHDx/2+zm9Xq9eeuklTZ482e+vBQAEh8tYmasBAIBDrV+/Xnfffbf+9re/tXnsxYsXa/ny5XrppZfaPDYAoG2QEAEAAACIWEyZAwAAABCxSIgAAAAARCwSIgAAAAARi4QIAAAAQMQiIQIAAAAQsUiIAAAAAEQsEiIAAAAAEYuECAAAAEDEIiECAAAAELFIiAAAAABErP8HFU7kuuuBRXQAAAAASUVORK5CYII=",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# regularised transect\n",
+ "profile_indices, distance_regular = get_profile_indices(distance_1d)\n",
+ "var_masked = build_masked_array(var_up, profile_indices, len(distance_regular))\n",
+ "\n",
+ "xticks_reg = np.linspace(\n",
+ " float(distance_regular.min()),\n",
+ " float(distance_regular.max()),\n",
+ " len(distance_regular),\n",
+ ")\n",
+ "\n",
+ "# plot regularised transect\n",
+ "fig, ax = plt.subplots(figsize=(10, 6), dpi=90)\n",
+ "\n",
+ "ax.grid(True, which=\"both\", color=\"lightgrey\", linestyle=\"-\", linewidth=0.7, alpha=0.5)\n",
+ "\n",
+ "mesh = ax.pcolormesh(\n",
+ " distance_regular / 1000, # distance in km\n",
+ " z1d,\n",
+ " var_masked.T,\n",
+ " cmap=VARIABLES[plot_variable][\"cmap\"],\n",
+ ")\n",
+ "\n",
+ "ax.set_ylabel(\"Depth (m)\")\n",
+ "ax.set_xlabel(\"Distance from start (km)\")\n",
+ "\n",
+ "# ax.set_ylim(-600,)\n",
+ "\n",
+ "plt.colorbar(mesh, ax=ax, label=VARIABLES[plot_variable][\"label\"])\n",
+ "plt.tight_layout()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "68c5c8c2",
+ "metadata": {},
+ "source": [
+ "In the plot above, we can see that there are gaps in the transects where no CTD casts have been made. After all, it's impossible to take measurements at every point across the transect! There will always be gaps when making tens of deployments across transects 1000s of kms long 🙃 This makes expedition/sampling site planning all the more important...\n",
+ "\n",
+ "We can also also plot a 'filled' version without the distance bins, to give an alternative view of the evolution across the transect which is not dominated by gaps and white space. This time we will also add a 'sea bed' to the plot.\n",
+ "\n",
+ "
\n",
+ "Note: It is important to remember that the gaps do actually exist in reality and this is a caveat which must be considered when interpreting the transect derived from CTD casts. Indeed, if you look at the x-axis of the plot below you will see that the deployments are not necessarily regularly spaced and some gaps are larger than others.\n",
+ "
+ Straight transect starting from the deep shelve
+
+
+ Inslingeren: (zeem.) opnieuw wennen aan het zeemansleven
+
+
Travel and CTD deployment time
+
SURF Research Cloud setup by Jamie
+
Instructions in Jupyter Notebook
+
+
+
+
VR and life @ sea
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/user-guide/teacher-content/UU-ocean-of-future/Presentation.qmd b/docs/user-guide/teacher-content/UU-ocean-of-future/Presentation.qmd
new file mode 100644
index 00000000..16777361
--- /dev/null
+++ b/docs/user-guide/teacher-content/UU-ocean-of-future/Presentation.qmd
@@ -0,0 +1,146 @@
+---
+title: "Ocean observations"
+subtitle: "Ocean of the future 13-11-2025"
+author: "Emma Daniels & Jamie Atkins Postdocs @ UU Virtual Ship Classroom"
+format:
+ revealjs:
+ slide-number: true
+ theme: sky
+ logo: "https://virtualship.readthedocs.io/en/latest/_static/virtual_ship_logo.png"
+ controls: true
+ incremental: true
+title-slide-attributes:
+ data-background-image: "https://cloudfront-us-east-2.images.arcpublishing.com/reuters/CQFY2GVMTNJ45KAVHBVEWJYZ44.jpg"
+ data-background-size: contain
+ # data-background-iframe: "https://www.youtube.com/embed/qeeipUefe8A?autoplay=1&controls=0&loop=1"
+ # data-background-video-loop: true
+ # data-background-video-muted: true
+---
+
+##
+
+
+## {background-iframe="https://wordwall.net/embed/4f6b5ced54d644c2bab35354305bb0eb?themeId=65&templateId=30&fontStackId=1" background-interactive="true"}
+
+## Difficulties of Measuring
+
+- **Vast Scale**: Covers 71% of Earth's surface, average depth 3,688m
+- **Real-Time Monitoring**: Difficulty transmitting data from deep or remote locations
+- **Extreme conditions**: Pressure, storms and salt require specialized equipment
+- **Accessibility**: Remote locations, harsh weather, high operational costs
+- **Light Limitation**: Optical methods only work in top ~200m
+
+## Satellite Observations
+
+
+Note that it's not possible to investigate the interior ocean with satellites
+
+## Satellite Observations
+
+::: {.nonincremental}
+- **Advantages**:
+ - Global coverage and accessibility
+ - Continuous monitoring of large areas
+ - Cost-effective compared to ship-based surveys
+
+- **Limitations**:
+ - Affected by atmospheric conditions
+ - Cannot penetrate deep into the ocean
+:::
+
+## PACE Satellite (2024)
+
+
+## Light Attenuation in the Ocean
+
+
+## Light Attenuation in the Ocean
+
+- Remote sensing only observes top few meters of ocean
+- Cameras and optical instruments are ineffective at depth
+- Alternative methods required:
+ - Cabled networks and instruments
+ - Acoustic sensors
+ - Resurfacing equipment
+
+
+
+## Observation Methods
+
+
+## HMS Challenger (1872-1876)
+
+
+##
+```{=html}
+
+```
+
+## Ship-Based Measurements
+
+- **CTD Casts**: Conductivity, Temperature, and Depth profiling and water samples
+- **Acoustic Doppler Current Profilers (ADCP)**: Measuring ocean currents
+- **Multibeam Echosounders**: High-resolution seafloor mapping
+- **Sediment Cores**: Extracting seafloor samples for geological and climate studies
+- **Tows**: Biological sampling of plankton and marine organisms
+
+##
+
+
+##
+
+
+## Benefits of CTD Measurements
+
+- **High Vertical Resolution**: Continuous profiling from surface to seafloor
+- **Targeted Sampling**: Niskin bottles collect water at specific depths of interest
+- **Multiple Parameters**: Temperature, salinity, depth, plus additional sensors (O₂, chlorophyll, turbidity)
+- **Water Mass Identification**: Characterize ocean layers and circulation patterns
+- **Calibration Standard**: Validates satellite and autonomous sensor data
+
+##
+
+
+## Plankton measurements
+
+- Water Sampling
+- In-Situ Imaging
+- Chlorophyll Analysis
+- eDNA Filtering
+- Tows: nets or CPR
+
+##
+
+
+##
+
+
+##
+
+
+##
+
+
+##
+```{=html}
+
+```
+
+## VirtualShip expedition
+
+- 9 days of ship time
+- Depart and arrive from Texel, Netherlands
+- Straight transect starting from the deep shelve
+- Inslingeren: (zeem.) opnieuw wennen aan het zeemansleven
+- Travel and CTD deployment time
+- SURF Research Cloud setup by Jamie
+- Instructions in Jupyter Notebook
+
+## VR and life @ sea
+
diff --git a/docs/user-guide/teacher-content/UU-ocean-of-future/Tutorial1.ipynb b/docs/user-guide/teacher-content/UU-ocean-of-future/Tutorial1.ipynb
new file mode 100644
index 00000000..3914f93c
--- /dev/null
+++ b/docs/user-guide/teacher-content/UU-ocean-of-future/Tutorial1.ipynb
@@ -0,0 +1,101 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "1536cbb7",
+ "metadata": {},
+ "source": [
+ "# Tutorial 1\n",
+ "\n",
+ "## VirtualShip exercise\n",
+ "You can work on the exercise either individually or in pairs. You will work with the same person during the tutorials on November 17th as well. \n",
+ "You will plan and execute a virtual oceanographic expedition using the VirtualShip software. Follow the steps below to complete the exercise.\n",
+ "\n",
+ "### Form and register your duo (preferably with different backgrounds) through Teams\n",
+ "- Choose a month in the summer half year in which you want to do the expedition (i.e. April - October)\n",
+ "- Fill in the sheet in the Teams channel and remember your group number (e.g. GROUP1) and the year it is associated with (e.g. 1993)\n",
+ "\n",
+ "### Check out your expeditions measurement locations\n",
+ "https://nioz.marinefacilitiesplanning.com/cruiselocationplanning#\n",
+ "- Save and upload the cruise track (.xlsx file)\n",
+ "- Select _Texel - Netherlands_ as the Port of Departure \n",
+ "- Check out the measurement locations and depths, and travel time between stations\n",
+ "- Do a (rough) calculation on the time the CTD measurements will take at each station \n",
+ " - Assume 10 minutes for deployment and retrieval of the CTD at each station\n",
+ " - Assume 1 m/s for the CTD to go down and up (i.e., for a station at 100 m depth, it will take approximately 200 seconds to go down and up)\n",
+ "\n",
+ "### Open a terminal and prepare your expedition in VirtualShip:\n",
+ "```\n",
+ "cd data/virtualship_storage/\n",
+ "virtualship plan GROUP#\n",
+ "```\n",
+ "Replace # with your actual group number (e.g., GROUP1)\n",
+ "\n",
+ "- Decide on the exact timing of your expedition with in the year and month registered though Teams\n",
+ "- Remember you have 9 days of ship time available, including travel time from and to Texel, Netherlands\n",
+ "\n",
+ "### Follow the instructions in the terminal to set up your expedition\n",
+ "- From the Schedule Editor select Waypoints & Instrument Selection\n",
+ "- Click each waypoint to set the time (consider _transit times_ between locations and _time needed for measurements_) \n",
+ "- Ensure that the CTD and CTD_BGC instruments are selected for use at all waypoints (they should be by default).\n",
+ "- Save your changes\n",
+ "\n",
+ "### Fetch the data needed for your expedition\n",
+ "```\n",
+ "virtualship fetch GROUP#\n",
+ "```\n",
+ "Replace GROUP# with your actual expedition name (e.g., GROUP1)\n",
+ "\n",
+ "- Provide your Copernicus username and password when prompted\n",
+ "- (Sign up for a Copernicus account if you don't have one yet: https://data.marine.copernicus.eu/register)\n",
+ "- Practice your patience - a key skill in oceanography! - data download may take some time ;-)\n",
+ "\n",
+ "
\n",
+ "In the waiting time you can learn about (life on board) research vessels:\n",
+ "\n",
+ "- https://www.youtube.com/watch?v=hUl0TA-gCK0\n",
+ "\n",
+ "- https://www.youtube.com/watch?v=G82kIgc1imk\n",
+ "\n",
+ "- https://schmidtocean.org/cruise-log-post/four-unexpected-things-i-learned-while-working-on-a-research-vessel/\n",
+ "\n",
+ "Or browse through some blogs from many different cruises, e.g. https://www.nioz.nl/en/blog/topic/1027\n",
+ "
\n",
+ "\n",
+ "\n",
+ "### Start your expedition\n",
+ "`virtualship run GROUP#`\n",
+ "\n",
+ "### Verify your expedition was successful by checking the output directory for data files\n",
+ "- Navigate to the results directory:\n",
+ "`cd GROUP#/results`\n",
+ "- List the files in the results directory:\n",
+ "`ls`\n",
+ "\n",
+ "- Hand in the filepath to your results via Brightspace before Wednesday 19-11-2025 13:00h:\n",
+ "`pwd`"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "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.9.6"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/user-guide/teacher-content/UU-ocean-of-future/Tutorial2.ipynb b/docs/user-guide/teacher-content/UU-ocean-of-future/Tutorial2.ipynb
new file mode 100644
index 00000000..c73d111f
--- /dev/null
+++ b/docs/user-guide/teacher-content/UU-ocean-of-future/Tutorial2.ipynb
@@ -0,0 +1,55 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "c6b4d243",
+ "metadata": {},
+ "source": [
+ "## Open the notebook CTD transects and run all cells to generate some plots\n",
+ "- modify your data directory\n",
+ "data_dir\n",
+ "\n",
+ "## Plot CTD transects for different variables\n",
+ "Remember your MFP cruise plan and the bathymetry of the North West Shelf TODO: (links)\n",
+ "- Explain why the data is available at different depths throughout the expedition \n",
+ "- Are there temperatures that stand out? For example, temperatures below 0 degrees?\n",
+ "\n",
+ "Play around setting ax.set_ylim() to zoom in on certain depth ranges\n",
+ "- Describe the evolution of salinity along the transect\n",
+ "- Explain why salinity changes close to land\n",
+ "\n",
+ "Oxygen:\n",
+ "- Describe the evolution of oxygen along the transect\n",
+ "- Can you identify any oxygen minimum zones? Hypoxia?\n",
+ "\n",
+ "Nitrate: \n",
+ "Is there nearest to land there are very high nitrate values\n",
+ "- Explain why this is the case\n",
+ "\n",
+ "pH\n",
+ "- Hypothesize why pH is lower in some spots\n",
+ "- Is there an obvious correlation with temperature or other variables?\n",
+ "\n",
+ "Phytoplankton (Chlorophyll)\n",
+ "- Where are the highest concentrations of chlorophyll?\n",
+ "- Is there an obvious correlation with temperature or other variables?\n",
+ "\n",
+ "## Discussion points\n",
+ "- Which variables are most affected by proximity to land?\n",
+ "- How do you expect these variables to change with climate change?\n",
+ "\n",
+ "## Reflection\n",
+ "- Are there locations where you would have liked to take more measurements? \n",
+ "- Why? and how would you modify the cruise plan?\n",
+ "\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "language_info": {
+ "name": "python"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/user-guide/teacher-content/UU-ocean-of-future/_publish.yml b/docs/user-guide/teacher-content/UU-ocean-of-future/_publish.yml
new file mode 100644
index 00000000..16810521
--- /dev/null
+++ b/docs/user-guide/teacher-content/UU-ocean-of-future/_publish.yml
@@ -0,0 +1,4 @@
+- source: Presentation.qmd
+ quarto-pub:
+ - id: 79cbaa11-d5f3-4317-895e-162a1a0b3a5f
+ url: https://ammedd.quarto.pub/oceans_of_the_future
diff --git a/docs/user-guide/teacher-content/UU-ocean-of-future/plot_3D.py b/docs/user-guide/teacher-content/UU-ocean-of-future/plot_3D.py
new file mode 100644
index 00000000..31c5c22b
--- /dev/null
+++ b/docs/user-guide/teacher-content/UU-ocean-of-future/plot_3D.py
@@ -0,0 +1,250 @@
+"""N.B. Quick, inflexible (under active development) version whilst experimenting best approaches!""" # noqa: D400
+# TODO: WORK IN PROGRESS
+
+# %%
+import os
+from glob import glob
+
+import cmocean.cm as cmo
+import matplotlib as mpl
+import numpy as np
+import plotly.graph_objects as go
+import xarray as xr
+
+var = "temperature" # change this to your chosen variable
+
+
+base_dir = os.getcwd()
+filename = "ctd.zarr" if var in ["temperature", "salinity"] else "ctd_bgc.zarr"
+grp_dirs = sorted(glob(os.path.join(base_dir, "GRP????/results/", filename)))
+
+
+VARIABLES = {
+ "temperature": {
+ "cmap": cmo.thermal,
+ "label": "Temperature (°C)",
+ "ds_name": "temperature",
+ },
+ "salinity": {
+ "cmap": cmo.haline,
+ "label": "Salinity (psu)",
+ "ds_name": "salinity",
+ },
+ "oxygen": {
+ "cmap": cmo.oxy,
+ "label": r"Dissolved oxygen (mmol m$^{-3}$)",
+ "ds_name": "o2",
+ },
+ "nitrate": {
+ "cmap": cmo.matter,
+ "label": r"Nitrate (mmol m$^{-3}$)",
+ "ds_name": "no3",
+ },
+ "phosphate": {
+ "cmap": cmo.matter,
+ "label": r"Phosphate (mmol m$^{-3}$)",
+ "ds_name": "po4",
+ },
+ "ph": {
+ "cmap": cmo.balance,
+ "label": "pH",
+ "ds_name": "ph",
+ },
+ "phytoplankton": {
+ "cmap": cmo.algae,
+ "label": r"Total phytoplankton (mmol m$^{-3}$)",
+ "ds_name": "phyc",
+ },
+ "primary_production": {
+ "cmap": cmo.matter,
+ "label": r"Total primary production of phytoplankton (mg m$^{-3}$ day$^{-1}$)",
+ "ds_name": "nppv",
+ },
+ "chlorophyll": {
+ "cmap": cmo.algae,
+ "label": r"Chlorophyll (mg m$^{-3}$)",
+ "ds_name": "chl",
+ },
+}
+
+
+def haversine(lon1, lat1, lon2, lat2):
+ """Great-circle distance (meters) between two points."""
+ lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])
+ dlon, dlat = lon2 - lon1, lat2 - lat1
+ a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
+ c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
+ return 6371000 * c
+
+
+def distance_from_start(ds):
+ """Add 'distance' variable: meters from first waypoint."""
+ lon0, lat0 = (
+ ds.isel(trajectory=0)["lon"].values[0],
+ ds.isel(trajectory=0)["lat"].values[0],
+ )
+ d = np.zeros_like(ds["lon"].values, dtype=float)
+ for ob, (lon, lat) in enumerate(zip(ds["lon"], ds["lat"], strict=False)):
+ d[ob] = haversine(lon, lat, lon0, lat0)
+ ds["distance"] = xr.DataArray(
+ d,
+ dims=ds["lon"].dims,
+ attrs={"long_name": "distance from first waypoint", "units": "m"},
+ )
+ return ds
+
+
+def descent_only(ds, variable):
+ """Extract descending CTD data (downcast), pad with NaNs for alignment."""
+ min_z_idx = ds["z"].argmin("obs")
+ da_clean = []
+ for i, traj in enumerate(ds["trajectory"].values):
+ idx = min_z_idx.sel(trajectory=traj).item()
+ descent_vals = ds[variable][
+ i, : idx + 1
+ ] # take values from surface to min_z_idx (inclusive)
+ da_clean.append(descent_vals)
+ max_len = max(len(arr[~np.isnan(arr)]) for arr in da_clean)
+ da_padded = np.full((ds["trajectory"].size, max_len), np.nan)
+ for i, arr in enumerate(da_clean):
+ da_dropna = arr[~np.isnan(arr)]
+ da_padded[i, : len(da_dropna)] = da_dropna
+ return xr.DataArray(
+ da_padded,
+ dims=["trajectory", "obs"],
+ coords={"trajectory": ds["trajectory"], "obs": np.arange(max_len)},
+ )
+
+
+def build_masked_array(data_up, profile_indices, n_profiles):
+ arr = np.full((n_profiles, data_up.shape[1]), np.nan)
+ for i, idx in enumerate(profile_indices):
+ if idx is not None:
+ arr[i, :] = data_up.values[idx, :]
+ return arr
+
+
+def get_profile_indices(distance_1d):
+ """
+ Returns regular distance bins and profile indices for CTD transect plotting.
+
+ Bin size is set to one order of magnitude lower than max distance.
+ """
+ dist_min, dist_max = float(distance_1d.min()), float(distance_1d.max())
+ if dist_max > 1e6:
+ dist_step = 1e5
+ elif dist_max > 1e5:
+ dist_step = 1e4
+ elif dist_max > 1e4:
+ dist_step = 1e3
+ else:
+ dist_step = 1e2 # fallback for very short transects
+
+ distance_regular = np.arange(dist_min, dist_max + dist_step, dist_step)
+ threshold = dist_step / 2
+ profile_indices = [
+ np.argmin(np.abs(distance_1d.values - d))
+ if np.min(np.abs(distance_1d.values - d)) < threshold
+ else None
+ for d in distance_regular
+ ]
+ return profile_indices, distance_regular
+
+
+# %%
+
+# pre processing, concat to 3D array
+expeditions = []
+times = []
+for i, path in enumerate(grp_dirs):
+ ctd_ds = xr.open_dataset(path)
+
+ # add distance from start
+ ctd_distance = distance_from_start(ctd_ds)
+
+ # extract descent-only data
+ if i == 0:
+ z_up = descent_only(ctd_distance, "z")
+ d_up = descent_only(ctd_distance, "distance")
+ var_up = descent_only(ctd_distance, VARIABLES[var]["ds_name"])
+
+ # append
+ expeditions.append(var_up)
+ times.append(ctd_ds["time"][0][0].values)
+
+# concat
+var_concat = xr.concat(expeditions, dim="expedition")
+
+
+# 1d array of depth dimension (from deepest trajectory)
+traj_idx, obs_idx = np.where(z_up == np.nanmin(z_up))
+z1d = z_up.values[traj_idx[0], :]
+
+# distance as 1d array
+distance_1d = d_up.isel(obs=0)
+
+# %%
+
+## plotting
+
+# trim to upper 600m
+var_trim = var_concat.where(z_up >= -600)
+
+# Convert cmo.thermal to Plotly colorscale
+thermal_cmap = cmo.thermal
+thermal_colorscale = [
+ [i / 255, mpl.colors.rgb2hex(thermal_cmap(i / 255))] for i in range(256)
+]
+
+# meshgrid for 3D plotting
+expeditions = var_trim["expedition"].values
+trajectories = distance_1d.values
+depths = z1d
+
+xx, yy, zz = np.meshgrid(expeditions, trajectories, depths, indexing="ij")
+
+# values
+values = var_trim.values # shape: (expedition, trajectory, obs)
+valid_values = values[~np.isnan(values)]
+isomin = np.nanpercentile(valid_values, 2.5)
+isomax = np.nanpercentile(valid_values, 97.5)
+
+fig = go.Figure(
+ data=go.Volume(
+ x=xx.flatten(),
+ y=yy.flatten() / 1000.0, # convert to km
+ z=zz.flatten(),
+ value=np.nan_to_num(values, nan=-9999).flatten(),
+ isomin=isomin,
+ isomax=isomax,
+ opacity=0.3,
+ surface_count=21,
+ # opacityscale=[[2, 0.2], [5, 0.5], [5, 0.5], [8, 1]],
+ # opacityscale="extremes",
+ # colorscale=thermal_colorscale,
+ caps=dict(x_show=False, y_show=False, z_show=False), # Hide caps for clarity
+ )
+)
+
+fig.update_layout(
+ scene=dict(
+ zaxis=dict(title="Depth (m)", range=[-600, 0]),
+ yaxis=dict(
+ title="Distance from start (km)",
+ range=[0, np.nanmax(trajectories) / 1000.0],
+ ),
+ xaxis=dict(
+ title="Year",
+ tickvals=np.array([i for i in range(len(expeditions))])[::-1],
+ ticktext=[
+ str(np.datetime64(times[i], "Y")) for i in range(len(expeditions))
+ ][::-1],
+ ),
+ ),
+ margin=dict(l=0, r=0, b=0, t=40),
+ title="3D Volume Plot of " + VARIABLES[var]["label"],
+)
+
+fig.show()
+
+fig.write_html(f"./sample_3D_{var}.html")
diff --git a/docs/user-guide/teacher-content/UU-ocean-of-future/plot_slider.py b/docs/user-guide/teacher-content/UU-ocean-of-future/plot_slider.py
new file mode 100644
index 00000000..324c5ba4
--- /dev/null
+++ b/docs/user-guide/teacher-content/UU-ocean-of-future/plot_slider.py
@@ -0,0 +1,279 @@
+"""N.B. Quick (under active development) version whilst experimenting best approaches!""" # noqa: D400
+# TODO: WORK IN PROGRESS
+
+# %%
+import os
+from glob import glob
+
+import cmocean.cm as cmo
+import matplotlib as mpl
+import numpy as np
+import plotly.graph_objs as go
+import xarray as xr
+
+var = "primary_production" # change this to your chosen variable
+
+
+base_dir = os.getcwd()
+filename = "ctd.zarr" if var in ["temperature", "salinity"] else "ctd_bgc.zarr"
+grp_dirs = sorted(glob(os.path.join(base_dir, "GRP????/results/", filename)))
+
+
+VARIABLES = {
+ "temperature": {
+ "cmap": cmo.thermal,
+ "label": "Temperature (°C)",
+ "ds_name": "temperature",
+ },
+ "salinity": {
+ "cmap": cmo.haline,
+ "label": "Salinity (PSU)",
+ "ds_name": "salinity",
+ },
+ "oxygen": {
+ "cmap": cmo.oxy,
+ "label": r"Dissolved oxygen (mmol m-3)",
+ "ds_name": "o2",
+ },
+ "nitrate": {
+ "cmap": cmo.matter,
+ "label": r"Nitrate (mmol m-3)",
+ "ds_name": "no3",
+ },
+ "phosphate": {
+ "cmap": cmo.matter,
+ "label": r"Phosphate (mmol m-3)",
+ "ds_name": "po4",
+ },
+ "ph": {
+ "cmap": cmo.balance,
+ "label": "pH",
+ "ds_name": "ph",
+ },
+ "phytoplankton": {
+ "cmap": cmo.algae,
+ "label": r"Total phytoplankton (mmol m-3)",
+ "ds_name": "phyc",
+ },
+ "primary_production": {
+ "cmap": cmo.matter,
+ "label": "Total primary production of phytoplankton (mg m-3 day-1)",
+ "ds_name": "nppv",
+ },
+ "chlorophyll": {
+ "cmap": cmo.algae,
+ "label": "Chlorophyll (mg m-3)",
+ "ds_name": "chl",
+ },
+}
+
+
+def haversine(lon1, lat1, lon2, lat2):
+ """Great-circle distance (meters) between two points."""
+ lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])
+ dlon, dlat = lon2 - lon1, lat2 - lat1
+ a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
+ c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
+ return 6371000 * c
+
+
+def distance_from_start(ds):
+ """Add 'distance' variable: meters from first waypoint."""
+ lon0, lat0 = (
+ ds.isel(trajectory=0)["lon"].values[0],
+ ds.isel(trajectory=0)["lat"].values[0],
+ )
+ d = np.zeros_like(ds["lon"].values, dtype=float)
+ for ob, (lon, lat) in enumerate(zip(ds["lon"], ds["lat"], strict=False)):
+ d[ob] = haversine(lon, lat, lon0, lat0)
+ ds["distance"] = xr.DataArray(
+ d,
+ dims=ds["lon"].dims,
+ attrs={"long_name": "distance from first waypoint", "units": "m"},
+ )
+ return ds
+
+
+def descent_only(ds, variable):
+ """Extract descending CTD data (downcast), pad with NaNs for alignment."""
+ min_z_idx = ds["z"].argmin("obs")
+ da_clean = []
+ for i, traj in enumerate(ds["trajectory"].values):
+ idx = min_z_idx.sel(trajectory=traj).item()
+ descent_vals = ds[variable][
+ i, : idx + 1
+ ] # take values from surface to min_z_idx (inclusive)
+ da_clean.append(descent_vals)
+ max_len = max(len(arr[~np.isnan(arr)]) for arr in da_clean)
+ da_padded = np.full((ds["trajectory"].size, max_len), np.nan)
+ for i, arr in enumerate(da_clean):
+ da_dropna = arr[~np.isnan(arr)]
+ da_padded[i, : len(da_dropna)] = da_dropna
+ return xr.DataArray(
+ da_padded,
+ dims=["trajectory", "obs"],
+ coords={"trajectory": ds["trajectory"], "obs": np.arange(max_len)},
+ )
+
+
+def build_masked_array(data_up, profile_indices, n_profiles):
+ arr = np.full((n_profiles, data_up.shape[1]), np.nan)
+ for i, idx in enumerate(profile_indices):
+ if idx is not None:
+ arr[i, :] = data_up.values[idx, :]
+ return arr
+
+
+def get_profile_indices(distance_1d):
+ """
+ Returns regular distance bins and profile indices for CTD transect plotting.
+
+ Bin size is set to one order of magnitude lower than max distance.
+ """
+ dist_min, dist_max = float(distance_1d.min()), float(distance_1d.max())
+ if dist_max > 1e6:
+ dist_step = 1e5
+ elif dist_max > 1e5:
+ dist_step = 1e4
+ elif dist_max > 1e4:
+ dist_step = 1e3
+ else:
+ dist_step = 1e2 # fallback for very short transects
+
+ distance_regular = np.arange(dist_min, dist_max + dist_step, dist_step)
+ threshold = dist_step / 2
+ profile_indices = [
+ np.argmin(np.abs(distance_1d.values - d))
+ if np.min(np.abs(distance_1d.values - d)) < threshold
+ else None
+ for d in distance_regular
+ ]
+ return profile_indices, distance_regular
+
+
+# %%
+
+# pre processing, concat to 3D array
+expeditions = []
+times = []
+for i, path in enumerate(grp_dirs):
+ ctd_ds = xr.open_dataset(path)
+
+ # add distance from start
+ ctd_distance = distance_from_start(ctd_ds)
+
+ # extract descent-only data
+ if i == 0:
+ z_up = descent_only(ctd_distance, "z")
+ d_up = descent_only(ctd_distance, "distance")
+ var_up = descent_only(ctd_distance, VARIABLES[var]["ds_name"])
+
+ # append
+ expeditions.append(var_up)
+ times.append(ctd_ds["time"][0][0].values)
+
+# concat
+var_concat = xr.concat(expeditions, dim="expedition")
+var_concat["expedition"] = times
+
+# 1d array of depth dimension (from deepest trajectory)
+traj_idx, obs_idx = np.where(z_up == np.nanmin(z_up))
+z1d = z_up.values[traj_idx[0], :]
+
+# distance as 1d array
+distance_1d = d_up.isel(obs=0)
+
+# %%
+
+## plotting (interactive with Plotly)
+
+depth_lim = -200 # [m]
+
+# trim to upper 600m
+var_trim = var_concat.where(z_up >= depth_lim)
+
+
+# Prepare colorscale for Plotly from matplotlib colormap
+def mpl_to_plotly(cmap, n=256):
+ return [[i / (n - 1), mpl.colors.rgb2hex(cmap(i / (n - 1)))] for i in range(n)]
+
+
+plotly_cmap = mpl_to_plotly(VARIABLES[var]["cmap"])
+
+# Prepare slider steps
+steps = []
+data = []
+for t in range(var_trim.shape[0]):
+ seabed = xr.where(np.isnan(var_trim[t]), 1, None).T
+
+ # main cross-section
+ trace = go.Heatmap(
+ z=var_trim[t].T,
+ x=distance_1d / 1000.0, # distance in km
+ y=z1d,
+ zmin=np.nanmin(var_trim.values),
+ zmax=np.nanmax(var_trim.values),
+ colorscale=plotly_cmap,
+ colorbar=dict(title=VARIABLES[var]["label"]),
+ showscale=True,
+ visible=(t == 0),
+ customdata=None,
+ hovertemplate="Distance: %{x:.2f} km Depth: %{z:.1f} m Value: %{value:.2f}",
+ )
+ # Seabed overlay (tan color)
+ seabed_trace = go.Heatmap(
+ z=seabed,
+ x=distance_1d / 1000.0, # distance in km
+ y=z1d,
+ colorscale=[[0, "tan"], [1, "tan"]],
+ showscale=False,
+ opacity=1.0,
+ visible=(t == 0),
+ name="Land / sea bed",
+ hoverinfo="skip",
+ )
+ data.append(trace)
+ data.append(seabed_trace)
+ steps.append(
+ {
+ "method": "update",
+ "args": [
+ {"visible": [i // 2 == t for i in range(2 * var_trim.shape[0])]},
+ {
+ "title": f"{VARIABLES[var]['label']} (Date {np.datetime_as_string(var_trim['expedition'][t].values, unit='D')})"
+ },
+ ],
+ "label": str(
+ np.datetime_as_string(var_trim["expedition"][t].values, unit="D")
+ ),
+ }
+ )
+
+sliders = [
+ dict(active=0, currentvalue={"prefix": "Date: "}, pad={"t": 50}, steps=steps)
+]
+
+layout = go.Layout(
+ title=f"{VARIABLES[var]['label']} (Date {np.datetime_as_string(var_trim['expedition'][0].values, unit='D')})",
+ xaxis=dict(
+ title="Distance from start (km)",
+ tickvals=(distance_1d / 1000.0),
+ tickformat=".0f",
+ ),
+ yaxis=dict(
+ title="Depth (m)",
+ range=[depth_lim, np.nanmax(z1d)],
+ ),
+ sliders=sliders,
+ legend=dict(itemsizing="constant"),
+ width=900,
+ height=600,
+)
+
+fig = go.Figure(data=data, layout=layout)
+fig.show()
+
+fig.write_html(f"./sample_slider_{var}.html")
+
+
+# %%
diff --git a/docs/user-guide/teacher-content/UU-ocean-of-future/schedule_monitor.ipynb b/docs/user-guide/teacher-content/UU-ocean-of-future/schedule_monitor.ipynb
new file mode 100644
index 00000000..aa6d8aa0
--- /dev/null
+++ b/docs/user-guide/teacher-content/UU-ocean-of-future/schedule_monitor.ipynb
@@ -0,0 +1,311 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "623b96d7-7c2d-4797-a18d-6a34c79a5296",
+ "metadata": {},
+ "source": [
+ "### Schedule duration monitor\n",
+ "\n",
+ "This notebook will monitor - in live time - the duration of the expeditions in directories GROUP1, GROUP2, ... , GROUP66.\n",
+ "\n",
+ "The resultant plot once all cells of the notebook are run will be refreshed every n seconds (prescribable in the `REFRESH` constant below).\n",
+ "\n",
+ "CTDs are assumed to take approx. 20 minutes each. 3 days sailing time is added to all groups for the outbound journey from Texel to the first waypoint.\n",
+ "\n",
+ "
\n",
+ "This script uses an infinite `while` loop to refresh the plotting indefinitely. To stop the running, the \"Interupt the kernel\" button (square button) should be pressed.\n",
+ "
\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "64a8edb1-46c3-488f-a0dc-d104d47bbaa5",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import time\n",
+ "import os\n",
+ "import sys\n",
+ "import yaml\n",
+ "import numpy as np\n",
+ "from pathlib import Path\n",
+ "from matplotlib import pyplot as plt\n",
+ "from IPython.display import clear_output"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "d93d52a2-b55d-43b6-bc2e-a56aecbe8fa7",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# plot refresh rate\n",
+ "REFRESH = 30 # [seconds]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "2c61c6b5-e729-46d0-bc9e-2f87a447ee76",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# config\n",
+ "BASE_DIR = Path(\"/home/shared/data/virtualship_storage/\")\n",
+ "SAIL_OUT_TIME = np.timedelta64(3, \"D\") # [days]\n",
+ "CTD_TIME = np.timedelta64(200, \"m\") # [minutes]\n",
+ "SHIP_TIME_THRESHOLD = 9 # [days]\n",
+ "ROTATION_CHANGE = 18 # when to change from 45 to 90 degree rotation in x axis labels"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "4a91fae1-46d1-4e49-855a-b0bec7b127d9",
+ "metadata": {
+ "jupyter": {
+ "source_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "def preprocess(\n",
+ " base_dir: Path, sail_out_time: np.timedelta64, ctd_time: np.timedelta64\n",
+ ") -> dict:\n",
+ " \"\"\"\n",
+ " Reads schedule data from YAML files, calculates the total expedition duration\n",
+ " for each group, and returns a dictionary of valid groups and their durations.\n",
+ " \"\"\"\n",
+ " groups = {}\n",
+ "\n",
+ " # group directories 1 to 66 (inclusive)\n",
+ " for i in range(1, 67):\n",
+ " group_name = f\"GROUP{i}\"\n",
+ " group_dir = base_dir / group_name\n",
+ " schedule_file = group_dir / \"schedule.yaml\"\n",
+ "\n",
+ " if not schedule_file.exists():\n",
+ " groups[group_name] = np.nan\n",
+ " continue\n",
+ "\n",
+ " try:\n",
+ " with open(schedule_file, \"r\", encoding=\"utf-8\") as f:\n",
+ " schedule_data = yaml.safe_load(f)\n",
+ "\n",
+ " waypoints = schedule_data.get(\"waypoints\", [])\n",
+ "\n",
+ " if not waypoints:\n",
+ " groups[group_name] = np.nan\n",
+ " continue\n",
+ "\n",
+ " start_time_str = waypoints[0].get(\"time\")\n",
+ " end_time_str = waypoints[-1].get(\"time\")\n",
+ "\n",
+ " if not start_time_str or not end_time_str:\n",
+ " groups[group_name] = np.nan\n",
+ " else:\n",
+ " start_time, end_time = (\n",
+ " np.datetime64(start_time_str),\n",
+ " np.datetime64(end_time_str),\n",
+ " )\n",
+ "\n",
+ " difference = end_time - start_time\n",
+ " difference_days = difference.astype(\"timedelta64[D]\") # [days]\n",
+ "\n",
+ " total_time_timedelta = difference_days + sail_out_time + ctd_time\n",
+ " total_time_days = total_time_timedelta.astype(\n",
+ " \"timedelta64[h]\"\n",
+ " ) / np.timedelta64(24, \"h\")\n",
+ "\n",
+ " groups[group_name] = total_time_days.item()\n",
+ "\n",
+ " except Exception as e:\n",
+ " groups[group_name] = np.nan\n",
+ " print(f\"Error processing {group_name}: {e}\", file=sys.stderr)\n",
+ "\n",
+ " # filter dict to remove groups with NaN\n",
+ " filter_groups = {key: value for key, value in groups.items() if not np.isnan(value)}\n",
+ "\n",
+ " return filter_groups"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "786e2cb8-1dc0-46c0-a454-37ac9e603329",
+ "metadata": {
+ "jupyter": {
+ "source_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "def plot(filter_groups: dict, ship_time_threshold: float, rotation_change: int):\n",
+ " \"\"\"\n",
+ " Generates a bar plot showing the expedition duration for each group\n",
+ " relative to the ship time threshold.\n",
+ " \"\"\"\n",
+ " groups_keys = list(filter_groups.keys())\n",
+ " groups_values = list(filter_groups.values())\n",
+ "\n",
+ " # bar colors dependent on whether above or below ship time threshold\n",
+ " bar_colors = [\n",
+ " \"crimson\" if value > ship_time_threshold else \"mediumseagreen\"\n",
+ " for value in groups_values\n",
+ " ]\n",
+ "\n",
+ " # fig\n",
+ " fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(16, 8), dpi=300)\n",
+ "\n",
+ " # bars\n",
+ " ax.bar(\n",
+ " groups_keys,\n",
+ " groups_values,\n",
+ " color=bar_colors,\n",
+ " edgecolor=\"k\",\n",
+ " linewidth=1.5,\n",
+ " zorder=3,\n",
+ " width=0.7,\n",
+ " )\n",
+ "\n",
+ " # labels and title\n",
+ " ax.set_ylabel(\"Days\", fontsize=15)\n",
+ " ax.set_title(\"Expedition Duration\", fontsize=20)\n",
+ "\n",
+ " # customise ticks\n",
+ " ax.set_xticks(ax.get_xticks())\n",
+ " rotation = 45 if len(groups_values) <= rotation_change else 90\n",
+ " ax.set_xticklabels(groups_keys, rotation=rotation, ha=\"center\", fontsize=15)\n",
+ " ax.tick_params(axis=\"y\", labelsize=15)\n",
+ "\n",
+ " # set y-limit based on the maximum valid value\n",
+ " max_duration = np.nanmax(groups_values) if groups_values else 0\n",
+ " ax.set_ylim(0, max_duration + 0.5)\n",
+ "\n",
+ " # grid\n",
+ " ax.set_facecolor(\"gainsboro\")\n",
+ " ax.grid(axis=\"y\", linestyle=\"-\", alpha=1.0, color=\"white\")\n",
+ "\n",
+ " # horizontal line for threshold days\n",
+ " ax.axhline(\n",
+ " y=ship_time_threshold,\n",
+ " color=\"r\",\n",
+ " linestyle=\"--\",\n",
+ " linewidth=2.5,\n",
+ " label=\"Ship-time limit\",\n",
+ " zorder=2,\n",
+ " )\n",
+ "\n",
+ " plt.legend(fontsize=15, loc=\"upper left\")\n",
+ " plt.tight_layout()\n",
+ " plt.show()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "73a78ef2-1b58-48be-802f-0a447c9549a2",
+ "metadata": {
+ "jupyter": {
+ "source_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "def periodic_task(interval_seconds: int):\n",
+ " \"\"\"\n",
+ " Loop that runs the preprocessing and plotting functions periodically.\n",
+ " :param interval_seconds: The number of seconds to wait between runs.\n",
+ " \"\"\"\n",
+ " while True:\n",
+ " try:\n",
+ " clear_output(wait=True)\n",
+ " print(f\"--- Running task at {time.ctime()} ---\")\n",
+ "\n",
+ " data = preprocess(BASE_DIR, SAIL_OUT_TIME, CTD_TIME)\n",
+ "\n",
+ " if data:\n",
+ " plot(data, SHIP_TIME_THRESHOLD, ROTATION_CHANGE)\n",
+ " else:\n",
+ " print(\"No valid data found to plot.\")\n",
+ "\n",
+ " except KeyboardInterrupt:\n",
+ " print(\"\\nPeriodic task interrupted by user.\")\n",
+ " break\n",
+ " except Exception as e:\n",
+ " print(f\"\\nAn error occurred during the periodic run: {e}\")\n",
+ "\n",
+ " time.sleep(interval_seconds)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "939a0a6b-9261-4e34-80f0-168e9bb133dd",
+ "metadata": {
+ "collapsed": true,
+ "jupyter": {
+ "outputs_hidden": true
+ },
+ "scrolled": true
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "--- Running task at Wed Nov 12 21:45:02 2025 ---\n",
+ "No valid data found to plot.\n"
+ ]
+ },
+ {
+ "ename": "KeyboardInterrupt",
+ "evalue": "",
+ "output_type": "error",
+ "traceback": [
+ "\u001b[31m---------------------------------------------------------------------------\u001b[39m",
+ "\u001b[31mKeyboardInterrupt\u001b[39m Traceback (most recent call last)",
+ "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[7]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m \u001b[43mperiodic_task\u001b[49m\u001b[43m(\u001b[49m\u001b[43minterval_seconds\u001b[49m\u001b[43m \u001b[49m\u001b[43m=\u001b[49m\u001b[43m \u001b[49m\u001b[43mREFRESH\u001b[49m\u001b[43m)\u001b[49m\n",
+ "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[6]\u001b[39m\u001b[32m, line 24\u001b[39m, in \u001b[36mperiodic_task\u001b[39m\u001b[34m(interval_seconds)\u001b[39m\n\u001b[32m 21\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mException\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m e:\n\u001b[32m 22\u001b[39m \u001b[38;5;28mprint\u001b[39m(\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33mAn error occurred during the periodic run: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00me\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m)\n\u001b[32m---> \u001b[39m\u001b[32m24\u001b[39m \u001b[43mtime\u001b[49m\u001b[43m.\u001b[49m\u001b[43msleep\u001b[49m\u001b[43m(\u001b[49m\u001b[43minterval_seconds\u001b[49m\u001b[43m)\u001b[49m\n",
+ "\u001b[31mKeyboardInterrupt\u001b[39m: "
+ ]
+ }
+ ],
+ "source": [
+ "periodic_task(interval_seconds=REFRESH)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "be0a7bfd-dc2a-437a-8fa6-ffb78cdd2687",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "virtualship",
+ "language": "python",
+ "name": "virtualship"
+ },
+ "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.12.12"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/user-guide/teacher-content/UU-ocean-of-future/timeseries.py b/docs/user-guide/teacher-content/UU-ocean-of-future/timeseries.py
new file mode 100644
index 00000000..24d97547
--- /dev/null
+++ b/docs/user-guide/teacher-content/UU-ocean-of-future/timeseries.py
@@ -0,0 +1,177 @@
+# %%
+
+"""N.B. Quick, inflexible (under active development) version whilst experimenting best approaches!""" # noqa: D400
+# TODO: WORK IN PROGRESS!
+
+import glob
+import os
+
+import matplotlib.pyplot as plt
+import numpy as np
+import xarray as xr
+
+# TODO: incorporate uncertainty estimates in the plots, box plots per year/expedition
+
+# TODO: build conmplexity of plots, single points -> lines -> uncertainty/boxplots -> colour boxplots by month of the (half) year
+
+# TODO: timeseries - can just do it for surface...
+
+# TODO: 3D plots of CTD Transects!
+
+variables = ["phyc", "temperature", "salinity", "o2", "no3", "po4"]
+
+base_dir = os.getcwd()
+
+dict_vars = {}
+for var in variables:
+ print(f"Processing variable: {var}")
+ filename = "ctd.zarr" if var in ["temperature", "salinity"] else "ctd_bgc.zarr"
+ grp_dirs = sorted(glob.glob(os.path.join(base_dir, "GRP????/results/", filename)))
+
+ var_values = []
+ times = []
+
+ tmp = {}
+ for zarr_path in grp_dirs:
+ ds = xr.open_zarr(zarr_path)
+
+ # extract variable values and time
+ var_values.append(ds[var].values.flatten())
+ times.append(ds["time"].values[0][0])
+
+ # organise to dict
+ tmp["values"], tmp["time"] = var_values, times
+
+ # master dict
+ dict_vars[var] = tmp
+
+# %%
+
+plot_dict = {
+ "phyc": {
+ "label": "Phytoplankton",
+ "units": "mmol m$^{-3}$",
+ "color": "forestgreen",
+ },
+ "temperature": {
+ "label": "Temperature",
+ "units": "°C",
+ "color": "crimson",
+ },
+ "salinity": {
+ "label": "Salinity",
+ "units": "PSU",
+ "color": "lightseagreen",
+ },
+ "o2": {
+ "label": "Oxygen",
+ "units": "mmol m$^{-3}$",
+ "color": "dodgerblue",
+ },
+ "no3": {
+ "label": "Nitrate",
+ "units": "mmol m$^{-3}$",
+ "color": "darkorchid",
+ },
+ "po4": {
+ "label": "Phosphate",
+ "units": "mmol m$^{-3}$",
+ "color": "coral",
+ },
+}
+
+# %%
+
+combined_vars = [v for v in variables if v not in ["no3", "po4"]] + ["no3_po4"]
+fig, axs = plt.subplots(
+ len(combined_vars), 1, figsize=(10, 10), dpi=96, sharex=True, sharey=False
+)
+
+for i, ax in enumerate(axs):
+ if i < len(combined_vars) - 1:
+ var = combined_vars[i]
+ color = plot_dict[var]["color"]
+
+ ax.scatter(
+ dict_vars[var]["time"],
+ [np.nanmean(values) for values in dict_vars[var]["values"]],
+ color=color,
+ zorder=3,
+ s=50,
+ )
+
+ ax.plot(
+ dict_vars[var]["time"],
+ [np.nanmean(values) for values in dict_vars[var]["values"]],
+ linestyle="dotted",
+ alpha=1.0,
+ color=color,
+ lw=2.25,
+ )
+
+ ax.set_title(f"{plot_dict[var]['label']}", fontsize=12)
+ ax.set_ylabel(plot_dict[var]["units"], fontsize=11)
+ else:
+ color_no3 = plot_dict["no3"]["color"]
+ color_po4 = plot_dict["po4"]["color"]
+
+ ax.scatter(
+ dict_vars["no3"]["time"],
+ [np.nanmean(values) for values in dict_vars["no3"]["values"]],
+ color=color_no3,
+ zorder=3,
+ s=50,
+ label=plot_dict["no3"]["label"],
+ )
+ ax.plot(
+ dict_vars["no3"]["time"],
+ [np.nanmean(values) for values in dict_vars["no3"]["values"]],
+ linestyle="dotted",
+ alpha=1.0,
+ color=color_no3,
+ lw=2.25,
+ )
+ ax.set_ylabel(plot_dict["no3"]["units"], fontsize=11, color=color_no3)
+ ax.tick_params(axis="y", labelcolor=color_no3)
+
+ ax2 = ax.twinx()
+ ax2.scatter(
+ dict_vars["po4"]["time"],
+ [np.nanmean(values) for values in dict_vars["po4"]["values"]],
+ color=color_po4,
+ zorder=3,
+ s=50,
+ label=plot_dict["po4"]["label"],
+ )
+ ax2.plot(
+ dict_vars["po4"]["time"],
+ [np.nanmean(values) for values in dict_vars["po4"]["values"]],
+ linestyle="dotted",
+ alpha=1.0,
+ color=color_po4,
+ lw=2.25,
+ )
+ ax2.set_ylabel(plot_dict["po4"]["units"], fontsize=11, color=color_po4)
+ ax2.tick_params(axis="y", labelcolor=color_po4)
+
+ ax.set_title("Nutrients", fontsize=12)
+
+ handles, labels = [], []
+ for a in [ax, ax2]:
+ h, label = a.get_legend_handles_labels()
+ handles += h
+ labels += label
+ ax.legend(handles, labels, loc="upper right")
+
+ ax.set_xlim(np.datetime64("1993-01-01"), np.datetime64("2025-12-31"))
+
+ if i == len(axs) - 1: # bottom panel only for single column of subplots
+ ax.set_xlabel("Time")
+
+ ax.set_facecolor("gainsboro")
+ ax.grid(color="white", linewidth=1.0)
+
+
+plt.tight_layout()
+plt.show()
+# %%
diff --git a/docs/user-guide/teacher-content/index.md b/docs/user-guide/teacher-content/index.md
index d3b6f5c8..864392a5 100644
--- a/docs/user-guide/teacher-content/index.md
+++ b/docs/user-guide/teacher-content/index.md
@@ -12,7 +12,7 @@ The 360 videos are available on our YouTube channel [**_@VirtualShip Classroom_*
- Smartphones/Tablets – Move your device or swipe the screen to explore.
- PC/Mac Browsers – Click and drag with your mouse to look around.
-The VSC design focuses on creating didactically sound, authentic learning experiences grounded in established learning theories in science education, such as constructivism [(Piaget 1954)](https://doi.org/10.4324/9781315009650) and constructionism [(Papert 1980)](https://worrydream.com/refs/Papert_1980_-_Mindstorms,_1st_ed.pdf). By integrating realistic tasks and a gamified narrative approach within Jupyter notebooks, students learn within a digital replica of the real world, constructing knowledge through ‘learning by doing’ and ‘trial and error’ as they explore oceanography concepts, research methods, and analysis tools.
+The VSC design focuses on creating didactically sound, authentic learning experiences grounded in established learning theories in science education, such as constructivism [(Piaget 1954)](https://www.taylorfrancis.com/books/mono/10.4324/9781315009650/construction-reality-child-jean-piaget) and constructionism [(Papert 1980)](https://worrydream.com/refs/Papert_1980_-_Mindstorms,_1st_ed.pdf). By integrating realistic tasks and a gamified narrative approach within Jupyter notebooks, students learn within a digital replica of the real world, constructing knowledge through ‘learning by doing’ and ‘trial and error’ as they explore oceanography concepts, research methods, and analysis tools.
We evaluated in several (under)graduate courses and find that the VirtualShip Classroom is highly engaging, and students report on enhanced confidence and knowledge [(Daniels et al. 2025)](https://current-journal.com/articles/10.5334/cjme.121).
@@ -24,3 +24,12 @@ caption: Teaching material
ILOs.ipynb
```
+
+## UU Ocean of the Future
+
+```{nbgallery}
+---
+maxdepth: 1
+---
+UU-ocean-of-future/Tutorial1.ipynb
+```
diff --git a/docs/user-guide/tutorials/index.md b/docs/user-guide/tutorials/index.md
index 9b181aff..592aba69 100644
--- a/docs/user-guide/tutorials/index.md
+++ b/docs/user-guide/tutorials/index.md
@@ -6,6 +6,7 @@ maxdepth: 1
caption: Tutorials
---
surf_research_cloud_setup.ipynb
+surf_collaborative_setup.ipynb
ADCP_data_tutorial.ipynb
CTD_data_tutorial.ipynb
Drifter_data_tutorial.ipynb
diff --git a/docs/user-guide/tutorials/surf_collaborative_setup.ipynb b/docs/user-guide/tutorials/surf_collaborative_setup.ipynb
new file mode 100644
index 00000000..94bb1e43
--- /dev/null
+++ b/docs/user-guide/tutorials/surf_collaborative_setup.ipynb
@@ -0,0 +1,120 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "98770716",
+ "metadata": {},
+ "source": [
+ "# SURF Resarch Cloud: Collaborative Workspace Setup Guide\n",
+ "\n",
+ "```\n",
+ "Note: This guide is specific to students who are enrolled at Utrecht University.\n",
+ "```\n",
+ "\n",
+ "In the class, we will use VirtualShip in the cloud (in this case, SURF Research Cloud - called SURF RC from here-on). This has several advantages:\n",
+ "\n",
+ "- You aren't limited to the power of your laptop.\n",
+ "- Everyone can work in the same collaborative environment.\n",
+ "- The environment is pre-configured with all necessary software and dependencies.\n",
+ "\n",
+ "\n",
+ "## 1. Accepting SURF RC invite\n",
+ "\n",
+ "In your student email you'll have an invite from SURF Research Access Management (SRAM) to join a project on SURF RC. Accept this invite.\n",
+ "\n",
+ "## 2. Open the workspace\n",
+ "\n",
+ "Navigate to the [SURF Research Cloud Dashboard](https://portal.live.surfresearchcloud.nl/), and click \"access\" on the shared workspace.\n",
+ "\n",
+ "\n",
+ "## 3. Open Terminal session\n",
+ "\n",
+ "In the Jupyter launcher, you should see an option to open Terminal session. Click this to open Terminal.\n",
+ "\n",
+ "\n",
+ "## 4. Launch VirtualShip environment\n",
+ "\n",
+ "❗️ Before proceeding any further, you should type the following command in the Terminal and then hit Enter: `bash` ❗️\n",
+ "\n",
+ "\n",
+ "This will ensure that you are in a bash shell for the rest of the setup.\n",
+ "\n",
+ "You will see that the Terminal prompt has changed to something like:\n",
+ "\n",
+ "```bash\n",
+ "(base) metheuser@mywsp:\n",
+ "```\n",
+ "\n",
+ "This is conda telling you that you are currently in the \"base\" environment.\n",
+ "\n",
+ "From here, you already have another environment set up for you. Running `conda env list` in the Terminal, you should see:\n",
+ "\n",
+ "```bash\n",
+ "conda env list\n",
+ "\n",
+ "# conda environments:\n",
+ "#\n",
+ "base * /etc/miniconda3\n",
+ "virtualship /etc/miniconda3/envs/virtualship\n",
+ "```\n",
+ "\n",
+ "Next, to launch the VirtualShip environment, type the following command in the Terminal and hit Enter: `conda activate virtualship`\n",
+ "\n",
+ "This will activate the VirtualShip conda environment, which has all the necessary dependencies installed. You can confirm that you are in the correct environment by checking that your Terminal prompt now looks something like:\n",
+ "\n",
+ "```bash\n",
+ "(virtualship) metheuser@mywsp:\n",
+ "```\n",
+ "\n",
+ "With the `virtualship` environment, you now have access to the `virtualship` command in your Terminal.\n",
+ "\n",
+ "This can be confirmed by typing the following command in the Terminal and hitting Enter: `virtualship --help`.\n",
+ "\n",
+ "You should see something like:\n",
+ "\n",
+ "```\n",
+ "virtualship --help\n",
+ "\n",
+ "Usage: virtualship [OPTIONS] COMMAND [ARGS]...\n",
+ "\n",
+ "Options:\n",
+ " --version Show the version and exit.\n",
+ " --help Show this message and exit.\n",
+ "\n",
+ "Commands:\n",
+ " fetch Download input data for an expedition.\n",
+ " init Initialize a directory for a new expedition, with an example...\n",
+ " plan Launch UI to help build schedule and ship config files.\n",
+ " run Run the expedition.\n",
+ " ```\n",
+ "\n",
+ "
\n",
+ "If you close the Terminal window at any point, you may need to re-open it and re-run the `bash` and `conda activate virtualship` commands to ensure you are in a bash shell and the correct conda environment. \n",
+ "
\n",
+ "\n",
+ "## 5. Navigate to the shared storage folder\n",
+ "\n",
+ "We will be working from a shared storage folder in the workspace. To navigate to this folder, type the following command in the Terminal and hit Enter: `cd data/virtualship_storage/`\n",
+ "\n",
+ "Depending on the specific set-up for the course you are taking, if you now enter `ls` to list the contents of the directory, you may see various group folders, for example: `GROUP1`, `GROUP2`, ... and so on.\n",
+ "\n",
+ "If you have been pre-assigned into groups, this is where will be working from for the rest of the course, with the group name for each folder corresponding to your assigned group.\n",
+ "\n",
+ "You are ready to continue with the VirtualShip analysis workflow as described in the course materials and/or VirtualShip [quickstart guide](https://virtualship.readthedocs.io/en/latest/user-guide/quickstart.html)."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3926833e",
+ "metadata": {},
+ "source": []
+ }
+ ],
+ "metadata": {
+ "language_info": {
+ "name": "python"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}