Skip to content

Habitat selection with hrHSA

Design for phase 41, asked for by Tim on 2026-10-08: check the hrHSA package by Paul Kasko and Ralph Kühn and see whether its functionality can be a separate analysis module. It can. Decisions D313 to D318 were asked and taken on 2026-10-08, the recommended answer each time. H1 to H7 were built the same day (section 11 says where the build departed from this text); H8 is open. This document is the plan.

1. What hrHSA is

hrHSA ("High-resolution Habitat Selection Analysis", https://hrhsa.readthedocs.io, https://github.com/PantheraPaulus/hrHSA) is a Python framework that fits habitat selection models from telemetry and environmental rasters. Read on 2026-10-08: the documentation (index, getting started, theory, implementation, performance, case studies, acknowledgements), the repository's pyproject.toml, LICENSE, src/hsa/__init__.py, src/hsa/types.py, src/hsa/rsf/{__init__,base,frequentist,cv,validation,surface}.py, src/hsa/movement/geometry.py, src/hsa/compute/{raster,io}.py and the commit list.

  • Version 0.1.0, BSD 3-Clause, copyright 2026 Paul Kasko and Ralph Kühn; 34 commits between 2026-09-13 and 2026-10-08, one committer, no release tag, not on PyPI; installed with pip install -e ".[hpc]" from a clone; imported as hsa.
  • Models: resource selection functions (RSF: logistic regression of used against sampled available points, relative selection w(x) = exp(x'β)), step selection functions (SSF: a choice set per step with movement-informed alternatives, conditional logit) and integrated SSF (iSSF: the movement kernel, L, log L, cos θ, fitted with the selection). Frequentist inference through statsmodels (Newton, L-BFGS) or Bayesian through PyMC with hierarchical individual coefficients β_i = μ + b_i and partial pooling.
  • Validation: leave-one-individual-out (LOIO) with a continuous Boyce index and calibration curves, temporal blocking, posterior predictive checks. Prediction: a relative selection surface as an xarray raster.
  • Inputs: relocations as a GeoDataFrame with an individual id, a timestamp and a point in a projected CRS (prepare_trajectory_data projects, rounds and drops duplicate fixes); covariates as an xarray DataArray with dims band, y, x (a time dimension for dynamic fields), band names as the predictor names, the same CRS as the relocations. The getting started opens a Zarr stack; the sampler (sample_raster_stack_chunked) takes any DataArray or Dataset of that shape, so an in-memory stack built from GeoTIFFs with rioxarray is the same thing.
  • The RSF path, as the code reads: FrequentistRSF(reloc, env, spec=FeatureSpec(linear=[...], quadratic=[...], interactions=[...], categorical=[...]), id_col=, timestamp_col=, domain=None, domain_quantile=0.95); one availability domain per individual, the minimum convex polygon at the quantile, unless a common domain is given; .fit(sampling_factor=10, thin_dt="12h", seed=42, method="newton") returns a FrequentistRSFFit whose coefficients() is a table of term, estimate, standard error, p value, lower and upper, and whose predict_surface(env) is a DataArray with one band rsf; .validate(LeaveOneIndividualOut(...)) runs leave_one_individual_out_rsf, which trains on every other animal and answers a summary per fold, the coefficients per fold, the Boyce bins, the calibration bins and the fold diagnostics.
  • Dependencies: numpy<2.5, pandas, geopandas, shapely, pyproj, scipy, scikit-learn, statsmodels, xarray, rioxarray, rasterio, matplotlib; optional PyMC and ArviZ (Bayesian), Dask and dask-jobqueue (HPC), Zarr, Earth Engine. Python 3.10 or later.
  • The case studies: 26 ground pangolins (July 2024 to April 2026, NDVI, Sentinel-1 VH, radar edge metrics, slope, soil; a hierarchical RSF), 21 lions (river proximity by season) and 5 bearded vultures (an iSSF with terrain and ERA5 wind and heat). The pangolin study looks like Smart Parks data; whether the authors ran it on exports of ours is a question for Tim.

2. Why a module, and why these limits

The admission rule of the analytics brief (docs/ANALYTICS_PHASE1_PLAN.md, section 20) asks that a module be used repeatedly, read data Protect already holds, support a real decision, be easier than the external tool and cost maintenance in proportion. Habitat selection meets the first three for the ecologists Protect serves: where an animal chooses to be, against what was available, is the question behind every zoning and corridor decision, and the fixes are here. The fourth is the honest doubt: the GeoParquet export of decision D312 already feeds hrHSA directly, and an ecologist with Python has the whole framework there. The module earns its place by what the export cannot do: the covariates fetched, cached and projected for the project's area without a GIS session, the run kept with its parameters and its warnings beside the movement and grazing runs, the surface on the same map, a report, and a person without Python getting a validated answer. The fifth sets the shape: the first step is the frequentist RSF with LOIO alone (D313), on a worker image of its own (D314), with the Bayesian fits and the step selection models as a second step once the first has run on a real project.

Three cautions the authors state are the module's own limitations, printed on every run: use is not habitat quality (an ideal free distribution puts animals where they are, not where it is best for them); a coefficient says how the animal redistributed relative to what was available, so the availability domain decides every contrast; and the selection surface is relative, never a probability of occurrence.

3. Decisions

Id Decision Choice
D313 hrHSA is the engine of a habitat selection module; the frequentist RSF with LOIO first The module habitat_selection wraps hrHSA pinned to one commit (BSD 3-Clause, the licence next to the pin): prepare_trajectory_data, FrequentistRSF, LeaveOneIndividualOut, predict_surface. Nothing of it is reimplemented, and nothing of ours judges what it answers. Step one is the pooled frequentist RSF, one availability domain per animal, LOIO with the Boyce index, the selection surface; the SSF, the iSSF and the Bayesian hierarchical fits are step two, after a real run. Over writing our own logistic RSF on numpy (a published method rewritten, without its validation) and over an export recipe alone. Tim, 2026-10-08.
D314 The analysis worker gets an image of its own hrHSA pins numpy<2.5 and brings pandas, geopandas, scipy, statsmodels, xarray and rasterio, about a gigabyte; the shared image pins numpy 2.5.2 and carries none of them (decision D200). docker/analysis.Dockerfile builds the worker from a project file of its own (services/analysis/image/pyproject.toml with its own uv.lock: the workspace packages as path dependencies, hrHSA from the pinned git commit, numpy 2.4). The API, the decoder and the rest keep the lean image. A worker without hsa fails such a run with MODULE_UNAVAILABLE (D318). Over downgrading numpy in the one image and carrying the stack in every service. Tim, 2026-10-08.
D315 Covariates come from three sources: openEO, PostGIS and a GeoTIFF upload A raster layer kind on the environmental boundary beside the weekly series (D245): per-pixel rasters over the run's area. From the Copernicus Data Space openEO account the server has (D250): the mean NDVI of the period from Sentinel-2 (SENTINEL2_L2A, cloud masked) and the elevation of COPERNICUS_30, both as GeoTIFF, slope derived from the elevation on our side. From PostGIS: the distance to the nearest feature of a chosen type (sites, routes, zones, fences) on the run's grid. From a project: a GeoTIFF uploaded under Project admin, Features, as a named layer. Every raster is cached in MinIO with its extent, resolution and period, so a rerun reads it back. Over a GeoTIFF upload alone (every project would have to bring its layers) and over fetched layers alone. Tim, 2026-10-08.
D316 Availability is one domain per animal, the MCP at a quantile hrHSA's default: each animal's available points are sampled inside its own minimum convex polygon at domain_quantile (0.95), sampling_factor available points per used fix (10), the fixes thinned to thin_dt (12 hours) before the fit. All three on the form under Method. A common domain (a zone of the project, or the project's extent) and the movement module's KDE range as the domain are later options. Recorded by Claude, 2026-10-08, from the framework's defaults.
D317 What the result holds Per run: the coefficient table (term, estimate, standard error, p value, 95 percent interval, on the standardised scale as hrHSA fits it, with the scaler's mean and spread per predictor so a reader can go back to units), an estimates chart with the interval; the LOIO summary per held-out animal (the Boyce index, the fixes, the fit's deviance) and the Boyce curve per animal (predicted selection against the observed to expected ratio); the selection surface as cells of the analysis grid, coloured by rank in fifths like the vegetation mosaic (D251), with the used fixes and the domains as chips; warnings for few fixes, a short period, a covariate without variation, a Boyce index under 0.5, a fold that did not converge; the three cautions of section 2 as limitations; the PDF report with the same. Recorded by Claude, 2026-10-08.
D318 Offered by default, refused with a reason where the image lacks it habitat_selection joins ANALYSIS_MODULES' default. The module's code imports hsa inside run, never at import time, so the API validates parameters and lists the module from the lean image; a worker whose image cannot import hsa marks the run failed with MODULE_UNAVAILABLE and the message "the analysis worker is not built with hrHSA". The Dockerfile of D314 is the compose default for the analysis service, so a server that builds from the repository has it. Over a second settings switch. Recorded by Claude, 2026-10-08.

4. The data flow of a run

  1. Subjects and fixes. Entities by id, group or type, at most MAX_SUBJECTS_HABITAT (40: LOIO fits one model per animal); the period at most MAX_DAYS; the fixes through load_trajectory (device fixes, valid, effective time and geometry, attributed by the assignment history), exclude_impossible and the stop rule of D303 off by default (a parked receiver's drift is used habitat too, and the fold would thin it; the switch is on the form).
  2. The area. The union of the animals' domains (hrHSA's MCPs at the quantile, computed by us with the same rule so the rasters cover them) buffered by area_buffer_m (1,000 m), in the UTM zone of its centroid (utm_srid in primitives/spatial.py). The grid's resolution starts at the finest source (30 m for the DEM, 10 m for NDVI, taken as 30 m) and doubles until the area fits MAX_RASTER_CELLS (4 million: a 60 by 60 km park at 30 m), so a large park answers coarser rather than refusing (architecture 13.10).
  3. The covariates (D315), each a GeoTIFF in the cache or fetched now, reprojected onto the run's grid with rioxarray (reproject_match, bilinear for continuous layers), stacked as one DataArray with the band names the form chose:
    • ndvi: the mean over the period, Sentinel-2 L2A cloud masked, one openEO job per run area and period (save_result as GTiff; the backend lists GTiff with deflate compression). Cached by area hash and period.
    • elevation and slope: COPERNICUS_30 over the area, one openEO job per area, cached without a period; the slope in degrees from the elevation with numpy on the run's grid.
    • distance_to_<type>: for a feature type of the project, the distance from each cell centre to the nearest feature of that type, ST_Distance on the geography in one statement over the grid's cell centres (bounded by the cell count), cached by area hash and the features' newest updated_at.
    • a project layer: a GeoTIFF a project admin uploaded with a name, kept in the analysis-layers bucket with its CRS, extent and resolution read on upload; continuous or categorical as marked.
  4. The fit. prepare_trajectory_data with source_crs 4326 and the UTM target, round_freq none, duplicates dropped; FrequentistRSF(..., spec=FeatureSpec(linear=chosen, quadratic=chosen_quadratic), id_col="entity_id", timestamp_col="time", domain_quantile=q).fit(sampling_factor=f, thin_dt=t, seed=42); progress per step (fixes, area, each layer, the fit, each LOIO fold, the surface).
  5. Validation. rsf.validate(LeaveOneIndividualOut(heldout="all", sampling_factor_train=f, n_background=100_000, seed=42)) when the run has at least MIN_SUBJECTS_LOIO (3) animals; the summary and the Boyce bins per fold go into the document; a fold that fails is a warning, not a failed run.
  6. The surface. fit.predict_surface(env) on the run's grid, then read onto the analysis grid's cells (the mean of the surface pixels in each cell), at most MAX_SURFACE_CELLS (2,000), the cell size doubled until it fits, as vegetation_grid does; the cells are Geometry rows of kind selection with the value and its rank; the domains are kind domain polygons per animal.
  7. The document. Summary per animal and for the fleet (fixes, thinned fixes, available points, the Boyce index), the tables coefficients, validation (per held-out animal), layers (name, source, resolution, period, cells with a value), the charts estimates (bar with the interval, drawn as a line series of two points per term until Chart learns an error bar), boyce (one line per animal), the warnings and the provenance with hrHSA's commit in sources.

Bounds: 40 animals, 366 days, 200,000 fixes per animal before thinning, 4 million raster cells, 2,000 surface cells, 5,000 geometries, a 4 MiB document; the worker's 900 s timeout holds for 40 LOIO folds of a logistic regression on a few hundred thousand rows each (statsmodels Newton converges in under a second on such a fit; measured on the first real run and recorded in docs/operations/benchmarks.md). Memory: one stack of at most four million cells by at most eight bands of float32 is 128 MB.

5. Framework and core changes

  • The image (D314). docker/analysis.Dockerfile from python:3.12-slim with GDAL's runtime libraries (libgdal for rasterio's wheels is not needed: rasterio ships its own; PROJ data the same), services/analysis/image/pyproject.toml (smartparks-protect-shared and smartparks-protect-analysis as path dependencies, hrHSA @ git+https://github.com/PantheraPaulus/hrHSA@<commit>, numpy==2.4.x), its own uv.lock; docker-compose.yml's analysis service builds it; the Ansible role's docker compose build builds both; CI's compose job builds it and a new job analysis-image runs tests/analysis and the habitat tests inside it; pip-audit over its exported lock joins the audit job. The LICENSE of hrHSA is copied beside the pin with its attribution.
  • The raster boundary. shared/analysis/rasters.py: RasterLayer (name, kind continuous or categorical, source, resolution, period, the object key), RasterProvider protocol (fetch(layer, area, time_from, time_to) -> GeoTIFF bytes), the cache table environment_rasters (migration: project, provider, layer, area hash, period, object key, extent, resolution, fetched at) and the bucket analysis-layers; project_rasters(session, project_id) lists what a run may choose. The openEO provider gains fetch_ndvi_raster and fetch_elevation (two process graphs with save_result GTiff); the PostGIS distance layer lives in rasters.py itself; the upload is POST /projects/{id}/layers (features:write, LAYER_MAX_BYTES 200 MB, the CRS and extent read with rasterio in the API, which the lean image cannot do: the API stores the file and the worker reads its metadata on first use, or the API gains rasterio; the first is the recommendation, keeping the API lean) with GET, DELETE and a Layers card on the Features page.
  • The module. shared/analysis/modules/habitat.py (HabitatParameters: the common parameters, layers as a list of names with quadratic flags, domain_quantile, sampling_factor, thin_hours, area_buffer_m, loio, fold_stops), shared/analysis/primitives/habitat.py (the pure parts: the domains, the grid, the stack, the dataframe hrHSA takes, the surface onto cells, the Boyce series; hsa imported inside the functions that need it), AnalysisModuleKey.HABITAT_SELECTION, the migration that widens the run row's check, MAX_SUBJECTS_HABITAT in limits.py and SUBJECT_LIMITS in the router, ANALYSIS_MODULES' default with the compose and the test_config check.
  • The frontend. pages/project/HabitatPage.tsx, components/analysis/HabitatForm.tsx (animals, period, the layers of the project as a multi-select grouped by source with "NDVI" and "Elevation and slope" offered when the server has the openEO account and distance layers per feature type the project has, the Method fold), habitatPresentation and habitatLabels in presentations.tsx, HabitatLimitations, the selection and domain kinds in analysisLayers.ts with a ramp and a legend by rank, "Analyse habitat" on an animal's entity page beside "Analyse movement", the navigation entry, Dutch.
  • The report. HABITAT_LABELS, HABITAT_KEY_FIGURES, HABITAT_LIMITATIONS in report/render.py; mapimage.py draws the selection cells with the ramp.
  • Docs. docs/analytics/habitat-selection.md (the guide: what an RSF answers and does not, the form, the layers, the method with hrHSA named and cited, the limitations), the pages guide, DEVELOPERS.md, ADR 0039 (a third-party scientific package in a worker image of its own), the changelog, this document's state.

6. Tests

  • tests/shared/test_analysis_habitat.py (runs only where hsa imports, skipped otherwise, and in the analysis-image CI job always): a synthetic landscape (two rasters, one a gradient, one noise) and animals whose fixes are drawn with probability proportional to exp(β · gradient); the fitted coefficient of the gradient is within 20 percent of β and the noise coefficient's interval covers zero; the LOIO Boyce index over six such animals is above 0.8; the surface's rank agrees with the gradient; a categorical layer; the stack's bands in the order asked; the domains cover the fixes; a run with two animals skips LOIO with a warning.
  • tests/shared/test_analysis_rasters.py: the distance layer on the migrated database against a known site (the cell at the site reads 0, a cell 1 km east reads about 1,000); the cache by area hash and period; the grid doubling to the cell bound; the reprojection onto the grid with a recorded GeoTIFF fixture; the openEO process graphs for NDVI and the DEM as documents compared with recorded ones (tests/fixtures/openeo/, with their README).
  • tests/api/test_analysis_habitat_run.py: a run through the API and the runner with the provider stubbed by recorded GeoTIFFs and a distance layer, the geometries of kind selection and domain, the access matrix, a member's scope, the 422 for a layer the project does not have; the layer upload and its refusal of a file without a CRS; the lean worker's MODULE_UNAVAILABLE.
  • tests/analysis/test_worker.py gains the unavailable case.
  • Vitest: the form's parameters, habitatPresentation, the ramp and legend, lib/analyses.test.ts.
  • The honest check, by hand on the first real run: the same fixes exported as GeoParquet and the same layers given to hrHSA in a notebook outside Protect give the same coefficients; recorded in the session log.

7. Phase 41 tasks

  • [x] H1 the image: docker/analysis.Dockerfile, services/analysis/image/, its lock with hrHSA pinned to a commit and its licence, compose, Ansible, CI (compose builds it, analysis-image runs the tests in it, the audit covers its lock), MODULE_UNAVAILABLE in the runner.
  • [x] H2 the raster boundary: shared/analysis/rasters.py, the cache table and bucket (migration), the openEO NDVI and DEM rasters with their recorded fixtures, the PostGIS distance layers, the grid.
  • [x] H3 the project layers: the upload endpoint and bucket, the Layers card on the Features page, Dutch.
  • [x] H4 the module and its primitives, the key, the migration of the run row's check, the limits, the default list.
  • [x] H5 the frontend: the page, the form, the presentation, the map kinds and legend, the entity page's button, the navigation, Dutch.
  • [x] H6 the report.
  • [x] H7 docs: the guide, the pages guide, DEVELOPERS.md, ADR 0039, the changelog, this document.
  • [ ] H8 the dev server: the image built and deployed, a run over a real project of Tim's choice with NDVI, slope and a distance layer, read by Tim; the by-hand check of section 6 against hrHSA outside Protect; the run time and memory in docs/operations/benchmarks.md (the first run is there and in section 11; the six-animal run follows).

Order: H1 first, since nothing runs without hsa in the worker; H2 and H3 together, since the module has nothing to fit without layers; then H4 to H7; H8 last. Each task lands with its tests and docs, committed on Tim's word.

8. Exit criteria

  • On the dev server, a run over at least six animals of one project with NDVI, slope and the distance to the project's sites answers a coefficient table with intervals, a Boyce index per held-out animal, and a selection surface on the map that reads against the fixes; the PDF carries the same.
  • The same fixes and the same three layers given to hrHSA outside Protect give the same coefficients to four decimals.
  • A run with the analysis service on the lean image fails at once with MODULE_UNAVAILABLE and a plain message; the API, the decoder and every other service build and run unchanged, and tests/shared/test_analysis_boundary.py holds.
  • A project without the openEO account can still run on an uploaded GeoTIFF and a distance layer; a project with neither is told so on the form before a run is queued.
  • The access matrix holds for the new routes; a member scoped to some animals runs on those alone.

9. Later, not here

The step selection and integrated step selection functions (the choice sets per step, the movement basis, the vultures' study); the Bayesian hierarchical fits with PyMC (individual coefficients with partial pooling, which need minutes to hours and the bayesian extra in the image); time-varying layers (NDVI per week sampled at each fix's own time, which hrHSA supports and the weekly cache of D245 already half holds); Sentinel-1 backscatter and radar edge metrics (SENTINEL1_GRD is on the same openEO backend); ERA5 weather; the movement module's KDE range or a project zone as the availability domain; the surface drawn as an image layer rather than cells; a comparison period; scheduled reruns.

10. Inputs from Tim

  • Which project and animals run first (no pangolin entities exist on dev today; FreeNature's SP050969 holds four months of five-minute fixes, the Natuurmonumenten and Grazelands cattle a season each).
  • Whether to ask the authors to relax numpy<2.5 and to tag a release, which would let the pin move from a commit to a version; and whether the pangolin case study used Smart Parks data, which decides whether their coefficients are a check for ours.
  • The openEO quota: a DEM read per project area once, an NDVI read per run area and period; the first real run says what a run costs.

11. As built, 2026-10-08

H1 to H7 landed on 2026-10-08 in one session, in the order of section 7. Where the build departs from the sections above:

  • The distance layers are not PostGIS. primitives/habitat.py computes distance_to_<type> on the run's grid with a shapely STRtree over the project's features projected to the grid's CRS: faster than a round trip per cell, bounded by the grid, and the worker already holds the features. The API's project_layers still decides which types a project may name from the features it has.
  • hrHSA needs its dask extra. hsa.rsf imports dask at module level although the authors list it as optional; the image's lock carries hrhsa[dask]. The lock holds 109 packages (numpy 2.4.2, pandas 3.0.6, geopandas 1.2.0, rasterio 1.5.2, statsmodels 0.15.0, xarray 2026.9.0, scipy 1.18.1, pyproj 3.8.0). The decoder is in the image's dev group because the API test helpers import it.
  • One uv sync in the Dockerfile. The workspace packages are path dependencies of the image project, not workspace members, so the dependency layer cannot be built before the sources are copied; the sync runs once after the copy, and the uv cache keeps a code change to seconds. INSTALL_DEV=1 adds the test group for CI.
  • The lean API reads a GeoTIFF header itself. shared/analysis/geotiff.py parses TIFF 6.0 and BigTIFF with the GeoTIFF 1.1 keys, so an upload without a CRS, with more than one band or outside the size bound is refused by the API without rasterio.
  • Ansible needs nothing. The server builds through docker compose build, which builds the analysis image beside the others.
  • A Fit carries the rank breaks, so the map legend and the report read the same five values the cells were coloured by.
  • Test lessons. A site at the centre of a gradient makes the distance to it collinear with the gradient; the run test's site stands 3 km north and the distance's interval is asserted to hold zero. The habitat tests skip where hsa does not import and run in the analysis-image job.

The first run on dev (2026-10-08, Tim, project BPC). One spotted hyena (SP100429), the last 7 days, NDVI and elevation, the defaults. It completed in 6 min 52 s: the NDVI batch job on openEO took 4 min 38 s and the DEM job 1 min 50 s, both fetched for real through the Copernicus account and cached (2.3 MB and 1.8 MB at 10 m over the 9 km by 6 km grid, 559,328 cells, EPSG:32734); the fit, the surface and the document took the remaining 14 s. The worker's memory stayed under 300 MB. The animal had 41 fixes in the week (96 percent missing by its expected interval), which thinning to twelve hours cut to 6 used and 60 available points; the coefficients came out with wide intervals (NDVI 0.16 ± 0.44, elevation −1.23 ± 0.73 on the standardised scale), the validation was skipped for one animal, and the 601 surface cells and the domain drew on the map with the legend by rank. Three defects came out of it, fixed the same day: the elevation layer's source read "Sentinel-2" (the provider now names each raster layer's source), the layers table showed the grid's 10 m for the 30 m DEM (it shows the layer's own resolution), and nothing warned that only 6 fixes carried the fit (few_used, under FEW_FIXES); the cards also printed a count with decimals. A rerun over the same area and period reads both rasters from the cache and should take seconds.

The second day's runs on dev (2026-10-08, Okonjima). Tim's run on one animal over a week failed in the worker on fewer than twenty fixes (the estimate now refuses that before the run is queued, through min_fixes on the module). His run on the same animal over 30 days completed. The example run that followed, four animals over 30 days, failed inside hrHSA's validation with "Found array with 0 sample(s)": the raster cache keyed the area by the bounding box rounded to the whole degree, so the four animals' larger grid was served the single animal's smaller rasters, most cells had no value, and one fold had no rows. The key now rounds to a hundred-thousandth of a degree, the module warns when a layer has a value in fewer than nine in ten of the grid's cells (layer_sparse), and the slope is offered whenever the provider answers the elevation (the first catalogue left it out).

The example run, second attempt. Four animals over 30 days on the corrected cache: the grid came out at about 340 km² at 10 m (3.4 million cells, under the 4 million bound), and the NDVI batch job on openEO was still running after the worker's 900 s: the job's input grows with the area and the number of Sentinel-2 scenes in the period (the one-animal week on BPC was 4.5 megapixels of input and 215 s on the Copernicus side). Three changes: the habitat module carries a timeout of its own, an hour, which the runner takes over the shared 900 s; the provider's rasters of a run are fetched at the same time (cached_rasters), so the DEM no longer waits for the NDVI; the module logs the grid and the fetch time. The guide says a first run over a large area and a long period waits, and that a rerun reads the cache. Not done: asking openEO for the NDVI at the grid's resolution before the cloud mask and the mean (the process resamples after them), which may or may not cut the job's input; and a run that survives a worker restart while a job runs (section 9).

The example run, third attempt (four subjects, 14 days). Completed in 14 min 27 s, 12.8 of them the NDVI job over a 2.6 million cell grid; the Boyce curves, the four domains and 708 surface cells rendered. Two findings. openEO answered 429 when the DEM job was started in the same second as the NDVI job, so the run went on with NDVI alone and two layer_failed warnings: a job request refused with 429 is now retried with a doubling wait (Retry-After when they say), and a job that would not start is deleted on their side. And three of the four subjects, picked by their fix counts, were 4x4 vehicles: the module admits animals alone, every tracked type but People and Vehicles and their sub-types (non_animals; the form offers the same), as the vehicle module admits vehicles alone. The example run that counts is the fourth: Okonjima's pangolins, hrHSA's own study animal, over the last 30 days.

The example run, fourth attempt: 25 pangolins, 30 days. Completed in 22 min 34 s on elevation and slope alone: the NDVI job over the pangolins' 1.18 million cell grid at 20 m was still running on the Copernicus side when the provider's 20 minute job limit cut it, so the run went on without the layer, as designed, and the fit found a small positive slope coefficient and no selection to speak of (mean Boyce index −0.05; the pangolins report two fixes a day at most, so 20 to 127 fixes each over the month). Two changes: a raster job waits 50 minutes and carries its layer, grid and period in its title, and a run looks the account's jobs up by that title before creating one, so a job a run gave up on keeps running on their side and the next run over the grid picks it up; and the warnings box shows eight lines and folds the rest, since 25 animals with three quality warnings each made a wall.

The example run that stands: 25 pangolins, 30 days, NDVI, elevation and slope (run 325fbedd in Okonjima, "second pass"). Completed in 22 min 34 s, 20 min 24 s of them the NDVI job (the DEM and the slope came from the cache), the fit, the validation over 25 folds and the surface in about two minutes; 564 used against 5,640 available points on a 1.18 million cell grid at 20 m. Coefficients on the standardised scale: NDVI −0.20 (±0.09), elevation −0.03 (not distinguishable from zero), slope +0.12 (±0.07): over this month the pangolins were found on the sparser, steeper ground more than its availability would give. The leave-one-out validation gives a mean Boyce index of 0.27, seven animals above 0.65 and eight below zero, which is what three coarse layers can say about an animal that reports two fixes a night. One held-out animal with 22 fixes has no Boyce index and no reason given (hrHSA answers none when the bins cannot be filled); the validation table should say so, a later polish. The page shows the folded warnings, the 25 cards, the map with the 25 domains and 1,218 cells, the bars, the 25 Boyce curves and the tables.

Open from section 8: a run over at least six animals with a distance layer (Okonjima has no features yet), Tim's reading, and the by-hand check against hrHSA outside Protect (H8).