Calibration

The default workflow relies on several separate calibrations that each transform outputs of a dedicated solve into a git-tracked input consumed by every subsequent solve. Running tools/calibrate regenerates them all in the right dependency order; individual steps can also be run directly.

The calibration artefacts live under data/curated/calibration/ and are version-controlled so that ordinary builds don’t need to re-solve anything.

Note

Canonical configuration pattern. Every calibration section has two flags: enabled controls whether the calibration is applied at solve/build time, and generate controls whether the workflow produces the calibration file from a source scenario. The canonical pattern for generation is enabled: false, generate: true so that enabled is the single source of truth at runtime. The alternative (enabled: true, generate: true) is rejected by workflow/validation/calibration.py to keep configurations unambiguous. The same validator also checks that the named source scenario is defined under config["scenarios"] when generate: true, and that referenced calibration files exist on disk when enabled: true, generate: false.

Step

Config

Produces

Purpose

feed

config/calibration/feed.yaml

grassland_yield.csv, fodder_conversion.csv, exogenous_forage.csv, exogenous_protein.csv

Per-country corrections that balance ruminant-forage and monogastric/ruminant-protein supply against the GLEAM3-derived baseline demand.

food_waste

config/calibration/food_waste.yaml

food_waste.yaml

Per-food-group multiplier on the consumer-side waste fraction that absorbs systematic food-bus surpluses/shortages relative to the GDD-IA intake baseline.

food_demand

config/calibration/food_demand.yaml

food_demand.csv

Per-food global multiplier on the baseline-diet target_mt that closes the residual per-food gap between FAOSTAT QCL supply and GDD-IA demand left over after the food-waste step.

cost

config/calibration/cost.yaml

crop_cost.csv, grassland_cost.csv, animal_cost.csv

Additive production-cost corrections derived from stability- constraint duals (observed allocation -> optimal allocation).

stability

config/calibration/stability.yaml

deviation_penalty.yaml

The L1 penalty pair \((\ell^c_1, \ell^a_1)\) that brings both land-use and animal-feed deviations to ~5 % of observed totals.

Dependency order

When upstream data or build logic changes, rerun in this order:

  1. feed — other calibrations solve against a model whose feed slack is already closed by the forage and protein corrections.

  2. food_waste — uses the calibrated feed behaviour so that food-bus slack is not contaminated by feed-side mismatches.

  3. food_demand — uses the calibrated food-waste fractions so that any per-food gap that remains reflects a genuine supply / demand mismatch (FAOSTAT QCL vs GDD-IA) and not a waste mis-attribution.

  4. cost — the cost-calibration solve uses the calibrated feed, waste, and demand behaviour to extract duals that make economic sense. Without the food-demand step, residual per-food mismatch leaks into the cost-calibration duals as spurious sign (e.g. olive-oil cost driven negative, coffee/cocoa pegged at the slack ceiling) and inflates the stability L1 cost downstream.

  5. stability — the L1 Broyden iteration uses all previous corrections so that the observed deviations reflect the fully-calibrated baseline.

Running the calibrations

Everything is wrapped by tools/calibrate:

tools/calibrate              # all, in dependency order
tools/calibrate feed         # one step (forage + protein feed slack)
tools/calibrate food_waste
tools/calibrate food_demand
tools/calibrate cost
tools/calibrate stability
tools/calibrate --check      # per-step staleness, no execution

The wrapper invokes tools/smk with the matching config and the appropriate output targets. Any extra flags are passed through, e.g. tools/calibrate cost -j8 --slurm.

The stability calibration runs locally in-process and is inherently sequential (each Broyden step depends on the previous solve), so HPC offloading isn’t worthwhile at this size. Each iteration is one paired solve (baseline + main), and 3–5 iterations are typically enough.

Consuming the calibrated values

All calibration outputs are consumed automatically by the default workflow when their configuration blocks are enabled (the default):

  • grazing.grassland_forage_calibration.enabled: true loads the three forage-side CSVs at solve time (see Grassland Forage Calibration).

  • feed_protein_calibration.enabled: true loads exogenous_protein.csv and injects free per-country generators on the monogastric/ruminant protein feed buses (see Exogenous Protein Feed).

  • food_demand_calibration.enabled: true loads data/curated/calibration/food_demand.csv at solve time and applies each per-food multiplier uniformly to the baseline-diet target_mt in _match_baseline_to_consume_links (see Food-demand calibration).

  • cost_calibration.enabled: true loads the three cost-correction CSVs at build time (see Calibration Correction).

  • deviation_penalty.calibration.enabled: true resolves the sentinel "calibrated" on any of deviation_penalty.{land,feed,diet}.l1_cost from data/curated/calibration/deviation_penalty.yaml at solve time (see Deviation Penalty for the config reference). Scenarios that want an explicit numeric value simply override the sentinel with a number; scenarios that want to scan around the calibrated value can leave the sentinel in place and set the matching l1_cost_factor.

Feed calibration

The feed step generates two parallel sets of corrections from a single validation solve:

  • Forage corrections (surplus + deficit on feed:ruminant_forage:*). Surplus countries get a per-country multiplier on grassland yield and fodder-conversion efficiency; deficit countries get an exogenous-forage supply written to data/curated/calibration/exogenous_forage.csv. See Grassland Forage Calibration in the livestock chapter for the algorithm.

  • Protein corrections (deficit side only, on feed:monogastric_protein:* and feed:ruminant_protein:*). The positive slack on each protein feed bus is written to data/curated/calibration/exogenous_protein.csv. See Exogenous Protein Feed for what real-world sources it stands in for.

Both rules read the same solved validation network. The relevant Snakemake rules are compute_grassland_calibration (forage) and compute_protein_feed_calibration (protein), both in workflow/rules/animals.smk. generate: true lives in config/calibration/feed.yaml and is false everywhere else, which breaks the otherwise circular dependency.

Food-waste calibration

See the food-loss-and-waste discussion in Food Processing & Trade for the underlying SDG 12.3 derivation. Rule: compute_food_waste_calibration in workflow/rules/diet.smk; generate: true lives in config/calibration/food_waste.yaml.

Food-demand calibration

Even after the food-waste step closes the group-level gap between FAOSTAT-derived supply and GDD-IA-derived intake, per-food residuals remain: foods within a group can be jointly consistent at the group total while individual foods are systematically over- or under-demanded. The food-demand calibration absorbs this residual into a per-food global multiplier on the baseline-diet target_mt.

The multiplier is derived from the global food-bus balance reported by an uncalibrated validation-mode solve (scenario: uncalibrated in config/calibration/food_demand.yaml, with food_demand_calibration.enabled: false and generate: true so the solve does not read the calibration file it is about to write, following the canonical pattern):

\[m_f = \mathrm{clip}\!\left( \frac{C_f}{C_f + N_f},\ [m_{\min},\ m_{\max}] \right)\]

where \(C_f = \sum_c p^0_{\ell_{c,f}}\) is total food-consumption flow for food \(f\) and \(N_f = \mathrm{slack}^{+}_{f} - \mathrm{slack}^{-}_{f}\) is the net food-bus slack on the two slack_positive_food / slack_negative_food generators (positive slack = LP had to invoke a shortage filler, so demand was too high and the multiplier shrinks; negative slack = LP absorbed excess, so demand was too low and the multiplier grows). Both quantities are summed over countries for each food. The clip bounds (min_multiplier / max_multiplier in the config) default to [0.5, 2.0]; they are tight on purpose so that an out-of-range value flags a structural data issue rather than being silently absorbed.

At solve time _match_baseline_to_consume_links (in workflow/scripts/solve_model/core.py) applies the multiplier uniformly across all countries for each food when food_demand_calibration.enabled is true.

Rule: compute_food_demand_calibration in workflow/rules/diet.smk. Script: workflow/scripts/compute_food_demand_calibration.py.

Cost calibration

The cost calibration is a two-step paired solve:

Step 1 (consumer-value extraction). A baseline solve with enforce_baseline_diet: true extracts food-bus duals to build the piecewise consumer-utility blocks used downstream. Step 1 enables hard production-stability bounds at +/-20 % for crops, grassland, and animals; this prevents the LP from idealising supply patterns and pushing the consumer-value duals below realistic supply cost, which in earlier versions forced step 2 to absorb a large negative correction. The +/-20 % band is loose enough to accommodate the structural FAOSTAT-vs-FBS mismatch carried by most foods. For the small set of foods whose mismatch still exceeds the band (buckwheat, plantain, coffee, tea, olive-oil), a file-level validation.slack_marginal_cost: 5.0 ($5 000/t) override caps the slack-driven duals at the upper end of realistic wholesale prices – instead of the default ~$50 000/t slack ceiling – while leaving enough headroom for legitimately high-value foods.

Step 2 (cost-correction extraction). A second solve activates the piecewise utility built in step 1 and tightens production stability to +/-1 %. The dual \(\mu^+_\ell - \mu^-_\ell\) on each tight production-stability constraint indicates how much the link’s marginal cost would need to shift for the observed allocation to be cost-optimal; the per-group median becomes an additive correction. See Calibration Correction for how the corrections are applied at build time.

Rule: extract_cost_calibration in workflow/rules/crops.smk. Script: workflow/scripts/extract_cost_calibration.py. The two scenarios (baseline and calibration) live in config/calibration/cost.yaml.

Production-stability L1 calibration

Motivation

A pure cost-minimisation solve of a global food system model is free to reorganise production arbitrarily: if a country produces wheat more cheaply than its neighbour, the optimiser will shift the neighbour’s entire wheat output across the border. This is unrealistic — real production patterns reflect a long tail of frictions (rotations, contracts, infrastructure, labour, insurance, policy) that the model does not represent. Without a counterweight, the optimal allocation diverges sharply from observed production and analyses that build on top (marginal-cost attribution, counterfactual comparisons, sensitivity analysis) become different to relate to the current food system.

The model therefore adds a production-stability penalty (see Deviation Penalty for the full configuration reference) that discourages departures from the observed-year baseline. Every crop, grassland and animal-feed production link \(\ell\) carries a linear \(L_1\) term in the objective,

\[\sum_{\ell \in \text{crop,grass}} \ell^c_1 \cdot |x_\ell - \bar x_\ell| \;+\; \sum_{\ell \in \text{animal}} \ell^a_1 \cdot |x_\ell - \bar x_\ell|,\]

where \(\bar x_\ell\) is the baseline activity of the link (area in Mha for crops / grassland, feed use in Mt DM for animals) and \(\ell^c_1\), \(\ell^a_1\) are the two penalty coefficients calibrated here. The \(L_1\) form is convenient: it can be implemented linearly so the LP stays an LP.

Why two coefficients?

Land activity and animal-feed activity are measured in different units and have different baseline totals (roughly 4,000 Mha of land vs 6,500 Mt DM of feed). A single shared coefficient would penalise one axis much more strongly than the other. Splitting the penalty into a crop/grassland coefficient \(\ell^c_1\) (bn USD per Mha of deviation) and an animal-feed coefficient \(\ell^a_1\) (bn USD per Mt DM) lets us tune each axis independently.

Calibration target

We pick \((\ell^c_1, \ell^a_1)\) so that the optimal solution exhibits ~5 % total deviation on each axis (summed absolute deviation divided by the baseline total). The 5 % target is a compromise: large enough that the optimiser can still express meaningful shifts in response to scenarios (carbon prices, diet changes, yield shocks), small enough that the resulting production pattern stays recognisably close to observed production and interpretation remains tractable.

Formally the calibration solves

\[\min_{(\ell^c_1, \ell^a_1)} \quad \big\lVert \text{land\_dev}(\ell^c_1, \ell^a_1) - 5\,\%\big\rVert, \quad \big\lVert \text{feed\_dev}(\ell^c_1, \ell^a_1) - 5\,\%\big\rVert.\]

Broyden iteration

The deviation map

\[F : (\ell^c_1, \ell^a_1) \;\longmapsto\; (\text{land\_dev}, \text{feed\_dev})\]

is monotone and near-affine in log-log coordinates (raising \(\ell^c_1\) mainly tightens land deviation, raising \(\ell^a_1\) mainly tightens feed deviation, with small cross coupling). Calibration is therefore a 2-D root-finding problem, solved with Broyden’s quasi-Newton method on \(x = (\log \ell^c_1, \log \ell^a_1)\) with residual \(r(x) = (\log(\text{land\_dev}/t),\, \log(\text{feed\_dev}/t))\). A trust-region cap of \(\lvert \Delta x \rvert_\infty \le \log 2\) prevents single-step overshoot near the zero-baseline growth caps.

Each iteration is one paired solve (baseline with enforce_baseline_diet=true to derive consumer values, then main with piecewise utility active). Convergence is typically reached in 3–5 iterations from a cold start and 1–2 from a warm start (the previously calibrated YAML is auto-detected and used as the seed). The initial Jacobian is \(\mathrm{diag}(-1, -1)\), which is the exact log-log slope for a relationship of the form \(\text{dev} \propto 1/\ell_1\).

Convergence target: \(\lvert \log(\text{dev}/t) \rvert_\infty < 0.02\), i.e. all calibrated deviations within +/-2 % of the target. The calibrated coefficients are written to data/curated/calibration/deviation_penalty.yaml under l1_costs.<component> and resolved at solve time wherever the sentinel "calibrated" appears in deviation_penalty.{land,feed,diet}.l1_cost (see Deviation Penalty).

A per-iteration diagnostic CSV is written to results/{name}/calibration/deviation_penalty_trace.csv with the per-component iterate, achieved deviations, and residual norm for each step.

The set of components driven simultaneously is configured via deviation_penalty.calibration.components (default [land, feed]). Diet calibration is available as an opt-in components: [land, feed, diet] profile for specific investigations where the priced optimum would otherwise reshuffle the diet substantially.

Implementation

Rule: calibrate_deviation_penalty in workflow/rules/deviation_penalty.smk. Script: workflow/scripts/calibrate_deviation_penalty.py.

The calibrated L1 centre also defines the reference regime for the GSA scenario groups (gsa, gsa-l1-low, gsa-l1-high); see Deviation-penalty regime (gsa-l1-low / gsa-l1-high scenario groups).

Staleness detection

tools/smk prints a one-line reminder when any file under data/curated/ (excluding data/curated/calibration/ itself) is newer than the oldest file in data/curated/calibration/. This is a cheap mtime heuristic and may produce false positives after git pull (because checkout touches mtimes); for the authoritative answer run

tools/calibrate --check

which performs a Snakemake dry-run against each calibration target and reports [up-to-date] / [STALE] per step.

Set SMK_SKIP_CALIBRATION_HINT=1 to silence the reminder in scripted contexts.