Upload a netCDF to create a grid
You are viewing in-progress documentation for v2 (Beta). Switch to the stable version for the current production release.
This guide creates a grid resource from a CF-conformant netCDF you supply. Use
it whenever you have gridded data — a 2D raster of fuel-model codes, a 3D
voxelized bulk-density volume, anything that fits one or more data variables on
a regular (y, x) or (z, y, x) grid — that you want to attach to a domain so
the rest of the FastFuels API can operate on it.
The endpoint is POST /domains/{domain_id}/grids/upload/netcdf and the
workflow is three calls:
- Create the grid — the API returns a
201 Createdand a short-lived signed URL. - Upload the file —
PUTthe netCDF to that signed URL; GCS returns200 OK. - Poll the grid —
GETit untilstatusiscompleted(orfailed).
Unlike the GeoTIFF upload guide,
the request body has no bands field. The netCDF’s data-variable names,
units, and dtype come from the file itself.
Prerequisites
Section titled “Prerequisites”-
An API key. Create one in the FastFuels web app under your account settings. Every request below sends it in the
api-keyheader — set it once here: my-api-key. It will propagate to every code block on the page. -
A domain. All grids hang off a domain. If you do not have one yet, see the Create a domain how-to guide, then come back here. Plug your domain’s
idin once: your-domain-id. -
A CF-conformant netCDF. The uploader enforces a small subset of the CF Conventions — concretely:
- Format: netCDF-4 (
.nc), readable byh5netcdf. - Dimensions: each data variable must have dims exactly
("y", "x")for 2D or("z", "y", "x")for 3D, in that order. Mixed ranks in the same file are rejected. - CRS: a CF
grid_mapping(typicallyspatial_ref) on the data variables. The CRS must match the domain CRS exactly — no auto-reproject. - Units: if a data variable has a
unitsattribute, it must be in UDUNITS-2 canonical ASCII with**for exponents (e.g.kg/m**3,1/m,%). - Z axis (3D only): the
zcoordinate must carrypositive = "up"and be uniformly spaced, with at least two levels. - Pixels: square (
dx == dy). - Extent: must cover the domain bbox (expanded by
num_buffer_cells × native_pixel_sizeif you set the buffer field). - Size: ≤ 1 GB.
rioxarray.Dataset.rio.write_crs()stamps the CRS correctly.ncdump -h your.ncis a quick way to verify dims, coordinate attrs, andgrid_mappingbefore you upload. - Format: netCDF-4 (
Step 1 — Create the grid
Section titled “Step 1 — Create the grid”Submit a POST to /domains/{{DOMAIN_ID}}/grids/upload/netcdf. The body only
carries resource-level metadata — name, description, tags,
num_buffer_cells. The bands themselves are derived from the file at process
time.
Two shapes cover most uploads:
A 2D netCDF — one or more data variables with dims ("y", "x").
Categorical (e.g. fuel-model codes) or continuous (e.g. an elevation
surface). Integer dtypes become categorical bands; floating-point dtypes
become continuous bands.
curl -X 'POST' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/grids/upload/netcdf' \ -H 'accept: application/json' \ -H 'api-key: my-api-key' \ -H 'Content-Type: application/json' \ -d '{ "name": "Custom FBFM40"}'import requests
url = ( "https://api-v2-prod-nyvjyh5ywa-uw.a.run.app" "/domains/your-domain-id/grids/upload/netcdf")
headers = { "accept": "application/json", "api-key": "my-api-key", "Content-Type": "application/json",}
data = { "name": "Custom FBFM40",}
response = requests.post(url, headers=headers, json=data)response.raise_for_status()created = response.json()
grid_id = created["grid"]["id"]upload_url = created["upload"]["url"]{ "grid": { "id": "your-grid-id", "domain_id": "your-domain-id", "name": "Custom FBFM40", "description": "", "status": "pending", "progress": null, "created_on": "2026-05-19T17:57:48.206725Z", "modified_on": "2026-05-19T17:57:48.206725Z", "source": { "name": "upload", "format": "netcdf", "object_name": "grids/your-grid-id/upload.nc", "num_buffer_cells": 0 }, "modifications": [], "bands": [], "georeference": null, "error": null, "chunks": null, "tags": [] }, "upload": { "method": "PUT", "url": "https://storage.googleapis.com/silvx-fastfuels-uploads-v2/grids/your-grid-id/upload.nc?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=...&X-Goog-Date=20260519T175748Z&X-Goog-Expires=3600&X-Goog-SignedHeaders=content-type%3Bhost%3Bx-goog-content-length-range&X-Goog-Signature=...", "content_type": "application/x-netcdf", "expires_at": "2026-05-19T18:57:48.206725Z", "max_size_bytes": 1073741824 }}A 3D netCDF — one or more data variables with dims ("z", "y", "x"). Use
this for voxelized fields like bulk density, leaf area density, or any
layered profile. The z coordinate must be uniformly spaced and carry
positive = "up".
curl -X 'POST' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/grids/upload/netcdf' \ -H 'accept: application/json' \ -H 'api-key: my-api-key' \ -H 'Content-Type: application/json' \ -d '{ "name": "Custom 3D bulk density", "description": "Voxelized bulk density from external LiDAR pipeline.", "tags": ["lidar"]}'import requests
url = ( "https://api-v2-prod-nyvjyh5ywa-uw.a.run.app" "/domains/your-domain-id/grids/upload/netcdf")
headers = { "accept": "application/json", "api-key": "my-api-key", "Content-Type": "application/json",}
data = { "name": "Custom 3D bulk density", "description": "Voxelized bulk density from external LiDAR pipeline.", "tags": ["lidar"],}
response = requests.post(url, headers=headers, json=data)response.raise_for_status()created = response.json()
grid_id = created["grid"]["id"]upload_url = created["upload"]["url"]{ "grid": { "id": "your-grid-id", "domain_id": "your-domain-id", "name": "Custom 3D bulk density", "description": "Voxelized bulk density from external LiDAR pipeline.", "status": "pending", "progress": null, "created_on": "2026-05-19T17:55:21.415282Z", "modified_on": "2026-05-19T17:55:21.415282Z", "source": { "name": "upload", "format": "netcdf", "object_name": "grids/your-grid-id/upload.nc", "num_buffer_cells": 0 }, "modifications": [], "bands": [], "georeference": null, "error": null, "chunks": null, "tags": ["lidar"] }, "upload": { "method": "PUT", "url": "https://storage.googleapis.com/silvx-fastfuels-uploads-v2/grids/your-grid-id/upload.nc?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=...&X-Goog-Date=20260519T175521Z&X-Goog-Expires=3600&X-Goog-SignedHeaders=content-type%3Bhost%3Bx-goog-content-length-range&X-Goog-Signature=...", "content_type": "application/x-netcdf", "expires_at": "2026-05-19T18:55:21.415282Z", "max_size_bytes": 1073741824 }}Four fields on the response matter for the next steps:
grid.id— record this; you’ll need it to poll status.upload.url— the signed URL youPUTyour netCDF to. Single use, valid for one hour.upload.content_type— must be sent as theContent-Typeheader on thePUT. Alwaysapplication/x-netcdf.upload.max_size_bytes— must be echoed asx-goog-content-length-range: 0,<max_size_bytes>on thePUT. GCS rejects the request with400 Bad Requestif this header is missing or doesn’t match.
Step 2 — Upload the netCDF
Section titled “Step 2 — Upload the netCDF”PUT the file directly to the signed URL. The request does not go through
the FastFuels API — it goes to Google Cloud Storage. Send no api-key header.
Two request headers are required and both must match the values returned in
step 1:
Content-Type: application/x-netcdf— fromupload.content_type.x-goog-content-length-range: 0,<max_size_bytes>— fromupload.max_size_bytes.
curl -X 'PUT' \ 'paste-signed-url-from-201-response' \ -H 'Content-Type: application/x-netcdf' \ -H 'x-goog-content-length-range: 0,1073741824' \ --data-binary '@/path/to/your.nc' \ -o /dev/null -w 'HTTP %{http_code}\n'import requests
upload_url = "paste-signed-url-from-201-response"netcdf_path = "/path/to/your.nc"max_size_bytes = 1073741824 # value from `upload.max_size_bytes` in the 201 response
with open(netcdf_path, "rb") as f: response = requests.put( upload_url, data=f, headers={ "Content-Type": "application/x-netcdf", "x-goog-content-length-range": f"0,{max_size_bytes}", }, )
response.raise_for_status()print(response.status_code)A successful upload returns an empty body with HTTP 200 OK. The uploader
service is then triggered automatically.
Step 3 — Poll the grid until it is completed
Section titled “Step 3 — Poll the grid until it is completed”The uploader opens the netCDF from GCS, validates the CF contract against the
domain, clips to the domain extent, derives band metadata from the file’s data
variables, and writes the result into the grid’s storage. While that runs,
GET the grid to watch its status.
curl -X 'GET' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/grids/your-grid-id' \ -H 'accept: application/json' \ -H 'api-key: my-api-key'import timeimport requests
url = ( "https://api-v2-prod-nyvjyh5ywa-uw.a.run.app" "/domains/your-domain-id/grids/your-grid-id")
headers = { "accept": "application/json", "api-key": "my-api-key",}
while True: grid = requests.get(url, headers=headers).json() status = grid["status"] print(status) if status in ("completed", "failed"): break time.sleep(5)
if grid["status"] == "failed": raise RuntimeError(grid["error"])Status walks pending → running → completed (or → failed). Only the last two
are terminal — keep polling on the first two.
{ "id": "your-grid-id", "domain_id": "your-domain-id", "name": "Custom FBFM40", "description": "", "status": "pending", "progress": null, "created_on": "2026-05-19T17:57:05.089784Z", "modified_on": "2026-05-19T17:57:05.089784Z", "source": { "num_buffer_cells": 0, "format": "netcdf", "name": "upload", "object_name": "grids/your-grid-id/upload.nc" }, "modifications": [], "bands": [], "georeference": null, "error": null, "chunks": null, "tags": []}Keep polling. The status sits at pending until the uploader picks up the
file, then moves to running while it validates the CF contract, clips to
the domain, and writes the result. bands is [] and georeference is
null until the uploader finishes inspecting the file. Typical end-to-end
time is a few seconds for small files, up to a minute or two for files
near the 1 GB ceiling.
{ "id": "your-grid-id", "domain_id": "your-domain-id", "name": "Custom FBFM40", "description": "", "status": "completed", "progress": { "percent": 100, "message": "Complete" }, "created_on": "2026-05-19T17:57:48.206725Z", "modified_on": "2026-05-19T17:58:07.022116Z", "source": { "num_buffer_cells": 0, "format": "netcdf", "name": "upload", "object_name": "grids/your-grid-id/upload.nc" }, "modifications": [], "bands": [{ "key": "fbfm", "type": "categorical", "unit": null, "index": 0 }], "georeference": { "crs": "EPSG:32611", "transform": [10.0, 0.0, 720217.94, 0.0, -10.0, 5190645.05], "shape": [89, 132] }, "error": null, "chunks": { "shape": [512, 512], "count": 1, "count_by_axis": { "x": 1, "y": 1 } }, "tags": []}bands is now populated from the data variables in the file — one entry
per variable, with key taken from the variable name, unit from its
units attribute (or null), and type derived from its dtype
(categorical for integer types, continuous for floats). georeference
carries the CRS, affine transform, and shape in pixels.
{ "id": "your-grid-id", "domain_id": "your-domain-id", "name": "Custom 3D bulk density", "description": "Voxelized bulk density from external LiDAR pipeline.", "status": "completed", "progress": { "percent": 100, "message": "Complete" }, "created_on": "2026-05-19T17:55:21.415282Z", "modified_on": "2026-05-19T17:55:40.830305Z", "source": { "num_buffer_cells": 0, "format": "netcdf", "name": "upload", "object_name": "grids/your-grid-id/upload.nc" }, "modifications": [], "bands": [ { "key": "bulk_density.foliage", "type": "continuous", "unit": "kg/m**3", "index": 0 } ], "georeference": { "crs": "EPSG:32611", "transform": [10.0, 0.0, 720217.94, 0.0, -10.0, 5190645.05], "shape": [8, 89, 132], "z_resolution": 1.0, "z_origin": 0.0 }, "error": null, "chunks": { "shape": [8, 512, 512], "count": 1, "count_by_axis": { "z": 1, "y": 1, "x": 1 } }, "tags": ["lidar"]}A 3D georeference adds z_resolution (cell thickness, derived from the
z coordinate spacing) and z_origin (the bottom edge of the lowest cell
— half a cell below the first z coordinate value). chunks.shape adds a
z axis sized for the full vertical column, so reading one chunk returns
a complete vertical profile.
{ "id": "your-grid-id", "domain_id": "your-domain-id", "name": "Custom 3D bulk density", "description": "Voxelized bulk density from external LiDAR pipeline.", "status": "failed", "progress": { "percent": 100, "message": "Failed" }, "created_on": "2026-05-19T18:02:52.918081Z", "modified_on": "2026-05-19T18:03:05.136897Z", "source": { "num_buffer_cells": 0, "format": "netcdf", "name": "upload", "object_name": "grids/your-grid-id/upload.nc" }, "modifications": [], "bands": [], "georeference": null, "error": { "code": "MISSING_Z_POSITIVE", "message": "netCDF z-coord must have attr positive='up'; got None.", "suggestion": null }, "chunks": null, "tags": ["lidar"]}The error.message describes what went wrong. Fix the netCDF and start
over from step 1 — the grid resource itself cannot be retried in place.
See Common pitfalls below for the full set of error
codes the uploader can emit.
Sizing the netCDF to your domain
Section titled “Sizing the netCDF to your domain”By default, the uploader keeps exactly the pixels that fall inside the domain
bbox. If your file’s extent is smaller than the domain bbox, the upload
will fail validation (NO_OVERLAP if the file misses the domain entirely);
if it is larger, the extra pixels are clipped away silently.
Two knobs control how forgiving this is:
num_buffer_cellsin the create request (default0). Setting this to e.g.2keeps two native-resolution cells of padding around the domain on each side, which is useful when downstream operations (resample, focal filters) are edge-sensitive.- The domain extent itself. If you want FastFuels to ingest a region larger than your domain, expand the domain — domains define what “in scope” means for every grid hanging off them.
The expression that must hold is:
netCDF extent ⊇ domain bbox expanded by (num_buffer_cells × native_pixel_size)Bands are derived from your file
Section titled “Bands are derived from your file”The netCDF endpoint has no bands field in the request body. Instead, each
data variable in the file becomes one band on the resulting grid, populated as
follows when the upload finishes:
| Band field | Derived from |
|---|---|
key | The data variable’s name in the netCDF (e.g. fbfm, bulk_density.foliage). |
type | categorical if the variable’s dtype is integer; continuous if floating-point. |
unit | The variable’s units attribute, or null if unset. Must be UDUNITS-2 canonical ASCII (kg/m**3, 1/m, %) when set. |
index | The position of the variable in the dataset. |
Common pitfalls
Section titled “Common pitfalls”The uploader emits one of the following error.code values in the failed
response:
MISSING_CRS— the dataset has no CFgrid_mapping. Callds.rio.write_crs("EPSG:<code>")before saving the file.CRS_MISMATCH— the netCDF CRS does not match the domain CRS. The uploader does not auto-reproject; reproject in your authoring pipeline before uploading.WRONG_DIMS— at least one data variable has dims other than("y", "x")or("z", "y", "x")in that order, or the file mixes 2D and 3D variables. Transpose withda.transpose("y", "x")(or"z", "y", "x") and drop or split out the variables that don’t fit.INVALID_UNITS— a data variable’sunitsattribute isn’t valid UDUNITS-2 canonical ASCII. Rewrite the attribute (e.g.kg/m**3instead ofkg/m³).MISSING_Z_POSITIVE— thezcoordinate doesn’t carrypositive = "up". Setds["z"].attrs["positive"] = "up"before saving.SINGLE_Z_LAYER— a 3D variable has only onezlevel, so cell thickness cannot be inferred. Either upload as 2D (drop thezdim) or include at least twozlevels.NONUNIFORM_Z—zcoordinates are not uniformly spaced. Resample to a uniform grid before uploading; the API storesz_resolutionas a scalar.NON_SQUARE_PIXELS—dx ≠ dy. Resample to square pixels (typically withgdalwarp -tr <res> <res>orda.rio.reproject(..., resolution=res)).NO_OVERLAP— the netCDF doesn’t intersect the domain extent. Verify the file covers the domain region in the domain’s CRS.
Two additional notes:
- The signed URL is single-use. If a
PUTfails for any reason, start over from step 1 to get a fresh URL. - Variable names become band keys verbatim. Pick names you’d be happy to
see in subsequent API responses; dot notation (e.g.
bulk_density.foliage) is supported and conventional for grouping related bands.