{ "cells": [ { "cell_type": "markdown", "id": "82fea25a", "metadata": {}, "source": [ "# Animating footprint time series\n", "\n", "`fluxfootprints.footprint_animation` turns the time-resolved footprint\n", "rasters produced by any model in this package into a **video or animated GIF**,\n", "one frame per time step, with the timestamp drawn on each frame.\n", "\n", "This notebook covers:\n", "\n", "1. building a footprint time series from half-hourly tower data,\n", "2. animating it at **hourly**, **daily**, and **monthly** time steps,\n", "3. drawing the footprint over a **georeferenced basemap**,\n", "4. exporting **MP4** as well as GIF, and\n", "5. the styling options (color scaling, masking, source-area contours).\n", "\n", "## Requirements\n", "\n", "GIF output works with a bare install. Basemaps and MP4/WebM need the optional\n", "extra, which pulls in [contextily](https://contextily.readthedocs.io) (map\n", "tiles) and [imageio-ffmpeg](https://github.com/imageio/imageio-ffmpeg) (a\n", "bundled `ffmpeg` binary):\n", "\n", "```bash\n", "pip install \"fluxfootprints[animation]\"\n", "```\n", "\n", "An `ffmpeg` already on your `PATH` is used in preference to the bundled copy." ] }, { "cell_type": "markdown", "id": "7fa96fcb", "metadata": {}, "source": [ "## Setup" ] }, { "cell_type": "code", "execution_count": 1, "id": "13117d48", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T21:23:28.922589Z", "iopub.status.busy": "2026-08-22T21:23:28.922368Z", "iopub.status.idle": "2026-08-22T21:23:30.581108Z", "shell.execute_reply": "2026-08-22T21:23:30.580070Z" } }, "outputs": [], "source": [ "import base64\n", "import os\n", "import sys\n", "from pathlib import Path\n", "\n", "import numpy as np\n", "import pandas as pd\n", "from IPython.display import HTML\n", "\n", "sys.path.insert(0, os.path.abspath(\"../../src\")) # adjust path as needed\n", "\n", "from fluxfootprints import (\n", " FootprintAnimator,\n", " animate_footprint,\n", " build_climatology,\n", " summarize_periods,\n", ")\n", "from fluxfootprints.footprint_animation import ensure_ffmpeg\n", "\n", "OUT = Path(\"output\")\n", "OUT.mkdir(exist_ok=True)\n", "\n", "\n", "def show(path, width=460):\n", " \"\"\"Embed an animation inline, base64-encoded.\n", "\n", " ``IPython.display.Image`` renders GIFs in Jupyter but the ``image/gif``\n", " output is dropped when Sphinx builds these pages, so the animations below\n", " are embedded as HTML instead -- that renders everywhere.\n", " \"\"\"\n", " path = Path(path)\n", " data = base64.b64encode(path.read_bytes()).decode(\"ascii\")\n", " if path.suffix == \".mp4\":\n", " return HTML(\n", " f''\n", " )\n", " mime = \"image/gif\" if path.suffix == \".gif\" else \"image/png\"\n", " return HTML(f'')\n", "\n", "# Example site location (used for georeferencing further down):\n", "# irrigated pasture in Cache Valley, Utah.\n", "STATION_LAT, STATION_LON = 41.66, -111.89" ] }, { "cell_type": "markdown", "id": "91575017", "metadata": {}, "source": [ "## 1. Half-hourly tower data\n", "\n", "The animator needs a footprint time series, which in turn needs half-hourly\n", "micrometeorology. For a real site you would read it with\n", "`load_amf_df()`; here we synthesise six months of\n", "plausible-looking data so the notebook runs anywhere.\n", "\n", "The wind direction drifts on a multi-day cycle and the turbulence follows a\n", "diurnal cycle, so the footprint both rotates and grows/shrinks over time --\n", "which is what makes the animation worth watching." ] }, { "cell_type": "code", "execution_count": 2, "id": "11310c2f", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T21:23:30.584111Z", "iopub.status.busy": "2026-08-22T21:23:30.583446Z", "iopub.status.idle": "2026-08-22T21:23:30.605577Z", "shell.execute_reply": "2026-08-22T21:23:30.604802Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
wind_dirumeanustarolsigmavzmz0hLE
datetime_start
2023-04-01 00:00:00149.7206781.9029120.105943120.00.3254402.00.05500.032.588329
2023-04-01 00:30:00133.5842642.1275310.097906120.00.3510482.00.05500.010.238564
2023-04-01 01:00:00155.0694872.4920850.169734120.00.2556802.00.05500.027.798096
2023-04-01 01:30:00157.3508502.5059370.088282120.00.3099272.00.05500.018.950612
2023-04-01 02:00:00122.6516511.5910550.103610120.00.2929632.00.05500.021.253645
\n", "
" ], "text/plain": [ " wind_dir umean ustar ol sigmav zm \\\n", "datetime_start \n", "2023-04-01 00:00:00 149.720678 1.902912 0.105943 120.0 0.325440 2.0 \n", "2023-04-01 00:30:00 133.584264 2.127531 0.097906 120.0 0.351048 2.0 \n", "2023-04-01 01:00:00 155.069487 2.492085 0.169734 120.0 0.255680 2.0 \n", "2023-04-01 01:30:00 157.350850 2.505937 0.088282 120.0 0.309927 2.0 \n", "2023-04-01 02:00:00 122.651651 1.591055 0.103610 120.0 0.292963 2.0 \n", "\n", " z0 h LE \n", "datetime_start \n", "2023-04-01 00:00:00 0.05 500.0 32.588329 \n", "2023-04-01 00:30:00 0.05 500.0 10.238564 \n", "2023-04-01 01:00:00 0.05 500.0 27.798096 \n", "2023-04-01 01:30:00 0.05 500.0 18.950612 \n", "2023-04-01 02:00:00 0.05 500.0 21.253645 " ] }, "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "rng = np.random.default_rng(42)\n", "\n", "times = pd.date_range(\"2023-04-01\", \"2023-09-30 23:30\", freq=\"30min\")\n", "n = len(times)\n", "\n", "hour = (times.hour + times.minute / 60).to_numpy()\n", "doy = times.dayofyear.to_numpy()\n", "\n", "# Daytime fraction: 0 at night, 1 at solar noon.\n", "day = np.clip(np.sin(np.pi * (hour - 6) / 12), 0, None)\n", "\n", "met = pd.DataFrame(\n", " {\n", " # prevailing direction swings back and forth over ~25-day cycles\n", " \"wind_dir\": (200 + 70 * np.sin(2 * np.pi * doy / 25) + rng.normal(0, 12, n)) % 360,\n", " \"umean\": 2.0 + 2.5 * day + rng.normal(0, 0.3, n),\n", " \"ustar\": (0.12 + 0.35 * day + rng.normal(0, 0.04, n)).clip(0.05, None),\n", " # unstable by day, stable at night\n", " \"ol\": np.where(day > 0.1, -50.0 / (day + 0.05), 120.0),\n", " \"sigmav\": (0.30 + 0.60 * day + rng.normal(0, 0.05, n)).clip(0.10, None),\n", " \"zm\": 2.0, # measurement height (m)\n", " \"z0\": 0.05, # roughness length (m)\n", " \"h\": 500 + 1200 * day, # boundary-layer height (m)\n", " \"LE\": (20 + 220 * day + rng.normal(0, 10, n)).clip(0, None), # W m-2\n", " },\n", " index=times,\n", ")\n", "met.index.name = \"datetime_start\"\n", "met.head()" ] }, { "cell_type": "markdown", "id": "36d89985", "metadata": {}, "source": [ "## 2. Build the footprint time series\n", "\n", "Any model registered with `build_climatology()` works. The\n", "animator reads the per-timestep rasters through `get_footprint_timeseries()`,\n", "so it is model-agnostic -- swap `model_type` for `\"kormann-meixner\"`, `\"ls\"`,\n", "or `\"wang\"` and everything below is unchanged." ] }, { "cell_type": "code", "execution_count": 3, "id": "525ba357", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T21:23:30.607307Z", "iopub.status.busy": "2026-08-22T21:23:30.607095Z", "iopub.status.idle": "2026-08-22T21:23:36.224842Z", "shell.execute_reply": "2026-08-22T21:23:36.224105Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "footprint time series: {'time': 7426, 'x': 55, 'y': 55}\n", "time range: 2023-04-01T00:00 -> 2023-09-30T23:30\n" ] } ], "source": [ "model = build_climatology(\n", " met,\n", " model_type=\"ffp\",\n", " ustar=\"ustar\",\n", " ol=\"ol\",\n", " umean=\"umean\",\n", " sigmav=\"sigmav\",\n", " wind_dir=\"wind_dir\",\n", " zm=\"zm\",\n", " z0=\"z0\",\n", " h=\"h\",\n", " dx=15.0,\n", " dy=15.0,\n", " domain=(-400.0, 400.0, -400.0, 400.0),\n", " verbosity=0,\n", ")\n", "\n", "f_2d = model.get_footprint_timeseries()\n", "print(\"footprint time series:\", dict(f_2d.sizes))\n", "print(\"time range:\", str(f_2d.time.values[0])[:16], \"->\", str(f_2d.time.values[-1])[:16])" ] }, { "cell_type": "markdown", "id": "c454ed96", "metadata": {}, "source": [ "## 3. Hourly animation\n", "\n", "`freq=\"hourly\"` averages the half-hourly rasters into hourly frames. The other\n", "accepted values are `\"daily\"`, `\"monthly\"`, `\"native\"`/`None` (no resampling),\n", "and any pandas offset alias such as `\"3h\"`.\n", "\n", "A few options that make footprints legible:\n", "\n", "- `mask_quantile` makes the near-zero background transparent, so only the\n", " meaningful part of the footprint is painted;\n", "- `contour_fractions=(0.5, 0.8)` outlines the 50 % and 80 % source areas;\n", "- `time_slice` trims the series -- here to a single day, to keep the GIF small.\n", "\n", "The timestamp format defaults to the time step (`%Y-%m-%d %H:%M` for hourly)\n", "and can be overridden with `timestamp_format`." ] }, { "cell_type": "code", "execution_count": 4, "id": "b588d1b3", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T21:23:36.227166Z", "iopub.status.busy": "2026-08-22T21:23:36.226969Z", "iopub.status.idle": "2026-08-22T21:23:39.108505Z", "shell.execute_reply": "2026-08-22T21:23:39.107649Z" } }, "outputs": [ { "data": { "text/html": [ "" ], "text/plain": [ "" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "hourly_gif = animate_footprint(\n", " model,\n", " OUT / \"ffp_hourly.gif\",\n", " freq=\"hourly\",\n", " time_slice=(\"2023-06-05\", \"2023-06-05 23:59\"),\n", " mask_quantile=0.55,\n", " contour_fractions=(0.5, 0.8),\n", " title=\"Hourly footprint\",\n", " fps=5,\n", " dpi=72,\n", " figsize=(5.0, 4.0),\n", ")\n", "\n", "show(hourly_gif)" ] }, { "cell_type": "markdown", "id": "387f855e", "metadata": {}, "source": [ "## 4. Daily and monthly steps\n", "\n", "Two routes give you daily and monthly frames:\n", "\n", "- pass `freq=\"daily\"` / `freq=\"monthly\"` and let the animator resample, or\n", "- animate the summaries from `summarize_periods()` with\n", " `FootprintAnimator.from_summary()`, which also gives you\n", " the **ET-weighted** layers.\n", "\n", "`from_summary` takes the timestamp format from the layer, so monthly frames are\n", "labelled `June 2023` rather than `2023-06-01`." ] }, { "cell_type": "code", "execution_count": 5, "id": "50ed2112", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T21:23:39.110235Z", "iopub.status.busy": "2026-08-22T21:23:39.110013Z", "iopub.status.idle": "2026-08-22T21:23:44.045705Z", "shell.execute_reply": "2026-08-22T21:23:44.045079Z" } }, "outputs": [ { "data": { "text/html": [ "" ], "text/plain": [ "" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "summaries = summarize_periods(model, met, et_source=\"LE\", is_le=True)\n", "\n", "daily_gif = FootprintAnimator.from_summary(\n", " summaries,\n", " layer=\"daily_mean\",\n", " time_slice=(\"2023-06-01\", \"2023-06-30\"),\n", " mask_quantile=0.55,\n", " contour_fractions=(0.8,),\n", " title=\"Daily mean footprint, June 2023\",\n", " fps=8,\n", " dpi=72,\n", " figsize=(5.0, 4.0),\n", ").save(OUT / \"ffp_daily.gif\")\n", "\n", "show(daily_gif)" ] }, { "cell_type": "markdown", "id": "2c9afc1b", "metadata": {}, "source": [ "Monthly frames from the same summaries. `layer=\"monthly_etw\"` would use the\n", "evapotranspiration-weighted footprint instead of the plain mean -- the layer\n", "that answers *\"where did the water actually come from this month?\"*" ] }, { "cell_type": "code", "execution_count": 6, "id": "4c3d1ca2", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T21:23:44.048159Z", "iopub.status.busy": "2026-08-22T21:23:44.047886Z", "iopub.status.idle": "2026-08-22T21:23:44.811526Z", "shell.execute_reply": "2026-08-22T21:23:44.810444Z" } }, "outputs": [ { "data": { "text/html": [ "" ], "text/plain": [ "" ] }, "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ "monthly_gif = FootprintAnimator.from_summary(\n", " summaries,\n", " layer=\"monthly_mean\",\n", " mask_quantile=0.5,\n", " contour_fractions=(0.5, 0.8),\n", " title=\"Monthly mean footprint\",\n", " fps=1,\n", " dpi=80,\n", " figsize=(5.5, 4.5),\n", ").save(OUT / \"ffp_monthly.gif\")\n", "\n", "show(monthly_gif)" ] }, { "cell_type": "markdown", "id": "acdf2523", "metadata": {}, "source": [ "## 5. Georeferenced basemap\n", "\n", "Give the animator `station_lat` / `station_lon` and the grid stops being\n", "\"metres from the tower\": it is placed in the local UTM zone (or whatever\n", "projected CRS you pass as `crs_out`), using the same convention as\n", "`export_rasters_geotiff()`.\n", "\n", "With `basemap=True`, contextily fetches map tiles for that window once and\n", "draws them under every frame. Because the footprint is drawn semi-transparently\n", "(`alpha`, default 0.75 with a basemap) and the background is masked out, you can\n", "see which fields the flux is coming from.\n", "\n", "> Tile fetching needs network access. If it fails, the animation is still\n", "> written -- you just get a warning and no basemap." ] }, { "cell_type": "code", "execution_count": 7, "id": "4a125de1", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T21:23:44.813499Z", "iopub.status.busy": "2026-08-22T21:23:44.813205Z", "iopub.status.idle": "2026-08-22T21:23:49.925841Z", "shell.execute_reply": "2026-08-22T21:23:49.925228Z" } }, "outputs": [ { "data": { "text/html": [ "" ], "text/plain": [ "" ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "import contextily as cx\n", "\n", "basemap_gif = animate_footprint(\n", " summaries.f_daily_mean,\n", " OUT / \"ffp_daily_basemap.gif\",\n", " time_slice=(\"2023-06-01\", \"2023-06-10\"),\n", " station_lat=STATION_LAT,\n", " station_lon=STATION_LON,\n", " basemap=True,\n", " basemap_source=cx.providers.Esri.WorldImagery,\n", " norm=\"log\",\n", " cmap=\"inferno\",\n", " mask_quantile=0.6,\n", " alpha=0.7,\n", " contour_fractions=(0.8,),\n", " title=\"Daily footprint over aerial imagery\",\n", " fps=3,\n", " dpi=72,\n", " figsize=(5.0, 4.2),\n", ")\n", "\n", "show(basemap_gif)" ] }, { "cell_type": "markdown", "id": "504fb8b3", "metadata": {}, "source": [ "The example above asks for Esri aerial imagery; leaving `basemap_source` out\n", "uses contextily's default (OpenStreetMap), and any other\n", "[contextily provider](https://contextily.readthedocs.io/en/latest/providers_deepdive.html)\n", "works too. `basemap_zoom` pins the tile zoom level when the automatic choice is\n", "too coarse or too slow:\n", "\n", "```python\n", "animate_footprint(\n", " summaries.f_daily_mean,\n", " \"out.gif\",\n", " station_lat=STATION_LAT,\n", " station_lon=STATION_LON,\n", " basemap=True,\n", " basemap_source=cx.providers.OpenStreetMap.Mapnik,\n", " basemap_zoom=16,\n", ")\n", "```\n", "\n", "Note the `log` color scale in the example: footprint densities span many orders\n", "of magnitude, so `norm=\"log\"` with `log_decades` (4 by default) shows the tail\n", "of the footprint that a linear scale flattens to a single color." ] }, { "cell_type": "markdown", "id": "b76c3448", "metadata": {}, "source": [ "## 6. MP4 output\n", "\n", "The file suffix picks the writer: `.gif` (Pillow), `.mp4` / `.mov` / `.m4v` /\n", "`.webm` (ffmpeg), and `.html` (a self-contained player).\n", "`ensure_ffmpeg()` reports which binary\n", "will be used -- an `ffmpeg` on `PATH` if there is one, otherwise the copy\n", "bundled with `imageio-ffmpeg`." ] }, { "cell_type": "code", "execution_count": 8, "id": "42503aa2", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T21:23:49.930486Z", "iopub.status.busy": "2026-08-22T21:23:49.930329Z", "iopub.status.idle": "2026-08-22T21:23:57.617471Z", "shell.execute_reply": "2026-08-22T21:23:57.616402Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "ffmpeg: ffmpeg-win-x86_64-v7.1.exe\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "output\\ffp_hourly.mp4 (59 KB)\n" ] }, { "data": { "text/html": [ "" ], "text/plain": [ "" ] }, "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ "ffmpeg_exe = ensure_ffmpeg()\n", "print(\"ffmpeg:\", Path(ffmpeg_exe).name if ffmpeg_exe else \"not found\")\n", "\n", "mp4 = animate_footprint(\n", " model,\n", " OUT / \"ffp_hourly.mp4\",\n", " freq=\"hourly\",\n", " time_slice=(\"2023-06-05\", \"2023-06-07\"),\n", " mask_quantile=0.55,\n", " title=\"Hourly footprint\",\n", " fps=6,\n", " dpi=80,\n", " figsize=(5.5, 4.5),\n", ")\n", "print(f\"{mp4} ({mp4.stat().st_size / 1024:.0f} KB)\")\n", "\n", "show(mp4)" ] }, { "cell_type": "markdown", "id": "037a2b60", "metadata": {}, "source": [ "## 7. Styling, frames, and embedding\n", "\n", "`color_scale` decides whether frames are comparable to each other:\n", "\n", "- `\"global\"` (default) fixes the color limits across the whole animation, so a\n", " fading footprint really looks like it is fading;\n", "- `\"per_frame\"` rescales every frame to its own maximum, which maximises\n", " contrast but hides changes in magnitude.\n", "\n", "`annotation_fn` adds a second line of text under the timestamp, and\n", "`frames_dir` writes every frame out as a numbered PNG next to the animation --\n", "handy for pulling a single date into a paper figure." ] }, { "cell_type": "code", "execution_count": 9, "id": "e12bd750", "metadata": { "execution": { "iopub.execute_input": "2026-08-22T21:23:57.620995Z", "iopub.status.busy": "2026-08-22T21:23:57.620585Z", "iopub.status.idle": "2026-08-22T21:23:59.869468Z", "shell.execute_reply": "2026-08-22T21:23:59.868221Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "6 frames written, e.g. frame_0_20230401T0000.png\n" ] }, { "data": { "text/html": [ "" ], "text/plain": [ "" ] }, "execution_count": 9, "metadata": {}, "output_type": "execute_result" } ], "source": [ "anim = FootprintAnimator(\n", " summaries.f_monthly_mean,\n", " station_lat=STATION_LAT,\n", " station_lon=STATION_LON,\n", " color_scale=\"per_frame\",\n", " norm=\"log\",\n", " mask_quantile=0.5,\n", " contour_fractions=(0.5, 0.8),\n", " annotation_fn=lambda ts, arr: f\"peak {np.nanmax(arr):.1e} m$^{{-2}}$\",\n", " timestamp_loc=\"lower right\",\n", " title=\"Monthly footprint, per-frame scaling\",\n", " fps=1,\n", " dpi=80,\n", " figsize=(5.5, 4.5),\n", ")\n", "anim.save(OUT / \"ffp_monthly_styled.gif\", frames_dir=OUT / \"frames\")\n", "\n", "frames = sorted((OUT / \"frames\").glob(\"*.png\"))\n", "print(f\"{len(frames)} frames written, e.g. {frames[0].name}\")\n", "\n", "show(frames[2])" ] }, { "cell_type": "markdown", "id": "5e8f7d13", "metadata": {}, "source": [ "To embed an animation in a notebook without writing a file at all, use\n", "`build()` and matplotlib's own HTML player:\n", "\n", "```python\n", "fig, animation = anim.build()\n", "HTML(animation.to_jshtml())\n", "```\n", "\n", "## Parameter cheat-sheet\n", "\n", "| Parameter | What it does |\n", "| --- | --- |\n", "| `freq` | `\"hourly\"`, `\"daily\"`, `\"monthly\"`, `\"native\"`, or a pandas alias |\n", "| `reducer` | how frames are aggregated when resampling (`\"mean\"`, `\"sum\"`, ...) |\n", "| `normalize_each_frame` | rescale each frame to sum to 1 (compare shape, not magnitude) |\n", "| `time_slice` | `(start, stop)` subset of the time axis |\n", "| `station_lat` / `station_lon` | georeference the grid; required for basemaps |\n", "| `crs_out` | target projected CRS (`\"auto\"` = local UTM zone) |\n", "| `basemap`, `basemap_source`, `basemap_zoom` | contextily tile layer |\n", "| `norm`, `cmap`, `vmin`, `vmax`, `percentiles` | color scaling |\n", "| `color_scale` | `\"global\"` (comparable frames) or `\"per_frame\"` (max contrast) |\n", "| `mask_below`, `mask_quantile`, `alpha` | hide/soften the near-zero background |\n", "| `contour_fractions` | source-area contours drawn per frame |\n", "| `title`, `timestamp_format`, `timestamp_loc`, `annotation_fn` | text |\n", "| `fps`, `dpi`, `figsize` | output size and speed |\n", "\n", "See the [API reference](../fluxfootprints.rst) for the full signature." ] } ], "metadata": { "kernelspec": { "display_name": "fluxfootprints (3.14.5)", "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.14.5" } }, "nbformat": 4, "nbformat_minor": 5 }