Source code: Glacier velocity in Python with NASA ITS_LIVE: Jakobshavn Isbræ, 1985–2025
Runnable Python scripts, requirements and console output for the tutorial “Glacier velocity in Python with NASA ITS_LIVE: Jakobshavn Isbræ, 1985–2025”.
Last updated
These are the exact scripts used to produce every figure and number in Glacier velocity in Python with NASA ITS_LIVE: Jakobshavn Isbræ, 1985–2025. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- glacier_velocity_its_live.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython glacier_velocity_its_live.pyglacier_velocity_its_live.py
"""Glacier velocity of Jakobshavn Isbrae (Sermeq Kujalleq), Greenland, from NASA ITS_LIVE in Python.
Finds the ITS_LIVE datacube that covers a point from the public catalogue, opens the image-pairvelocity datacube and the annual composites as Zarr straight from AWS S3 (no login), maps thevelocity field, traces a flowline by integrating the velocity vectors, extracts speed profiles alongit for 1985-2025, filters the image-pair time series at points on the flowline by error, timeseparation and a rolling-median test, and computes strain rates and flow directions.Run with: python glacier_velocity_its_live.py"""import platformfrom importlib.metadata import versionfrom pathlib import Path
import geopandas as gpdimport matplotlib
matplotlib.use("Agg")import matplotlib.dates as mdatesimport matplotlib.pyplot as pltimport numpy as npimport pandas as pdimport requestsimport xarray as xrimport zarrfrom matplotlib.colors import LogNorm, TwoSlopeNormfrom pyproj import Transformerfrom scipy import ndimagefrom scipy.interpolate import RegularGridInterpolatorfrom shapely.geometry import Point
HERE = Path(__file__).parentOUT, DATA = HERE / "output", HERE / "data"for d in (OUT, DATA): d.mkdir(exist_ok=True)plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})pd.set_option("display.width", 140)
print("=== versions ===")print(f"python {platform.python_version()} | xarray {xr.__version__} | zarr {zarr.__version__} | " f"numpy {np.__version__} | pandas {pd.__version__} | scipy {version('scipy')} | matplotlib {matplotlib.__version__}")
def section(title: str) -> None: print(f"\n=== {title} ===")
def km_axes(ax) -> None: ax.xaxis.set_major_formatter(lambda val, _: f"{val / 1000:.0f}") ax.yaxis.set_major_formatter(lambda val, _: f"{val / 1000:.0f}")
# --- Step 1: find the datacube that covers the glacier ---------------------------------------# ITS_LIVE splits the world into 100 km x 100 km Zarr datacubes in local polar stereographic or UTM grids.# The catalogue is a GeoJSON file listing every cube's footprint (in lon/lat) and its URL.LON, LAT = -49.14, 69.10 # a point on the main trunk of Jakobshavn Isbrae, ~25 km above the frontCATALOG = "https://its-live-data.s3.amazonaws.com/datacubes/catalog_v02.json"cat_path = DATA / "catalog_v02.json"if not cat_path.exists(): cat_path.write_bytes(requests.get(CATALOG, timeout=300).content)catalog = gpd.read_file(cat_path)hit = catalog[catalog.contains(Point(LON, LAT))].iloc[0]section("datacube lookup")print(f"catalogue: {len(catalog)} datacubes")print(f"cube: {hit.zarr_url}")print(f"composite: {hit.composite_zarr_url}")print(f"EPSG:{hit.epsg}, {hit.granule_count} image pairs")
cube = xr.open_dataset(hit.zarr_url, engine="zarr", chunks={}) # lazy: nothing downloaded yetcomp = xr.open_dataset(hit.composite_zarr_url, engine="zarr", chunks={})print(f"datacube: {cube.sizes['mid_date']} image pairs on a {cube.sizes['y']} x {cube.sizes['x']} grid " f"of {float(cube.x[1] - cube.x[0]):.0f} m pixels, chunks {cube.v.encoding['chunks']}")print(f"mid_date range: {pd.Timestamp(cube.mid_date.values.min()):%Y-%m-%d} to " f"{pd.Timestamp(cube.mid_date.values.max()):%Y-%m-%d}")print(f"composites: {comp.sizes['time']} annual mosaics, {pd.Timestamp(comp.time.values[0]).year}-" f"{pd.Timestamp(comp.time.values[-1]).year}")
# --- Step 2: velocity maps from the annual composites ----------------------------------------section("annual composites")years = pd.DatetimeIndex(comp.time.values).yearv0 = comp.v0.load() # 2014-2024 climatological speed (time intercept 2018-01-01)vx0, vy0 = comp.vx0.load(), comp.vy0.load()v_amp, v_phase = comp.v_amp.load(), comp.v_phase.load()landice = comp.landice.load()x, y = comp.x.values, comp.y.valuesext = [x[0] - 60, x[-1] + 60, y[-1] - 60, y[0] + 60]print(f"fastest climatological speed in the cube: {float(v0.max()):,.0f} m/yr")print(f"pixels with v0 > 1 km/yr: {int((v0 > 1000).sum()):,} ({int((v0 > 1000).sum()) * 0.12 ** 2:,.0f} km2)")
# --- Step 3: trace a flowline by integrating the velocity vectors ----------------------------# Smooth the climatological vectors a little, then step along the unit flow direction (RK4) from a seed# on the trunk: upstream until the ice is slow or we leave the cube, and downstream to the front.valid = ndimage.gaussian_filter(np.isfinite(vx0.values).astype(float), 2)sx = ndimage.gaussian_filter(np.nan_to_num(vx0.values), 2) / np.maximum(valid, 1e-6)sy = ndimage.gaussian_filter(np.nan_to_num(vy0.values), 2) / np.maximum(valid, 1e-6)sx[valid < 0.5] = np.nansy[valid < 0.5] = np.nangrid = (y[::-1], x) # RegularGridInterpolator needs ascending axesix = RegularGridInterpolator(grid, sx[::-1], bounds_error=False, fill_value=np.nan)iy = RegularGridInterpolator(grid, sy[::-1], bounds_error=False, fill_value=np.nan)spd = RegularGridInterpolator(grid, v0.values[::-1], bounds_error=False, fill_value=np.nan)
def unit(p): u_, v_ = ix([p[::-1]])[0], iy([p[::-1]])[0] n = np.hypot(u_, v_) return np.array([u_, v_]) / n if np.isfinite(n) and n > 0 else None
def trace(start, sign, step=120.0, max_km=150, min_speed=200.0): pts_ = [np.asarray(start, float)] for _ in range(int(max_km * 1000 / step)): p = pts_[-1] k1 = unit(p) k2 = unit(p + sign * step / 2 * k1) if k1 is not None else None k3 = unit(p + sign * step / 2 * k2) if k2 is not None else None k4 = unit(p + sign * step * k3) if k3 is not None else None if k4 is None: break q = p + sign * step / 6 * (k1 + 2 * k2 + 2 * k3 + k4) if not (x[0] < q[0] < x[-1] and y[-1] < q[1] < y[0]) or not spd([q[::-1]])[0] >= min_speed: break pts_.append(q) return np.array(pts_)
lon_to = Transformer.from_crs("EPSG:4326", f"EPSG:{hit.epsg}", always_xy=True)seed = np.array(lon_to.transform(LON, LAT))up = trace(seed, -1)[::-1]down = trace(seed, +1)line = np.vstack([up, down[1:]])# End the flowline where the static land-ice mask ends: beyond it is the ice melange in the fjord.on_ice = landice.sel(x=xr.DataArray(line[:, 0]), y=xr.DataArray(line[:, 1]), method="nearest").values == 1line = line[: np.flatnonzero(on_ice)[-1] + 1]seg = np.hypot(*np.diff(line, axis=0).T)dist_km = (np.r_[0, np.cumsum(seg)][-1] - np.r_[0, np.cumsum(seg)]) / 1000 # distance upstream of the endsection("flowline")print(f"seed (lon {LON}, lat {LAT}) -> EPSG:{hit.epsg} x={seed[0]:.0f}, y={seed[1]:.0f}")print(f"flowline: {len(line)} vertices, {dist_km[0]:.1f} km long; ice-mask edge at x={line[-1, 0]:.0f}, " f"y={line[-1, 1]:.0f}; upstream end at x={line[0, 0]:.0f}, y={line[0, 1]:.0f}")
POINTS_KM = {"A": 5, "B": 15, "C": 30, "D": 60}pts = {k: line[np.argmin(np.abs(dist_km - d))] for k, d in POINTS_KM.items()}for k, p in pts.items(): print(f"point {k}: {POINTS_KM[k]:>2} km upstream, x={p[0]:.0f}, y={p[1]:.0f}, " f"v0={float(v0.sel(x=p[0], y=p[1], method='nearest')):,.0f} m/yr")
# Speed along the flowline in every annual composite.lx, ly = xr.DataArray(line[:, 0], dims="d"), xr.DataArray(line[:, 1], dims="d")prof = comp.v.sel(x=lx, y=ly, method="nearest").load()prof_err = comp.v_error.sel(x=lx, y=ly, method="nearest").load()prof = prof.where(prof_err < 0.25 * prof) # drop poorly constrained annual valuescover = prof.notnull().mean("d")print("annual composite coverage along the flowline (fraction of vertices with a value):")print(pd.Series(cover.values.round(2), index=years).to_frame("coverage").T.to_string())
# --- Figure 1: climatological velocity map with flowline -------------------------------------ne_zip = DATA / "ne_50m_admin_0_countries.zip"if not ne_zip.exists(): ne_zip.write_bytes(requests.get("https://naciscdn.org/naturalearth/50m/cultural/ne_50m_admin_0_countries.zip", timeout=300).content)greenland = gpd.read_file(ne_zip).query("ADMIN == 'Greenland'").to_crs(hit.epsg)
PCOL = {"A": "#d7191c", "B": "#fdae61", "C": "#abd9e9", "D": "#2c7bb6"}fig, ax = plt.subplots(figsize=(8, 6.2))im = ax.imshow(v0.where(v0 > 0), extent=ext, cmap="magma", norm=LogNorm(10, 15000))ax.plot(line[:, 0], line[:, 1], color="white", lw=1.6)for k, p in pts.items(): ax.plot(*p, "o", ms=7, mfc=PCOL[k], mec="black") ax.annotate(k, p, xytext=(5, 6), textcoords="offset points", color="white", fontweight="bold")ax.set_xlabel("x (km, EPSG:3413)"); ax.set_ylabel("y (km, EPSG:3413)"); km_axes(ax)cb = fig.colorbar(im, ax=ax, shrink=0.85, pad=0.02); cb.set_label("speed, 2014–2024 climatology (m/yr, log scale)")ax.set_title("Jakobshavn Isbræ (Sermeq Kujalleq): ITS_LIVE surface speed and flowline")ins = ax.inset_axes([0.0, 0.62, 0.26, 0.38])greenland.plot(ax=ins, color="#d9d9d9", edgecolor="#555555", lw=0.4)ins.set_xlim(greenland.total_bounds[[0, 2]]); ins.set_ylim(greenland.total_bounds[[1, 3]])ins.add_patch(plt.Rectangle((x[0], y[-1]), x[-1] - x[0], y[0] - y[-1], fc="none", ec="#d7191c", lw=1.5))ins.set_xticks([]); ins.set_yticks([]); ins.set_xlabel(""); ins.set_ylabel(""); ins.set_facecolor("white")fig.savefig(OUT / "01-jakobshavn-velocity-map.png"); plt.close(fig)
# --- Figure 2: annual maps and change ---------------------------------------------------------MAP_YEARS = [2013, 2017, 2024]yr = {y_: comp.v.sel(time=f"{y_}-01-01").load() for y_ in MAP_YEARS}zoom = [-200000, -150000, -2295000, -2255000]fig, axs = plt.subplots(2, 2, figsize=(10, 7.4), sharex=True, sharey=True, layout="constrained")for ax, y_ in zip(axs.flat, MAP_YEARS): im = ax.imshow(yr[y_], extent=ext, cmap="magma", norm=LogNorm(100, 15000)) ax.set_title(f"{y_} annual mean speed")fig.colorbar(im, ax=axs[0, :], shrink=0.8, label="speed (m/yr, log scale)")im2 = axs[1, 1].imshow(yr[2017] - yr[2013], extent=ext, cmap="RdBu_r", norm=TwoSlopeNorm(0, -4000, 4000))axs[1, 1].set_title("2017 minus 2013")fig.colorbar(im2, ax=axs[1, :], shrink=0.8, label="speed change (m/yr)")for ax in axs.flat: ax.set_xlim(zoom[:2]); ax.set_ylim(zoom[2:]); ax.plot(line[:, 0], line[:, 1], color="#bbbbbb", lw=0.8) km_axes(ax)fig.supxlabel("x (km, EPSG:3413)"); fig.supylabel("y (km, EPSG:3413)")fig.savefig(OUT / "02-annual-velocity-change.png"); plt.close(fig)
section("speed along the flowline in selected years (m/yr)")rows = []for y_ in [1985, 1995, 2000, 2003, 2007, 2013, 2015, 2017, 2018, 2019, 2021, 2022, 2024, 2025]: p = prof.sel(time=f"{y_}-01-01").values rows.append({"year": y_, **{f"{k} ({d} km)": p[np.argmin(np.abs(dist_km - d))] for k, d in POINTS_KM.items()}, "max on line": np.nanmax(p) if np.isfinite(p).any() else np.nan})tab = pd.DataFrame(rows).set_index("year")print(tab.round(0).to_string())
# --- Figure 3: flowline profiles ---------------------------------------------------------------PROF_YEARS = [1995, 2000, 2003, 2007, 2013, 2017, 2019, 2024]cmap = plt.get_cmap("viridis")fig, axs = plt.subplots(1, 2, figsize=(11, 4.2), width_ratios=[1.5, 1], layout="constrained")for i, y_ in enumerate(PROF_YEARS): axs[0].plot(dist_km, prof.sel(time=f"{y_}-01-01"), color=cmap(i / (len(PROF_YEARS) - 1)), lw=1.3, label=str(y_))axs[0].plot(dist_km, spd(line[:, ::-1]), color="black", lw=1, ls="--", label="2014–24 clim.")axs[0].invert_xaxis(); axs[0].set_xlabel("distance upstream of the flowline end (km)")axs[0].set_ylabel("annual mean speed (m/yr)"); axs[0].set_title("Speed along the flowline")axs[0].legend(ncol=2, fontsize=7, frameon=False)top = axs[0].get_ylim()[1]for k, d in POINTS_KM.items(): axs[0].axvline(d, color=PCOL[k], lw=0.8, ls=":") axs[0].text(d, top * 0.97, k, color="black", ha="center", va="top", fontweight="bold")for k, d in POINTS_KM.items(): s = prof.isel(d=np.argmin(np.abs(dist_km - d))) axs[1].plot(years, s, "o-", ms=3, color=PCOL[k], mec="black", mew=0.3, label=f"{k}: {d} km")axs[1].set_xlabel("year"); axs[1].set_ylabel("annual mean speed (m/yr)"); axs[1].set_title("Annual speed at points A–D")axs[1].legend(frameon=False, fontsize=8); axs[1].grid(alpha=0.3)fig.savefig(OUT / "03-flowline-profiles.png"); plt.close(fig)
# --- Step 4: image-pair time series at points, with error filtering ----------------------------section("image-pair time series at points")VARS = ["v", "v_error", "date_dt", "mission_img1", "satellite_img1"]series = {}for k, p in pts.items(): s = cube[VARS].sel(x=p[0], y=p[1], method="nearest").load().to_dataframe() s = s.dropna(subset=["v"]).sort_index() s["sensor"] = s.mission_img1.astype(str) + s.satellite_img1.astype(str) series[k] = s print(f"point {k}: {len(s):,} image pairs with a value, {s.index.min():%Y-%m-%d} to {s.index.max():%Y-%m-%d}")
def clean(s: pd.DataFrame, max_dt=48, max_rel_err=0.15, window="45D", n_mad=3.0) -> pd.DataFrame: """Flag short, well-constrained pairs whose speed is close to the local rolling median.""" s = s[s.index >= "2014-01-01"].copy() s["keep_dt"] = s.date_dt <= max_dt s["keep_err"] = s.v_error <= max_rel_err * s.v s1 = s[s.keep_dt & s.keep_err] med = s1.v.rolling(window, center=True, min_periods=5).median() mad = (s1.v - med).abs().rolling(window, center=True, min_periods=5).median() * 1.4826 ok = (s1.v - med).abs() <= n_mad * np.maximum(mad, 0.02 * med) s["keep_mad"] = False s.loc[s1.index[ok.values], "keep_mad"] = True s["keep"] = s.keep_dt & s.keep_err & s.keep_mad return s
filt = {k: clean(s) for k, s in series.items()}summary = pd.DataFrame({k: {"pairs since 2014": len(f), "dt <= 48 d": int(f.keep_dt.sum()), "+ error <= 15%": int((f.keep_dt & f.keep_err).sum()), "+ rolling MAD": int(f.keep.sum()), "kept %": 100 * f.keep.mean()} for k, f in filt.items()}).Tprint(summary.round(1).to_string())print("\nsensors among kept pairs at point B:")print(filt["B"][filt["B"].keep].sensor.value_counts().to_string())
# --- Figure 4: what filtering does at point B ----------------------------------------------------f = filt["B"]fig, axs = plt.subplots(2, 1, figsize=(10, 6), sharex=True, layout="constrained", height_ratios=[2, 1])rej = f[~f.keep]axs[0].scatter(rej.index, rej.v, s=4, c="#bbbbbb", label=f"rejected ({len(rej):,})")SENS_COL = {"S1A": "#2c7bb6", "S1B": "#abd9e9", "S2A": "#1a9641", "S2B": "#a6d96a", "L8": "#d7191c", "L9": "#fdae61", "L7": "#7b3294"}kept = f[f.keep]for sname, g in kept.groupby("sensor"): axs[0].scatter(g.index, g.v, s=5, c=SENS_COL.get(sname, "black"), label=f"{sname} ({len(g):,})")axs[0].set_ylim(0, np.nanpercentile(f.v, 99.5) * 1.1)axs[0].set_ylabel("speed (m/yr)")axs[0].set_title(f"Point B ({POINTS_KM['B']} km upstream): every image pair since 2014, kept and rejected")axs[0].legend(ncol=4, fontsize=7, markerscale=2.5, frameon=False, loc="upper left")axs[1].scatter(f.index, f.date_dt, s=3, c=np.where(f.keep, "#2c7bb6", "#bbbbbb"))axs[1].axhline(48, color="#d7191c", lw=1, ls="--")axs[1].text(f.index.min(), 56, "48-day limit", color="#d7191c", fontsize=8)axs[1].set_yscale("log"); axs[1].set_ylabel("days between images")axs[1].xaxis.set_major_locator(mdates.YearLocator(2))fig.savefig(OUT / "04-image-pair-filtering.png"); plt.close(fig)
# --- Figure 5: seasonal and multi-year speed -----------------------------------------------------section("seasonal cycle and multi-year change (filtered pairs, 2014 onwards)")fig, axs = plt.subplots(1, 2, figsize=(11, 4.4), width_ratios=[1.7, 1], layout="constrained")seas_rows = []for k, f in filt.items(): g = f[f.keep] axs[0].scatter(g.index, g.v, s=2, color=PCOL[k], alpha=0.25) m = g.v.resample("MS").median() axs[0].plot(m.index + pd.Timedelta(days=14), m, color=PCOL[k], lw=1.4, label=f"{k}: {POINTS_KM[k]} km") # Seasonal cycle: each monthly median relative to a centred 12-month mean, so the multi-year trend drops out, # then the median of those departures for each calendar month. trend = m.rolling(13, center=True, min_periods=7).mean() rel = (100 * (m / trend - 1)).groupby(m.index.month).median() axs[1].plot(rel.index, rel, "o-", color=PCOL[k], mec="black", mew=0.3, ms=4, label=k) ann = g.v.groupby(g.index.year).median() seas_rows.append({"point": k, "km": POINTS_KM[k], "pairs": len(g), "peak month": int(rel.idxmax()), "min month": int(rel.idxmin()), "seasonal range %": rel.max() - rel.min(), "2014 median": ann.get(2014, np.nan), "2017 median": ann.get(2017, np.nan), "2024 median": ann.get(2024, np.nan)})axs[0].set_ylabel("speed (m/yr)"); axs[0].set_title("Filtered image pairs (dots) and monthly medians (lines)")axs[0].legend(frameon=False, fontsize=8, ncol=4, loc="upper left"); axs[0].grid(alpha=0.3)axs[0].set_ylim(0, None)axs[1].axhline(0, color="black", lw=0.6)axs[1].set_xticks(range(1, 13), list("JFMAMJJASOND")); axs[1].set_ylabel("departure from the point's mean (%)")axs[1].set_title("Median seasonal cycle, 2014–2025"); axs[1].legend(frameon=False, fontsize=8); axs[1].grid(alpha=0.3)fig.savefig(OUT / "05-seasonal-multiyear-speed.png"); plt.close(fig)seas = pd.DataFrame(seas_rows).set_index("point")print(seas.round(1).to_string())
# Cross-check: calendar-year medians of the filtered pairs against the annual composites.b = filt["B"][filt["B"].keep]bm = b.v.resample("MS").median()chk = pd.DataFrame({"pairs median": b.v.groupby(b.index.year).median(), "mean of monthly medians": bm.groupby(bm.index.year).mean(), "months": bm.groupby(bm.index.year).count(), "composite": prof.isel(d=np.argmin(np.abs(dist_km - POINTS_KM["B"]))).to_series().set_axis(years)})print("\npoint B, calendar-year median of filtered pairs vs annual composite (m/yr):")print(chk.dropna().round(0).astype(int).T.to_string())
print("\nITS_LIVE climatological seasonal fit (2014-2024) at the points:")for k, p in pts.items(): a = float(v_amp.sel(x=p[0], y=p[1], method="nearest")) ph = float(v_phase.sel(x=p[0], y=p[1], method="nearest")) vv = float(v0.sel(x=p[0], y=p[1], method="nearest")) print(f" {k}: v0 {vv:,.0f} m/yr, amplitude {a:,.0f} m/yr ({100 * a / vv:.0f}%), peak on day {ph:.0f} " f"(~{(pd.Timestamp('2018-01-01') + pd.Timedelta(days=ph - 1)):%d %b})")
# --- Step 5: strain rates and flow direction -------------------------------------------------------section("strain rates (2014-2024 climatology)")dx, dy = float(x[1] - x[0]), float(y[1] - y[0]) # dy is negative: y decreases down the rows# Derivatives amplify noise, so smooth more strongly first (sigma 5 pixels = 600 m, a few ice thicknesses).w = ndimage.gaussian_filter(np.isfinite(vx0.values).astype(float), 5)u = ndimage.gaussian_filter(np.nan_to_num(vx0.values), 5) / np.maximum(w, 1e-6)v = ndimage.gaussian_filter(np.nan_to_num(vy0.values), 5) / np.maximum(w, 1e-6)mask = (w > 0.9) & (landice.values == 1)dudy, dudx = np.gradient(u, dy, dx)dvdy, dvdx = np.gradient(v, dy, dx)exy = 0.5 * (dudy + dvdx)speed = np.hypot(u, v)e_long = (u ** 2 * dudx + u * v * (dudy + dvdx) + v ** 2 * dvdy) / speed ** 2 # along-flow strain rate, 1/yre_eff = np.sqrt(0.5 * (dudx ** 2 + dvdy ** 2 + (dudx + dvdy) ** 2) + exy ** 2) # effective strain rate, 1/yr (ezz from incompressibility)for arr in (e_long, e_eff): arr[~mask | ~(speed >= 50)] = np.nanalong = RegularGridInterpolator(grid, e_long[::-1], bounds_error=False, fill_value=np.nan)(line[:, ::-1])eff_line = RegularGridInterpolator(grid, e_eff[::-1], bounds_error=False, fill_value=np.nan)(line[:, ::-1])for k, d in POINTS_KM.items(): i = np.argmin(np.abs(dist_km - d)) print(f"point {k}: longitudinal strain rate {along[i]:+.3f} /yr, effective {eff_line[i]:.3f} /yr")print(f"max longitudinal stretching on the flowline: {np.nanmax(along):.2f} /yr at {dist_km[np.nanargmax(along)]:.1f} km")print(f"max compression on the flowline: {np.nanmin(along):.2f} /yr at {dist_km[np.nanargmin(along)]:.1f} km")
fig, axs = plt.subplots(1, 2, figsize=(11, 4.4), sharex=True, sharey=True, layout="constrained")im = axs[0].imshow(e_long, extent=ext, cmap="RdBu_r", norm=TwoSlopeNorm(0, -0.6, 0.6))fig.colorbar(im, ax=axs[0], shrink=0.85, label="longitudinal strain rate (1/yr)\nred = stretching, blue = compression")axs[0].set_title("Strain rate along flow")im = axs[1].imshow(np.where(mask, speed, np.nan), extent=ext, cmap="magma", norm=LogNorm(50, 15000))fig.colorbar(im, ax=axs[1], shrink=0.85, label="speed (m/yr, log scale)")st = 25X, Y = np.meshgrid(x[::st], y[::st])U, V = u[::st, ::st], v[::st, ::st]S = np.hypot(U, V)q = (S > 100) & mask[::st, ::st]axs[1].quiver(X[q], Y[q], (U / S)[q], (V / S)[q], color="white", scale=32, width=0.003, headwidth=4)axs[1].set_title("Flow direction (unit arrows every 3 km)")for ax in axs: ax.plot(line[:, 0], line[:, 1], color="black", lw=0.8) ax.set_xlim(-205000, -120000); ax.set_ylim(-2300000, -2245000); km_axes(ax)fig.supxlabel("x (km, EPSG:3413)"); fig.supylabel("y (km, EPSG:3413)")fig.savefig(OUT / "06-strain-rate-flow-direction.png"); plt.close(fig)
# --- Step 6: save results -------------------------------------------------------------------------out = pd.DataFrame({"dist_km": dist_km, "x": line[:, 0], "y": line[:, 1], "v0": spd(line[:, ::-1]), "e_long": along})out = out.join(pd.DataFrame(prof.values.T, columns=[f"v_{y_}" for y_ in years]))out.to_csv(OUT / "flowline_profiles.csv", index=False, float_format="%.3f")pd.concat({k: f[f.keep][["v", "v_error", "date_dt", "sensor"]] for k, f in filt.items()}, names=["point"]).to_csv( OUT / "point_timeseries_filtered.csv", float_format="%.2f")print("\nwrote output/flowline_profiles.csv and output/point_timeseries_filtered.csv")