This is the technical reference: how to install, what the numbers mean, and how the workflows run. Climate Risk Commons turns hazard curves, asset locations and damage functions into comparable, auditable risk numbers, on a laptop, over open data. For why the project exists, start on the project site. If you would rather not write Python, use the without-code guide.
Build with code
Python users: install the SDK and run a portfolio analysis offline.
Every analysis in the framework is the same three inputs meeting in one place. Learning where each one lives is most of the learning curve.
Input
What it looks like in code
Where it comes from
Climate hazard data
A canonical Parquet file of fitted curves, one row per hazard, horizon, pathway and H3 cell
Ingested from open rasters, or supplied by any conforming provider
Physical assets
An Arrow table or Parquet file with an id and either coordinates or an H3 cell_index
Your portfolio, loan book or asset register
Impact functions
A callable or a registry-backed transform mapping exposure to damage
The open starter library, the registry, or one you write
The output is one row per asset, hazard, horizon and pathway, with a value column for each return period you asked for, and metadata recording exactly how each number was produced.
Read these two pages before you trust a number
Probability and distributions explains what a return period means here and why the 100-year event is queried at q=0.99. Impact functions explains the difference between evaluating an event and transforming a distribution, the easiest thing in the framework to get subtly wrong.
Names you will meet
Climate Risk Commons
The whole of it: the specification, the reference implementation, the community that maintains them, and RiskThinkingFoundation, the neutral non-profit that stewards them. Shortened to CRC, which is where every package name comes from.
The CRC specification
The standard itself. Input contracts for climate hazard data, physical assets and impact functions, plus the canonical hazard schema, the output model and the conformance rules.
Reference pipeline
An end-to-end implementation of the spec. The runnable scripts live in crc-docs/pipelines/ and are built on crc-sdk.
crc_framework
The import name of the computation core. Distributions and metrics live here.
crc_sdk
The import name of the SDK. Data access, ingest and workflows live here, and it re-exports the stable framework API through crc_sdk.core, .fitting and .impacts.
Climate Digital Twin (VELO/CDT)
RiskThinking.AI's commercial high-fidelity data and enterprise platform. Separate from these packages, and reached through a commercial agreement.
Getting started
Installation
Baseline install is one command. The extras exist because format adapters are heavy and most callers need none of them.
Pick an entry point
There are two reasonable ways in, and they answer different questions.
Run the worked examples
Best if you want to see a complete analysis before writing any code. Clone crc-docs and let uv resolve everything, including the notebook stack.
git clone https://github.com/RiskThinking/crc-docs
cd crc-docs
uv sync
# notebooks expect notebooks/ as the working directory,# so their data/ cache lands next to them
uv run jupyter lab notebooks/
# pipelines run from the repo root
uv run python pipelines/asset_portfolio_pipeline.py
Add it to your own project
Best if you already know which surface you need. Install crc-sdk and add extras as the import errors tell you to.
uv add crc-sdk
# or, with the adapters for OS-Climate Zarr and H3 geometry
uv add "crc-sdk[zarr,geometry]"
# framework only: distributions, fitting and risk metrics, no data access layer
python -m pip install crc-framework
What comes in the box
DuckDB, Arrow, psutil and the remote-storage transports (fsspec, s3fs, gcsfs) are baseline dependencies, not extras. Every connector and workflow in the SDK is built on that stack, so gating it would just move the same install onto every real caller.
Everything else is a specific format adapter or a pure-geometry dependency. Importing crc_sdk or any subpackage never needs more than the baseline.
Extra
Adds
You need it for
zarr
zarr
OSClimateProvider and ZarrRaster: OS-Climate Zarr raster ingest
raster
rasterio
GeoTiffRaster: GeoTIFF and COG ingest, streamed through GDAL's VSI layer
netcdf
h5netcdf, h5py
NetCDFRaster: NetCDF and CF ingest
geometry
h3, h3ronpy, shapely
H3Indexer, intersecting_cells, cell_polygon, vectorised batch H3 over Arrow, raster-to-H3 sampling
agriculture
icechunk, Zarr v3, pyproj
USDA CDL Icechunk scans. Check the platform guidance below for extra requirements
test
mypy, pytest, ruff
Development only
Extras announce themselves
Every function that needs an extra-gated dependency imports it lazily and raises an ImportError naming the extra to install. You do not have to guess up front: install the baseline, run your code, and add what it asks for.
One dependency pip cannot give you
PMTiles generation shells out to tippecanoe. Both tippecanoe and tile-join must be on PATH; they are expected to be part of your runtime image rather than a Python install, so there is no extra for them.
from crc_sdk.geometry.pmtiles import require_tippecanoe, require_tile_join
require_tippecanoe() # raises with install instructions if missing
require_tile_join()
Platforms and Python versions
We recommend Python 3.12 or later for new environments. Choose a version supported by your selected packages and extras. For current requirements and prebuilt wheels, check crc-sdk on PyPI and crc-framework on PyPI. Wheel availability depends on your Python version, operating system and architecture.
A compatible prebuilt wheel needs no Rust toolchain. For source builds and extra-specific dependencies, follow the SDK and framework instructions on GitHub.
Then check that the SDK's engine layer imports and that the framework API is reachable through it:
import crc_sdk
from crc_sdk.workflows import HazardDataset
from crc_sdk.core import TabulatedDistribution # re-exported from crc_framework
curve = TabulatedDistribution.from_return_periods(
[10, 50, 100], [0.4, 1.1, 1.6], tail="upper",
)
print(curve.ppf(0.99)) # the 100-year value
DuckDB thread, memory and spill-directory limits are detected automatically and can be overridden with environment variables; see Resources and tuning.
Getting started
Quickstart
Flood depths for a small portfolio, offline, in about ten minutes. No cloud credentials, no account, no hazard data to download.
Why this path first
The asset-portfolio track reads checked-in Parquet fixtures for Cologne under fixtures/os_climate/. It is fully offline and it is ordered, so each notebook builds on the one before. The spatial track is just as interesting but it fetches boundaries and rasters over the network, which is a worse first experience.
1. Get the repository and the environment
git clone https://github.com/RiskThinking/crc-docs
cd crc-docs
uv sync
uv sync reads the lockfile, so you get the exact dependency set the notebooks were executed against.
2. Run a whole analysis before reading any of it
uv run python pipelines/asset_portfolio_pipeline.py
Outputs land in pipeline_output/. Every notebook in the ordered track has a headless twin like this one, which makes the examples usable as regression checks and as a starting point for your own scripts.
3. Open the notebook that matches it
uv run jupyter lab notebooks/
Start with asset_portfolio_evaluation.ipynb. Launch from the repo root as shown so the notebook's data/ cache lands beside it rather than in your working directory.
4. Point the same workflow at your own assets
This is the whole portfolio surface. An Arrow table in, a Parquet file out, one value column per return period.
import pyarrow as pa
from crc_sdk.workflows import HazardDataset
assets = pa.table({
"asset_id": ["warehouse-a", "warehouse-b"],
"longitude": [6.9603, 7.5010],
"latitude": [50.9375, 51.0030],
"sector": ["logistics", "manufacturing"],
})
result = (
HazardDataset.local("flood.parquet")
.for_assets(assets)
.select(horizons=[2050], pathways=["ssp585"])
.return_periods([25, 50, 100, 250, 500, 1000])
.write_parquet("portfolio-flood.parquet")
)
You get value_rp25 through value_rp1000. The column names asset_id, longitude, latitude and cell_index are inferred, so a conventional schema needs no configuration at all.
5. Turn depth into damage
An impact function replaces the sampled hazard values with event-aligned impact values before writing.
Now value_rp100 is the damage ratio at the 100-year flood, not the depth. The return period still identifies the source hazard event.
Getting started
Learning path
Notebooks in two ordered and unordered tracks, sharing the same packages. Most have a headless pipeline twin that does the same work without Jupyter. Every notebook also opens in Colab with no install.
Asset portfolios
Ordered, offline, and the right place to start. It reads the checked-in Cologne fixtures under fixtures/os_climate/, so it runs without AWS access or any other credentials.
#
Notebook
Pipeline twin
What it shows
1
hurdle_fit_primer
none
Reconstruct a canonical hurdle curve and read its sample diagnostics
2
asset_portfolio_evaluation
asset_portfolio_pipeline.py
Fluent HazardDataset portfolio evaluation at return periods
3
portfolio_impact
portfolio_impact_pipeline.py
Event-aligned PiecewiseLinearImpact through to currency damage
4
portfolio_risk_metrics
portfolio_risk_pipeline.py
Microscores through to portfolio VaR and CVaR, with attribution
5
multi_scenario_portfolio
multi_scenario_pipeline.py
Historical against transparent local tail stresses in one evaluation
The multi-scenario cases are stresses, not projections. Notebook 5 runs sensitivity stresses on local tails. They are deliberately transparent and deliberately not calibrated climate projections. Do not present them as scenario forecasts.
Spatial and thematic
Unordered, and they fetch public inputs over the network. Best read once you know what a canonical hazard dataset is.
Notebook
Pipeline twin
What it shows
flood_risk_by_province
flood_admin_pipeline.py
OS-Climate to H3, joined to administrative boundaries, enriched with Overture places, written out as PMTiles
jrc_global_flood_hazard
jrc_flood_pipeline.py
Streamed GeoTIFF and COG sampled to H3, using JRC LISFLOOD
jrc_edo_drought_index
jrc_drought_pipeline.py
EDO Soil Moisture Index drought, fitted on the lower tail
agricultural_climate_risk
agricultural_climate_pipeline.py
USDA CDL crop cover or Fields of The World fields against flood or drought hazards
era5_historical_baseline
none
An open historical heat and rainfall baseline, such as a 1-in-10-year hottest day
cmip6_rx1day_projection
none
Projected wettest-day (Rx1day) rainfall from CMIP6
Running them
The two working directories differ, and it matters.
# notebooks: run with notebooks/ as the working directory
uv run jupyter lab notebooks/
# pipelines: run from the repo root
uv run python pipelines/flood_admin_pipeline.py
notebooks/data/
Cached notebook inputs and outputs. Gitignored.
pipeline_output/
Everything the headless pipelines write. Gitignored.
fixtures/os_climate/
Checked-in Cologne Parquet fixtures. This is what makes the portfolio track offline.
Plotly figures on GitHub
GitHub's notebook preview cannot run JavaScript, so notebooks executed locally before commit emit a static PNG fallback through kaleido. Figures will look interactive locally and flat in the browser preview. Nothing is wrong.
Core concepts
Probability and distributions
Everything the framework computes passes through a distribution interface. Two conventions do most of the work, and getting them straight prevents an entire class of quiet error.
Non-exceedance probability is the currency
Every metric API takes a non-exceedance probability q in [0, 1]. Return periods are accepted in exactly one place: when constructing a tabulated distribution.
For an upper-tail hazard, a return period T converts as q = 1 − 1/T. So the 100-year return level is queried at q=0.99, and the 500-year at q=0.998.
Return period
q (upper tail)
Output column
25
0.96
value_rp25
50
0.98
value_rp50
100
0.99
value_rp100
250
0.996
value_rp250
500
0.998
value_rp500
1000
0.999
value_rp1000
Lower-tail hazards invert this
Drought is a lower-tail hazard: the damaging outcome is a small value, not a large one. Canonical metadata records the probability convention and the source support for the dataset, so evaluation selects the correct tail automatically. If you are writing your own ingest, you are the one who records that convention.
One interface, five shapes
All distributions expose pdf, cdf, ppf, quantiles and sample. Evaluation methods accept both scalars and NumPy arrays. Choose the shape by the form of the data you actually have, not by what you wish you had.
Class
Use it when
EmpiricalDistribution
You hold raw observations
TabulatedDistribution
You hold explicit probability and value pairs, such as return-period knots from a published hazard map
FittedDistribution
You know the parametric family and its parameters
HurdleDistribution
A point mass at one value, then a truncated parametric tail. The usual shape for a zero-heavy hazard such as inundation depth over dry land
PointMassDistribution
The value is constant across the whole probability support
Tabulated curves refuse to guess
TabulatedDistribution does not extrapolate. Querying outside its declared probability range raises, unless you opt in explicitly.
from crc_framework import TabulatedDistribution
curve = TabulatedDistribution.from_return_periods(
[5, 10, 25, 50, 100],
[0.2, 0.6, 1.1, 1.5, 1.9],
tail="upper",
)
curve.ppf(0.99) # fine: the 100-year knot is the edge of support
curve.ppf(0.999) # raises: beyond the declared range
loose = TabulatedDistribution.from_return_periods(
[5, 10, 25, 50, 100],
[0.2, 0.6, 1.1, 1.5, 1.9],
tail="upper", extrapolate=True,
)
This is a deliberate design choice, and it is the behaviour you want. A silent extrapolation from a 100-year source map to a 1000-year answer is exactly the kind of number nobody can defend later.
Interpolation, extrapolation, and what your source supports
Portfolio evaluation warns when a requested return period falls outside the source support recorded in the dataset metadata. With EFAS source rasters covering RP10 to RP500, asking for RP250 is interpolation, and asking for RP1000 is extrapolation. Both return numbers. Only one of them is defensible without a footnote.
Core concepts
Fitting curves
Fitting is always explicit. Metrics sample the distribution you hand them and never quietly substitute a fitted curve, which is what makes the modelling choice auditable.
Three entry points
Function
Input
Family
Diagnostics
fit_distribution
Raw observations or an EmpiricalDistribution
Selected by KS p-value, or named
KS, RMSE, R-squared
fit_quantiles
Probability and value knots
Explicit, required
Residuals in value space
fit_hurdle_quantiles
Knots plus a known point mass
Explicit, required
Residuals in value space
Knots are not observations
Passing a tabulated or fitted distribution to fit_distribution is an error, not a convenience. Return-period knots are not independent samples, so a KS statistic computed against them would be meaningless. Use fit_quantiles for knots.
Pass family="auto", the default, to select among them. Pass a name to fit one family. Use fit_all to inspect every candidate, and quality_metrics to compute diagnostics for a distribution you have already chosen.
FitConstraints is an acceptance constraint, not a hint. It lets you reject a fit that puts an implausible value at a probability you care about.
Fitting published return-period knots
This is the common case when you are canonicalising a public hazard map. fit_quantiles minimises weighted differences in value space between your knots and the family's PPF.
A flood-depth map over mostly dry land is mostly zeros. The framework will not infer an exact point mass from sparse zero-valued knots, because the inference is not sound. Supply the probability yourself.
from crc_framework import fit_hurdle_quantiles
fit = fit_hurdle_quantiles(
curve,
family="gumbel_r",
atom_probability=0.5, # half the support sits on the atom
atom_location=0.0, # and the atom is at zero depth
)
Notebook 1 in the ordered track, hurdle_fit_primer, reconstructs one of these from a canonical row and walks its diagnostics. It is worth an hour.
Lossless against lossy
TabulatedDistribution is the lossless interpolation of the knots you supplied. Parametric and hurdle fits are lossy. If your system has to reconstruct its source exactly, persist the original probability and value pairs separately.
What this means for canonical files
Source knots and fit diagnostics are transient ingest inputs, not a second persisted contract. Values reported at source return periods are fitted curve evaluations, not guaranteed bit-for-bit reproductions of source pixels. Schema 1.2 does let you persist a tabulated curve directly when you need the lossless form.
Core concepts
Impact functions
An impact function maps exposure to damage. It has two deliberately distinct operations, and choosing the wrong one is the most common subtle error in the whole framework.
Two operations, two meanings
Call
What it does
Use it when
impact.evaluate(values)
Evaluates event-aligned exposure values. Input shape and order are preserved, including for decreasing or non-monotonic callables
Return periods continue to identify the source hazard event
impact(distribution)
Transforms an exposure distribution into an impact distribution. Decreasing built-in transforms reorder the output quantiles so the result stays a valid distribution
You are doing risk analysis on the impact distribution itself
Why the portfolio workflow picks the first one
The SDK samples each hazard return period, then calls impact.evaluate(...) on that row's value vector. So value_rp100 = impact(hazard_rp100) and the return period still names the hazard event. This differs on purpose from transforming a full distribution and then taking an impact quantile, which can reorder decreasing or non-monotonic impacts. If you want that interpretation, use the distribution interface in crc-framework directly.
Built-in transforms
LinearImpact, SigmoidImpact and PiecewiseLinearImpact support both operations. CallableImpact adapts a vectorised NumPy callable for point evaluation; CallableTransform also supports distribution transformation.
import numpy as np
from crc_framework import CallableImpact, LinearImpact
impact_function = LinearImpact(slope=0.25, maximum=1.0)
# event-aligned: two events in, two impacts out, same order
event_impacts = impact_function.evaluate(np.array([0.5, 2.0]))
# distributional: an exposure distribution in, an impact distribution out
impact = impact_function(exposure)
custom = CallableImpact(lambda values: np.clip(values / 2.0, 0.0, 1.0))
The registry, keyed by risk factor and context
impacts.for_factor(...) selects a registry-backed climate transform. Supply a TransformContext with the geography, building type and any historical values it needs; pass overrides when your application must replace transform parameters.
The generated H3 cell_index is always supplied to the framework impact context.
Configured context columns are read from every asset, including when they are not kept as output passthrough columns.
Stored registry context provides fallback values for fields with no asset value.
Impact metadata records the event-aligned interpretation, source units and semantics, output units and semantics, function name and type, and the context-column mapping.
Lambdas cannot be parallelised
Framework impact objects and top-level Python callables run in the existing process pool. Lambdas and closures are not picklable, so they run serially when the worker count is implicit. Asking for more than one worker with a lambda raises rather than silently degrading.
from crc_sdk.workflows import ExecutionOptions
# a lambda plus explicit parallelism is an error
.impact(lambda depth: np.clip(depth / 2.0, 0.0, 1.0), name="ratio")
.write_parquet("out.parquet", execution=ExecutionOptions(max_workers=1))
# promote it to a module-level function to use the pool
def depth_to_ratio(values):
return np.clip(values / 2.0, 0.0, 1.0)
Core concepts
Microscores, VaR and CVaR
The path from a hazard distribution to a portfolio risk number runs through binary outcomes. It is a small API with one sharp edge: branch count grows as two to the power of the number of factors.
Microscores turn quantiles into outcomes
A microscore samples exposure and impact at the probabilities you name, and reduces them to a binary risk outcome.
The sampled exposure and impact values behind every score.
suite.at(q)
The binary outcome at a requested non-exceedance probability.
suite.exposure_statistics
Summary diagnostics for the exposure side.
suite.impact_statistics
Summary diagnostics for the impact side.
Aggregating factors into VaR and CVaR
Each microscore becomes a BinaryOutcome. Combining outcomes produces the full set of independent downside and upside branches, and risk is computed at one or more confidence levels.
Branch generation grows as 2**n for n factors. Ten factors is a thousand branches; twenty is a million. Set max_branches on compute_spanning_set or compute_risk to impose a limit your application can actually defend.
Factors
Branches
Comfort
4
16
Trivial
10
1,024
Fine
16
65,536
Set a limit
24
16,777,216
Rethink the decomposition
RiskLevel.attribution reports each factor's contribution to VaR and CVaR, which is usually the number a committee actually wants to see.
Spatial lookups
Spatial helpers take an H3 cell as an integer or a string, normalise it to H3 resolution 4, and return None when no reference mapping exists. That None is meaningful: handle it rather than asserting.
from crc_framework import lookup_geography, lookup_ipcc_region
cell = 600550049193132031
geography = lookup_geography(cell)
region = lookup_ipcc_region(cell)
if geography is not None:
print(geography.continent, geography.countries)
lookup_continent returns a continent name, lookup_ipcc_region returns the IPCC region, and lookup_geography returns a Geography with the continent and the intersecting ISO3 country codes.
Data
Canonical hazard datasets
One versioned Arrow and Parquet contract holds every fitted hazard. Understanding this file is understanding the standard: it is the thing two teams can compare.
One self-describing file
Each dataset is a single Parquet file, expanded by H3 cell so it can be joined spatially. You choose the full destination path and filename. Writes go through DuckDB, so supplying a configured connection lets the same API use its filesystems, extensions, secrets and settings.
external raster/table→source curves and geometry→selected family fit→conservative intersecting H3 cells→canonical Arrow→Parquet
The row key
The logical row key is (hazard_name, horizon, pathway, cell_index, source_id). Canonical files are sorted by that key by default, which enables predicate pruning and merge joins.
cell_index is a join key, not an identifier
It is the spatial join key, not a globally unique row identifier. Several source curves can land in one cell. When that happens for a single asset, hazard, horizon and pathway, evaluation raises rather than silently picking or averaging one.
Curve columns are a tagged union
Rows carry the parameters needed to reconstruct a framework distribution. curve_kind tells you which shape you are holding, and which columns are populated.
curve_kind
curve_type
What is populated
Since
fitted
The parametric family
Scalar curve parameters. curve_shape is null for Gumbel families, which have no shape parameter
1.0
hurdle
The parametric family
Scalar parameters plus atom probability and location
1.0
point_mass
point_mass
Zero scale, probability one at curve_location. Constant across the whole source support
1.1
tabulated
linear_probability
The list columns curve_probabilities and curve_values. Scalar parameters are null
1.2
no_data
An explicit scientific reason code
Nothing. All parameter fields are null, and batch quantile evaluation returns nulls for these rows
1.2
This layout avoids a sprawl of redundant status, fit-stage, interpolation and reason columns. Dataset metadata and run manifests carry ordered-family and aggregate treatment provenance once, rather than on every row.
Metadata carries the meaning
Dataset-wide facts are stored once as a complete JSON payload under the Parquet key crc.hazard.metadata. Without it the numbers are ambiguous, so read it before you trust a file someone hands you.
Recorded
Why you need it
Schema version
Tells you which curve kinds can appear
H3 resolution
One uncompacted resolution per dataset. Asset points are converted to it
Non-exceedance probability convention
Which tail is the damaging one
Source probability support
Where interpolation ends and extrapolation begins
Value unit and semantics
Metres of depth, an index value, a ratio
WKB CRS
The coordinate reference system of any stored source geometry
Producer and source provenance
Who made it and from what
Curve-fit policy and creation version
How the fit was chosen, and by which version of the code
Evaluation writes its own record under crc.hazard.evaluation: value unit, value semantics, and the complete mapping from return period to probability to column name.
Cells are a conservative superset
Boundary candidate generation uses H3 overlap coverage, not centre polyfill. The integer join therefore returns a conservative superset, which is then refined exactly.
Where source WKB is present, the join is refined with ST_Covers(source_geometry, asset_point).
Rows without WKB keep cell-level precision.
source_id and spatial_match, which is either exact_geometry or h3_cell, stay in the output so you can see which happened.
Resolution estimates report measured coverage error and expanded row count; ingest policy selects and records the dataset resolution.
Writing a canonical stream
write_hazard_stream has two modes, and the choice is a memory-against-storage trade.
ordered=True (default)
DuckDB materialises the stream, rejects duplicate keys, globally sorts, and may spill to the work directory after its memory limit. Keeps engine memory predictable, compresses better, and can speed up scans that benefit from physical key clustering.
ordered=False
Appends validated Arrow batches straight to a local Parquet staging file, scans only the projected row-key columns for duplicates, and publishes atomically after validation. An explicit low-memory alternative for unusually large partitions or tight containers.
Canonical schema, metadata, uniqueness and every downstream curve and percentile API are identical either way. Only the physical row order differs.
Measured on one machine
These figures measure the persistence pass only, on one machine. Treat them as evidence of the trade-off, not as throughput guarantees.
Sample
Mode
Peak RSS
File size
Time
2,148,497 rows
streaming
656 MB
45 MB
16.03 s
2,148,497 rows
ordered
1.69 GB
35 MB
16.38 s
300,000 rows
streaming
282 MB
6.3 MB
3.01 s
300,000 rows
ordered
525 MB
4.9 MB
2.78 s
Storage reduction from ordering was consistent across both samples. The small timing difference changed direction between them, so do not plan around it. Whichever mode you choose, reserve process headroom beyond the engine limit for Arrow and Python buffers and the final Parquet write.
Data
Ingesting public hazards
Acquisition is an immutable, lazy workflow. Builder calls do no network work at all, so you can print a plan and read it before anything is fetched or fitted.
Plan first, then materialise
Builder methods and explain() never resolve a release or fetch a raster. materialize() resolves latest once, records the pinned version in the cache manifest and in canonical provenance, caches the AOI crops, fits the curves, and hands back an ordinary HazardDataset.
from crc_sdk.workflows import HazardDataset, JRCFloodPolicy
plan = (
HazardDataset.efas(version="latest")
.for_area((7.75, 49.75, 8.45, 50.25))
.cache("cache/efas", mode="reuse")
.source_periods("all")
.canonicalize(policy=JRCFloodPolicy.curated(h3_resolution=10))
)
print(plan.explain()) # no network access yet
hazard = plan.materialize("hazards/efas-rhine.parquet")
mode="reuse"
Use the cache if it is there, fetch what is missing.
mode="offline"
Fail rather than touch the network. Pair it with prefetch().
mode="refresh"
Re-resolve the release and replace cached crops.
mode="stream"
Read remotely with no persistent source cache.
Cache manifests pin the resolved version, years, bounds, source URLs, local objects and checksums. Run prefetch(), then switch the same plan to mode="offline", and the run is reproducible without network access.
Source periods are not evaluation periods
This distinction catches people. Source periods choose which rasters are used for fitting, and default to every period in the resolved release. Evaluation periods are what you ask the fitted curve for afterwards.
The compact chain lazily materialises a deterministic canonical file inside the configured cache, then delegates to the same local portfolio evaluator that HazardDataset.local(...) uses. Requests beyond the source return-period support produce a warning; see interpolation and extrapolation.
JRC flood: GLOFAS and EFAS
The two releases are laid out differently, and the dataset descriptions encode that difference so an AOI workflow never exposes tiles or filenames to you.
Dataset
Version
Layout
GLOFAS
2.1.2
JRC's tiled global layout
EFAS
3.1.1
Nine continental return-period rasters
JRC and EDO drought
EDO Soil Moisture Index data uses the same lazy workflow, with complete years reduced to compact annual-minimum AOI cache objects before fitting.
The curated policy uses the lower return-period tail and requires at least twenty complete years. Metadata stores both that tail and the Gringorten support of the selected annual record, so evaluation picks the correct lower tail on its own and warns on extrapolation.
OS-Climate return-period rasters
OS-Climate rasters are canonicalised with OSClimateIngestPolicy and canonicalize_os_climate. You must choose the distribution family, and for a zero-heavy hazard you must provide an explicit HurdleFitPolicy: the SDK will not infer an exact point mass from sparse knots.
from crc_sdk.workflows import OSClimateIngestPolicy, canonicalize_os_climate
Plain curves are fitted with fit_quantiles; hurdle curves with fit_hurdle_quantiles. Persisted rows are queried back through LocalProvider and HazardQuery. The zarr extra is required for OSClimateProvider and ZarrRaster.
Private buckets
Authenticated remote sources are configured the idiomatic DuckDB way: raw secret DDL, passed as setup_sql so it runs on connect, right after extensions load. There is deliberately no secret-builder type in the SDK, because DuckDB's own secret DDL is already the documented interface.
import os
from crc_sdk.connectors.duckdb import DuckDBConnection, sql_quote
setup_sql = []
key_id, secret = os.getenv("GCS_ACCESS_KEY"), os.getenv("GCS_ACCESS_SECRET")
if key_id and secret:
setup_sql.append(
f"CREATE OR REPLACE SECRET gcs (TYPE GCS, KEY_ID {sql_quote(key_id)}, "
f"SECRET {sql_quote(secret)})"
)
con = DuckDBConnection.for_analytics(work_dir, setup_sql=setup_sql).connect()
One DuckDB quirk worth knowing
PROVIDER is a bare keyword (config, credential_chain and so on), not a quoted string literal, unlike every other secret option. sql_quote and sql_identifier are exported for building that SQL safely.
Data
Connectors and sources
Connectors are source-format readers and query engines. Ingest adapters do the conversion into the canonical contract; connectors themselves stay simple on purpose.
Where things live
crc_sdk.core
The stable public API of crc_framework, re-exported.
crc_sdk.fitting
Fitting entry points, re-exported from the framework.
crc_sdk.impacts
Impact transforms and the registry, re-exported from the framework.
crc_sdk.connectors
External formats and query engines. DuckDB connection helpers, Zarr and GeoTIFF ingest, the lazy pipeline seam.
The coordination layer, where data access meets computation. Most of what you will call day to day.
Import the stable surface
Use crc_framework's public API rather than reaching into crc_framework._core. The underscore module is an implementation detail and may change between releases.
The lazy process seam
DuckDBRelationSource, ArrowBatchSource and DuckDBPipeline form one common seam. Native SQL and Parquet adapters return relations; chunk stores yield bounded Arrow batches. Both feed the same immutable filter, project, aggregate and write pipeline.
Reader
Extra
Notes
GeoTiffRaster
raster
GeoTIFF and COG, streamed from local paths or gs://, s3:// and http(s):// URIs through GDAL's own range-request support. No local download by default
Agricultural requests are immutable and bounded before they can scan. Builder calls perform no network I/O; relation(), to_arrow_reader() and write_parquet() are the execution points.
from crc_sdk.workflows import AgriculturalLayer
crop_mix = (
AgriculturalLayer.usda_cdl()
.resolution("30m")
.for_area((-93.46, 42.14, -93.45, 42.15))
.years(2025)
.classes([1, 5]) # corn and soybeans
.scan()
.pipeline()
.aggregate("count(*) AS sampled_pixels", groups="year, crop_code, crop_name")
)
for batch in crop_mix.to_arrow_reader():
process(batch)
For global predicted field units, swap the source and keep the same surface:
Do not overclaim these layers. FTW fields are remote-sensing units. They are not cadastral parcels and not evidence of ownership. CDL crop pixels are land-cover observations, not acreage, yield or financial exposure.
The two sources take different routes for a real reason. DuckDB's community duckdb_zarr extension can scan ordinary remote Zarr v2 and v3 stores, but it cannot open Icechunk's versioned repository and session model. USDA CDL therefore uses the official Icechunk client for version resolution and chunk reads, then exposes bounded Arrow batches to DuckDB; it never materialises the full raster or a full AOI in memory. FTW GeoParquet stays native in DuckDB, applying bbox pruning before exact spatial filtering.
PMTiles
PMTilesBuild streams a GeoParquet source, either a single file or a Hive-partitioned dataset glob, into one .pmtiles archive in a single tiling pass. The GeoParquet to GeoJSON bridge is built in DuckDB spatial SQL (ST_AsGeoJSON, ST_ReducePrecision, ST_Transform) rather than shelling out to an external converter. Thread defaults and the pre-flight disk check are covered in Resources and tuning.
Public inputs used by the examples
Source
Used for
OS-Climate
Return-period hazard rasters, in Zarr
JRC LISFLOOD, GLOFAS, EFAS
Global and European flood hazard
JRC EDO
Soil Moisture Index, for drought
ERA5
Historical heat and rainfall baselines
geoBoundaries
Administrative boundaries for spatial joins
Overture
Places, to enrich spatial results and discover candidate assets
USDA CDL
United States crop land cover, through Icechunk
Fields of The World
Global predicted field units, as GeoParquet
Workflows
Asset portfolios
Evaluate canonical curve parameters for a whole portfolio without returning to the source format and without refitting. One row per asset, hazard, horizon and pathway; one value column per return period. The minimal example is in the quickstart.
Four ways to hand over assets
You pass
What happens
An Arrow table
Registered directly with the engine
A Path
Read as an asset Parquet file
A string
Treated as caller-supplied DuckDB SQL
Assets with H3 cells
Pass cell_index_column="cell_index" instead of coordinates. Skips point conversion and exact source-geometry refinement
The column names asset_id, longitude, latitude and cell_index are inferred. Reach for AssetPortfolio, PointColumns or CellColumn only when your asset schema is unconventional.
Point assets are converted to the H3 resolution recorded by the canonical dataset.
The integer H3 join produces a conservative superset of candidates.
Where source WKB is present, the join is refined with ST_Covers(source_geometry, asset_point).
Rows with no WKB keep cell-level precision, and say so through spatial_match.
Ambiguity raises, it does not resolve itself
Multiple source curves for one asset, hazard, horizon and pathway raise an error rather than being silently selected or aggregated. Missing asset or scenario matches raise rather than being dropped from the output. A short portfolio is a bug, so the framework refuses to produce one quietly.
Adding an impact function
Sampling happens first, then impact.evaluate(...) runs on the row's value vector, so value_rp100 = impact(hazard_rp100). See Impact functions for why.
Always pass value_unit and value_semantics. They are written into the evaluation metadata, and they are the difference between a column of numbers and a column somebody else can interpret.
Execution options
Worker, batch and connection controls are grouped under ExecutionOptions on write_parquet, deliberately kept out of the analytical chain. Output evaluation is streamed in bounded Arrow batches to compressed Parquet, so a large portfolio does not have to fit in memory. Lambdas run serially; see why.
For rows you have already selected, distribution_from_hazard_row remains available as the low-level curve reconstruction utility.
Workflows
Spatial
The other track. Instead of asking what a portfolio is exposed to, it asks what a place is exposed to, and renders the answer as tiles you can put on a map.
Two notebooks cover it. flood_risk_by_province runs OS-Climate to H3, joins administrative boundaries, enriches with Overture places and writes PMTiles. jrc_global_flood_hazard streams a GeoTIFF or COG and samples it to H3 using JRC LISFLOOD. Both fetch their public inputs over the network.
H3 indexing
Two paths exist because two situations exist. H3Indexer does DuckDB-native polyfill; polyfill_wkb does vectorised batch polyfill over Arrow data and needs the geometry extra for h3ronpy.
Helper
Does
H3Indexer
DuckDB-native H3 polyfill
polyfill_wkb
Vectorised batch polyfill over Arrow
expand_polygon_candidates
Candidate expansion for boundary coverage
intersecting_cells
Cells intersecting a geometry
cell_polygon
The polygon for a cell
pixel_grid_resolution
Choose an H3 resolution for a pixel grid
sample_grid_to_h3
Sample a raster grid onto cells
write_exploded_coverage
Write exploded coverage output
LookupCatalog
Optional nested lookup derivation, with write_lookup_contract and write_partitioned_lookup
Because candidate generation uses overlap coverage rather than centre polyfill (see Canonical hazard datasets), a spatial result can look like it has more cells than you expected: the extras are dropped at the refinement step, not before it.
Constructors tune themselves
OSClimateProvider, ZarrRaster and H3Indexer have no natural caller-supplied directory of their own, so they build a resource-tuned connection by default through DuckDBConnection.for_analytics rather than a bare untuned one. This scales out of the box with no configuration.
Passing an explicit connection or con always wins and skips that entirely. Otherwise the spill directory defaults to a stable location under the system temp directory through default_work_dir(), not a fresh one per call, and can be set per call with each constructor's work_dir parameter, or globally with CRC_DUCKDB_WORK_DIR.
Running it well
Resources and tuning
The SDK detects DuckDB limits when asked and relays them through the connection config. The defaults are deliberately conservative about threads, for a reason worth understanding.
How the thread count is derived
Resource limits are detected on request, through RuntimeResources.detect or DuckDBConnection.for_analytics.
threads = min(cpus, usable_RAM / GiB_per_thread)
usable_RAM ~= 60% of detected memory
GiB_per_thread = 2.5 by default
Spatial work through GEOS often gets slower when over-threaded, which is why the ceiling is memory-derived rather than simply the core count. memory_limit and max_temp_directory_size remain hard process caps.
Environment variables
Variable
Overrides
CRC_DUCKDB_THREADS
The derived thread count
CRC_DUCKDB_MEMORY
The hard process memory limit
CRC_DUCKDB_BYTES_PER_THREAD_GIB
The per-thread memory assumption used to derive the thread count. Defaults to about 2.5 GiB
CRC_DUCKDB_WORK_DIR
The spill and temp directory. Defaults to a stable location under the system temp directory
CRC_DUCKDB_PROFILE
Set to 1 for query profiling around the enrich and coverage stages
Or skip the detection entirely and pass an explicit config dict.
PMTiles threading is different on purpose
Unlike DuckDB's GEOS-throttled default, tippecanoe_threads and duckdb_threads in PMTilesBuild default to every detected core, because tippecanoe's tile building has no documented per-thread memory ceiling to protect against.
A pre-flight budget check raises a clear error if a source is estimated to exceed available scratch disk, rather than degrading into a slower multi-batch fallback. If it raises, give it more scratch disk or narrow the scope.
Choosing a write mode
See Writing a canonical stream for the measured trade-off. In short: keep the default ordered=True unless you are memory-bound, and reserve headroom above the engine limit for Arrow and Python buffers and the final Parquet write.
Before a long run
Call explain() on the plan and read it. Builder calls do no I/O, so this costs nothing.
prefetch() the source cache, then switch to mode="offline" so a network wobble cannot kill hour three.
Point work_dir at a disk with room to spill, not at a small container overlay.
Check that requested return periods sit inside the source support, or accept the extrapolation warning knowingly.
If you are using an impact function, make sure it is a module-level function rather than a lambda before asking for workers.
Running it well
Troubleshooting
Common setup and analysis issues, each with a pointer to the page that explains it.
Setting up
ImportError naming an extra
Not a broken install. Extras are imported lazily and the error names exactly what to add. See Installation.
tippecanoe is not pip-installable
PMTiles needs tippecanoe and tile-join on PATH. Check with require_tippecanoe() before a long job, not after.
Notebook working directory
Launch as uv run jupyter lab notebooks/. Pipelines run from the repo root. Getting this backwards scatters cache directories.
By design. Pass extrapolate=True only when you mean it.
Requested period outside source support
You get a warning, not an error, and you get a number. Read the warning.
Knots are not observations
Passing a tabulated or fitted distribution to fit_distribution is an error. Use fit_quantiles.
No point mass is inferred for you
Zero-heavy hazards need an explicit HurdleFitPolicy or atom_probability.
Fitted values are not source pixels
If you need bit-for-bit reproduction, persist the knots yourself or use a tabulated curve.
Gumbel has no shape
curve_shape is legitimately null for Gumbel families. It is not a missing value to impute.
Impacts and portfolios
Two operations, one object
impact.evaluate(values) is event-aligned; impact(distribution) transforms a distribution. See Impact functions.
Lambdas are not picklable
They run serially, and asking for more than one worker raises. Promote them to module-level functions.
Ambiguous or missing matches raise
Multiple curves for one asset, hazard, horizon and pathway is an error, not something to average.
Units are yours to declare
value_unit and value_semantics land in metadata and are how the next person reads your column.
At scale
Branches are exponential
compute_spanning_set grows as 2**n. Set max_branches.
Ordered writes cost memory
A global sort roughly tripled peak RSS on a two-million-row sample. Use ordered=False when containers are tight.
Engine limits are not process limits
memory_limit caps DuckDB, not Arrow buffers, Python objects or the Parquet writer. Leave headroom.
Thread count below your core count
That is the memory-derived ceiling. Override with CRC_DUCKDB_THREADS if you know better.
Spill directory in a container
The system temp default is often wrong in a container. Set CRC_DUCKDB_WORK_DIR.
Claims about the outputs
Multi-scenario notebooks are stresses
Sensitivity stresses on local tails, not calibrated projections.
FTW fields and CDL pixels are not exposure
Remote-sensing units and land-cover observations, not parcels, ownership, acreage or yield.
Open data changes precision, not credibility
The engine and schema match the commercial stack; precision depends on input resolution, so report it.
Licence
Licence, versions and contributing
The packages are on PyPI and the code is AGPL. Hosted access and participation in the Commons are separate, covered on the project site.
The code licence
crc-sdk and crc-framework are licensed AGPL-3.0-or-later. Install them from PyPI today; no application, no account. Parts of the numerical distribution implementation derive from or are informed by SciPy and Cephes, whose notices are preserved in the package's third-party notices.
AGPL obligations are worth reading with your legal office before you embed either package in a service you distribute. Commercial use of RiskThinking.AI's hosted platform or data is a separate question again; see what is open and what is commercial.
Lock your versions
This is a preview. Specs, APIs and data schemas may change, sometimes with little notice, and package releases can change independently.
Resolve compatible releases together and commit uv.lock alongside pyproject.toml. Use the lockfile to reproduce the environment:
Fixes, tooling, docs and open impact functions go through the published contribution process. The most useful early contribution is unglamorous: run the reference pipeline on the sample data and report what you found. Spec gaps you hit feed into the roadmap directly.
If you are a
The surface to build on
Data provider
Publish hazard, asset or exposure data against the CRC input contracts
Modeller
Contribute sector, peril or region-specific damage functions to the open library, or offer them commercially against the spec
Tool builder
Consume outputs through the APIs; build integrations into risk platforms, GIS, portfolio and disclosure workflows
Implementer
Alternative pipelines, validation and conformance tooling, benchmarking suites
Researcher
Test, extend and improve the methods encoded in the spec, published so others can reproduce it
If your component works with the reference pipeline on reference data, it works with the ecosystem. Conformance is what makes it plug-compatible, and it is the claim you can put in front of your own users.