Docs

Everything you need to run Fuel Gauge

How to install and run each stage, then the reference for every public function, generated from the code (version 0.3.0).

Install

Python 3.10 or newer. The core package needs NumPy, SciPy, pandas, OpenCV and Pillow. Extras add what each stage needs:

pip install "fuelgauge[geo,seg,eval]"
georasterio, pystac-client, planetary-computer: terrain and land cover from Microsoft Planetary Computer, GeoTIFF output
segonnxruntime: the SegFormer-B2 model for sky and vegetation (about 100 MB, downloaded once to ~/.cache/fuelgauge, override with FUELGAUGE_CACHE)
evalscikit-learn, pyarrow, openpyxl: scoring against field data

Quickstart

Put the photos from one fixed camera in a folder. Capture times are read from the file name (2019-06-14_1230.jpg, IMG_20190614T123000.jpg, PhenoCam's site_2019_06_14_123005.jpg, or a Unix timestamp as HPWREN uses) and otherwise from EXIF.

fuelgauge run photos/ out/ridge

That registers every usable photo onto fixed views, then measures them. A thousand photos take about five minutes on two CPU cores. You get:

out/ridge_blocks.npzdates, mean RGB of every 24-pixel block of every registered photo (768 x 576 working size), block validity, haze score, stretch and view index, and each view's anchor frame
out/ridge_qa.jsonper-photo screening metrics and registration transform, the stretches and how they were joined into views
out/ridge_daily.csvdaily grvi, grvi_wb and gcc_wb per view (suffix _v0, _v1...) and index, the white-balanced GRVI standardised per view and joined across views
out/ridge_regions.pngthe anchor frame with measured vegetation (green) and colour reference blocks (orange)

Open the regions image first. If the green blocks sit on the vegetation you care about, the series is measuring it. Without the seg extra, run fuelgauge measure out/ridge --no-model and regions are chosen by seasonal swing alone.

Calibrate a camera

Calibration needs one clear photo with a visible skyline, the camera's latitude and longitude, its ground elevation in metres, and a rough heading. Height above ground defaults to 10 m.

fuelgauge calibrate ridge frame.jpg --lat 33.4008 --lon -117.1905 --elev 483 --yaw 90

It prints the fitted camera (heading, tilt, roll, field of view, distortion) and the skyline loss, and writes ridge_lookup.npz with the distance, latitude and longitude of every 8-pixel block. The default lens (101.15°, k₁ = 0.080) is the one fitted to HPWREN's Mobotix cameras; pass --hfov and --k1 for other hardware, or fit a shared lens for several cameras of one model with terrain.fit_shared_lens.

Check the fit by drawing the rendered skyline over the photo (terrain.skyline_residuals gives the per-column error). Errors under 0.2° are typical; a large error usually means the heading guess is more than 20° off or the location is wrong.

Map coverage and site cameras

Both commands take a CSV with lat and lon columns, one row per camera, and pull a 90 m terrain model for the area around them.

fuelgauge viewshed cameras.csv coverage.tif --radius 30000 --mast 10
fuelgauge site cameras.csv --k 10 --block 6000 > new_sites.csv

coverage.tif counts the cameras with line of sight to each cell. site ranks hilltops (the highest cell in every --block-metre square) by how much unseen ground each would add, counting each cell once; --area (metres) and --lat/--lon set the search area. For a statewide run like the one on this site, see scripts/coverage.py and scripts/siting.py, which tile the work.

Score against field data

evaluate.compare runs leave-one-year-out ridge regressions on top of a baseline with its own intercept and seasonal harmonics for every site and species. Predictors only get credit for knowing a year was unusual. The input is one row per field sample with site, date, year, lfmc and your predictor columns.

from fuelgauge import evaluate
samples = evaluate.add_season(samples)
scores, preds = evaluate.compare(samples, {"camera": ["cam_g", "cam_d"], "season": []})
scores["camera"]["anomaly_r"], scores["camera"]["years_beat_season"]

Command line

Register a folder of photos and measure it, or run each stage on its own.

fuelgauge run

fuelgauge run [-h] [--no-model] frames out

positional arguments:
  frames
  out

options:
  -h, --help  show this help message and exit
  --no-model

fuelgauge register-archive

fuelgauge register-archive [-h] frames out

positional arguments:
  frames
  out

options:
  -h, --help  show this help message and exit

fuelgauge measure

fuelgauge measure [-h] [--no-model] out

positional arguments:
  out

options:
  -h, --help  show this help message and exit
  --no-model

fuelgauge calibrate

fuelgauge calibrate [-h] --lat LAT --lon LON --elev ELEV [--agl AGL]
                           [--yaw YAW] [--hfov HFOV] [--k1 K1]
                           cam image

positional arguments:
  cam
  image

options:
  -h, --help   show this help message and exit
  --lat LAT
  --lon LON
  --elev ELEV
  --agl AGL
  --yaw YAW
  --hfov HFOV
  --k1 K1

fuelgauge viewshed

fuelgauge viewshed [-h] [--radius RADIUS] [--mast MAST] cameras out

positional arguments:
  cameras
  out

options:
  -h, --help       show this help message and exit
  --radius RADIUS
  --mast MAST

fuelgauge site

fuelgauge site [-h] [--k K] [--radius RADIUS] [--mast MAST]
                      [--block BLOCK] [--area AREA] [--lat LAT] [--lon LON]
                      cameras

positional arguments:
  cameras

options:
  -h, --help       show this help message and exit
  --k K
  --radius RADIUS
  --mast MAST
  --block BLOCK
  --area AREA
  --lat LAT
  --lon LON

fuelgauge.archive

Process a camera archive end to end: screen -> track -> link -> warp into each view -> block colours.

A camera that was moved to a new spot gets a second view (its own anchor frame) instead of losing those years.
Featureless scenes (open-grass close-ups) fragment into short stretches that cannot be joined; when fewer than
half of the usable frames land in a view, every usable frame is measured in place, the way fixed PhenoCam
regions are.

    from fuelgauge.archive import process
    out = process(sorted(glob('frames/*.jpg')), times, 'out/mycam')

Writes <out>_blocks.npz (dates, per-block mean RGB, validity, haze score, segment) and <out>_qa.json.

def process(paths, times, out_prefix, log=<built-in function print>)

fuelgauge.measure

Registered block colours -> a daily vegetation series you can compare across years.

For each view of the camera (a camera moved to a new spot has more than one):
  1. flag clear frames by haze score (edge agreement with the view's anchor frame, relative to the recent norm)
  2. pick measurement regions automatically: SegFormer vegetation labels plus a strong seasonal swing, and
     reference blocks (rock, soil, road, distant ridges) that stay flat through the year
  3. white-balance every frame against the reference blocks (trailing two-month median, so it follows the camera,
     not snow or wet soil)
  4. GRVI and GCC over the vegetation blocks, clear frames only, then a causal smooth (weekly 90th percentile,
     two-week mean) so each day uses only data up to that day

    from fuelgauge.measure import measure
    daily, regions, info = measure('out/mycam_blocks.npz', 'out/mycam')

Writes <out>_daily.csv, <out>_regions.png (vegetation in green, reference in orange) and <out>_measure.json.

def daily(series, times, clear, ffill=21)

Per-frame values -> a causal daily series from clear frames only (None if fewer than 30 clear days).

def zscore(s)

def combine(parts)

Join per-view series in time (views rarely overlap; where they do, average).

def semantic_fractions(master, gh, gw, use_model=True)

Per-block fraction of vegetation-like and not-ground pixels in a view's anchor frame. Without the
segmentation model every block counts as vegetation and only the seasonal swing picks regions.

def view_series(rgb, valid, times, clear, master, use_model=True)

Automatic regions on one view, and that view's daily GRVI (raw and white-balanced).

def regions_image(master, R, path)

Anchor frame with vegetation blocks tinted green and reference blocks tinted orange.

def measure(blocks_npz, out_prefix=None, use_model=True)

Returns (daily DataFrame, regions of the first view, info dict). Columns: grvi (raw), grvi_wb, gcc_wb,
each per view; `index` is the white-balanced GRVI standardised per view and joined across views, which is
what to compare across years. With `out_prefix`, also writes the CSV, a regions PNG and a JSON summary.

fuelgauge.track

Long-term registration for fixed cameras that drift, get bumped, re-aimed or re-zoomed over years.

Visual-odometry style keyframe tracking inside segments, then loop closure between segments:

  track()          match each frame (SIFT + RANSAC 4-DOF similarity) to the latest keyframes of the
                   current segment; when tracking is lost for several clear frames, start a new segment.
  link_segments()  match every segment's clearest keyframes against every other segment's and join them
                   with a maximum-inlier spanning tree anchored on the largest segment.
  to_master()      compose per-frame transforms into the master view.

Transforms are 2x3 affine matrices in registration-resolution pixels.

def compose(A, B)

Apply A, then B.

def invert(A)

class Matcher(size=(512, 384), nfeatures=1500, terrain_from=0.15, ratio=0.75, min_scale=0.85, max_scale=1.18)

SIFT on CLAHE-equalised grey, ratio test, RANSAC similarity. Only the frame
(below the top sky band) contributes features.

Matcher.feats(self, img_bgr)

Matcher.match(self, a, b)

Similarity transform mapping frame a onto frame b and its RANSAC inlier count.

class Segment(ref: 'int', keyframes: 'list' = <factory>) -> None

Segment(ref: 'int', keyframes: 'list' = <factory>)

class TrackResult(seg: 'np.ndarray', T: 'list', inliers: 'np.ndarray', segments: 'list') -> None

TrackResult(seg: 'np.ndarray', T: 'list', inliers: 'np.ndarray', segments: 'list')

def track(frames, matcher: 'Matcher | None' = None, min_inliers=35, lost_after=4, kf_inliers=160, kf_clarity=1.6, progress=None) -> 'TrackResult'

frames: iterable of (bgr_image or None, clarity score). Images flagged None are skipped.

def group_views(res: 'TrackResult', times, matcher: 'Matcher | None' = None, min_size=20, min_inliers=30, min_frames=60, min_span_days=300, reps=10)

Like link_segments, but a camera that was moved to a new spot keeps its later years: every connected group
of segments becomes its own view, anchored on its largest segment, if it holds at least `min_frames` frames
over at least `min_span_days`. Views are ordered by size. Returns a list of dict(anchor, to_view, links).

def to_master(res: 'TrackResult', links: 'dict')

Per-frame transform into the master view (None where the frame or its segment is unregistered).

def sustained_moves(times, tx, ty, window=15, min_px=30, min_gap_days=60)

Dates where the median camera offset jumps and stays: bumps, re-aims, re-zooms.

fuelgauge.quality

Cheap frame screening (night, glare, blank, blur) and a haze score after registration.

def screen(img_bgr, work=(512, 384)) -> 'dict'

Global checks on a frame. Returns metrics plus `ok` and a `clarity` score used to pick keyframes.

def edge_map(img_bgr)

def edge_agreement(warped_bgr, master_edges, cover, lower_from=0.4) -> 'float'

Correlation of edge strength with the master view over the lower frame. Fog and haze wash out edges.

def clear_flags(edge_corr, times, window='90D', q=0.8, frac=0.8)

Clear if edge agreement is at least `frac` of its recent 80th percentile. Relative to the recent norm,
so a camera re-aim (which changes how well edges match the master) does not read as months of fog.

fuelgauge.colour

Colour measurements on registered frames.

Indices are computed from block-mean RGB:
    GCC  = G / (R + G + B)              green chromatic coordinate (the PhenoCam standard)
    GRVI = (G - R) / (G + R)            green-red vegetation index; tracks browning of evergreen brush better
A diagonal (von Kries) white balance against stable non-vegetation ground in the same frame removes part of
the camera's colour drift: red and blue are rescaled so the reference blocks keep a constant red/green and
blue/green balance. Given frame times, the reference balance is first smoothed with a trailing two-month median,
so the correction follows the camera (sensor drift, replacement, settings) and not the weather on the reference
ground (snow, rain-darkened soil, shadow).

def block_means(img_rgb_f32, cover, block)

Mean RGB per block over covered pixels; blocks with <90% coverage are marked invalid.

def gcc(rgb)

def grvi(rgb)

def white_balance(rgb_blocks, valid, ref_mask, times=None, window='61D')

rgb_blocks (N, gh, gw, 3). Returns blocks with red and blue rescaled so the reference blocks' red/green and
blue/green ratios stay at their long-run median. With `times`, the per-frame reference ratios are smoothed with
a trailing median over `window` first (robust to snow or wet ground on the reference blocks).

def smooth_causal(series, p90_window='7D', mean_days=15)

Trailing 90th percentile over a week, then a trailing two-week mean. Uses only data up to each day.

fuelgauge.rois

Automatic measurement regions on a registered camera.

Vegetation blocks: covered by every camera period, labelled as plants / trees / grass / hillside by the
segmentation model in the master view, in the lower part of the frame, and with a strong seasonal swing in
greenness. Reference blocks (for white balance): covered, not vegetation, little seasonal swing. Blocks of sky
along the horizon can qualify; leaving them out made the correction worse on the 21-camera study (anomaly
correlation 0.13 -> 0.10), since sky colour carries the same daylight and sensor balance as the ground.
No hand-drawn masks.

def seasonal_amplitude(index_blocks, times, good)

def choose(rgb_blocks, valid, times, good, veg_frac, bad_frac, lower_from=0.45, cover_min=0.85, amp_quantile=0.5)

Returns dict(veg, ref, amp). veg_frac / bad_frac: per-block fraction of vegetation / not-ground pixels
in the master view (from segment.masks).

fuelgauge.segment

Semantic segmentation with a pretrained SegFormer-B2 (ADE20K, 150 classes) exported to ONNX.

Used to find the sky (for skyline matching) and to drop roads, buildings, water and people from the
ground measurements. Weights: Xenova/segformer-b2-finetuned-ade-512-512 on Hugging Face (ONNX export of
NVIDIA's SegFormer-B2). Downloaded on first use into ~/.cache/fuelgauge.

def labels_map() -> 'dict[int, str]'

def session()

def logits(img: 'np.ndarray', tiles: 'bool' = True) -> 'np.ndarray'

RGB uint8 image -> class logits (150, H, W). Full view plus four overlapping 60% tiles, averaged.

def labels(img: 'np.ndarray', tiles: 'bool' = True) -> 'np.ndarray'

def masks(lab: 'np.ndarray')

sky, vegetation-like ground, and not-ground masks from a label image.

fuelgauge.camera

Camera model for fixed outdoor cameras: equidistant fisheye with one radial term.

A pixel (u, v) maps to a viewing direction (azimuth, elevation) given
    yaw    - azimuth of the optical axis, degrees clockwise from north
    pitch  - tilt of the optical axis above the horizon, degrees
    roll   - rotation about the optical axis, degrees
    hfov   - angle spanned by the image half-width times two, in the undistorted model, degrees
    k1     - radial term: off-axis angle = (r / f) * (1 + k1 * (r / f)^2)

Wildfire cameras (Mobotix, Axis) are strongly barrel-distorted, so a pinhole model does not fit them.
With k1 = 0 and a narrow field of view this reduces to a close approximation of a pinhole camera.

class Camera(width: 'int', height: 'int', yaw: 'float' = 0.0, pitch: 'float' = 0.0, roll: 'float' = 0.0, hfov: 'float' = 100.0, k1: 'float' = 0.0) -> None

Camera(width: 'int', height: 'int', yaw: 'float' = 0.0, pitch: 'float' = 0.0, roll: 'float' = 0.0, hfov: 'float' = 100.0, k1: 'float' = 0.0)

Camera.focal

Pixels per radian of the undistorted angle.

Camera.params(self) -> 'list[float]'

Camera.with_params(self, p) -> "'Camera'"

Camera.to_dict(self) -> 'dict'

Camera.pixel_to_dir(self, u, v)

Pixels -> (azimuth deg, elevation deg).

Camera.dir_to_pixel(self, az, el)

(azimuth deg, elevation deg) -> pixels. Inverse of pixel_to_dir (Newton on the radial term).

Camera.effective_hfov(self) -> 'float'

Real angular width across the image after distortion, degrees.

fuelgauge.terrain

Tie a fixed camera to the ground.

1. Render the panorama a camera should see from a DEM: for every azimuth, the elevation angle of the
   terrain at each distance (with earth curvature and refraction), its running maximum (the visible
   skyline), and the ground point at each distance.
2. Fit the camera pose (and optionally lens) by matching the image skyline to the rendered skyline.
3. Back-project pixels onto the ground: distance, latitude, longitude.

DEM access defaults to Copernicus GLO-30 on Microsoft Planetary Computer, but any (array, transform)
pair in geographic coordinates works, which is how the tests use a synthetic DEM.

class Raster(arr: 'np.ndarray', lon0: 'float', dlon: 'float', lat0: 'float', dlat: 'float') -> None

Geographic raster: arr[row, col], with lon = lon0 + (col + .5) * dlon, lat = lat0 + (row + .5) * dlat.

Raster.sample(self, lat, lon, order=1)

def load_planetary(collection: 'str', asset: 'str', lat: 'float', lon: 'float', radius_m: 'float', cache: 'str | None' = None, prefer: 'str | None' = None) -> 'Raster'

Mosaic tiles of a Planetary Computer collection around a point (e.g. 'cop-dem-glo-30'/'data',
'esa-worldcover'/'map'). Cached as .npz when `cache` is given.

def destination(lat, lon, az_deg, d)

class Panorama(az: 'np.ndarray', d: 'np.ndarray', ang: 'np.ndarray', cummax: 'np.ndarray', lat: 'np.ndarray', lon: 'np.ndarray') -> None

Panorama(az: 'np.ndarray', d: 'np.ndarray', ang: 'np.ndarray', cummax: 'np.ndarray', lat: 'np.ndarray', lon: 'np.ndarray')

Panorama.skyline

Panorama.unwrap(self, az)

Panorama.skyline_at(self, az)

def render_panorama(dem: 'Raster', lat: 'float', lon: 'float', height_m: 'float', az_lo: 'float', az_hi: 'float', az_step: 'float' = 0.05, max_d: 'float' = 32000.0, step_d: 'float' = 30.0) -> 'Panorama'

def skyline_rows(sky_mask: 'np.ndarray', gray: 'np.ndarray | None' = None, edge_frac=0.05, snap=6, min_level=30)

Row of the first non-sky pixel per column, from a boolean sky mask connected to the top edge.
Optionally snapped to the strongest vertical gradient within `snap` pixels.

def skyline_residuals(cam: 'Camera', pano: 'Panorama', rows: 'np.ndarray')

def trimmed_loss(r, keep=0.8)

def fit_extrinsics(cam: 'Camera', pano: 'Panorama', rows, yaw0: 'float', init=None, yaw_range=20.0)

Fit yaw, pitch, roll with the lens held fixed. Coarse grid, then bounded Powell.

def fit_shared_lens(cams: 'list[Camera]', panos: 'list[Panorama]', rows: 'list', yaw0s: 'list[float]', hfov_grid=array([ 94., 96., 98., 100., 102., 104., 106., 108., 110., 112.]), k1_grid=array([-0.04, 0. , 0.04, 0.08, 0.12, 0.16]))

Joint fit: one lens (hfov, k1) shared by every camera of the same model, extrinsics per camera.
Cameras with a flat, hazy horizon cannot pin the lens down alone; the network can.

def backproject(cam: 'Camera', pano: 'Panorama', step: 'int' = 8)

Ground hit for a grid of pixels: dict of u, v, az, el, dist (m), lat, lon (NaN = sky / beyond range).

def slope_aspect(dem: 'Raster', lat, lon)

Slope (deg) and the compass direction a slope faces (deg) at points.

fuelgauge.viewshed

What can a camera see? Terrain viewsheds and smoke-column line of sight.

Works on any north-up elevation raster (fuelgauge.terrain.Raster, e.g. from load_planetary('cop-dem-glo-90', 'data', ...)).
Rays are cast from the camera at a fixed angular step; along each ray a sample is visible if its elevation angle,
after earth curvature and atmospheric refraction, is at least the steepest angle seen so far.

    from fuelgauge import terrain, viewshed
    dem = terrain.load_planetary('cop-dem-glo-90', 'data', lat, lon, 32000)
    vis = viewshed.viewshed(dem, lat, lon, mast_m=10)          # boolean grid on the DEM's cells
    viewshed.sees_point(dem, lat, lon, 10, lat2, lon2, 300)     # can it see a 300 m smoke column there?
    picks = viewshed.site(dem, existing=[(lat, lon)], k=5)      # where would five more cameras see the most?

def rays(dem, lat, lon, mast_m=10.0, r_max=30000.0, az_step=0.1, step=None)

Visible samples around a camera. Returns (lat, lon, distance_m) arrays of the visible ray samples.
The camera stands `mast_m` above the ground at its location.

def viewshed(dem, lat, lon, mast_m=10.0, r_max=30000.0, az_step=0.1)

Boolean grid, same shape as dem.arr, True where the camera has line of sight to the ground.

def sees_point(dem, lat, lon, mast_m, lat2, lon2, above_ground_m=0.0, step=None)

Line of sight from a camera to a point `above_ground_m` over the terrain at (lat2, lon2).

def coverage(dem, cameras, mast_m=10.0, r_max=30000.0, az_step=0.1)

Count of cameras with line of sight to each cell. `cameras` is a list of (lat, lon) pairs.

def candidates(dem, block_m=6000.0, mask=None)

Hilltops: the highest cell in each block of about `block_m` metres (inside `mask` if given).
Returns a list of (lat, lon, elevation).

def site(dem, existing=(), k=10, mast_m=10.0, r_max=30000.0, target=None, block_m=6000.0, az_step=0.25, cands=None, progress=None)

Greedy camera siting: pick `k` new sites, one at a time, each the candidate that brings the most
currently unseen target cells into view (each newly seen cell counts once).

existing  (lat, lon) pairs of cameras already in place
target    boolean grid like dem.arr of the ground worth watching (default: every cell)
cands     (lat, lon, elev) candidates; default: the highest cell in every `block_m` square
Returns a list of dicts: lat, lon, elev, new_cells, new_km2.

fuelgauge.evaluate

Leave-one-year-out comparison of predictors of live fuel moisture.

Every model is ridge regression on top of a per-unit seasonal baseline: one intercept and one set of
day-of-year harmonics per sampling unit (a site and species), so "the calendar" already knows that sagebrush
and chamise dry down on different schedules. Predictors add to that baseline with shared coefficients. For each
held-out year, the model (and the baseline-only climatology used to score anomalies) is refit on the other
years only, and the held-out year's predictor values are clipped to the range seen in the training years.

def add_season(df, date_col='date')

def loyo(df, feats, site_col='site', min_train=30, season_per_site=True)

df needs columns: site, date, year, lfmc, the season columns and `feats`. Returns (pred df, metrics).

With season_per_site (default) the baseline is an intercept plus seasonal harmonics for every unit, and any
SEASON columns listed in `feats` are folded into that baseline.

def metrics(t, crit=80.0)

def compare(df, sets: 'dict', common=True)

Run every predictor set on the same rows (rows where all sets' features exist when common=True).

fuelgauge.sources.phenocam

PhenoCam Network archive (https://phenocam.nau.edu). Midday images and curated ROI series are public.

def cameras()

def midday_paths(site)

Archive paths of one midday image per day.

def path_date(p)

def download(path, dst, max_side=960)

def roi_series(site)

Curated (hand-drawn ROI) 1-day greenness CSV names for a site.

fuelgauge.sources.hpwren

HPWREN camera network (https://hpwren.ucsd.edu). The public CDN keeps roughly the last 90 days.

Image lists:  https://cdn.hpwren.ucsd.edu/hpwren-cameras/<cam>/<YYYY>/<YYYYMMDD>/<YYYYMMDD>_<cam>_Q<n>.txt
Images:       https://cdn.hpwren.ucsd.edu/MTA/<cam>/large/<YYYYMMDD>/Q<n>/<unix>.jpg
Q1..Q8 are three-hour blocks of local time starting at midnight (Q5 = 12:00-15:00).
Camera positions and nominal headings: https://www.hpwren.ucsd.edu/cameras/sites.js
Attribution: images courtesy of HPWREN (http://hpwren.ucsd.edu).

def sites()

Camera sites with lat/long/elev and per-camera azimuth, from the public camera page.

def frame_times(cam, day: 'dt.date', quarter=5)

def nearest_frames(cam, day: 'dt.date', targets=((12, 30), (13, 30), (14, 30)), tol_s=1200)

def frame(cam, day: 'dt.date', unix_t: 'int', max_side=1536)

fuelgauge.sources.lfmc

Globe-LFMC 2.0 field live fuel moisture (Yebra et al. 2024, Scientific Data; figshare collection 6980418).

def load(cache_dir='data/globe', country='USA')

Returns a DataFrame: site, lat, lon, date, lfmc, species, plus the original columns.

fuelgauge.sources.modis

MODIS MCD43A4 v6.1 nadir BRDF-adjusted reflectance at points, via Microsoft Planetary Computer.

Each point gets the mean of a 3 x 3 window of 500 m pixels. Bands: 1 red, 2 NIR, 3 blue, 4 green, 6 SWIR 1.6 um.

def catalog()

def tile_of(cat, lat, lon)

def read_item(item, points: 'dict')

def indices(df)

Add NDVI, NDII (NIR vs SWIR 1.6 um), visible GCC and GRVI columns to a frame with b1..b6.

def series(points: 'dict', years=range(2000, 2024), every_days=8, workers=16)

Point time series for all points sharing one MODIS tile (call per tile).