Cultured Chicken Cost Model
  • Dashboard
  • Learn
  • TEA Comparison
  • Model formulas
  • Discuss
  • About
  • an Unjournal project ↗

On this page

  • Interactive Model
    • Model Parameters
  • Quick Start
  • Pivotal Questions Context
  • Model formulas
  • Key Insights
  • Sources & How They Inform the Model

Cultured Chicken Production Cost Model

  • Show All Code
  • Hide All Code

  • View Source

Interactive Monte Carlo TEA for Cost Projections

Important: Model Status

This model is largely AI-generated and as of March 2026 we cannot vouch for all parameter values. It is provided to fix ideas, give a sense of the sort of modeling we are interested in, and present a framework for discussion and comparison. Do not treat the outputs as authoritative cost estimates. We welcome feedback to improve it — see below.

Upcoming Workshop: Cultivated Meat Cost Trajectories

We’re organizing an online expert workshop (late April / early May 2026) to dig into the key cost cruxes — media costs, bioreactor scale-up, and the gap between TEA projections and commercial reality. This model is one of the tools we’ll use.

Workshop details & signup → · State your cost beliefs →

We Want Your Feedback

For substantive or longer-form discussion — please post on 💬 GitHub Discussions. That’s where the conversation can get involved, where others can reply and build on each other, and where everything stays threaded and organized. See the 📖 Discussion Map for where to post what, or jump to a hub:

  • 🧠 Substantive hub — bio / econ / stats / engineering / welfare (the main event)
  • 🎯 PQ framing · 💬 Workshop logistics · 🖥️ Platform & UX

For quick inline notes on specific text or a parameter, use Hypothesis (click the < tab on the right edge). For anything beyond a brief highlight, prefer GitHub Discussions so the conversation stays organized and discoverable.

🎧 Listen: Technical Review (22 min MP3) — Audio walkthrough of model architecture and areas for review

Other ways to reach us: Open a GitHub issue · Email contact@unjournal.org

View
New to Cultured Meat?

Read our deep dive: How Cultured Chicken is Made — a detailed guide covering cell banking, bioreactors, media composition, growth factors, and why each step affects costs.

Quick summary: Cultured chicken is produced by growing avian muscle cells in bioreactors. The main cost drivers are media (amino acids, nutrients), growth factors (signaling proteins), bioreactors (capital equipment), and operating costs. For a side-by-side comparison of how published TEAs differ in their assumptions and estimates, see our TEA Comparison page.

   CELL BANK  →  SEED TRAIN  →  PRODUCTION  →  HARVEST  →  PRODUCT
      [O]          [OOO]        [OOOOOOO]       [===]       [≡≡≡]
Learn: Understanding Uncertainty & Distributions

This model uses Monte Carlo simulation: we sample thousands of possible futures and see what distribution of costs emerges.

Distribution Types Used:

Parameter Type Distribution Why Examples in model
Costs, intensities Lognormal Always positive, right-skewed (some very high values possible) Media cost per liter, GF price per gram, cell density
Probabilities, fractions Beta Bounded between 0 and 1, flexible shape Maturity factor, adoption probabilities, uptime. For switching parameters (e.g., hydrolysate adoption), the model first samples an adoption probability from a Beta distribution, then uses a Bernoulli draw to determine whether each run uses the "adopted" or "non-adopted" cost regime.
Bounded ranges Uniform Equal probability within bounds; used when we have only a plausible range and no reason to favor values within it Asset life (years), CAPEX scale exponent, fixed OPEX scaling factor

We are open to considering alternative distributional forms (e.g., triangular, PERT, or mixture distributions) and to making the modeling flexible enough for users to select or compare different distributional assumptions. Suggestions are welcome via Hypothesis annotations.

Reading the Results:

  • p5 (5th percentile): “Optimistic” - only 5% of simulations are cheaper
  • p50 (median): Middle outcome - half above, half below
  • p95 (95th percentile): “Pessimistic” - 95% of simulations cheaper

The “Maturity” Correlation:

A key feature is the latent maturity factor that links:

  • Technology adoption rates
  • Reactor costs
  • Financing costs (WACC)

In “good worlds” for cultured chicken (high maturity), multiple things improve together. This prevents unrealistic scenarios where technology succeeds but financing remains difficult.

This approach draws on the concept of copula-based correlation in Monte Carlo simulation, where a shared latent variable induces dependence among otherwise independent marginal distributions. See Vose (2008), Risk Analysis for a textbook treatment of correlated sampling in cost models, and Morgan & Henrion (1990), Uncertainty for foundational discussion of dependent uncertainties.

More detail on the maturity factor implementation The maturity factor is sampled once per simulation from a Beta distribution (mean set by the “Industry Maturity” slider, standard deviation 0.20). It then shifts the adoption probabilities (hydrolysates, food-grade, recombinant GFs) and adjusts WACC downward in high-maturity draws. This creates positive correlation among “good news” variables without imposing a rigid structure. The strength of the correlation is moderate by design – maturity explains some but not all of the variation in each parameter.

Interactive Model

Code
function mulberry32(seed) {
  return function() {
    let t = seed += 0x6D2B79F5;
    t = Math.imul(t ^ t >>> 15, t | 1);
    t ^= t + Math.imul(t ^ t >>> 7, t | 61);
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  }
}

// ============================================================
// STATISTICAL SAMPLING FUNCTIONS
// ============================================================

// Box-Muller transform for standard normal
function boxMuller(rng) {
  const u1 = rng();
  const u2 = rng();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// Sample from lognormal given p5 and p95
function sampleLognormalP5P95(rng, p5, p95, n) {
  const Z_95 = 1.6448536269514722;
  const mu = (Math.log(p5) + Math.log(p95)) / 2;
  const sigma = (Math.log(p95) - Math.log(p5)) / (2 * Z_95);

  const samples = new Array(n);
  for (let i = 0; i < n; i++) {
    samples[i] = Math.exp(mu + sigma * boxMuller(rng));
  }
  return samples;
}

// Sample uniform
function sampleUniform(rng, lo, hi, n) {
  const samples = new Array(n);
  for (let i = 0; i < n; i++) {
    samples[i] = lo + (hi - lo) * rng();
  }
  return samples;
}

// Beta distribution parameters from mean/stdev
function betaFromMeanStdev(mean, stdev) {
  let variance = stdev * stdev;
  const maxVar = mean * (1 - mean);
  if (variance <= 0 || variance >= maxVar) {
    stdev = Math.sqrt(maxVar * 0.99);
    variance = stdev * stdev;
  }
  const t = maxVar / variance - 1;
  const a = Math.max(mean * t, 0.01);
  const b = Math.max((1 - mean) * t, 0.01);
  return [a, b];
}

// Sample from beta using Joehnk's algorithm (simple, works for all a,b > 0)
function sampleBeta(rng, a, b) {
  // Use gamma ratio method
  const gammaA = sampleGamma(rng, a);
  const gammaB = sampleGamma(rng, b);
  return gammaA / (gammaA + gammaB);
}

// Sample from gamma distribution (Marsaglia and Tsang's method)
function sampleGamma(rng, shape) {
  if (shape < 1) {
    return sampleGamma(rng, shape + 1) * Math.pow(rng(), 1 / shape);
  }

  const d = shape - 1/3;
  const c = 1 / Math.sqrt(9 * d);

  while (true) {
    let x, v;
    do {
      x = boxMuller(rng);
      v = 1 + c * x;
    } while (v <= 0);

    v = v * v * v;
    const u = rng();

    if (u < 1 - 0.0331 * (x * x) * (x * x)) return d * v;
    if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
  }
}

// Sample from beta given mean/stdev
function sampleBetaMeanStdev(rng, mean, stdev, n) {
  const [a, b] = betaFromMeanStdev(mean, stdev);
  const samples = new Array(n);
  for (let i = 0; i < n; i++) {
    samples[i] = sampleBeta(rng, a, b);
  }
  return samples;
}

// Capital Recovery Factor
function crf(wacc, nYears) {
  return wacc * Math.pow(1 + wacc, nYears) / (Math.pow(1 + wacc, nYears) - 1);
}

// Clip values between min and max
function clip(arr, min, max) {
  return arr.map(v => Math.min(Math.max(v, min), max));
}

// Element-wise operations
function add(a, b) { return a.map((v, i) => v + b[i]); }
function mul(a, b) { return a.map((v, i) => v * b[i]); }
function div(a, b) { return a.map((v, i) => v / b[i]); }
function scale(arr, s) { return arr.map(v => v * s); }

// ============================================================
// MAIN SIMULATION FUNCTION
// ============================================================

function simulate(n, seed, params) {
  const rng = mulberry32(seed);

  // Latent maturity factor
  const maturity = sampleBetaMeanStdev(rng, params.maturity_mean, 0.20, n);

  // Scale and uptime
  const plant_kta = sampleLognormalP5P95(rng, params.plant_kta_p5, params.plant_kta_p95, n);
  const uptime = sampleBetaMeanStdev(rng, params.uptime_mean, 0.05, n);
  const output_kgpy = mul(scale(plant_kta, 1e6), uptime);

  // Adoption probabilities (maturity-adjusted)
  let p_hydro = sampleBetaMeanStdev(rng, params.p_hydro_mean, 0.10, n);
  p_hydro = clip(add(p_hydro, scale(maturity.map(m => m - 0.5), 0.25)), 0, 1);

  let p_recf = sampleBetaMeanStdev(rng, params.p_recfactors_mean, 0.15, n);
  p_recf = clip(add(p_recf, scale(maturity.map(m => m - 0.5), 0.25)), 0, 1);

  // Bernoulli draws for adoption
  const is_hydro = p_hydro.map(p => rng() < p);
  const is_recf_cheap = p_recf.map(p => rng() < p);

  // Process intensities
  const density_gL = sampleLognormalP5P95(rng, params.density_gL_p5, params.density_gL_p95, n);
  const cycle_days = sampleLognormalP5P95(rng, 0.5, 5.0, n);
  // "media_turnover" is the LEGACY variable name for what is now user-facing as the
  // "net media-use multiplier": a dimensionless ratio of fresh media consumed per
  // kg of cells to the nominal (1000/density) reactor-fill volume.
  //   = 1    → traditional batch mode (one reactor fill per kg of cells)
  //   > 1    → perfusion (multiple media volumes flow through during the run)
  //   < 1    → media recycling, fed-batch with concentrated feeds, or harvest
  //            concentration (e.g., settling/filtering cells above nominal density).
  // Default p5/p95 of 0.5–3.0 was chosen so the distribution can represent both the
  // GFI 2023 cost-competitive target (8–13 L/kg, implying ~0.5–1.2× at 60–90 g/L)
  // and standard perfusion (2–3×). Earlier default of 1–10× mechanically excluded
  // recycled / fed-batch scenarios below 1 and was biased high.
  const media_turnover = sampleLognormalP5P95(rng, params.media_turnover_p5, params.media_turnover_p95, n);
  const L_per_kg = mul(div(density_gL.map(_ => 1000), density_gL), media_turnover);

  // Media cost ($/L of basal media, including vitamins/minerals/trace salts,
  // excluding growth factors and supplemental recombinant proteins)
  // Basal micronutrients (vitamins, minerals, trace salts) are NOT modeled as a
  // separate line item — O'Neill et al. (2021) note vitamin sourcing is a "less
  // pressing issue" and required minerals can be obtained relatively inexpensively,
  // so they are rolled into media $/L alongside amino acids and glucose.
  // Hydrolysate-based: $0.20-1.20/L (O'Neill et al. 2021; Humbird 2021 hydrolysate estimate)
  const media_cost_hydro = sampleLognormalP5P95(rng, 0.2, 1.2, n);
  // Pharma-grade: $0.50-2.50/L — REVISED downward from $1.00-4.00
  // Rationale: GFI amino acid supply chain report (Dec 2025), based on real supplier quotes,
  // found Humbird's amino acid prices overestimated by 2-10x. Since amino acids are the
  // dominant basal media cost component (~60-80%), this substantially lowers the pharma range.
  // Previous range ($1-4) was anchored on Humbird's Table 3.4 values.
  const media_cost_pharma = sampleLognormalP5P95(rng, 0.5, 2.5, n);
  const media_cost_L = is_hydro.map((h, i) => h ? media_cost_hydro[i] : media_cost_pharma[i]);
  const cost_media = mul(L_per_kg, media_cost_L);

  // Recombinant growth factors (FGF-2, IGF-1, TGF-β, etc.)
  // CRITICAL: Literature shows GFs can be 55-95% of media cost at current prices
  // Current prices: FGF-2 ~$50,000/g, TGF-β up to $1M/g
  // Target prices: $1-10/g with scaled recombinant production

  // Quantity (g/kg meat) — derived from cited medium concentrations × media-use assumptions.
  // Humbird formulation reproduced in GFI 2023 recombinant-protein report:
  //   FGF 1.0e-4 g/L, TGFβ 2.0e-6 g/L → true-GF total ≈ 1.02e-4 g/L.
  // Efficient future scenarios in GFI 2023 assume ≈8–13 L media/kg product;
  // less-optimized processes may use up to ~60 L/kg (matching our L_per_kg range).
  //   0.102 mg/L × 8 L/kg  ≈ 0.00082 g/kg
  //   0.102 mg/L × 13 L/kg ≈ 0.00133 g/kg
  //   0.102 mg/L × 60 L/kg ≈ 0.00612 g/kg
  // Pasitka et al. (2024) ACF TEA implies ≈0.00187 g GF/kg wet biomass — inside this band.
  // Previous ranges (0.0001–0.02 g/kg) were too broad on both tails; tightened below.
  // Expensive regime: Humbird formulation across the full media-use range (no breakthrough).
  const g_recf_exp = sampleLognormalP5P95(rng, 1e-3, 6e-3, n);
  // Cheap regime: breakthrough technologies (thermostable FGF2-G3, autocrine lines,
  // recycling systems, polyphenol substitution) reduce effective per-kg usage ~3×.
  const g_recf_cheap = sampleLognormalP5P95(rng, 5e-4, 2e-3, n);
  const g_recf = is_recf_cheap.map((c, i) => c ? g_recf_cheap[i] : g_recf_exp[i]);

  // Price ($/g) - Scaled by gf_progress parameter (0-100%)
  // At 0% progress: current prices ($5k-500k expensive, $100-10k cheap)
  // At 100% progress: target prices ($50-5k expensive, $1-100 cheap)
  const progress = params.gf_progress / 100;  // 0 to 1

  // Interpolate price ranges based on progress
  // Cheap scenario: $100-10,000 at 0% → $1-100 at 100%
  const cheap_p5 = 100 * Math.pow(0.01, progress);   // 100 → 1
  const cheap_p95 = 10000 * Math.pow(0.01, progress); // 10000 → 100
  const price_recf_cheap = sampleLognormalP5P95(rng, cheap_p5, cheap_p95, n);

  // Expensive scenario: $5,000-500,000 at 0% → $50-5,000 at 100%
  const exp_p5 = 5000 * Math.pow(0.01, progress);    // 5000 → 50
  const exp_p95 = 500000 * Math.pow(0.01, progress); // 500000 → 5000
  const price_recf_exp = sampleLognormalP5P95(rng, exp_p5, exp_p95, n);

  const price_recf = is_recf_cheap.map((c, i) => c ? price_recf_cheap[i] : price_recf_exp[i]);
  const cost_recf = mul(g_recf, price_recf);

  // Other variable costs
  const other_var = sampleLognormalP5P95(rng, 0.5, 5.0, n);

  // VOC total (micronutrients folded into cost_media)
  const voc = add(add(cost_media, cost_recf), other_var);

  // CAPEX calculation
  let capex_perkg = new Array(n).fill(0);
  if (params.include_capex) {
    const prod_kg_L_day = div(scale(density_gL, 1/1000), cycle_days);
    const total_working_volume_L = div(output_kgpy, scale(prod_kg_L_day, 365));
    const reactor_cost_L_pharma = sampleLognormalP5P95(rng, 50, 500, n);
    const custom_ratio = sampleUniform(rng, 0.35, 0.85, n);
    let custom_share = sampleBetaMeanStdev(rng, 0.55, 0.15, n);
    custom_share = clip(add(custom_share, scale(maturity.map(m => m - 0.5), 0.30)), 0, 1);

    const reactor_cost_L_avg = reactor_cost_L_pharma.map((p, i) =>
      p * (custom_share[i] * custom_ratio[i] + (1 - custom_share[i]))
    );

    const capex_s = sampleUniform(rng, 0.6, 0.9, n);
    const plant_factor = sampleLognormalP5P95(rng, 1.5, 3.5, n);
    const V_ref = 1e6;

    const capex_total = reactor_cost_L_avg.map((r, i) =>
      r * total_working_volume_L[i] * Math.pow(total_working_volume_L[i] / V_ref, capex_s[i] - 1) * plant_factor[i]
    );

    let wacc = sampleLognormalP5P95(rng, params.wacc_p5, params.wacc_p95, n);
    wacc = clip(wacc.map((w, i) => w - 0.03 * (maturity[i] - 0.5)), 0.03, 1);

    const asset_life = sampleUniform(rng, params.asset_life_lo, params.asset_life_hi, n);
    const crf_val = wacc.map((w, i) => crf(w, asset_life[i]));

    capex_perkg = capex_total.map((c, i) => (c * crf_val[i]) / output_kgpy[i]);
  }

  // Fixed OPEX calculation
  let fixed_perkg = new Array(n).fill(0);
  if (params.include_fixed_opex) {
    const ref_output = 20e6 * 0.9;
    const fixed_perkg_ref = sampleUniform(rng, 1.0, 6.0, n);
    const fixed_annual_ref = scale(fixed_perkg_ref, ref_output);
    const fixed_scale = sampleUniform(rng, 0.6, 1.0, n);

    const fixed_annual = fixed_annual_ref.map((f, i) =>
      f * Math.pow(output_kgpy[i] / ref_output, fixed_scale[i])
    );
    fixed_perkg = div(fixed_annual, output_kgpy);
  }

  // Downstream processing (scaffolding, texturization) for structured products
  let downstream_perkg = new Array(n).fill(0);
  if (params.include_downstream) {
    // Downstream adds $2-15/kg for structured products
    downstream_perkg = sampleLognormalP5P95(rng, 2.0, 15.0, n);
  }

  // Total unit cost
  const unit_cost = add(add(add(voc, capex_perkg), fixed_perkg), downstream_perkg);

  return {
    unit_cost,
    cost_media,
    cost_recf,
    cost_other_var: other_var,
    cost_capex: capex_perkg,
    cost_fixed: fixed_perkg,
    cost_downstream: downstream_perkg,
    pct_hydro: is_hydro.filter(x => x).length / n,
    pct_recf_cheap: is_recf_cheap.filter(x => x).length / n,
    // Input parameters for sensitivity analysis
    maturity_samples: maturity,
    density_samples: density_gL,
    media_turnover_samples: media_turnover,
    L_per_kg_samples: L_per_kg,
    media_cost_L_samples: media_cost_L,
    is_hydro_samples: is_hydro.map(x => x ? 1 : 0),
    is_recf_cheap_samples: is_recf_cheap.map(x => x ? 1 : 0),
    g_recf_samples: g_recf,
    price_recf_samples: price_recf,
    plant_kta_samples: plant_kta,
    uptime_samples: uptime
  };
}

// Statistical helpers
function quantile(arr, q) {
  const sorted = [...arr].sort((a, b) => a - b);
  const idx = (sorted.length - 1) * q;
  const lo = Math.floor(idx);
  const hi = Math.ceil(idx);
  const frac = idx - lo;
  return sorted[lo] * (1 - frac) + sorted[hi] * frac;
}

function mean(arr) {
  return arr.reduce((a, b) => a + b, 0) / arr.length;
}

// Conditional-mean dollar swing.
// Signed $/kg difference between mean(uc) among samples where `paramArr`
// is in its top tail and mean(uc) among samples where it is in its bottom tail.
// Sign: positive → parameter increases cost; negative → decreases cost.
// This is a TOTAL-EFFECT statistic under the joint sampling distribution —
// it honors latent-variable propagation (e.g. maturity → hydrolysate adoption)
// but can overlap across correlated parameters and should not be summed.
function conditionalSwing(paramArr, uc, tailFrac = 0.10) {
  const n = paramArr.length;
  const paired = paramArr.map((p, i) => [p, uc[i]]).sort((a, b) => a[0] - b[0]);
  const tailCount = Math.max(1, Math.floor(n * tailFrac));
  let loSum = 0, hiSum = 0;
  for (let i = 0; i < tailCount; i++) loSum += paired[i][1];
  for (let i = n - tailCount; i < n; i++) hiSum += paired[i][1];
  return (hiSum - loSum) / tailCount; // $/kg, signed
}

// Spearman rank correlation coefficient (retained for reference; not currently plotted)
function spearmanCorr(x, y) {
  const n = x.length;

  // Rank function (handles ties with average rank)
  function rank(arr) {
    const sorted = arr.map((v, i) => ({v, i})).sort((a, b) => a.v - b.v);
    const ranks = new Array(n);
    let i = 0;
    while (i < n) {
      let j = i;
      while (j < n - 1 && sorted[j + 1].v === sorted[j].v) j++;
      const avgRank = (i + j) / 2 + 1;
      for (let k = i; k <= j; k++) ranks[sorted[k].i] = avgRank;
      i = j + 1;
    }
    return ranks;
  }

  const rx = rank(x);
  const ry = rank(y);

  // Pearson correlation of ranks
  const meanRx = mean(rx);
  const meanRy = mean(ry);

  let num = 0, denX = 0, denY = 0;
  for (let i = 0; i < n; i++) {
    const dx = rx[i] - meanRx;
    const dy = ry[i] - meanRy;
    num += dx * dy;
    denX += dx * dx;
    denY += dy * dy;
  }

  return num / Math.sqrt(denX * denY);
}
Code
// URL state helpers: slider defaults pick up values from a synchronous
// <head> script that extracts ?key=val on page load, stashes them in
// window.__CM_URL_STATE__, and strips the query string BEFORE Hypothes.is
// loads. See the include-in-header block at the top of this file.
// Reading from the global (not location.search) is critical — by the time
// OJS runs, the query string has already been stripped so that Hypothes.is
// can find annotations anchored to the bare canonical URL.
urlParams = window.__CM_URL_STATE__ || {}
Code
urlNum = function(key, def) {
  const v = urlParams[key];
  if (v === undefined) return def;
  const n = Number(v);
  return Number.isFinite(n) ? n : def;
}
Code
urlBool = function(key, def) {
  const v = urlParams[key];
  if (v === undefined) return def;
  return v === "1" || v === "true";
}

Model Parameters

Code
viewof simpleMode = Inputs.toggle({label: "Simplified view (recommended)", value: urlBool("simpleMode", true)})
Code
html`<div id="simplified-note" style="display: ${simpleMode ? 'block' : 'none'}; background: #f0f4e8; border-left: 4px solid #5a7a5a; padding: 0.7rem 0.9rem; margin-bottom: 1rem; font-size: 0.82em; line-height: 1.5; border-radius: 0 4px 4px 0;">
<strong>Simplified view:</strong> Less pivotal parameters (plant capacity, uptime, financing costs, media-use multiplier) are set to reasonable defaults.
In our sensitivity analysis, these contribute less than 10% of the variance in cost estimates.
<em>Switch off to adjust all parameters.</em>
</div>`
Code
// Reactive style block to hide/show full-mode-only inputs
html`<style>
  .full-mode-only { display: ${simpleMode ? 'none' : 'block'}; }
</style>`

Blended / Hybrid Product

Code
viewof include_blending = Inputs.toggle({label: "Show blended product cost", value: urlBool("include_blending", false)})
Code
viewof blending_share = Inputs.range([0.05, 1.0], {
    value: urlNum("blending_share", 0.25), step: 0.05,
    label: "CM inclusion rate (%)",
    disabled: !include_blending
  })
viewof filler_cost = Inputs.range([1, 10], {
    value: urlNum("filler_cost", 3), step: 0.5,
    label: "Filler cost ($/kg)",
    disabled: !include_blending
  })
What is blended product cost? Most cultivated meat products in development are hybrids — e.g., 10-30% cultured cells blended with plant-based filler. Toggle on to see estimated blended ingredient cost alongside pure cell cost. Default: 25% CM inclusion, $3/kg filler (plant protein / mycoprotein).

Model Structure

Code
viewof include_capex = Inputs.toggle({label: "Include capital costs (CAPEX)", value: urlBool("include_capex", true)})
viewof include_fixed_opex = Inputs.toggle({label: "Include plant overhead OPEX", value: urlBool("include_fixed_opex", true)})
viewof include_downstream = Inputs.toggle({label: "Include downstream processing", value: urlBool("include_downstream", false)})
What are these options? Why would you toggle them?
  • CAPEX: Bioreactor and facility capital costs, annualized. On by default because capital costs are a substantial portion of total production cost. You might toggle this off to isolate variable operating costs or to compare with analyses that report only VOC.
  • Plant overhead OPEX: Labor, maintenance, plant overhead. (Conventionally called “Fixed OPEX” in TEA literature, but it’s only fixed per plant — the total scales sub-linearly with plant size, so per-kg overhead falls as plants get larger.) On by default for the same reason as CAPEX. Toggle off to focus on marginal costs only.
  • Downstream: Scaffolding, texturization, and forming for structured products. Off by default because the base model estimates cost of unstructured cell mass. Toggle on if you are interested in structured products (steaks, chicken breast), which add $2-15/kg.
Note: Most published TEAs report costs with CAPEX and plant overhead OPEX included. Toggling them off produces numbers that are not comparable to standard literature estimates.

Key Parameters

These sliders set the center of each parameter’s distribution used in the Monte Carlo simulation. They are not simple point estimates – the simulation samples around these values. For example, setting Plant Capacity to 20 kTA means the simulation draws plant sizes from a lognormal distribution centered near 20 kTA (specifically, p5=10 and p95=40). All charts and results below update dynamically as you adjust these sliders.

Code
viewof plant_capacity = Inputs.range([5, 100], {
  value: urlNum("plant_capacity", 20), step: 5,
  label: "Plant Capacity (kTA/yr)"
})
What is this? Where do these ranges come from? (click to expand)

Annual production capacity in thousand metric tons (kTA). Current pilots: <1 kTA. Commercial target: 10-50 kTA.

Slider range (5-100 kTA): The lower bound (5 kTA) represents a modest first-generation commercial facility. The upper bound (100 kTA) represents an optimistic large-scale facility. The default (20 kTA) matches the reference scale in Risner et al. (2021) and is a commonly used benchmark in TEA literature.

What plant size affects:

Cost Component Effect of Larger Plant
CAPEX per kg ↓ Scales sub-linearly (power law ~0.6-0.9)
Plant overhead OPEX per kg ↓ Overhead spread over more output
Variable costs per kg — No direct effect (same $/L, $/g)
Larger plants have lower per-kg costs due to economies of scale in capital and fixed costs. Variable costs (media, growth factors) don’t change with scale — they depend on technology adoption.
Code
viewof uptime = Inputs.range([0.6, 0.99], {
  value: urlNum("uptime", 0.90), step: 0.01,
  label: "Utilization Rate"
})
What is this? Fraction of capacity actually used. Accounts for downtime, cleaning, maintenance. 90% is optimistic for new industry.
Code
viewof maturity = Inputs.range([0.1, 0.9], {
  value: urlNum("maturity", 0.5), step: 0.05,
  label: "Industry Maturity by 2036"
})
What is this? A latent factor (0=nascent, 1=mature) that affects all technology adoption, reactor costs, and financing. High maturity = correlated improvements.

Target Year

Code
viewof target_year = Inputs.range([2026, 2050], {
  value: urlNum("target_year", 2036), step: 1,
  label: "Projection Year"
})
What does the year affect? Earlier years → lower maturity, less technology adoption. Later years → more time for cost reductions and scale-up. The model adjusts maturity expectations based on years from 2024.

Technology Adoption by

Probability that each cost-reducing technology is widely adopted by the projection year.

Code
viewof reset_adoption = Inputs.button("↺ Reset adoption defaults", {
  reduce: () => {
    // Set viewof values back to defaults
    viewof p_hydro.value = 0.75;
    viewof p_hydro.dispatchEvent(new Event("input", {bubbles: true}));
    viewof p_recfactors.value = 0.5;
    viewof p_recfactors.dispatchEvent(new Event("input", {bubbles: true}));
    viewof gf_progress.value = 50;
    viewof gf_progress.dispatchEvent(new Event("input", {bubbles: true}));
  }
})
Code
viewof p_hydro = Inputs.range([0.3, 0.95], {
  value: urlNum("p_hydro", 0.75), step: 0.05,
  label: "P(Hydrolysates for basal media)"
})

Basal media = the bulk nutrient broth cells grow in (water + amino acids + glucose + salts + vitamins) — everything except the expensive recombinant growth factors, which we model on a separate line. Hydrolysates are cheap enzymatically-digested plant or yeast proteins; they replace the purified pharmaceutical-grade amino acids (vegan but ~10× more expensive) that dominate Humbird’s basal-media cost.

What is this? (click to expand)

Reminder: Basal media is the bulk nutrient broth cells grow in (water, amino acids, glucose, salts, vitamins) — everything except the expensive recombinant growth factors, which we model as a separate line.

Hydrolysates are enzymatically digested plant/yeast proteins that provide amino acids for basal media — the bulk nutrient broth that cells grow in.

Important clarification: Hydrolysates replace amino acids, NOT growth factors. These are completely separate cost categories:

Component What it provides Hydrolysate impact
Basal media Amino acids, glucose, vitamins ✅ Hydrolysates reduce cost ~70%
Growth factors FGF-2, IGF-1, TGF-β signaling proteins ❌ Hydrolysates don’t help

Cost comparison:

Media Type Cost ($/L) Source
Pharma-grade amino acids $0.50 – $2.50 Revised down from $1-4 based on GFI amino acid report (2025): real supplier quotes found Humbird’s AA prices 2-10x too high
Hydrolysate-based $0.20 – $1.20 Humbird 2021: ~$2/kg amino acids; O’Neill et al. 2021

Why 75% baseline? Recent research (2024-2025) shows hydrolysates are already validated across plant, yeast, insect, and fish sources. Companies like IntegriCulture have reduced media components by replacing amino acids with yeast extract. The main uncertainty is regulatory approval for food products, not technical feasibility.

How pivotal is this parameter? Arguably less pivotal than growth factors or cell density. The technical problem appears largely solved — hydrolysate-based media is already used in industry R&D. The remaining uncertainty is mostly regulatory: will food safety agencies in key markets (US, EU, Singapore) accept hydrolysate-based media for commercial products? Some jurisdictions may require pharmaceutical-grade ingredients regardless of technical equivalence. For a 2036 projection, 75% adoption is plausible but could be conservative. If you think regulatory barriers are minimal, push this above 85%. If you think regulatory caution will dominate, keep it at 50-65%.

See Maturity Correlation for how adoption probability is adjusted.
Note: basal micronutrients are folded into media $/L (click to expand)

This slider has been removed. A previous version of the model carried a separate “food-grade micronutrients” cost line (vitamins, minerals, trace salts). That structure double-counted basal media, which the model already describes as providing “amino acids, glucose, vitamins.”

Basal micronutrients (vitamins, minerals, trace salts) are not modeled separately here. Basal media on this page already includes vitamins, and the literature suggests vitamin/mineral sourcing is relatively low-cost compared with growth factors and recombinant proteins (O’Neill et al. 2021: vitamin sourcing is a “less pressing issue” and required minerals can “generally be obtained relatively inexpensively”). We therefore include basal micronutrients inside media $/L and reserve separate uncertainty terms for supplemental recombinant proteins (insulin, transferrin, albumin) if/when we add them as a standalone category — see the model change log below.

Code
viewof p_recfactors = Inputs.range([0.1, 0.9], {
  value: urlNum("p_recfactors", 0.5), step: 0.05,
  label: "P(Scalable GF technology)"
})
Code
viewof gf_progress = Inputs.range([0, 100], {
  value: urlNum("gf_progress", 50), step: 5,
  label: html`GF cost reduction by ${target_year} (%)`
})
What do these sliders control? (click to expand)

Two controls for growth factors: (1) P(Scalable GF technology) sets the probability of a breakthrough reaching commercial scale (switches between “expensive” and “cheap” price regimes). (2) GF cost reduction progress sets how far prices have fallen within each regime by the projection year — 0% = current prices, 100% = industry targets achieved.

Growth factors are signaling proteins (FGF-2, IGF-1, TGF-β, etc.) that tell cells to proliferate. Currently the most expensive media component — often 55-95% of media cost at research scale.

Current vs. projected prices:

Scenario Price ($/g) Status Source
Research-grade FGF-2 $50,000 Current (2024) CEN
Research-grade TGF-β $1,000,000 Current GFI analysis
Model “expensive” $500 - $50,000 Limited progress Conservative projection
Model “cheap” $1 - $100 Breakthrough achieved Industry target
Plant-based (BioBetter) ~$1 Target FoodNavigator

What triggers the “cheap” scenario? (Key breakthroughs)

The probability slider represents whether at least one of these technology approaches succeeds at commercial scale by the projection year:

Technology Mechanism Target price Status (2025)
Autocrine cell lines Engineer cells to produce their own FGF2, eliminating external supply ~$0/g (no purchase needed) Proof of concept 2023 — Mosa Meat, Tufts collaborations
Plant molecular farming Express GFs in transgenic tobacco/lettuce $1-10/g BioBetter, ORF Genetics pilots
Precision fermentation High-density E. coli/yeast expression (like insulin) $10-100/g GFI 2024 analysis — scaling remains key challenge
Polyphenol substitution Curcumin (NaCur) activates FGF receptors, reduces FGF2 needs by 80% N/A (reduces quantity) Research 2024
Thermostable variants FGF2-G3 with 20-day half-life (vs hours) Same $/g, less usage Enantis commercial product

Why this is modeled as a binary switch + continuous progress:

  • The switch represents whether any scalable technology reaches commercial viability
  • The progress slider represents how far costs have dropped within that regime
  • Even in the “expensive” scenario (no breakthrough), incremental improvements occur
  • This structure captures the bimodal nature of the uncertainty: either scalable production is achieved, or it isn’t

Model cost impact:

Scenario Usage (g/kg) Price ($/g) Cost/kg chicken
No breakthrough (Humbird formulation × 8–60 L/kg media use) 0.001 – 0.006 $500 – $50,000 $0.5 – $300
Breakthrough achieved (≈3× usage reduction) 0.0005 – 0.002 $1 – $100 $0.0005 – $0.2

Where the quantity range comes from. GFI’s 2023 recombinant-protein cost analysis reproduces the Humbird medium formulation with FGF at 0.1 mg/L and TGFβ at 0.002 mg/L — about 0.102 mg/L of true growth factors combined. GFI’s efficient-future scenarios assume roughly 8–13 L media per kg meat; less-optimized processes may use up to ~60 L/kg. Multiplying through gives ≈0.0008–0.006 g true GF/kg as the defensible cited range, with Pasitka et al. (2024)’s empirical ACF chicken process landing at ~0.00187 g/kg — comfortably inside this band. An earlier version of this model used 0.0001–0.02 g/kg, which was too broad on both tails.

This is a pivotal uncertainty. The GFI 2024 State of the Industry report identifies growth factor cost reduction as one of the top technical challenges. Note that tightening the quantity range means the price regime is now doing more of the work in the tornado — the swing in cost comes primarily from the $/g uncertainty, not the g/kg uncertainty.

See Maturity Correlation for how adoption probability is adjusted.

Financing

Code
viewof wacc_lo = Inputs.range([5, 20], {
  value: urlNum("wacc_lo", 8), step: 1,
  label: "WACC Low (%)"
})
viewof wacc_hi = Inputs.range([10, 35], {
  value: urlNum("wacc_hi", 20), step: 1,
  label: "WACC High (%)"
})
viewof asset_life_lo = Inputs.range([5, 15], {
  value: urlNum("asset_life_lo", 8), step: 1,
  label: "Asset Life Low (years)"
})
viewof asset_life_hi = Inputs.range([10, 30], {
  value: urlNum("asset_life_hi", 20), step: 1,
  label: "Asset Life High (years)"
})
What are WACC and Asset Life? (click to expand)

WACC (Weighted Average Cost of Capital): The blended rate of return required by debt and equity investors, used to annualize capital costs via the Capital Recovery Factor (CRF). A WACC of 8% is typical for established food manufacturing; 20%+ reflects the high risk premium investors demand for an unproven technology. The simulation samples WACC from a lognormal distribution between these bounds, further adjusted by the maturity factor.

Asset Life: How many years bioreactor and facility equipment is depreciated over. Shorter life (8 years) means higher annual capital charges; longer life (20 years) spreads costs but assumes equipment remains productive. The range reflects uncertainty about equipment durability in a novel industry.

These two parameters together determine the Capital Recovery Factor: CRF = r(1+r)^n / ((1+r)^n - 1), where r = WACC and n = asset life in years.

Cell Density Range

Code
viewof density_lo = Inputs.range([10, 100], {
  value: urlNum("density_lo", 30), step: 10,
  label: "Cell Density Low (g/L)"
})
viewof density_hi = Inputs.range([50, 300], {
  value: urlNum("density_hi", 200), step: 10,
  label: "Cell Density High (g/L)"
})
What is cell density and why does it matter so much? (click to expand)

Cell density (g/L at harvest) determines how much meat you get per liter of bioreactor volume. Higher density means less media per kilogram of product, which directly reduces the largest variable cost.

Density Media per kg Typical context
10 g/L ~100 L/kg Current lab scale
50 g/L ~20 L/kg Near-term commercial target
200 g/L ~5 L/kg Optimistic TEA projection

This is multiplicative. If media costs $1/L, going from 10 to 50 g/L cuts media cost from $100/kg to $20/kg. Going to 200 g/L cuts it to $5/kg. Cell density is arguably the single most important technical parameter for cost reduction.

Current state: Most published data shows 10-50 g/L. Some companies claim higher, but these claims are difficult to verify independently. Lever VC’s 2025 report claims 60-90 g/L has been achieved by “second generation” companies. Whether 200 g/L is achievable by 2036 is a genuine open question.

What about bioreactor volume / tank size? (click to expand)

Bioreactor volume is another major uncertainty that is currently implicit in this model rather than a direct parameter.

The model computes total working volume as: total_volume = annual_output / (density × productivity × 365). It then applies a power-law scaling for CAPEX. But individual bioreactor tank size matters for several reasons:

Factor Small tanks (2,000-5,000L) Large tanks (20,000-50,000L)
Cost per liter Higher Lower (economies of scale)
Contamination risk Lower Higher (single failure = large loss)
Mixing/O2 transfer Easier Harder at scale
Flexibility More modular Less redundancy
Industry precedent Pharma standard Requires new engineering

Key debate: Some companies (e.g., Vow) claim to have built 20,000L bioreactors for under $1M in 14 weeks using custom food-grade designs. If true, this dramatically changes the CAPEX picture. Humbird’s analysis assumed pharma-grade bioreactors at $50-500/L.

Why it’s not a direct slider (yet): Adding individual tank size would require modeling the number of tanks, contamination batch-failure rates, and the trade-off between scale and reliability. This is a planned enhancement. For now, the Plant Capacity and Cell Density parameters together determine total working volume, and the custom reactor ratio (in full view) captures the pharma-vs-food-grade cost difference.

Workshop discussion: This is one of the key cruxes for the upcoming CM workshop — what bioreactor scale is realistic, and what does it cost?

Advanced: Media-use multiplier (×)

What is this — and why can it be below 1? (click to expand)

The model computes media volume per kg as (1000 / density) × multiplier. A value of 1 is traditional batch mode (fill reactor once, harvest); >1 is perfusion (multiple media-volume equivalents flow through during the run); <1 represents media recycling, fed-batch with concentrated feeds, or harvest-side cell concentration. The Learn page walks through all three mechanisms.

Why the range changed (April 2026): the default p5–p95 was tightened from 1–10× to 0.5–3.0×. The old floor of 1.0 was too restrictive — the GFI 2023 cost-competitive scenarios assume 8–13 L/kg, which at 60–90 g/L density implies a multiplier of roughly 0.5–1.2. A floor of 1.0 mechanically excluded those scenarios no matter how high you pushed density. The new range covers both recycled/fed-batch (<1) and standard perfusion (up to ~3×); values of 5–10× remain plausible for heavily media-intensive processes but are now a stress-test region rather than the default.

Show multiplier sliders
Code
viewof media_turnover_lo = Inputs.range([0.25, 2], {
  value: urlNum("media_turnover_lo", 0.5), step: 0.05,
  label: "Media-use multiplier p5 (low end)"
})
viewof media_turnover_hi = Inputs.range([1, 10], {
  value: urlNum("media_turnover_hi", 3.0), step: 0.1,
  label: "Media-use multiplier p95 (high end)"
})
Code
// URL state writer: serialize every viewof value that DIFFERS FROM ITS
// DEFAULT into ?key=val pairs, then debounce-write to the URL via
// history.replaceState. Critical invariant: if every slider is at its
// default, the URL stays bare (pathname + hash only) — no query string.
// This is required so Hypothes.is can find annotations on the canonical
// bare URL; a polluted URL breaks annotation lookup for every visitor.
// The writer depends on every viewof name below so OJS re-runs it
// whenever any input changes. Reads nothing from urlParams.
{
  // Hard-coded defaults must stay in sync with each Inputs.range() /
  // Inputs.toggle() declaration above and with the reset_adoption button.
  const defaults = {
    simpleMode: true, include_blending: false, blending_share: 0.25, filler_cost: 3,
    include_capex: true, include_fixed_opex: true, include_downstream: false,
    plant_capacity: 20, uptime: 0.90, maturity: 0.5, target_year: 2036,
    p_hydro: 0.75, p_recfactors: 0.5, gf_progress: 50,
    wacc_lo: 8, wacc_hi: 20, asset_life_lo: 8, asset_life_hi: 20,
    density_lo: 30, density_hi: 200,
    media_turnover_lo: 0.5, media_turnover_hi: 3.0
  };

  const state = {
    simpleMode, include_blending, blending_share, filler_cost,
    include_capex, include_fixed_opex, include_downstream,
    plant_capacity, uptime, maturity, target_year,
    p_hydro, p_recfactors, gf_progress,
    wacc_lo, wacc_hi, asset_life_lo, asset_life_hi,
    density_lo, density_hi, media_turnover_lo, media_turnover_hi
  };

  const usp = new URLSearchParams();
  let hasDiff = false;
  for (const [k, v] of Object.entries(state)) {
    const def = defaults[k];
    let matches;
    if (typeof v === "boolean") matches = (v === def);
    else if (typeof v === "number") matches = Math.abs(v - def) < 1e-9;
    else matches = (v === def);

    if (!matches) {
      hasDiff = true;
      if (typeof v === "boolean") usp.set(k, v ? "1" : "0");
      else if (typeof v === "number" && Number.isFinite(v)) usp.set(k, String(v));
    }
  }

  if (window._urlWriteTimer) clearTimeout(window._urlWriteTimer);
  window._urlWriteTimer = setTimeout(() => {
    try {
      const newUrl = hasDiff
        ? (location.pathname + "?" + usp.toString() + location.hash)
        : (location.pathname + location.hash);
      history.replaceState(null, "", newUrl);
    } catch (e) {
      console.warn("URL state update failed:", e);
    }
  }, 300);

  return null;
}
Code
results = {
  // Adjust maturity based on target year (2024 baseline)
  // Later years allow more time for industry development
  const yearFactor = Math.max(0, Math.min(1, (target_year - 2024) / 20));
  const adjustedMaturity = maturity * (0.5 + 0.5 * yearFactor);

  const params = {
    plant_kta_p5: simpleMode ? 10 : plant_capacity * 0.5,
    plant_kta_p95: simpleMode ? 40 : plant_capacity * 2.0,
    uptime_mean: simpleMode ? 0.90 : uptime,
    maturity_mean: adjustedMaturity,
    p_hydro_mean: p_hydro,
    p_recfactors_mean: p_recfactors,
    wacc_p5: simpleMode ? 0.08 : wacc_lo / 100,
    wacc_p95: simpleMode ? 0.20 : wacc_hi / 100,
    asset_life_lo: simpleMode ? 8 : asset_life_lo,
    asset_life_hi: simpleMode ? 20 : asset_life_hi,
    density_gL_p5: density_lo,
    density_gL_p95: density_hi,
    media_turnover_p5: simpleMode ? 0.5 : media_turnover_lo,
    media_turnover_p95: simpleMode ? 3.0 : media_turnover_hi,
    include_capex: simpleMode ? true : include_capex,
    include_fixed_opex: simpleMode ? true : include_fixed_opex,
    include_downstream: simpleMode ? false : include_downstream,
    gf_progress: gf_progress
  };

  return simulate(30000, 42, params);
}

// Calculate statistics
stats = {
  const uc = results.unit_cost;
  const bs = typeof blending_share === "number" ? blending_share : 0.25;
  const fc = typeof filler_cost === "number" ? filler_cost : 3;
  const blended = uc.map(c => c * bs + fc * (1 - bs));
  return {
    n: uc.length,
    p5: quantile(uc, 0.05),
    p50: quantile(uc, 0.50),
    p95: quantile(uc, 0.95),
    prob_10: uc.filter(x => x < 10).length / uc.length * 100,
    prob_25: uc.filter(x => x < 25).length / uc.length * 100,
    prob_50: uc.filter(x => x < 50).length / uc.length * 100,
    prob_100: uc.filter(x => x < 100).length / uc.length * 100,
    blended_p50: quantile(blended, 0.50),
    blended_p5: quantile(blended, 0.05),
    blended_p95: quantile(blended, 0.95)
  };
}

Results Summary

Code
html`<div style="background: #f8f9fa; padding: 1rem 1.25rem; border-left: 4px solid #3498db; margin-bottom: 1.5rem; font-size: 0.95em; line-height: 1.6;">
<strong>What these numbers represent:</strong> Simulated <strong>production cost per kilogram of pure cultured chicken cells</strong> (<span title="Wet weight = the mass of cells as harvested from the bioreactor, including water content (~70-80%). This is the standard output basis used in most TEAs (Humbird 2021, Pasitka 2024). It does NOT include downstream processing into structured products, blending with plant-based ingredients, or retail margins. For comparison: Humbird reports $37/kg wet cell mass; Pasitka reports $13.75/kg wet cell mass (large perfusion). The widely-cited ~$6/lb Pasitka figure is for a 50/50 hybrid product, not pure cell mass. See our TEA Comparison page for details." style="text-decoration: underline dotted; cursor: help;">wet weight, unprocessed &#9432;</span>) in <strong>${target_year}</strong>, based on ${stats.n.toLocaleString()} Monte Carlo simulations. This is the cost to produce cell mass in a bioreactor — not the cost of a consumer product, and not retail price. <a href="compare.html" style="font-size: 0.9em;">[Compare to published TEAs →]</a>
<br><br>
<strong><span title="UPSIDE Foods' chicken cutlet is a blend of cultured chicken cells and plant-based ingredients. SuperMeat's chicken burger used ~30% cultured cells. The GFI State of the Industry 2024 report notes that 'hybrid products combining cultivated and plant-based ingredients are the most likely near-term path to market.' Eat Just/GOOD Meat's Singapore-approved product uses cultured chicken in a plant-protein matrix.">Pure cells vs. consumer products:</span></strong> Most cultivated meat products on the market or in development are <em>hybrid products</em> — blending a fraction of cultured cells with plant-based or mycoprotein ingredients. A product with (say) 20% cultured cells and 80% plant-based filler at $3/kg would have a blended ingredient cost far below the pure-cell cost shown here. The "price parity with conventional meat" threshold may therefore be achievable at higher per-kg cell costs than these numbers suggest.
<br><br>
<strong>Why it matters:</strong> If production costs for pure cells reach <strong>~$10/kg</strong>, even 100% cultured products could compete with conventional chicken. At <strong>$25-50/kg</strong>, hybrid products with moderate cell inclusion rates may still reach price parity. If costs remain <strong>>$100/kg</strong>, even hybrid products face significant price premiums. These thresholds inform whether animal welfare interventions should prioritize supporting this industry.
</div>`
Code
html`<div class="grid" style="grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 2rem;">

<div class="card" style="background: linear-gradient(135deg, #3498db, #2980b9); color: white; padding: 1.5rem; border-radius: 8px;">
<h4 style="margin: 0; opacity: 0.9;">Median Pure Cell Mass Cost (p50)</h4>
<h2 style="margin: 0.5rem 0;">$${Math.round(stats.p50)}/kg</h2>
<small>$/kg pure cell mass (wet weight) — half of simulations above, half below</small>
</div>

<div class="card" style="background: linear-gradient(135deg, #27ae60, #1e8449); color: white; padding: 1.5rem; border-radius: 8px;">
<h4 style="margin: 0; opacity: 0.9;">Optimistic (p5)</h4>
<h2 style="margin: 0.5rem 0;">$${Math.round(stats.p5)}/kg</h2>
<small>Only 5% of simulations cheaper</small>
</div>

<div class="card" style="background: linear-gradient(135deg, #e74c3c, #c0392b); color: white; padding: 1.5rem; border-radius: 8px;">
<h4 style="margin: 0; opacity: 0.9;">Pessimistic (p95)</h4>
<h2 style="margin: 0.5rem 0;">$${Math.round(stats.p95)}/kg</h2>
<small>95% of simulations cheaper</small>
</div>

</div>

${include_blending ? html`<div style="background: #eaf7ea; border-left: 4px solid #27ae60; padding: 0.8rem 1rem; margin-top: 0.5rem; font-size: 0.9em;">
<strong>Blended product estimate (${Math.round(blending_share * 100)}% CM, ${Math.round((1-blending_share)*100)}% filler at $${filler_cost}/kg):</strong>
Median <strong>$${stats.blended_p50.toFixed(1)}/kg</strong> · 90% CI: $${stats.blended_p5.toFixed(1)} – $${stats.blended_p95.toFixed(1)}/kg
</div>` : html`<div style="background: #fef9e7; border-left: 4px solid #f39c12; padding: 0.8rem 1rem; margin-top: 0.5rem; font-size: 0.9em;">
<strong>Hybrid product estimate:</strong> At a CM inclusion rate of ~25% with plant-based filler at ~$3/kg, the blended ingredient cost would be approximately <strong>$${(stats.p50 * 0.25 + 3 * 0.75).toFixed(1)}/kg</strong> (median). Enable "Show blended product cost" in the sidebar to adjust these assumptions.
</div>`}

</div>`
Code
// Save / share bar: copy the current shareable link, or export the full
// simulation (CSV of all 30,000 samples, or a compact JSON with the
// parameters + summary stats + component means). Buttons are plain DOM so
// we can attach click handlers that read the current `results` / `stats`
// closure — the cell re-runs on every results change, which is fine.
{
  function downloadBlob(content, filename, mime) {
    const blob = new Blob([content], {type: mime});
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  }

  const stamp = () => new Date().toISOString().slice(0, 10);

  function downloadCSV() {
    const cols = ["unit_cost", "cost_media", "cost_recf", "cost_other_var", "cost_capex", "cost_fixed", "cost_downstream"];
    const rows = [cols.join(",")];
    const n = results.unit_cost.length;
    for (let i = 0; i < n; i++) {
      rows.push(cols.map(c => results[c][i].toFixed(4)).join(","));
    }
    downloadBlob(rows.join("\n"), `cm_pq_results_${stamp()}.csv`, "text/csv");
  }

  function downloadJSON() {
    const config = {
      generated_at: new Date().toISOString(),
      dashboard_url: window.location.origin + window.location.pathname,
      shareable_link: window.location.href,
      parameters: {
        simpleMode, include_blending, blending_share, filler_cost,
        include_capex, include_fixed_opex, include_downstream,
        plant_capacity, uptime, maturity, target_year,
        p_hydro, p_recfactors, gf_progress,
        wacc_lo, wacc_hi, asset_life_lo, asset_life_hi,
        density_lo, density_hi, media_turnover_lo, media_turnover_hi
      },
      summary_stats_unit_cost_per_kg: {
        n_simulations: stats.n,
        p5: +stats.p5.toFixed(2),
        p50: +stats.p50.toFixed(2),
        p95: +stats.p95.toFixed(2),
        prob_under_10: +stats.prob_10.toFixed(1),
        prob_under_25: +stats.prob_25.toFixed(1),
        prob_under_50: +stats.prob_50.toFixed(1),
        prob_under_100: +stats.prob_100.toFixed(1)
      },
      component_means_per_kg: {
        media: +mean(results.cost_media).toFixed(2),
        growth_factors: +mean(results.cost_recf).toFixed(2),
        other_voc: +mean(results.cost_other_var).toFixed(2),
        capex_annualized: +mean(results.cost_capex).toFixed(2),
        fixed_opex: +mean(results.cost_fixed).toFixed(2),
        downstream: +mean(results.cost_downstream).toFixed(2)
      },
      notes: {
        units: "USD per kg of pure cultured cell mass, wet weight",
        simulation_seed: 42,
        monte_carlo_samples: 30000
      }
    };
    downloadBlob(JSON.stringify(config, null, 2), `cm_pq_config_${stamp()}.json`, "application/json");
  }

  const btnStyle = "padding: 0.45rem 0.85rem; font-size: 0.85rem; border: 1px solid #5a7a5a; border-radius: 4px; background: #e8f4e8; color: #2d4a2d; cursor: pointer; white-space: nowrap; font-weight: 500;";

  const container = html`<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin: 0 0 1.25rem 0; padding: 0.6rem 0.85rem; background: #fbfcf9; border: 1px solid #d4e0d4; border-radius: 6px;">
    <strong style="font-size: 0.85rem; color: #2d4a2d; margin-right: 0.25rem;">Save / share this scenario:</strong>
    <button data-btn="copy" style="${btnStyle}" title="Copy a URL that captures every slider and toggle value. Paste it anywhere to restore the exact scenario.">📋 Copy shareable link</button>
    <button data-btn="csv" style="${btnStyle}" title="Download all 30,000 Monte Carlo samples as CSV (unit cost + every component).">⬇ Results CSV</button>
    <button data-btn="json" style="${btnStyle}" title="Download a compact JSON with your parameter values, summary percentiles, and component means.">⬇ Config + summary JSON</button>
    <span data-status="" style="font-size: 0.82rem; font-weight: 500;"></span>
  </div>`;

  const statusEl = container.querySelector("[data-status]");
  function flash(msg, ok = true) {
    statusEl.textContent = msg;
    statusEl.style.color = ok ? "#27ae60" : "#c0392b";
    setTimeout(() => { statusEl.textContent = ""; }, 2200);
  }

  container.querySelector("[data-btn=copy]").addEventListener("click", () => {
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(window.location.href)
        .then(() => flash("✓ Link copied to clipboard"))
        .catch(() => flash("× Clipboard denied — copy the URL from the address bar", false));
    } else {
      flash("× Clipboard API unavailable — copy the URL from the address bar", false);
    }
  });
  container.querySelector("[data-btn=csv]").addEventListener("click", () => {
    try { downloadCSV(); flash("✓ CSV downloaded"); }
    catch (e) { flash("× CSV export failed: " + e.message, false); }
  });
  container.querySelector("[data-btn=json]").addEventListener("click", () => {
    try { downloadJSON(); flash("✓ JSON downloaded"); }
    catch (e) { flash("× JSON export failed: " + e.message, false); }
  });

  return container;
}

Tip: the URL in your address bar updates live as you move sliders, so the “Copy shareable link” button always reflects the scenario you’re currently viewing.

Cost Distribution

Code
Plot = import("https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/+esm")
Code
{
  const uc = results.unit_cost;
  const clipVal = Math.min(quantile(uc, 0.98), 200);
  const clipped = uc.filter(x => x <= clipVal);

  return Plot.plot({
    width: 800,
    height: 400,
    marginLeft: 60,
    marginBottom: 50,
    x: {
      label: "Pure Cell Mass Cost ($/kg, wet weight)",
      domain: [0, clipVal * 1.05]
    },
    y: {
      label: "Frequency",
      grid: true
    },
    marks: [
      Plot.rectY(clipped, Plot.binX({y: "count"}, {x: d => d, fill: "steelblue", fillOpacity: 0.7})),
      Plot.ruleX([stats.p5], {stroke: "green", strokeWidth: 2, strokeDasharray: "5,5"}),
      Plot.ruleX([stats.p50], {stroke: "blue", strokeWidth: 3}),
      Plot.ruleX([stats.p95], {stroke: "red", strokeWidth: 2, strokeDasharray: "5,5"}),
      Plot.ruleX([10], {stroke: "darkgreen", strokeWidth: 2, strokeDasharray: "2,2", strokeOpacity: 0.6}),
      Plot.ruleX([25], {stroke: "orange", strokeWidth: 2, strokeDasharray: "2,2", strokeOpacity: 0.6}),
      Plot.text([
        {x: stats.p5 + 2, y: 2500, text: `p5: $${stats.p5.toFixed(1)}`},
        {x: stats.p50 + 2, y: 3000, text: `p50: $${stats.p50.toFixed(1)}`},
        {x: stats.p95 + 2, y: 2500, text: `p95: $${stats.p95.toFixed(1)}`}
      ], {x: "x", y: "y", text: "text", fontSize: 12})
    ],
    title: `Projected ${target_year} Cultured Chicken Production Cost Distribution`
  })
}
How to read this chart

The histogram shows the distribution of simulated production costs.

  • Blue bars: Frequency of each cost level across 30,000 simulations
  • Green dashed line: 5th percentile (optimistic scenario)
  • Blue solid line: Median (50th percentile)
  • Red dashed line: 95th percentile (pessimistic scenario)
  • Dark green dotted: $10/kg reference (competitive with conventional chicken)
  • Orange dotted: $25/kg reference (premium product viable)
The width of the distribution shows uncertainty. Narrow = confident. Wide = uncertain.

Probability Thresholds

These percentages answer: “What’s the chance that production costs fall below key price points?” — based on the parameter distributions you’ve set above. These are the decision-relevant outputs for evaluating whether cultured meat can compete with conventional chicken.

Code
html`<div class="grid" style="grid-template-columns: repeat(4, 1fr); gap: 1rem; margin: 2rem 0;">

<div class="card" style="border: 2px solid ${stats.prob_10 > 25 ? '#27ae60' : '#ddd'}; padding: 1rem; border-radius: 8px; text-align: center;">
<h5>P(Cost < $10/kg)</h5>
<h2 style="color: #27ae60;">${stats.prob_10.toFixed(1)}%</h2>
<small>Pure cells competitive with conventional chicken (~$5-10/kg)</small>
</div>

<div class="card" style="border: 2px solid ${stats.prob_25 > 50 ? '#3498db' : '#ddd'}; padding: 1rem; border-radius: 8px; text-align: center;">
<h5>P(Cost < $25/kg)</h5>
<h2 style="color: #3498db;">${stats.prob_25.toFixed(1)}%</h2>
<small>Premium product viable</small>
</div>

<div class="card" style="border: 2px solid ${stats.prob_50 > 50 ? '#f39c12' : '#ddd'}; padding: 1rem; border-radius: 8px; text-align: center;">
<h5>P(Cost < $50/kg)</h5>
<h2 style="color: #f39c12;">${stats.prob_50.toFixed(1)}%</h2>
<small>Specialty/niche market</small>
</div>

<div class="card" style="border: 2px solid #ddd; padding: 1rem; border-radius: 8px; text-align: center;">
<h5>P(Cost < $100/kg)</h5>
<h2 style="color: #e74c3c;">${stats.prob_100.toFixed(1)}%</h2>
<small>Below early estimates</small>
</div>

</div>`

Cost Breakdown

Where does the cost come from? This chart shows the average contribution of each cost component across all simulations. The largest bars are the cost drivers to focus on — these are where technological progress or parameter uncertainty has the most impact.

Code
{
  const allComponents = [
    {name: "Media (incl. basal micros)", value: mean(results.cost_media), color: "#27ae60"},
    {name: "Growth Factors", value: mean(results.cost_recf), color: "#9b59b6"},
    {name: "Other VOC", value: mean(results.cost_other_var), color: "#7f8c8d"},
    {name: "CAPEX (annualized)", value: mean(results.cost_capex), color: "#e74c3c"},
    {name: "Plant overhead OPEX", value: mean(results.cost_fixed), color: "#f39c12"},
    {name: "Downstream", value: mean(results.cost_downstream), color: "#1abc9c"}
  ];
  // Filter out zero-value components (e.g., downstream when not included)
  const components = allComponents.filter(c => c.value > 0.001).sort((a, b) => b.value - a.value);

  const total = components.reduce((s, c) => s + c.value, 0);

  const chartContainer = document.createElement("div");
  chartContainer.style.position = "relative";

  // Expand/collapse button
  const expandBtn = document.createElement("button");
  expandBtn.textContent = "Expand Chart";
  expandBtn.style.cssText = "padding: 0.3rem 0.7rem; font-size: 0.8rem; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #f8f9fa; margin-bottom: 0.5rem;";
  let expanded = false;
  expandBtn.onclick = () => {
    expanded = !expanded;
    expandBtn.textContent = expanded ? "Collapse Chart" : "Expand Chart";
    chartEl.replaceWith(makeChart(expanded));
    chartEl = chartContainer.querySelector(".cost-breakdown-plot");
  };
  chartContainer.appendChild(expandBtn);

  function makeChart(large) {
    const w = large ? 1200 : 1000;
    const h = large ? 700 : 580;
    const fontSize = large ? 14 : 13;
    const p = Plot.plot({
      width: w,
      height: h,
      marginLeft: 200,
      marginRight: 140,
      x: {
        label: "Average Cost ($/kg)",
        grid: true
      },
      y: {
        label: null
      },
      marks: [
        Plot.barX(components, {
          y: "name",
          x: "value",
          fill: "color",
          sort: {y: "-x"}
        }),
        Plot.text(components, {
          y: "name",
          x: d => d.value + 0.5,
          text: d => `$${d.value.toFixed(2)} (${(d.value/total*100).toFixed(0)}%)`,
          textAnchor: "start",
          fontSize: fontSize
        })
      ],
      title: `Cost Breakdown by Component (Total: $${Math.round(total)}/kg)`
    });
    p.classList.add("cost-breakdown-plot");
    return p;
  }

  let chartEl = makeChart(false);
  chartContainer.appendChild(chartEl);
  return chartContainer;
}
Understanding the cost components

Variable Operating Costs (VOC):

  • Media: Nutrient broth for cell growth (amino acids, glucose, vitamins, minerals, trace salts) — basal micronutrients are included here, not as a separate line item
  • Recombinant: Growth factors (proteins signaling cell division)
  • Other VOC: Utilities, consumables, waste disposal

Capacity-anchored Costs (per-plant, scale sub-linearly with plant size):

  • CAPEX: Capital costs (reactors, facilities) annualized via Capital Recovery Factor
  • Plant overhead OPEX: Labor, maintenance, plant overhead (conventionally called “Fixed OPEX” — total scales sub-linearly with plant size, so per-kg overhead falls as plants grow)
The relative sizes shift dramatically based on technology adoption assumptions.

Component Distributions

Individual distributions for each cost driver. These charts update dynamically when you change the sidebar parameters. If you adjust plant capacity, the CAPEX and plant-overhead OPEX distributions will shift; if you change technology adoption probabilities, the media and growth factor distributions will change. (Note: some parameters, like plant capacity, primarily affect CAPEX and overhead rather than variable costs like media.)

Code
function formatCost(val) {
  if (val >= 30) return Math.round(val).toString();
  if (val >= 1) return val.toFixed(1);
  if (val >= 0.1) return val.toFixed(2);
  return val.toFixed(3);
}

{
  const allComponents = [
    {name: "Media (incl. basal micros)", data: results.cost_media, color: "#27ae60"},
    {name: "Growth Factors", data: results.cost_recf, color: "#9b59b6"},
    {name: "Other VOC", data: results.cost_other_var, color: "#7f8c8d"},
    {name: "CAPEX (annualized)", data: results.cost_capex, color: "#e74c3c"},
    {name: "Plant overhead OPEX", data: results.cost_fixed, color: "#f39c12"},
    {name: "Downstream", data: results.cost_downstream, color: "#1abc9c"}
  ];

  // Filter out components with all zeros (e.g., downstream when not included)
  const components = allComponents.filter(c => mean(c.data) > 0.001);

  const plotData = components.map(comp => {
    const p5 = quantile(comp.data, 0.05);
    const p50 = quantile(comp.data, 0.50);
    const p95 = quantile(comp.data, 0.95);
    const clipVal = Math.max(quantile(comp.data, 0.98), 0.1);
    const clipped = comp.data.filter(x => x <= clipVal && x >= 0);

    const plot = Plot.plot({
      width: 420,
      height: 180,
      marginLeft: 45,
      marginBottom: 35,
      marginTop: 10,
      x: {
        label: "$/kg",
        domain: [0, clipVal * 1.1]
      },
      y: {
        label: null,
        ticks: []
      },
      marks: [
        Plot.rectY(clipped, Plot.binX({y: "count"}, {x: d => d, fill: comp.color, fillOpacity: 0.7})),
        Plot.ruleX([p5], {stroke: "black", strokeWidth: 1.5, strokeDasharray: "3,3"}),
        Plot.ruleX([p50], {stroke: "black", strokeWidth: 2}),
        Plot.ruleX([p95], {stroke: "black", strokeWidth: 1.5, strokeDasharray: "3,3"})
      ]
    });

    const label = `${comp.name}: $${formatCost(p50)} (90% CI: ${formatCost(p5)} – ${formatCost(p95)})`;
    return {plot, label};
  });

  return html`<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; margin: 1rem 0;">
    ${plotData.map(d => html`<div style="font-size: 0.9em;">
      <div style="font-weight: normal; margin-bottom: 0.3rem; color: #333;">${d.label}</div>
      ${d.plot}
    </div>`)}
  </div>`;
}
Reading these distributions

Each mini-histogram shows the distribution for one cost component across 30,000 simulations:

  • Solid black line: Median (p50) - the middle value
  • Dashed black lines: 5th and 95th percentiles (90% confidence interval)
  • Width of distribution: Uncertainty - wider means more uncertain
These distributions are generated from the simulation using the parameters you set in the sidebar above. When you adjust the sliders, these distributions update accordingly. They are analogous to Squiggle/Guesstimate visualizations – showing the full range of possible values, not just a point estimate.

Technology Adoption (Realized)

Code
html`<div class="grid" style="grid-template-columns: repeat(2, 1fr); gap: 1rem; margin: 2rem 0;">

<div class="card" style="border: 1px solid #ddd; padding: 1rem; border-radius: 8px; text-align: center;">
<h5>Hydrolysates Adopted</h5>
<h2 style="color: #27ae60;">${(results.pct_hydro * 100).toFixed(0)}%</h2>
<small>of simulations use hydrolysates</small>
</div>

<div class="card" style="border: 1px solid #ddd; padding: 1rem; border-radius: 8px; text-align: center;">
<h5>Cheap Growth Factors</h5>
<h2 style="color: #9b59b6;">${(results.pct_recf_cheap * 100).toFixed(0)}%</h2>
<small>of simulations have cheap factors</small>
</div>

</div>`

Sensitivity Analysis (Tornado Chart)

Which parameters have the most impact on the final cost? Each bar shows the difference in mean unit cost ($/kg) between simulations where the parameter is in its top 10% and simulations where it is in its bottom 10%.

Code
{
  const uc = results.unit_cost;

  // Deduplicated parameter list.
  // Removed vs. previous version (see explainer below for details):
  //   • L/kg (volume)     — deterministic function of density × media-use multiplier
  //   • Uses Hydrolysates — regime-switch subsumed into Media $/L
  //   • Has Cheap GFs     — regime-switch subsumed into GF Price / GF Quantity
  const params = [
    {name: "Cell Density (g/L)",                 data: results.density_samples,        kind: "primitive"},
    {name: "Media-use multiplier (×)",           data: results.media_turnover_samples, kind: "primitive"},
    {name: "Media $/L (incl. hydrolysate regime)", data: results.media_cost_L_samples, kind: "mixture"},
    {name: "GF Price ($/g, incl. regime)",       data: results.price_recf_samples,     kind: "mixture"},
    {name: "GF Quantity (g/kg, incl. regime)",   data: results.g_recf_samples,         kind: "mixture"},
    {name: "Industry Maturity (latent — see note)", data: results.maturity_samples,    kind: "latent"},
    {name: "Plant Capacity (kTA)",               data: results.plant_kta_samples,      kind: "primitive"},
    {name: "Utilization Rate",                   data: results.uptime_samples,         kind: "primitive"}
  ];

  const swings = params.map(p => ({
    name: p.name,
    kind: p.kind,
    swing: conditionalSwing(p.data, uc, 0.10)
  }));

  const sorted = swings
    .map(s => ({...s, absSwing: Math.abs(s.swing)}))
    .sort((a, b) => b.absSwing - a.absSwing);

  const maxAbs = Math.max(...sorted.map(s => s.absSwing), 1);
  const pad = maxAbs * 0.30;

  const tornadoPlot = Plot.plot({
    width: 900,
    height: 440,
    marginLeft: 290,
    marginRight: 100,
    x: {
      label: "Δ mean unit cost ($/kg): top 10% − bottom 10% of parameter",
      domain: [-maxAbs - pad, maxAbs + pad],
      grid: true,
      labelOffset: 40,
      tickFormat: d => (d >= 0 ? "+$" : "−$") + Math.abs(d).toFixed(0)
    },
    y: {
      label: null,
      tickFormat: d => d,
      tickSize: 0
    },
    color: {
      domain: ["Increases cost", "Decreases cost"],
      range: ["#e74c3c", "#27ae60"]
    },
    style: {
      fontSize: "13px"
    },
    marks: [
      Plot.barX(sorted, {
        y: "name",
        x: "swing",
        fill: d => d.swing > 0 ? "Increases cost" : "Decreases cost",
        sort: {y: "-x", reduce: d => Math.abs(d)}
      }),
      Plot.ruleX([0], {stroke: "black", strokeWidth: 1}),
      Plot.text(sorted, {
        y: "name",
        x: d => d.swing > 0 ? d.swing + maxAbs * 0.025 : d.swing - maxAbs * 0.025,
        text: d => (d.swing > 0 ? "+$" : "−$") + Math.abs(d.swing).toFixed(1) + "/kg",
        textAnchor: d => d.swing > 0 ? "start" : "end",
        fontSize: 12,
        fontWeight: 500
      })
    ]
  });

  return html`<div style="font-size: 1em;">
    <div style="font-weight: normal; font-size: 1.05em; margin-bottom: 0.5rem; color: #333;">Parameter Sensitivity: Dollar Swing in Mean Unit Cost</div>
    ${tornadoPlot}
  </div>`;
}
How to read this chart — and what changed from the earlier rank-correlation version (click to expand)

Scale. Each bar is the difference, in $/kg, between the mean unit cost among simulations where the parameter is in its top decile and the mean unit cost among simulations where the parameter is in its bottom decile. With 30,000 Monte Carlo samples, each tail bin contains ~3,000 simulations. Red (positive) means the parameter increases cost as it increases; green (negative) means it decreases cost.

This is a real-dollar quantity on the same scale as every other cost figure on the page. It replaces an earlier version of this chart that used Spearman rank correlation — a unitless number in [-1, 1] — which could tell you the direction of a monotonic relationship but not the magnitude in any unit comparable to the rest of the model.

Why three parameters from the old chart were dropped. The previous chart listed eleven parameters; three of them were double-counting signals that other parameters already carried:

  • L/kg (volume) was a pure deterministic function of Cell Density × Media-use multiplier — not an independent driver, just a third lens on the same two variables.
  • Uses Hydrolysates (binary) chose which lognormal regime Media $/L was sampled from. The continuous Media $/L bar already captures the regime switch plus within-regime noise.
  • Has Cheap GFs (binary) chose the regime for both GF Price and GF Quantity. Same issue: subsumed into those continuous bars.

Industry Maturity is a latent driver — do not sum its bar with downstream bars. Maturity is not sampled independently of the other parameters in the chart. It perturbs P(hydrolysate adoption), P(cheap GFs), WACC, and the custom-reactor share. So its bar captures the total effect of a shift in maturity propagating through all of those downstream channels, and the samples that sit in its “top decile” are also more likely to have cheaper media and cheaper GFs as a consequence. If you add Industry Maturity’s bar to Media $/L and GF Price, you will double count.

What the other bars mean. The mixture bars (Media $/L, GF Price, GF Quantity) bundle the regime switch with the within-regime lognormal noise — the swing is the combined effect. The primitive bars (Cell Density, Media-use multiplier, Plant Capacity, Utilization Rate) are sampled close to independently, so their swings are close to honest marginal effects over the joint distribution.

Technical caveats on the dollar-swing statistic.

  • This is a total-effect, conditional-expectation statistic — not a Sobol first-order index. It does not cleanly decompose variance and can overlap across correlated parameters (which is why the latent-variable caveat above matters).
  • The tail width is 10%. Wider tails smooth more; narrower tails are noisier. At 10% × 30,000 samples the noise is small.
  • Because the statistic is computed on the joint sampling distribution, changing slider values can move bars for parameters you didn’t touch — a different joint distribution produces different conditional means.
  • For a fully decomposed analysis (first-order + total-effect Sobol indices) we would need dedicated re-simulation passes, which is plausible follow-up work but overkill given the model is still pre-review.

Industry Maturity Deep Dive

The maturity factor is a continuous variable ranging from 0 (nascent industry) to 1 (fully mature) that correlates multiple model inputs. It is not binary — intermediate values represent partial industry development. Higher maturity means:

  • Higher technology adoption rates
  • Lower reactor costs (more custom/food-grade)
  • Lower financing costs (WACC)
Code
{
  // Maturity distribution
  const matP5 = quantile(results.maturity_samples, 0.05);
  const matP50 = quantile(results.maturity_samples, 0.50);
  const matP95 = quantile(results.maturity_samples, 0.95);

  const maturityPlot = Plot.plot({
    width: 400,
    height: 220,
    marginLeft: 50,
    marginBottom: 40,
    marginTop: 10,
    x: {
      label: "Maturity Factor",
      domain: [0, 1]
    },
    y: {
      label: "Frequency",
      grid: true
    },
    marks: [
      Plot.rectY(results.maturity_samples, Plot.binX({y: "count"}, {x: d => d, fill: "#8e44ad", fillOpacity: 0.7})),
      Plot.ruleX([matP50], {stroke: "black", strokeWidth: 2}),
      Plot.ruleX([matP5], {stroke: "black", strokeWidth: 1.5, strokeDasharray: "3,3"}),
      Plot.ruleX([matP95], {stroke: "black", strokeWidth: 1.5, strokeDasharray: "3,3"})
    ]
  });
  const matLabel = `Maturity Distribution (median: ${matP50.toFixed(2)}, 90% CI: ${matP5.toFixed(2)} – ${matP95.toFixed(2)})`;

  // Cell density distribution
  const denP5 = quantile(results.density_samples, 0.05);
  const denP50 = quantile(results.density_samples, 0.50);
  const denP95 = quantile(results.density_samples, 0.95);

  const densityPlot = Plot.plot({
    width: 400,
    height: 220,
    marginLeft: 50,
    marginBottom: 40,
    marginTop: 10,
    x: {
      label: "Cell Density (g/L)",
      domain: [0, Math.min(quantile(results.density_samples, 0.98) * 1.1, 350)]
    },
    y: {
      label: "Frequency",
      grid: true
    },
    marks: [
      Plot.rectY(results.density_samples, Plot.binX({y: "count"}, {x: d => d, fill: "#16a085", fillOpacity: 0.7})),
      Plot.ruleX([denP50], {stroke: "black", strokeWidth: 2}),
      Plot.ruleX([denP5], {stroke: "black", strokeWidth: 1.5, strokeDasharray: "3,3"}),
      Plot.ruleX([denP95], {stroke: "black", strokeWidth: 1.5, strokeDasharray: "3,3"})
    ]
  });
  const denLabel = `Cell Density Distribution (median: ${denP50.toFixed(0)} g/L, 90% CI: ${denP5.toFixed(0)} – ${denP95.toFixed(0)})`;

  return html`<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; margin: 1rem 0;">
    <div style="font-size: 0.9em;">
      <div style="font-weight: normal; margin-bottom: 0.3rem; color: #333;">${matLabel}</div>
      ${maturityPlot}
    </div>
    <div style="font-size: 0.9em;">
      <div style="font-weight: normal; margin-bottom: 0.3rem; color: #333;">${denLabel}</div>
      ${densityPlot}
    </div>
  </div>`;
}
Code
{
  // Sample subset for scatter plot (too many points otherwise)
  const sampleSize = 2000;
  const step = Math.floor(results.unit_cost.length / sampleSize);
  const scatterData = [];
  for (let i = 0; i < results.unit_cost.length; i += step) {
    scatterData.push({
      maturity: results.maturity_samples[i],
      cost: results.unit_cost[i],
      density: results.density_samples[i]
    });
  }

  const scatterPlot = Plot.plot({
    width: 800,
    height: 320,
    marginLeft: 60,
    marginBottom: 50,
    marginTop: 10,
    x: {
      label: "Industry Maturity Factor",
      domain: [0, 1]
    },
    y: {
      label: "Unit Cost ($/kg)",
      domain: [0, Math.min(quantile(results.unit_cost, 0.95), 150)],
      grid: true
    },
    color: {
      label: "Cell Density (g/L)",
      scheme: "viridis",
      legend: true
    },
    marks: [
      Plot.dot(scatterData, {
        x: "maturity",
        y: "cost",
        fill: "density",
        fillOpacity: 0.5,
        r: 3
      }),
      Plot.linearRegressionY(scatterData, {x: "maturity", y: "cost", stroke: "red", strokeWidth: 2})
    ]
  });

  return html`<div style="font-size: 0.95em;">
    <div style="font-weight: normal; margin-bottom: 0.3rem; color: #333;">Maturity vs Unit Cost (colored by cell density)</div>
    ${scatterPlot}
  </div>`;
}
Understanding the maturity correlation

The scatter plot shows how maturity (x-axis) relates to unit cost (y-axis), with points colored by cell density.

Key patterns:

  • Negative slope: Higher maturity → lower costs (red trend line)
  • Color gradient: Higher density (yellow) tends to cluster at lower costs
  • Spread: Even at high maturity, costs vary due to other random factors
This correlation structure prevents unrealistic scenarios where technology succeeds but financing remains difficult, or vice versa.

Quick Start

  1. Explore the “Learn” sections at the top to understand how cultured chicken production works and how this model handles uncertainty.

  2. Adjust parameters in the left sidebar. Each parameter has an expandable explanation.

  3. Results update automatically as you change the Basic Parameters sliders — the summary cards, cost distribution, and probability thresholds all recalculate. (The Component Distributions section shows the underlying distributions and updates when you change the relevant sliders.)

  4. Navigate the results to see:

    • Cost Distribution: Full probability distribution with key percentiles
    • Probability Thresholds: Chances of hitting key price points
    • Cost Breakdown: Which components drive costs
    • Technology Adoption: Realized adoption rates

Pivotal Questions Context

This model is part of The Unjournal’s Pivotal Questions initiative — a project to identify and quantify uncertainties that matter most for high-impact decisions.

What is a “Pivotal Question”?

A pivotal question is one where resolving the uncertainty would significantly change optimal resource allocation. For cultured chicken:

  • If costs reach <$10/kg by 2036: Large-scale displacement of conventional chicken becomes plausible → prioritize support for industry scale-up
  • If costs remain >$50/kg: Focus shifts to alternative interventions (plant-based, policy, etc.)

The pivotal question framework helps prioritize research and forecasting efforts where they matter most.

See: The Unjournal’s Pivotal Questions documentation

The cultured chicken pivotal question asks:

What will be the production cost of cultured chicken by the projection year, and what does this imply for animal welfare interventions?

Key links:

Resource Description
Pivotal Questions overview Full documentation on the PQ framework
Cultured meat on Metaculus Forecasting questions with community predictions
The Unjournal Independent evaluations of impactful research
UC Davis ACBM Calculator Original academic cost model (Risner et al.)
Good Food Institute Industry reports and data

Model formulas

Note

View Full Model Formulas page — Complete model formulas, parameter definitions, code reference, and methodology.

Quick reference:

\(\text{Unit Cost} = \text{VOC} + \text{CAPEX}_{/\text{kg}} + \text{Fixed OPEX}_{/\text{kg}} + \text{Downstream}_{/\text{kg}}\)

The model runs 30,000 Monte Carlo simulations, sampling from parameter distributions and technology adoption switches to produce cost distributions and probability thresholds.


Key Insights

The table below summarizes typical outcomes from this interactive model at different maturity settings. These are illustrative ranges – the actual results shown in the charts above update dynamically as you adjust the sidebar parameters.

Scenario Typical Median Cost Key Drivers
High maturity (0.7+) $15-30/kg All technologies adopted, low WACC
Baseline (0.5) $25-50/kg Mixed adoption, moderate uncertainty
Low maturity (0.3-) $50-100+/kg Pharma-grade inputs, high financing costs

Most sensitive parameters:

  1. Technology adoption (especially hydrolysates)
  2. Cell density at harvest
  3. Industry maturity (affects multiple factors)

Limitations & Caveats

Model status (March 2026): This model is largely AI-generated and we cannot yet vouch for all parameter values. It is provided to fix ideas, give a sense of the sort of modeling we’re interested in, and present a framework for discussion and comparison — not a definitive cost estimate. We welcome scrutiny and suggestions via Hypothesis annotations or email.

  1. Static snapshot model — Projects costs for a single projection year (adjustable 2026-2050). Does not model year-over-year learning curves; instead, the “maturity” parameter serves as a proxy for cumulative industry development.

  2. Downstream is optional — Scaffolding, texturization, and forming costs can be included via toggle (+$2-15/kg for structured products).

  3. No geography — Assumes generic global costs, not region-specific labor, energy, or regulatory factors.

  4. Limited contamination modeling — No batch failure or contamination event distributions.


Sources & How They Inform the Model

Honesty note on sourcing

This model was largely AI-generated. We have grounded key parameter ranges in published TEAs and recently revised the pharma-grade media cost range downward (from $1-4/L to $0.50-2.50/L) based on GFI’s 2025 amino acid supplier data. However, we have not yet systematically verified that every hardcoded range traces to a specific source. Cycle days is the main remaining parameter that still lacks a direct citation; growth factor quantities and the media-use multiplier were both tightened in April 2026 based on GFI 2023 / Humbird / Pasitka primary sources. This is one reason we are seeking expert review.

::: {.callout-note collapse=“true”} ## Model change log — basal micronutrients folded into media (April 2026) Earlier versions of this model carried a separate food-grade micronutrient cost line (vitamins, minerals, trace elements) with its own adoption toggle, usage range (0.1–10 g/kg), and price range ($0.02–20/g). External review flagged two problems:

  1. Category confusion / double counting. The page already describes basal media as containing “amino acids, glucose, vitamins,” and the model comments described media as “basal media, excluding growth factors.” A separate line for vitamins/minerals was therefore adding a second charge for the same inputs.
  2. Ranges not coherent with the literature. O’Neill et al. (2021) describe vitamin sourcing as a “less pressing issue” and note required minerals can “generally be obtained relatively inexpensively.” Meanwhile Pasitka et al. (2024) report an ACF-medium supplement (vitamins + minerals) at roughly 0.151 kg per kg wet biomass — orders of magnitude above the slider’s 0.1–2 g/kg range. So the old slider was not tracking any coherent category.

What changed: The separate slider and cost line were removed. Basal vitamins, minerals, and trace salts are now included inside media $/L along with amino acids and glucose, consistent with how the media-cost literature is reported. Supplemental recombinant proteins (insulin, transferrin, albumin) remain unmodeled as a standalone line for now; if we reintroduce a separate uncertainty term, it will be repurposed to these proteins where per-g prices and per-kg usage can be sourced. :::

Sources that directly informed model structure and parameter ranges:

Source What it informed in the model How
Risner et al. (2021) Reference plant scale (20 kTA), reactor cost structure Default plant capacity; CAPEX scaling reference
Humbird (2021) Pharma-grade media costs ($1-4/L), reactor costs ($50-500/L), scale exponents (0.6-0.9) Directly used for pharma-grade parameter ranges in simulate()
O’Neill et al. (2021) Serum-free media cost benchmarks Informed hydrolysate media cost range ($0.20-1.20/L)
GFI amino acid report (2025) Pharma-grade media cost revised downward ($1-4/L → $0.50-2.50/L) Real supplier quotes found Humbird AA prices 2-10x too high; since AA dominates basal media cost, this lowers the pharma range
GFI recombinant-protein cost analysis (2023) Growth-factor quantity range (tightened April 2026) Reproduces Humbird formulation (FGF 0.1 mg/L, TGFβ 0.002 mg/L) and cost-competitive media-use assumption (8–13 L/kg); drives the revised 0.0005–0.006 g/kg quantity range
GFI State of Industry (2024) Growth factor cost targets, technology status Informed GF price regime bounds and adoption probability defaults

Sources cited for context but NOT directly integrated into parameter values:

Source Relevance
Pasitka et al. (2024) Optimistic TEA ($6/lb); also provides an empirical ACF chicken process figure of ~0.00187 g growth factor/kg wet biomass, which is used as a cross-check for the GFI-derived quantity range (April 2026)
Goodwin et al. (2024) Scoping review comparing TEAs — informed our understanding of where TEAs disagree
CE Delft (2021) European cost benchmarks — cited in documentation but not directly used for parameter ranges
Lever VC (2025) Industry claims on cell density (60-90 g/L) — cited as context for density range upper bound
The Unjournal evaluations Independent evaluation of RP forecasting paper — informed our project framing

Parameters where source grounding is weakest (review priority):

  • Supplemental recombinant proteins (insulin, transferrin, albumin) are not currently broken out as a separate cost line — they are implicitly absorbed into “Other VOC” or into media $/L. A dedicated uncertainty term sourced from supplier quotes would be an improvement (see the model change log on why the old “micronutrients” line was removed).
  • Growth factor quantities — tightened April 2026 from a very broad 0.0001–0.02 g/kg stress-test range to a cited 0.0005–0.006 g/kg default, derived from the Humbird medium formulation reproduced in GFI 2023 (FGF 0.1 mg/L + TGFβ 0.002 mg/L) × media-use assumptions of 8–60 L/kg, and cross-checked against Pasitka et al. (2024)’s ~0.00187 g/kg empirical ACF figure. The remaining uncertainty lives in the $/g of growth factors, not in the grams/kg.
  • Media-use multiplier — renamed and tightened April 2026 from “Media turnover (1–10×)” to “Media-use multiplier (0.5–3.0×)”. The rename flags that the parameter is a bundled ratio (fresh media / nominal reactor volume), not a literal count of reactor-volume changes, so values below 1 (recycling, fed-batch, harvest concentration) are now allowed. The new range lets the model represent GFI 2023 cost-competitive scenarios (8–13 L/kg ≈ 0.5–1.2× at 60–90 g/L); the old floor of 1.0 mechanically excluded those. See Learn: media-use mechanisms for the derivation.
  • Cycle days (0.5-5.0 days) — hardcoded without source attribution
  • Plant factor (1.5-3.5×) — standard engineering multiplier range, not CM-specific

See the Technical Documentation for detailed parameter definitions and the expandable details under each slider for parameter-specific discussion.

Source Code
---
title: "Cultured Chicken Production Cost Model"
subtitle: "Interactive Monte Carlo TEA for Cost Projections"
format:
  html:
    page-layout: full
    css: styles.css
    include-in-header:
      text: |
        <script>
        // Run synchronously in the <head>, BEFORE Hypothes.is loads.
        // If the URL has a query string (shared link or a legacy write
        // from a previous session), extract the params into a global,
        // then strip the query from the URL. This way Hypothes.is sees
        // the bare canonical URL and can find annotations anchored to
        // it, while OJS still gets the slider values via the global.
        (function () {
          try {
            if (!window.location.search) return;
            var usp = new URLSearchParams(window.location.search);
            window.__CM_URL_STATE__ = {};
            usp.forEach(function (v, k) { window.__CM_URL_STATE__[k] = v; });
            history.replaceState(null, "",
              window.location.pathname + window.location.hash);
          } catch (e) {
            console.warn("CM URL state extraction failed:", e);
          }
        })();
        </script>
    include-after-body:
      text: |
        <script src="https://hypothes.is/embed.js" async></script>
---

::: {.callout-warning}
## Important: Model Status
This model is **largely AI-generated** and as of March 2026 we cannot vouch for all parameter values. It is provided to fix ideas, give a sense of the sort of modeling we are interested in, and present a framework for discussion and comparison. **Do not treat the outputs as authoritative cost estimates.** We welcome feedback to improve it — see below.
:::

::: {.callout-note collapse="true"}
## Upcoming Workshop: Cultivated Meat Cost Trajectories
We're organizing an online expert workshop (late April / early May 2026) to dig into the key cost cruxes — media costs, bioreactor scale-up, and the gap between TEA projections and commercial reality. This model is one of the tools we'll use.

**[Workshop details & signup →](https://uj-cm-workshop.netlify.app/)** · **[State your cost beliefs →](https://uj-cm-workshop.netlify.app/beliefs.html)**
:::

::: {.callout-note collapse="true"}
## We Want Your Feedback

**For substantive or longer-form discussion — please post on [💬 GitHub Discussions](https://github.com/unjournal/cm_pq_modeling/discussions).** That's where the conversation can get involved, where others can reply and build on each other, and where everything stays threaded and organized. See the [📖 Discussion Map](discuss.html) for where to post what, or jump to a hub:

- 🧠 [**Substantive hub**](https://github.com/unjournal/cm_pq_modeling/discussions/3) — bio / econ / stats / engineering / welfare (**the main event**)
- 🎯 [PQ framing](https://github.com/unjournal/cm_pq_modeling/discussions/4) · 💬 [Workshop logistics](https://github.com/unjournal/cm_pq_modeling/discussions/2) · 🖥️ [Platform & UX](https://github.com/unjournal/cm_pq_modeling/discussions/16)

**For quick inline notes** on specific text or a parameter, use [Hypothesis](https://hypothes.is/) (click the `<` tab on the right edge). For anything beyond a brief highlight, prefer GitHub Discussions so the conversation stays organized and discoverable.

**[🎧 Listen: Technical Review (22 min MP3)](model_review_report.mp3)** — Audio walkthrough of model architecture and areas for review

**Other ways to reach us:** [Open a GitHub issue](https://github.com/unjournal/cm_pq_modeling/issues/new) · [Email contact@unjournal.org](mailto:contact@unjournal.org)
:::

```{=html}
<script>
function toggleHypothesis() {
  const btn = document.getElementById('toggle-hypothesis') || document.getElementById('vc-hyp');
  // Hypothesis creates a sidebar with class 'hypothesis-sidebar' or as an annotator frame
  // Try multiple selectors to find the sidebar
  const selectors = [
    '.hypothesis-sidebar',
    '.annotator-frame',
    'hypothesis-sidebar',
    '[class*="hypothesis"][class*="sidebar"]',
    'iframe[src*="hypothes.is"]'
  ];

  let frame = null;
  for (const sel of selectors) {
    frame = document.querySelector(sel);
    if (frame) break;
  }

  if (frame) {
    if (frame.style.display === 'none' || frame.classList.contains('hypothesis-hidden')) {
      frame.style.display = '';
      frame.classList.remove('hypothesis-hidden');
      btn.textContent = 'Hide Annotations Sidebar';
    } else {
      frame.style.display = 'none';
      frame.classList.add('hypothesis-hidden');
      btn.textContent = 'Show Annotations Sidebar';
    }
  } else {
    // If we can't find the sidebar, it may not be loaded yet
    // Try using Hypothesis API if available
    if (window.hypothesisConfig) {
      console.log('Hypothesis config found but sidebar element not located');
    }
    btn.textContent = 'Sidebar not found';
    setTimeout(() => { btn.textContent = 'Hide Annotations Sidebar'; }, 2000);
  }
}

function toggleFullWidth() {
  const body = document.body;
  const btn = document.getElementById('toggle-fullwidth') || document.getElementById('vc-wide');
  // Quarto full-page layout uses column classes and sidebar
  const contentSelectors = [
    'main.content', 'main', '.page-columns', '#quarto-content',
    '.column-page', '.column-body', '.column-body-outset',
    '.panel-fill', '.panel-sidebar', '#quarto-sidebar',
    '.page-layout-full .content'
  ];

  if (body.classList.contains('fullwidth-mode')) {
    body.classList.remove('fullwidth-mode');
    // Remove injected style
    const injected = document.getElementById('fullwidth-style');
    if (injected) injected.remove();
    btn.textContent = 'Expand Content';
  } else {
    body.classList.add('fullwidth-mode');
    // Inject a style tag to override Quarto's layout constraints
    if (!document.getElementById('fullwidth-style')) {
      const style = document.createElement('style');
      style.id = 'fullwidth-style';
      style.textContent = `
        body.fullwidth-mode #quarto-content,
        body.fullwidth-mode main.content,
        body.fullwidth-mode main,
        body.fullwidth-mode .page-columns,
        body.fullwidth-mode .column-body,
        body.fullwidth-mode .column-page {
          max-width: 100% !important;
          width: 100% !important;
          padding-left: 1rem !important;
          padding-right: 1rem !important;
        }
        body.fullwidth-mode #quarto-sidebar,
        body.fullwidth-mode #quarto-margin-sidebar,
        body.fullwidth-mode .sidebar {
          display: none !important;
        }
        body.fullwidth-mode .page-columns {
          grid-template-columns: 1fr !important;
        }
      `;
      document.head.appendChild(style);
    }
    btn.textContent = 'Normal Width';
    // Also hide hypothesis when going fullwidth
    hideHypothesis();
  }
}

function toggleParams() {
  const btn = document.getElementById('toggle-params');
  const vcBtn = document.getElementById('vc-params');
  const sidebar = document.querySelector('.panel-sidebar');
  const fill = document.querySelector('.panel-fill');
  if (!sidebar) return;

  if (sidebar.classList.contains('params-hidden')) {
    sidebar.classList.remove('params-hidden');
    sidebar.style.display = '';
    if (fill) fill.style.gridColumn = '';
    if (btn) btn.textContent = '◀ Hide Parameters (expand charts)';
    if (vcBtn) { vcBtn.textContent = '◀ Parameters'; vcBtn.style.background = '#e8f4e8'; vcBtn.style.borderColor = '#5a7a5a'; }
  } else {
    sidebar.classList.add('params-hidden');
    sidebar.style.display = 'none';
    if (fill) fill.style.gridColumn = '1 / -1';
    if (btn) btn.textContent = '▶ Show Parameters';
    if (vcBtn) { vcBtn.textContent = '▶ Parameters'; vcBtn.style.background = '#f8f9fa'; vcBtn.style.borderColor = '#ccc'; }
  }
}

function toggleToc() {
  const btn = document.getElementById('toggle-toc') || document.getElementById('vc-toc');
  // Quarto TOC can be in several locations
  const selectors = [
    '#quarto-margin-sidebar',
    '#TOC',
    '.sidebar.toc-left',
    'nav.toc',
    '#quarto-sidebar'
  ];
  let toc = null;
  for (const sel of selectors) {
    toc = document.querySelector(sel);
    if (toc) break;
  }
  if (!toc) { btn.textContent = 'TOC not found'; return; }

  if (toc.style.display === 'none') {
    toc.style.display = '';
    btn.textContent = 'Hide Table of Contents';
  } else {
    toc.style.display = 'none';
    btn.textContent = 'Show Table of Contents';
  }
}

function hideHypothesis() {
  const btn = document.getElementById('toggle-hypothesis') || document.getElementById('vc-hyp');
  const selectors = [
    '.hypothesis-sidebar',
    '.annotator-frame',
    'hypothesis-sidebar',
    '[class*="hypothesis"][class*="sidebar"]'
  ];
  for (const sel of selectors) {
    const frame = document.querySelector(sel);
    if (frame) {
      frame.style.display = 'none';
      frame.classList.add('hypothesis-hidden');
      btn.textContent = 'Show Annotations Sidebar';
      break;
    }
  }
}
</script>

<!-- Fixed floating view controls — lower right -->
<div id="view-controls" style="position: fixed; bottom: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 6px; background: white; border: 1px solid #ccc; border-radius: 8px; padding: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.15); font-size: 12px; max-width: 160px;">
  <div style="font-size: 11px; color: #888; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">View</div>
  <button onclick="toggleParams()" id="vc-params" style="padding: 5px 8px; font-size: 12px; cursor: pointer; border: 1px solid #5a7a5a; border-radius: 4px; background: #e8f4e8; color: #2d4a2d; text-align: left;">◀ Parameters</button>
  <button onclick="toggleToc()" id="vc-toc" style="padding: 5px 8px; font-size: 12px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #f8f9fa; text-align: left;">▷ Contents</button>
  <button onclick="toggleHypothesis()" id="vc-hyp" style="padding: 5px 8px; font-size: 12px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #f8f9fa; text-align: left;">▷ Annotations</button>
  <button onclick="toggleFullWidth()" id="vc-wide" style="padding: 5px 8px; font-size: 12px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #f8f9fa; text-align: left;">↔ Expand</button>
</div>

<style>
.fullwidth-mode .page-columns,
.fullwidth-mode #quarto-content,
.fullwidth-mode main.content,
.fullwidth-mode main,
.fullwidth-mode .column-page,
.fullwidth-mode .column-body,
.fullwidth-mode .panel-fill,
.fullwidth-mode .panel-sidebar {
  max-width: 100% !important;
  width: 100% !important;
}
.fullwidth-mode .content {
  max-width: 100% !important;
}
/* Hide hypothesis sidebar when in fullwidth mode */
.fullwidth-mode .hypothesis-sidebar,
.fullwidth-mode .annotator-frame,
.fullwidth-mode hypothesis-sidebar {
  display: none !important;
}
/* Sidebar overflow fix — prevent details from bleeding into main content */
.panel-sidebar {
  overflow-y: auto !important;
  overflow-x: hidden !important;
  max-height: 90vh;
  position: sticky;
  top: 0;
}
.panel-sidebar details[open] {
  max-width: 100%;
  overflow-wrap: break-word;
}
/* When params sidebar is hidden, let charts take full width */
.params-hidden {
  display: none !important;
}
.hypothesis-hidden {
  display: none !important;
}
</style>
```

::: {.callout-note}
## New to Cultured Meat?

**[Read our deep dive: How Cultured Chicken is Made](learn.qmd)** — a detailed guide covering cell banking, bioreactors, media composition, growth factors, and why each step affects costs.

Quick summary: Cultured chicken is produced by growing avian muscle cells in bioreactors. The main cost drivers are **media** (amino acids, nutrients), **growth factors** (signaling proteins), **bioreactors** (capital equipment), and **operating costs**. For a side-by-side comparison of how published TEAs differ in their assumptions and estimates, see our **[TEA Comparison page](compare.qmd)**.

```
   CELL BANK  →  SEED TRAIN  →  PRODUCTION  →  HARVEST  →  PRODUCT
      [O]          [OOO]        [OOOOOOO]       [===]       [≡≡≡]
```
:::

::: {.callout-note collapse="true"}
## Learn: Understanding Uncertainty & Distributions

This model uses **Monte Carlo simulation**: we sample thousands of possible futures and see what distribution of costs emerges.

**Distribution Types Used:**

```{=html}
<table style="width: 100%; table-layout: fixed; font-size: 14px; border-collapse: collapse;">
<colgroup>
  <col style="width: 18%;">
  <col style="width: 12%;">
  <col style="width: 30%;">
  <col style="width: 40%;">
</colgroup>
<thead>
<tr style="border-bottom: 2px solid #ddd; text-align: left;">
  <th style="padding: 8px;">Parameter Type</th>
  <th style="padding: 8px;">Distribution</th>
  <th style="padding: 8px;">Why</th>
  <th style="padding: 8px;">Examples in model</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom: 1px solid #eee; vertical-align: top;">
  <td style="padding: 8px;">Costs, intensities</td>
  <td style="padding: 8px;"><strong>Lognormal</strong></td>
  <td style="padding: 8px;">Always positive, right-skewed (some very high values possible)</td>
  <td style="padding: 8px;">Media cost per liter, GF price per gram, cell density</td>
</tr>
<tr style="border-bottom: 1px solid #eee; vertical-align: top;">
  <td style="padding: 8px;">Probabilities, fractions</td>
  <td style="padding: 8px;"><strong>Beta</strong></td>
  <td style="padding: 8px;">Bounded between 0 and 1, flexible shape</td>
  <td style="padding: 8px;">Maturity factor, adoption probabilities, uptime. For switching parameters (e.g., hydrolysate adoption), the model first samples an adoption <em>probability</em> from a Beta distribution, then uses a Bernoulli draw to determine whether each run uses the "adopted" or "non-adopted" cost regime.</td>
</tr>
<tr style="vertical-align: top;">
  <td style="padding: 8px;">Bounded ranges</td>
  <td style="padding: 8px;"><strong>Uniform</strong></td>
  <td style="padding: 8px;">Equal probability within bounds; used when we have only a plausible range and no reason to favor values within it</td>
  <td style="padding: 8px;">Asset life (years), CAPEX scale exponent, fixed OPEX scaling factor</td>
</tr>
</tbody>
</table>
```

We are open to considering alternative distributional forms (e.g., triangular, PERT, or mixture distributions) and to making the modeling flexible enough for users to select or compare different distributional assumptions. Suggestions are welcome via Hypothesis annotations.

**Reading the Results:**

- **p5 (5th percentile)**: "Optimistic" - only 5% of simulations are cheaper
- **p50 (median)**: Middle outcome - half above, half below
- **p95 (95th percentile)**: "Pessimistic" - 95% of simulations cheaper

**The "Maturity" Correlation:**

A key feature is the **latent maturity factor** that links:

- Technology adoption rates
- Reactor costs
- Financing costs (WACC)

In "good worlds" for cultured chicken (high maturity), multiple things improve together. This prevents unrealistic scenarios where technology succeeds but financing remains difficult.

This approach draws on the concept of **copula-based correlation** in Monte Carlo simulation, where a shared latent variable induces dependence among otherwise independent marginal distributions. See [Vose (2008), *Risk Analysis*](https://www.wiley.com/en-us/Risk+Analysis%3A+A+Quantitative+Guide-p-9780470512845) for a textbook treatment of correlated sampling in cost models, and [Morgan & Henrion (1990), *Uncertainty*](https://doi.org/10.1017/CBO9780511840609) for foundational discussion of dependent uncertainties.

<details><summary>More detail on the maturity factor implementation</summary>

The maturity factor is sampled once per simulation from a Beta distribution (mean set by the "Industry Maturity" slider, standard deviation 0.20). It then **shifts** the adoption probabilities (hydrolysates, food-grade, recombinant GFs) and **adjusts** WACC downward in high-maturity draws. This creates positive correlation among "good news" variables without imposing a rigid structure. The strength of the correlation is moderate by design -- maturity explains some but not all of the variation in each parameter.
</details>
:::

---

## Interactive Model

```{ojs}
//| echo: false

// ============================================================
// SEEDED RANDOM NUMBER GENERATOR
// ============================================================

// Simple mulberry32 PRNG (fast, good quality for Monte Carlo)
function mulberry32(seed) {
  return function() {
    let t = seed += 0x6D2B79F5;
    t = Math.imul(t ^ t >>> 15, t | 1);
    t ^= t + Math.imul(t ^ t >>> 7, t | 61);
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  }
}

// ============================================================
// STATISTICAL SAMPLING FUNCTIONS
// ============================================================

// Box-Muller transform for standard normal
function boxMuller(rng) {
  const u1 = rng();
  const u2 = rng();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// Sample from lognormal given p5 and p95
function sampleLognormalP5P95(rng, p5, p95, n) {
  const Z_95 = 1.6448536269514722;
  const mu = (Math.log(p5) + Math.log(p95)) / 2;
  const sigma = (Math.log(p95) - Math.log(p5)) / (2 * Z_95);

  const samples = new Array(n);
  for (let i = 0; i < n; i++) {
    samples[i] = Math.exp(mu + sigma * boxMuller(rng));
  }
  return samples;
}

// Sample uniform
function sampleUniform(rng, lo, hi, n) {
  const samples = new Array(n);
  for (let i = 0; i < n; i++) {
    samples[i] = lo + (hi - lo) * rng();
  }
  return samples;
}

// Beta distribution parameters from mean/stdev
function betaFromMeanStdev(mean, stdev) {
  let variance = stdev * stdev;
  const maxVar = mean * (1 - mean);
  if (variance <= 0 || variance >= maxVar) {
    stdev = Math.sqrt(maxVar * 0.99);
    variance = stdev * stdev;
  }
  const t = maxVar / variance - 1;
  const a = Math.max(mean * t, 0.01);
  const b = Math.max((1 - mean) * t, 0.01);
  return [a, b];
}

// Sample from beta using Joehnk's algorithm (simple, works for all a,b > 0)
function sampleBeta(rng, a, b) {
  // Use gamma ratio method
  const gammaA = sampleGamma(rng, a);
  const gammaB = sampleGamma(rng, b);
  return gammaA / (gammaA + gammaB);
}

// Sample from gamma distribution (Marsaglia and Tsang's method)
function sampleGamma(rng, shape) {
  if (shape < 1) {
    return sampleGamma(rng, shape + 1) * Math.pow(rng(), 1 / shape);
  }

  const d = shape - 1/3;
  const c = 1 / Math.sqrt(9 * d);

  while (true) {
    let x, v;
    do {
      x = boxMuller(rng);
      v = 1 + c * x;
    } while (v <= 0);

    v = v * v * v;
    const u = rng();

    if (u < 1 - 0.0331 * (x * x) * (x * x)) return d * v;
    if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
  }
}

// Sample from beta given mean/stdev
function sampleBetaMeanStdev(rng, mean, stdev, n) {
  const [a, b] = betaFromMeanStdev(mean, stdev);
  const samples = new Array(n);
  for (let i = 0; i < n; i++) {
    samples[i] = sampleBeta(rng, a, b);
  }
  return samples;
}

// Capital Recovery Factor
function crf(wacc, nYears) {
  return wacc * Math.pow(1 + wacc, nYears) / (Math.pow(1 + wacc, nYears) - 1);
}

// Clip values between min and max
function clip(arr, min, max) {
  return arr.map(v => Math.min(Math.max(v, min), max));
}

// Element-wise operations
function add(a, b) { return a.map((v, i) => v + b[i]); }
function mul(a, b) { return a.map((v, i) => v * b[i]); }
function div(a, b) { return a.map((v, i) => v / b[i]); }
function scale(arr, s) { return arr.map(v => v * s); }

// ============================================================
// MAIN SIMULATION FUNCTION
// ============================================================

function simulate(n, seed, params) {
  const rng = mulberry32(seed);

  // Latent maturity factor
  const maturity = sampleBetaMeanStdev(rng, params.maturity_mean, 0.20, n);

  // Scale and uptime
  const plant_kta = sampleLognormalP5P95(rng, params.plant_kta_p5, params.plant_kta_p95, n);
  const uptime = sampleBetaMeanStdev(rng, params.uptime_mean, 0.05, n);
  const output_kgpy = mul(scale(plant_kta, 1e6), uptime);

  // Adoption probabilities (maturity-adjusted)
  let p_hydro = sampleBetaMeanStdev(rng, params.p_hydro_mean, 0.10, n);
  p_hydro = clip(add(p_hydro, scale(maturity.map(m => m - 0.5), 0.25)), 0, 1);

  let p_recf = sampleBetaMeanStdev(rng, params.p_recfactors_mean, 0.15, n);
  p_recf = clip(add(p_recf, scale(maturity.map(m => m - 0.5), 0.25)), 0, 1);

  // Bernoulli draws for adoption
  const is_hydro = p_hydro.map(p => rng() < p);
  const is_recf_cheap = p_recf.map(p => rng() < p);

  // Process intensities
  const density_gL = sampleLognormalP5P95(rng, params.density_gL_p5, params.density_gL_p95, n);
  const cycle_days = sampleLognormalP5P95(rng, 0.5, 5.0, n);
  // "media_turnover" is the LEGACY variable name for what is now user-facing as the
  // "net media-use multiplier": a dimensionless ratio of fresh media consumed per
  // kg of cells to the nominal (1000/density) reactor-fill volume.
  //   = 1    → traditional batch mode (one reactor fill per kg of cells)
  //   > 1    → perfusion (multiple media volumes flow through during the run)
  //   < 1    → media recycling, fed-batch with concentrated feeds, or harvest
  //            concentration (e.g., settling/filtering cells above nominal density).
  // Default p5/p95 of 0.5–3.0 was chosen so the distribution can represent both the
  // GFI 2023 cost-competitive target (8–13 L/kg, implying ~0.5–1.2× at 60–90 g/L)
  // and standard perfusion (2–3×). Earlier default of 1–10× mechanically excluded
  // recycled / fed-batch scenarios below 1 and was biased high.
  const media_turnover = sampleLognormalP5P95(rng, params.media_turnover_p5, params.media_turnover_p95, n);
  const L_per_kg = mul(div(density_gL.map(_ => 1000), density_gL), media_turnover);

  // Media cost ($/L of basal media, including vitamins/minerals/trace salts,
  // excluding growth factors and supplemental recombinant proteins)
  // Basal micronutrients (vitamins, minerals, trace salts) are NOT modeled as a
  // separate line item — O'Neill et al. (2021) note vitamin sourcing is a "less
  // pressing issue" and required minerals can be obtained relatively inexpensively,
  // so they are rolled into media $/L alongside amino acids and glucose.
  // Hydrolysate-based: $0.20-1.20/L (O'Neill et al. 2021; Humbird 2021 hydrolysate estimate)
  const media_cost_hydro = sampleLognormalP5P95(rng, 0.2, 1.2, n);
  // Pharma-grade: $0.50-2.50/L — REVISED downward from $1.00-4.00
  // Rationale: GFI amino acid supply chain report (Dec 2025), based on real supplier quotes,
  // found Humbird's amino acid prices overestimated by 2-10x. Since amino acids are the
  // dominant basal media cost component (~60-80%), this substantially lowers the pharma range.
  // Previous range ($1-4) was anchored on Humbird's Table 3.4 values.
  const media_cost_pharma = sampleLognormalP5P95(rng, 0.5, 2.5, n);
  const media_cost_L = is_hydro.map((h, i) => h ? media_cost_hydro[i] : media_cost_pharma[i]);
  const cost_media = mul(L_per_kg, media_cost_L);

  // Recombinant growth factors (FGF-2, IGF-1, TGF-β, etc.)
  // CRITICAL: Literature shows GFs can be 55-95% of media cost at current prices
  // Current prices: FGF-2 ~$50,000/g, TGF-β up to $1M/g
  // Target prices: $1-10/g with scaled recombinant production

  // Quantity (g/kg meat) — derived from cited medium concentrations × media-use assumptions.
  // Humbird formulation reproduced in GFI 2023 recombinant-protein report:
  //   FGF 1.0e-4 g/L, TGFβ 2.0e-6 g/L → true-GF total ≈ 1.02e-4 g/L.
  // Efficient future scenarios in GFI 2023 assume ≈8–13 L media/kg product;
  // less-optimized processes may use up to ~60 L/kg (matching our L_per_kg range).
  //   0.102 mg/L × 8 L/kg  ≈ 0.00082 g/kg
  //   0.102 mg/L × 13 L/kg ≈ 0.00133 g/kg
  //   0.102 mg/L × 60 L/kg ≈ 0.00612 g/kg
  // Pasitka et al. (2024) ACF TEA implies ≈0.00187 g GF/kg wet biomass — inside this band.
  // Previous ranges (0.0001–0.02 g/kg) were too broad on both tails; tightened below.
  // Expensive regime: Humbird formulation across the full media-use range (no breakthrough).
  const g_recf_exp = sampleLognormalP5P95(rng, 1e-3, 6e-3, n);
  // Cheap regime: breakthrough technologies (thermostable FGF2-G3, autocrine lines,
  // recycling systems, polyphenol substitution) reduce effective per-kg usage ~3×.
  const g_recf_cheap = sampleLognormalP5P95(rng, 5e-4, 2e-3, n);
  const g_recf = is_recf_cheap.map((c, i) => c ? g_recf_cheap[i] : g_recf_exp[i]);

  // Price ($/g) - Scaled by gf_progress parameter (0-100%)
  // At 0% progress: current prices ($5k-500k expensive, $100-10k cheap)
  // At 100% progress: target prices ($50-5k expensive, $1-100 cheap)
  const progress = params.gf_progress / 100;  // 0 to 1

  // Interpolate price ranges based on progress
  // Cheap scenario: $100-10,000 at 0% → $1-100 at 100%
  const cheap_p5 = 100 * Math.pow(0.01, progress);   // 100 → 1
  const cheap_p95 = 10000 * Math.pow(0.01, progress); // 10000 → 100
  const price_recf_cheap = sampleLognormalP5P95(rng, cheap_p5, cheap_p95, n);

  // Expensive scenario: $5,000-500,000 at 0% → $50-5,000 at 100%
  const exp_p5 = 5000 * Math.pow(0.01, progress);    // 5000 → 50
  const exp_p95 = 500000 * Math.pow(0.01, progress); // 500000 → 5000
  const price_recf_exp = sampleLognormalP5P95(rng, exp_p5, exp_p95, n);

  const price_recf = is_recf_cheap.map((c, i) => c ? price_recf_cheap[i] : price_recf_exp[i]);
  const cost_recf = mul(g_recf, price_recf);

  // Other variable costs
  const other_var = sampleLognormalP5P95(rng, 0.5, 5.0, n);

  // VOC total (micronutrients folded into cost_media)
  const voc = add(add(cost_media, cost_recf), other_var);

  // CAPEX calculation
  let capex_perkg = new Array(n).fill(0);
  if (params.include_capex) {
    const prod_kg_L_day = div(scale(density_gL, 1/1000), cycle_days);
    const total_working_volume_L = div(output_kgpy, scale(prod_kg_L_day, 365));
    const reactor_cost_L_pharma = sampleLognormalP5P95(rng, 50, 500, n);
    const custom_ratio = sampleUniform(rng, 0.35, 0.85, n);
    let custom_share = sampleBetaMeanStdev(rng, 0.55, 0.15, n);
    custom_share = clip(add(custom_share, scale(maturity.map(m => m - 0.5), 0.30)), 0, 1);

    const reactor_cost_L_avg = reactor_cost_L_pharma.map((p, i) =>
      p * (custom_share[i] * custom_ratio[i] + (1 - custom_share[i]))
    );

    const capex_s = sampleUniform(rng, 0.6, 0.9, n);
    const plant_factor = sampleLognormalP5P95(rng, 1.5, 3.5, n);
    const V_ref = 1e6;

    const capex_total = reactor_cost_L_avg.map((r, i) =>
      r * total_working_volume_L[i] * Math.pow(total_working_volume_L[i] / V_ref, capex_s[i] - 1) * plant_factor[i]
    );

    let wacc = sampleLognormalP5P95(rng, params.wacc_p5, params.wacc_p95, n);
    wacc = clip(wacc.map((w, i) => w - 0.03 * (maturity[i] - 0.5)), 0.03, 1);

    const asset_life = sampleUniform(rng, params.asset_life_lo, params.asset_life_hi, n);
    const crf_val = wacc.map((w, i) => crf(w, asset_life[i]));

    capex_perkg = capex_total.map((c, i) => (c * crf_val[i]) / output_kgpy[i]);
  }

  // Fixed OPEX calculation
  let fixed_perkg = new Array(n).fill(0);
  if (params.include_fixed_opex) {
    const ref_output = 20e6 * 0.9;
    const fixed_perkg_ref = sampleUniform(rng, 1.0, 6.0, n);
    const fixed_annual_ref = scale(fixed_perkg_ref, ref_output);
    const fixed_scale = sampleUniform(rng, 0.6, 1.0, n);

    const fixed_annual = fixed_annual_ref.map((f, i) =>
      f * Math.pow(output_kgpy[i] / ref_output, fixed_scale[i])
    );
    fixed_perkg = div(fixed_annual, output_kgpy);
  }

  // Downstream processing (scaffolding, texturization) for structured products
  let downstream_perkg = new Array(n).fill(0);
  if (params.include_downstream) {
    // Downstream adds $2-15/kg for structured products
    downstream_perkg = sampleLognormalP5P95(rng, 2.0, 15.0, n);
  }

  // Total unit cost
  const unit_cost = add(add(add(voc, capex_perkg), fixed_perkg), downstream_perkg);

  return {
    unit_cost,
    cost_media,
    cost_recf,
    cost_other_var: other_var,
    cost_capex: capex_perkg,
    cost_fixed: fixed_perkg,
    cost_downstream: downstream_perkg,
    pct_hydro: is_hydro.filter(x => x).length / n,
    pct_recf_cheap: is_recf_cheap.filter(x => x).length / n,
    // Input parameters for sensitivity analysis
    maturity_samples: maturity,
    density_samples: density_gL,
    media_turnover_samples: media_turnover,
    L_per_kg_samples: L_per_kg,
    media_cost_L_samples: media_cost_L,
    is_hydro_samples: is_hydro.map(x => x ? 1 : 0),
    is_recf_cheap_samples: is_recf_cheap.map(x => x ? 1 : 0),
    g_recf_samples: g_recf,
    price_recf_samples: price_recf,
    plant_kta_samples: plant_kta,
    uptime_samples: uptime
  };
}

// Statistical helpers
function quantile(arr, q) {
  const sorted = [...arr].sort((a, b) => a - b);
  const idx = (sorted.length - 1) * q;
  const lo = Math.floor(idx);
  const hi = Math.ceil(idx);
  const frac = idx - lo;
  return sorted[lo] * (1 - frac) + sorted[hi] * frac;
}

function mean(arr) {
  return arr.reduce((a, b) => a + b, 0) / arr.length;
}

// Conditional-mean dollar swing.
// Signed $/kg difference between mean(uc) among samples where `paramArr`
// is in its top tail and mean(uc) among samples where it is in its bottom tail.
// Sign: positive → parameter increases cost; negative → decreases cost.
// This is a TOTAL-EFFECT statistic under the joint sampling distribution —
// it honors latent-variable propagation (e.g. maturity → hydrolysate adoption)
// but can overlap across correlated parameters and should not be summed.
function conditionalSwing(paramArr, uc, tailFrac = 0.10) {
  const n = paramArr.length;
  const paired = paramArr.map((p, i) => [p, uc[i]]).sort((a, b) => a[0] - b[0]);
  const tailCount = Math.max(1, Math.floor(n * tailFrac));
  let loSum = 0, hiSum = 0;
  for (let i = 0; i < tailCount; i++) loSum += paired[i][1];
  for (let i = n - tailCount; i < n; i++) hiSum += paired[i][1];
  return (hiSum - loSum) / tailCount; // $/kg, signed
}

// Spearman rank correlation coefficient (retained for reference; not currently plotted)
function spearmanCorr(x, y) {
  const n = x.length;

  // Rank function (handles ties with average rank)
  function rank(arr) {
    const sorted = arr.map((v, i) => ({v, i})).sort((a, b) => a.v - b.v);
    const ranks = new Array(n);
    let i = 0;
    while (i < n) {
      let j = i;
      while (j < n - 1 && sorted[j + 1].v === sorted[j].v) j++;
      const avgRank = (i + j) / 2 + 1;
      for (let k = i; k <= j; k++) ranks[sorted[k].i] = avgRank;
      i = j + 1;
    }
    return ranks;
  }

  const rx = rank(x);
  const ry = rank(y);

  // Pearson correlation of ranks
  const meanRx = mean(rx);
  const meanRy = mean(ry);

  let num = 0, denX = 0, denY = 0;
  for (let i = 0; i < n; i++) {
    const dx = rx[i] - meanRx;
    const dy = ry[i] - meanRy;
    num += dx * dy;
    denX += dx * dx;
    denY += dy * dy;
  }

  return num / Math.sqrt(denX * denY);
}
```

```{ojs}
//| echo: false
// URL state helpers: slider defaults pick up values from a synchronous
// <head> script that extracts ?key=val on page load, stashes them in
// window.__CM_URL_STATE__, and strips the query string BEFORE Hypothes.is
// loads. See the include-in-header block at the top of this file.
// Reading from the global (not location.search) is critical — by the time
// OJS runs, the query string has already been stripped so that Hypothes.is
// can find annotations anchored to the bare canonical URL.
urlParams = window.__CM_URL_STATE__ || {}
```

```{ojs}
//| echo: false
urlNum = function(key, def) {
  const v = urlParams[key];
  if (v === undefined) return def;
  const n = Number(v);
  return Number.isFinite(n) ? n : def;
}
```

```{ojs}
//| echo: false
urlBool = function(key, def) {
  const v = urlParams[key];
  if (v === undefined) return def;
  return v === "1" || v === "true";
}
```

### Model Parameters

::: {.panel-sidebar}

```{=html}
<button id="toggle-params" onclick="toggleParams()" style="width: 100%; padding: 0.5rem; margin-bottom: 0.75rem; font-size: 0.85rem; cursor: pointer; border: 1px solid #5a7a5a; border-radius: 6px; background: #e8f4e8; color: #2d4a2d; font-weight: 500;">
  ◀ Hide Parameters (expand charts)
</button>
```

```{ojs}
//| echo: false
viewof simpleMode = Inputs.toggle({label: "Simplified view (recommended)", value: urlBool("simpleMode", true)})
```

```{ojs}
//| echo: false
html`<div id="simplified-note" style="display: ${simpleMode ? 'block' : 'none'}; background: #f0f4e8; border-left: 4px solid #5a7a5a; padding: 0.7rem 0.9rem; margin-bottom: 1rem; font-size: 0.82em; line-height: 1.5; border-radius: 0 4px 4px 0;">
<strong>Simplified view:</strong> Less pivotal parameters (plant capacity, uptime, financing costs, media-use multiplier) are set to reasonable defaults.
In our sensitivity analysis, these contribute less than 10% of the variance in cost estimates.
<em>Switch off to adjust all parameters.</em>
</div>`
```

```{ojs}
//| echo: false
// Reactive style block to hide/show full-mode-only inputs
html`<style>
  .full-mode-only { display: ${simpleMode ? 'none' : 'block'}; }
</style>`
```

**Blended / Hybrid Product**

```{ojs}
//| echo: false
viewof include_blending = Inputs.toggle({label: "Show blended product cost", value: urlBool("include_blending", false)})
```

```{ojs}
//| echo: false
viewof blending_share = Inputs.range([0.05, 1.0], {
    value: urlNum("blending_share", 0.25), step: 0.05,
    label: "CM inclusion rate (%)",
    disabled: !include_blending
  })
viewof filler_cost = Inputs.range([1, 10], {
    value: urlNum("filler_cost", 3), step: 0.5,
    label: "Filler cost ($/kg)",
    disabled: !include_blending
  })
```

<details><summary>What is blended product cost?</summary>
Most cultivated meat products in development are hybrids — e.g., 10-30% cultured cells blended with plant-based filler. Toggle on to see estimated blended ingredient cost alongside pure cell cost. Default: 25% CM inclusion, $3/kg filler (plant protein / mycoprotein).
</details>

```{=html}
<div class="full-mode-only">
```

**Model Structure**

```{ojs}
//| echo: false
viewof include_capex = Inputs.toggle({label: "Include capital costs (CAPEX)", value: urlBool("include_capex", true)})
viewof include_fixed_opex = Inputs.toggle({label: "Include plant overhead OPEX", value: urlBool("include_fixed_opex", true)})
viewof include_downstream = Inputs.toggle({label: "Include downstream processing", value: urlBool("include_downstream", false)})
```
<details><summary>What are these options? Why would you toggle them?</summary>

- **CAPEX**: Bioreactor and facility capital costs, annualized. **On by default** because capital costs are a substantial portion of total production cost. You might toggle this off to isolate variable operating costs or to compare with analyses that report only VOC.
- **Plant overhead OPEX**: Labor, maintenance, plant overhead. *(Conventionally called "Fixed OPEX" in TEA literature, but it's only fixed per plant — the total scales sub-linearly with plant size, so per-kg overhead falls as plants get larger.)* **On by default** for the same reason as CAPEX. Toggle off to focus on marginal costs only.
- **Downstream**: Scaffolding, texturization, and forming for structured products. **Off by default** because the base model estimates cost of unstructured cell mass. Toggle on if you are interested in structured products (steaks, chicken breast), which add $2-15/kg.

**Note:** Most published TEAs report costs with CAPEX and plant overhead OPEX included. Toggling them off produces numbers that are not comparable to standard literature estimates.
</details>

```{=html}
</div>
```

---

**Key Parameters**

```{=html}
<div class="full-mode-only">
```

These sliders set the **center** of each parameter's distribution used in the Monte Carlo simulation. They are not simple point estimates -- the simulation samples around these values. For example, setting Plant Capacity to 20 kTA means the simulation draws plant sizes from a lognormal distribution centered near 20 kTA (specifically, p5=10 and p95=40). All charts and results below update dynamically as you adjust these sliders.

```{ojs}
//| echo: false
viewof plant_capacity = Inputs.range([5, 100], {
  value: urlNum("plant_capacity", 20), step: 5,
  label: "Plant Capacity (kTA/yr)"
})
```
<details><summary>What is this? Where do these ranges come from? (click to expand)</summary>

**Annual production capacity** in thousand metric tons (kTA). Current pilots: <1 kTA. Commercial target: 10-50 kTA.

**Slider range (5-100 kTA):** The lower bound (5 kTA) represents a modest first-generation commercial facility. The upper bound (100 kTA) represents an optimistic large-scale facility. The default (20 kTA) matches the reference scale in [Risner et al. (2021)](https://www.mdpi.com/2304-8158/10/1/3) and is a commonly used benchmark in TEA literature.

**What plant size affects:**

| Cost Component | Effect of Larger Plant |
|----------------|----------------------|
| CAPEX per kg | ↓ Scales sub-linearly (power law ~0.6-0.9) |
| Plant overhead OPEX per kg | ↓ Overhead spread over more output |
| Variable costs per kg | — No direct effect (same $/L, $/g) |

Larger plants have **lower per-kg costs** due to economies of scale in capital and fixed costs. Variable costs (media, growth factors) don't change with scale — they depend on technology adoption.
</details>

```{ojs}
//| echo: false
viewof uptime = Inputs.range([0.6, 0.99], {
  value: urlNum("uptime", 0.90), step: 0.01,
  label: "Utilization Rate"
})
```
<details><summary>What is this?</summary>
Fraction of capacity actually used. Accounts for downtime, cleaning, maintenance. 90% is optimistic for new industry.
</details>

```{=html}
</div>
```

```{ojs}
//| echo: false
viewof maturity = Inputs.range([0.1, 0.9], {
  value: urlNum("maturity", 0.5), step: 0.05,
  label: "Industry Maturity by 2036"
})
```
<details><summary>What is this?</summary>
A latent factor (0=nascent, 1=mature) that affects all technology adoption, reactor costs, and financing. High maturity = correlated improvements.
</details>

---

**Target Year**

```{ojs}
//| echo: false
viewof target_year = Inputs.range([2026, 2050], {
  value: urlNum("target_year", 2036), step: 1,
  label: "Projection Year"
})
```
<details><summary>What does the year affect?</summary>
Earlier years → lower [maturity](#industry-maturity-deep-dive), less technology adoption.
Later years → more time for cost reductions and scale-up.
The model adjusts maturity expectations based on years from 2024.
</details>

---

**Technology Adoption by ${target_year}**

Probability that each cost-reducing technology is widely adopted by the projection year.

```{=html}
<div class="reset-button-row">
```

```{ojs}
//| echo: false
viewof reset_adoption = Inputs.button("↺ Reset adoption defaults", {
  reduce: () => {
    // Set viewof values back to defaults
    viewof p_hydro.value = 0.75;
    viewof p_hydro.dispatchEvent(new Event("input", {bubbles: true}));
    viewof p_recfactors.value = 0.5;
    viewof p_recfactors.dispatchEvent(new Event("input", {bubbles: true}));
    viewof gf_progress.value = 50;
    viewof gf_progress.dispatchEvent(new Event("input", {bubbles: true}));
  }
})
```

```{=html}
</div>
```

```{ojs}
//| echo: false
viewof p_hydro = Inputs.range([0.3, 0.95], {
  value: urlNum("p_hydro", 0.75), step: 0.05,
  label: "P(Hydrolysates for basal media)"
})
```

::: {.callout-note appearance="minimal" icon=false collapse=false}
**Basal media** = the bulk nutrient broth cells grow in (water + amino acids + glucose + salts + vitamins) — *everything except* the expensive recombinant growth factors, which we model on a separate line. **Hydrolysates** are cheap enzymatically-digested plant or yeast proteins; they replace the *purified pharmaceutical-grade amino acids* (vegan but ~10× more expensive) that dominate Humbird's basal-media cost.
:::
<details><summary>What is this? (click to expand)</summary>

*Reminder:* <abbr title="Basal media = the bulk nutrient broth cells grow in: water + amino acids + glucose + salts + vitamins/minerals. It is everything except the expensive recombinant growth factors, which are added separately.">**Basal media**</abbr> is the bulk nutrient broth cells grow in (water, amino acids, glucose, salts, vitamins) — everything except the expensive recombinant growth factors, which we model as a separate line.

**Hydrolysates** are enzymatically digested plant/yeast proteins that provide **amino acids for basal media** — the bulk nutrient broth that cells grow in.

**Important clarification:** Hydrolysates replace **amino acids**, NOT growth factors. These are completely separate cost categories:

| Component | What it provides | Hydrolysate impact |
|-----------|-----------------|-------------------|
| **Basal media** | Amino acids, glucose, vitamins | ✅ Hydrolysates reduce cost ~70% |
| **Growth factors** | FGF-2, IGF-1, TGF-β signaling proteins | ❌ Hydrolysates don't help |

**Cost comparison:**

| Media Type | Cost ($/L) | Source |
|------------|-----------|--------|
| Pharma-grade amino acids | $0.50 – $2.50 | Revised down from $1-4 based on [GFI amino acid report (2025)](https://gfi.org/resource/amino-acid-cost-and-supply-chain-analysis-for-cultivated-meat/): real supplier quotes found Humbird's AA prices 2-10x too high |
| Hydrolysate-based | $0.20 – $1.20 | [Humbird 2021](https://pmc.ncbi.nlm.nih.gov/articles/PMC11663224/): ~$2/kg amino acids; [O'Neill et al. 2021](https://ift.onlinelibrary.wiley.com/doi/abs/10.1111/1541-4337.12678) |

**Why 75% baseline?** [Recent research (2024-2025)](https://www.nature.com/articles/s41538-024-00352-0) shows hydrolysates are already validated across plant, yeast, insect, and fish sources. Companies like IntegriCulture have reduced media components by replacing amino acids with yeast extract. The main uncertainty is regulatory approval for food products, not technical feasibility.

**How pivotal is this parameter?** Arguably less pivotal than growth factors or cell density. The technical problem appears largely solved — hydrolysate-based media is already used in industry R&D. The remaining uncertainty is mostly regulatory: will food safety agencies in key markets (US, EU, Singapore) accept hydrolysate-based media for commercial products? Some jurisdictions may require pharmaceutical-grade ingredients regardless of technical equivalence. For a 2036 projection, 75% adoption is plausible but could be conservative. If you think regulatory barriers are minimal, push this above 85%. If you think regulatory caution will dominate, keep it at 50-65%.

*See [Maturity Correlation](#industry-maturity-deep-dive) for how adoption probability is adjusted.*
</details>

<details><summary>Note: basal micronutrients are folded into media $/L (click to expand)</summary>

**This slider has been removed.** A previous version of the model carried a separate "food-grade micronutrients" cost line (vitamins, minerals, trace salts). That structure double-counted basal media, which the model already describes as providing "amino acids, glucose, vitamins."

Basal micronutrients (vitamins, minerals, trace salts) are not modeled separately here. Basal media on this page already includes vitamins, and the literature suggests vitamin/mineral sourcing is relatively low-cost compared with growth factors and recombinant proteins ([O'Neill et al. 2021](https://ift.onlinelibrary.wiley.com/doi/abs/10.1111/1541-4337.12678): vitamin sourcing is a "less pressing issue" and required minerals can "generally be obtained relatively inexpensively"). We therefore include basal micronutrients inside media $/L and reserve separate uncertainty terms for supplemental recombinant proteins (insulin, transferrin, albumin) if/when we add them as a standalone category — see the [model change log](#micronutrient-change-note) below.
</details>

<a id="growth-factors-explanation"></a>
```{ojs}
//| echo: false
viewof p_recfactors = Inputs.range([0.1, 0.9], {
  value: urlNum("p_recfactors", 0.5), step: 0.05,
  label: "P(Scalable GF technology)"
})
```

```{ojs}
//| echo: false
viewof gf_progress = Inputs.range([0, 100], {
  value: urlNum("gf_progress", 50), step: 5,
  label: html`GF cost reduction by ${target_year} (%)`
})
```
<details><summary>What do these sliders control? (click to expand)</summary>

**Two controls for growth factors:** (1) **P(Scalable GF technology)** sets the probability of a breakthrough reaching commercial scale (switches between "expensive" and "cheap" price regimes). (2) **GF cost reduction progress** sets how far prices have fallen within each regime by the projection year — 0% = current prices, 100% = industry targets achieved.

**Growth factors** are signaling proteins (FGF-2, IGF-1, TGF-β, etc.) that tell cells to proliferate. Currently **the most expensive media component** — often 55-95% of media cost at research scale.

**Current vs. projected prices:**

| Scenario | Price ($/g) | Status | Source |
|----------|-------------|--------|--------|
| Research-grade FGF-2 | $50,000 | Current (2024) | [CEN](https://cen.acs.org/food/Inside-effort-cut-cost-cultivated/101/i33) |
| Research-grade TGF-β | $1,000,000 | Current | GFI analysis |
| Model "expensive" | $500 - $50,000 | Limited progress | Conservative projection |
| Model "cheap" | $1 - $100 | Breakthrough achieved | Industry target |
| Plant-based (BioBetter) | ~$1 | Target | [FoodNavigator](https://www.foodnavigator.com/Article/2022/09/12/biobetter-s-growth-factor-innovation-cuts-cost-of-cultured-meat/) |

---

**What triggers the "cheap" scenario? (Key breakthroughs)**

The probability slider represents whether **at least one** of these technology approaches succeeds at commercial scale by the projection year:

| Technology | Mechanism | Target price | Status (2025) |
|------------|-----------|--------------|---------------|
| **Autocrine cell lines** | Engineer cells to produce their own FGF2, eliminating external supply | ~$0/g (no purchase needed) | [Proof of concept 2023](https://pmc.ncbi.nlm.nih.gov/articles/PMC10153192/) — Mosa Meat, Tufts collaborations |
| **Plant molecular farming** | Express GFs in transgenic tobacco/lettuce | $1-10/g | [BioBetter](https://www.foodnavigator.com/Article/2022/09/12/biobetter-s-growth-factor-innovation-cuts-cost-of-cultured-meat/), [ORF Genetics](https://orfgenetics.com/) pilots |
| **Precision fermentation** | High-density E. coli/yeast expression (like insulin) | $10-100/g | [GFI 2024 analysis](https://gfi.org/science/the-science-of-cultivated-meat/) — scaling remains key challenge |
| **Polyphenol substitution** | Curcumin (NaCur) activates FGF receptors, reduces FGF2 needs by 80% | N/A (reduces quantity) | [Research 2024](https://pmc.ncbi.nlm.nih.gov/articles/PMC10119461/) |
| **Thermostable variants** | FGF2-G3 with 20-day half-life (vs hours) | Same $/g, less usage | [Enantis](https://www.enantis.com/) commercial product |

**Why this is modeled as a binary switch + continuous progress:**

- The **switch** represents whether *any* scalable technology reaches commercial viability
- The **progress slider** represents how far costs have dropped within that regime
- Even in the "expensive" scenario (no breakthrough), incremental improvements occur
- This structure captures the bimodal nature of the uncertainty: either scalable production is achieved, or it isn't

**Model cost impact:**

| Scenario | Usage (g/kg) | Price ($/g) | **Cost/kg chicken** |
|----------|-------------|-------------|---------------------|
| No breakthrough (Humbird formulation × 8–60 L/kg media use) | 0.001 – 0.006 | $500 – $50,000 | **$0.5 – $300** |
| Breakthrough achieved (≈3× usage reduction) | 0.0005 – 0.002 | $1 – $100 | **$0.0005 – $0.2** |

**Where the quantity range comes from.** GFI's 2023 recombinant-protein cost analysis reproduces the Humbird medium formulation with FGF at 0.1 mg/L and TGFβ at 0.002 mg/L — about 0.102 mg/L of true growth factors combined. GFI's efficient-future scenarios assume roughly 8–13 L media per kg meat; less-optimized processes may use up to ~60 L/kg. Multiplying through gives ≈**0.0008–0.006 g true GF/kg** as the defensible cited range, with Pasitka et al. (2024)'s empirical ACF chicken process landing at ~0.00187 g/kg — comfortably inside this band. An earlier version of this model used 0.0001–0.02 g/kg, which was too broad on both tails.

**This is a pivotal uncertainty.** The [GFI 2024 State of the Industry report](https://gfi.org/resource/cultivated-meat-eggs-and-dairy-state-of-the-industry-report/) identifies growth factor cost reduction as one of the top technical challenges. Note that tightening the quantity range means the **price** regime is now doing more of the work in the tornado — the swing in cost comes primarily from the $/g uncertainty, not the g/kg uncertainty.

*See [Maturity Correlation](#industry-maturity-deep-dive) for how adoption probability is adjusted.*
</details>

```{=html}
<div class="full-mode-only">
```

---

**Financing**

```{ojs}
//| echo: false
viewof wacc_lo = Inputs.range([5, 20], {
  value: urlNum("wacc_lo", 8), step: 1,
  label: "WACC Low (%)"
})
viewof wacc_hi = Inputs.range([10, 35], {
  value: urlNum("wacc_hi", 20), step: 1,
  label: "WACC High (%)"
})
viewof asset_life_lo = Inputs.range([5, 15], {
  value: urlNum("asset_life_lo", 8), step: 1,
  label: "Asset Life Low (years)"
})
viewof asset_life_hi = Inputs.range([10, 30], {
  value: urlNum("asset_life_hi", 20), step: 1,
  label: "Asset Life High (years)"
})
```
<details><summary>What are WACC and Asset Life? (click to expand)</summary>

**WACC (Weighted Average Cost of Capital):** The blended rate of return required by debt and equity investors, used to annualize capital costs via the Capital Recovery Factor (CRF). A WACC of 8% is typical for established food manufacturing; 20%+ reflects the high risk premium investors demand for an unproven technology. The simulation samples WACC from a lognormal distribution between these bounds, further adjusted by the maturity factor.

**Asset Life:** How many years bioreactor and facility equipment is depreciated over. Shorter life (8 years) means higher annual capital charges; longer life (20 years) spreads costs but assumes equipment remains productive. The range reflects uncertainty about equipment durability in a novel industry.

These two parameters together determine the **Capital Recovery Factor**: CRF = r(1+r)^n / ((1+r)^n - 1), where r = WACC and n = asset life in years.
</details>

```{=html}
</div>
```

---

**Cell Density Range**

```{ojs}
//| echo: false
viewof density_lo = Inputs.range([10, 100], {
  value: urlNum("density_lo", 30), step: 10,
  label: "Cell Density Low (g/L)"
})
viewof density_hi = Inputs.range([50, 300], {
  value: urlNum("density_hi", 200), step: 10,
  label: "Cell Density High (g/L)"
})
```
<details><summary>What is cell density and why does it matter so much? (click to expand)</summary>

**Cell density** (g/L at harvest) determines how much meat you get per liter of bioreactor volume. Higher density means less media per kilogram of product, which directly reduces the largest variable cost.

| Density | Media per kg | Typical context |
|---------|-------------|-----------------|
| 10 g/L | ~100 L/kg | Current lab scale |
| 50 g/L | ~20 L/kg | Near-term commercial target |
| 200 g/L | ~5 L/kg | Optimistic TEA projection |

**This is multiplicative.** If media costs $1/L, going from 10 to 50 g/L cuts media cost from $100/kg to $20/kg. Going to 200 g/L cuts it to $5/kg. Cell density is arguably the single most important technical parameter for cost reduction.

**Current state:** Most published data shows 10-50 g/L. Some companies claim higher, but these claims are difficult to verify independently. Lever VC's 2025 report claims 60-90 g/L has been achieved by "second generation" companies. Whether 200 g/L is achievable by 2036 is a genuine open question.

</details>

<details><summary>What about bioreactor volume / tank size? (click to expand)</summary>

**Bioreactor volume** is another major uncertainty that is currently *implicit* in this model rather than a direct parameter.

The model computes total working volume as: `total_volume = annual_output / (density × productivity × 365)`. It then applies a power-law scaling for CAPEX. But individual bioreactor tank size matters for several reasons:

| Factor | Small tanks (2,000-5,000L) | Large tanks (20,000-50,000L) |
|--------|--------------------------|------------------------------|
| Cost per liter | Higher | Lower (economies of scale) |
| Contamination risk | Lower | Higher (single failure = large loss) |
| Mixing/O2 transfer | Easier | Harder at scale |
| Flexibility | More modular | Less redundancy |
| Industry precedent | Pharma standard | Requires new engineering |

**Key debate:** Some companies (e.g., Vow) claim to have built 20,000L bioreactors for under $1M in 14 weeks using custom food-grade designs. If true, this dramatically changes the CAPEX picture. Humbird's analysis assumed pharma-grade bioreactors at $50-500/L.

**Why it's not a direct slider (yet):** Adding individual tank size would require modeling the number of tanks, contamination batch-failure rates, and the trade-off between scale and reliability. This is a planned enhancement. For now, the **Plant Capacity** and **Cell Density** parameters together determine total working volume, and the **custom reactor ratio** (in full view) captures the pharma-vs-food-grade cost difference.

**Workshop discussion:** This is one of the key cruxes for the [upcoming CM workshop](https://uj-cm-workshop.netlify.app/) — what bioreactor scale is realistic, and what does it cost?
</details>

```{=html}
<div class="full-mode-only">
```

**Advanced: <span title="Net fresh media consumed per kg of cells, divided by the nominal (1000/density) reactor-fill volume. 1 = batch; >1 = perfusion; <1 = recycling / fed-batch / harvest concentration.">Media-use multiplier (×)</span>**

<details>
<summary>What is this — and why can it be below 1? (click to expand)</summary>

The model computes media volume per kg as `(1000 / density) × multiplier`. A value of **1** is traditional batch mode (fill reactor once, harvest); **>1** is perfusion (multiple media-volume equivalents flow through during the run); **<1** represents media recycling, fed-batch with concentrated feeds, or harvest-side cell concentration. The [Learn page](learn.html#media-use-mechanisms) walks through all three mechanisms.

**Why the range changed (April 2026):** the default p5–p95 was tightened from 1–10× to **0.5–3.0×**. The old floor of 1.0 was too restrictive — the GFI 2023 cost-competitive scenarios assume 8–13 L/kg, which at 60–90 g/L density implies a multiplier of roughly **0.5–1.2**. A floor of 1.0 mechanically excluded those scenarios no matter how high you pushed density. The new range covers both recycled/fed-batch (<1) and standard perfusion (up to ~3×); values of 5–10× remain plausible for heavily media-intensive processes but are now a stress-test region rather than the default.

</details>

<details>
<summary>Show multiplier sliders</summary>

```{ojs}
//| echo: false
viewof media_turnover_lo = Inputs.range([0.25, 2], {
  value: urlNum("media_turnover_lo", 0.5), step: 0.05,
  label: "Media-use multiplier p5 (low end)"
})
viewof media_turnover_hi = Inputs.range([1, 10], {
  value: urlNum("media_turnover_hi", 3.0), step: 0.1,
  label: "Media-use multiplier p95 (high end)"
})
```
</details>

```{=html}
</div>
```

```{ojs}
//| echo: false
// URL state writer: serialize every viewof value that DIFFERS FROM ITS
// DEFAULT into ?key=val pairs, then debounce-write to the URL via
// history.replaceState. Critical invariant: if every slider is at its
// default, the URL stays bare (pathname + hash only) — no query string.
// This is required so Hypothes.is can find annotations on the canonical
// bare URL; a polluted URL breaks annotation lookup for every visitor.
// The writer depends on every viewof name below so OJS re-runs it
// whenever any input changes. Reads nothing from urlParams.
{
  // Hard-coded defaults must stay in sync with each Inputs.range() /
  // Inputs.toggle() declaration above and with the reset_adoption button.
  const defaults = {
    simpleMode: true, include_blending: false, blending_share: 0.25, filler_cost: 3,
    include_capex: true, include_fixed_opex: true, include_downstream: false,
    plant_capacity: 20, uptime: 0.90, maturity: 0.5, target_year: 2036,
    p_hydro: 0.75, p_recfactors: 0.5, gf_progress: 50,
    wacc_lo: 8, wacc_hi: 20, asset_life_lo: 8, asset_life_hi: 20,
    density_lo: 30, density_hi: 200,
    media_turnover_lo: 0.5, media_turnover_hi: 3.0
  };

  const state = {
    simpleMode, include_blending, blending_share, filler_cost,
    include_capex, include_fixed_opex, include_downstream,
    plant_capacity, uptime, maturity, target_year,
    p_hydro, p_recfactors, gf_progress,
    wacc_lo, wacc_hi, asset_life_lo, asset_life_hi,
    density_lo, density_hi, media_turnover_lo, media_turnover_hi
  };

  const usp = new URLSearchParams();
  let hasDiff = false;
  for (const [k, v] of Object.entries(state)) {
    const def = defaults[k];
    let matches;
    if (typeof v === "boolean") matches = (v === def);
    else if (typeof v === "number") matches = Math.abs(v - def) < 1e-9;
    else matches = (v === def);

    if (!matches) {
      hasDiff = true;
      if (typeof v === "boolean") usp.set(k, v ? "1" : "0");
      else if (typeof v === "number" && Number.isFinite(v)) usp.set(k, String(v));
    }
  }

  if (window._urlWriteTimer) clearTimeout(window._urlWriteTimer);
  window._urlWriteTimer = setTimeout(() => {
    try {
      const newUrl = hasDiff
        ? (location.pathname + "?" + usp.toString() + location.hash)
        : (location.pathname + location.hash);
      history.replaceState(null, "", newUrl);
    } catch (e) {
      console.warn("URL state update failed:", e);
    }
  }, 300);

  return null;
}
```

:::

::: {.panel-fill}

```{ojs}
//| echo: false

// Run simulation reactively whenever inputs change
results = {
  // Adjust maturity based on target year (2024 baseline)
  // Later years allow more time for industry development
  const yearFactor = Math.max(0, Math.min(1, (target_year - 2024) / 20));
  const adjustedMaturity = maturity * (0.5 + 0.5 * yearFactor);

  const params = {
    plant_kta_p5: simpleMode ? 10 : plant_capacity * 0.5,
    plant_kta_p95: simpleMode ? 40 : plant_capacity * 2.0,
    uptime_mean: simpleMode ? 0.90 : uptime,
    maturity_mean: adjustedMaturity,
    p_hydro_mean: p_hydro,
    p_recfactors_mean: p_recfactors,
    wacc_p5: simpleMode ? 0.08 : wacc_lo / 100,
    wacc_p95: simpleMode ? 0.20 : wacc_hi / 100,
    asset_life_lo: simpleMode ? 8 : asset_life_lo,
    asset_life_hi: simpleMode ? 20 : asset_life_hi,
    density_gL_p5: density_lo,
    density_gL_p95: density_hi,
    media_turnover_p5: simpleMode ? 0.5 : media_turnover_lo,
    media_turnover_p95: simpleMode ? 3.0 : media_turnover_hi,
    include_capex: simpleMode ? true : include_capex,
    include_fixed_opex: simpleMode ? true : include_fixed_opex,
    include_downstream: simpleMode ? false : include_downstream,
    gf_progress: gf_progress
  };

  return simulate(30000, 42, params);
}

// Calculate statistics
stats = {
  const uc = results.unit_cost;
  const bs = typeof blending_share === "number" ? blending_share : 0.25;
  const fc = typeof filler_cost === "number" ? filler_cost : 3;
  const blended = uc.map(c => c * bs + fc * (1 - bs));
  return {
    n: uc.length,
    p5: quantile(uc, 0.05),
    p50: quantile(uc, 0.50),
    p95: quantile(uc, 0.95),
    prob_10: uc.filter(x => x < 10).length / uc.length * 100,
    prob_25: uc.filter(x => x < 25).length / uc.length * 100,
    prob_50: uc.filter(x => x < 50).length / uc.length * 100,
    prob_100: uc.filter(x => x < 100).length / uc.length * 100,
    blended_p50: quantile(blended, 0.50),
    blended_p5: quantile(blended, 0.05),
    blended_p95: quantile(blended, 0.95)
  };
}
```

### Results Summary

```{ojs}
//| echo: false
html`<div style="background: #f8f9fa; padding: 1rem 1.25rem; border-left: 4px solid #3498db; margin-bottom: 1.5rem; font-size: 0.95em; line-height: 1.6;">
<strong>What these numbers represent:</strong> Simulated <strong>production cost per kilogram of pure cultured chicken cells</strong> (<span title="Wet weight = the mass of cells as harvested from the bioreactor, including water content (~70-80%). This is the standard output basis used in most TEAs (Humbird 2021, Pasitka 2024). It does NOT include downstream processing into structured products, blending with plant-based ingredients, or retail margins. For comparison: Humbird reports $37/kg wet cell mass; Pasitka reports $13.75/kg wet cell mass (large perfusion). The widely-cited ~$6/lb Pasitka figure is for a 50/50 hybrid product, not pure cell mass. See our TEA Comparison page for details." style="text-decoration: underline dotted; cursor: help;">wet weight, unprocessed &#9432;</span>) in <strong>${target_year}</strong>, based on ${stats.n.toLocaleString()} Monte Carlo simulations. This is the cost to produce cell mass in a bioreactor — not the cost of a consumer product, and not retail price. <a href="compare.html" style="font-size: 0.9em;">[Compare to published TEAs →]</a>
<br><br>
<strong><span title="UPSIDE Foods' chicken cutlet is a blend of cultured chicken cells and plant-based ingredients. SuperMeat's chicken burger used ~30% cultured cells. The GFI State of the Industry 2024 report notes that 'hybrid products combining cultivated and plant-based ingredients are the most likely near-term path to market.' Eat Just/GOOD Meat's Singapore-approved product uses cultured chicken in a plant-protein matrix.">Pure cells vs. consumer products:</span></strong> Most cultivated meat products on the market or in development are <em>hybrid products</em> — blending a fraction of cultured cells with plant-based or mycoprotein ingredients. A product with (say) 20% cultured cells and 80% plant-based filler at $3/kg would have a blended ingredient cost far below the pure-cell cost shown here. The "price parity with conventional meat" threshold may therefore be achievable at higher per-kg cell costs than these numbers suggest.
<br><br>
<strong>Why it matters:</strong> If production costs for pure cells reach <strong>~$10/kg</strong>, even 100% cultured products could compete with conventional chicken. At <strong>$25-50/kg</strong>, hybrid products with moderate cell inclusion rates may still reach price parity. If costs remain <strong>>$100/kg</strong>, even hybrid products face significant price premiums. These thresholds inform whether animal welfare interventions should prioritize supporting this industry.
</div>`
```

```{ojs}
//| echo: false
html`<div class="grid" style="grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 2rem;">

<div class="card" style="background: linear-gradient(135deg, #3498db, #2980b9); color: white; padding: 1.5rem; border-radius: 8px;">
<h4 style="margin: 0; opacity: 0.9;">Median Pure Cell Mass Cost (p50)</h4>
<h2 style="margin: 0.5rem 0;">$${Math.round(stats.p50)}/kg</h2>
<small>$/kg pure cell mass (wet weight) — half of simulations above, half below</small>
</div>

<div class="card" style="background: linear-gradient(135deg, #27ae60, #1e8449); color: white; padding: 1.5rem; border-radius: 8px;">
<h4 style="margin: 0; opacity: 0.9;">Optimistic (p5)</h4>
<h2 style="margin: 0.5rem 0;">$${Math.round(stats.p5)}/kg</h2>
<small>Only 5% of simulations cheaper</small>
</div>

<div class="card" style="background: linear-gradient(135deg, #e74c3c, #c0392b); color: white; padding: 1.5rem; border-radius: 8px;">
<h4 style="margin: 0; opacity: 0.9;">Pessimistic (p95)</h4>
<h2 style="margin: 0.5rem 0;">$${Math.round(stats.p95)}/kg</h2>
<small>95% of simulations cheaper</small>
</div>

</div>

${include_blending ? html`<div style="background: #eaf7ea; border-left: 4px solid #27ae60; padding: 0.8rem 1rem; margin-top: 0.5rem; font-size: 0.9em;">
<strong>Blended product estimate (${Math.round(blending_share * 100)}% CM, ${Math.round((1-blending_share)*100)}% filler at $${filler_cost}/kg):</strong>
Median <strong>$${stats.blended_p50.toFixed(1)}/kg</strong> · 90% CI: $${stats.blended_p5.toFixed(1)} – $${stats.blended_p95.toFixed(1)}/kg
</div>` : html`<div style="background: #fef9e7; border-left: 4px solid #f39c12; padding: 0.8rem 1rem; margin-top: 0.5rem; font-size: 0.9em;">
<strong>Hybrid product estimate:</strong> At a CM inclusion rate of ~25% with plant-based filler at ~$3/kg, the blended ingredient cost would be approximately <strong>$${(stats.p50 * 0.25 + 3 * 0.75).toFixed(1)}/kg</strong> (median). Enable "Show blended product cost" in the sidebar to adjust these assumptions.
</div>`}

</div>`
```

```{ojs}
//| echo: false
// Save / share bar: copy the current shareable link, or export the full
// simulation (CSV of all 30,000 samples, or a compact JSON with the
// parameters + summary stats + component means). Buttons are plain DOM so
// we can attach click handlers that read the current `results` / `stats`
// closure — the cell re-runs on every results change, which is fine.
{
  function downloadBlob(content, filename, mime) {
    const blob = new Blob([content], {type: mime});
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  }

  const stamp = () => new Date().toISOString().slice(0, 10);

  function downloadCSV() {
    const cols = ["unit_cost", "cost_media", "cost_recf", "cost_other_var", "cost_capex", "cost_fixed", "cost_downstream"];
    const rows = [cols.join(",")];
    const n = results.unit_cost.length;
    for (let i = 0; i < n; i++) {
      rows.push(cols.map(c => results[c][i].toFixed(4)).join(","));
    }
    downloadBlob(rows.join("\n"), `cm_pq_results_${stamp()}.csv`, "text/csv");
  }

  function downloadJSON() {
    const config = {
      generated_at: new Date().toISOString(),
      dashboard_url: window.location.origin + window.location.pathname,
      shareable_link: window.location.href,
      parameters: {
        simpleMode, include_blending, blending_share, filler_cost,
        include_capex, include_fixed_opex, include_downstream,
        plant_capacity, uptime, maturity, target_year,
        p_hydro, p_recfactors, gf_progress,
        wacc_lo, wacc_hi, asset_life_lo, asset_life_hi,
        density_lo, density_hi, media_turnover_lo, media_turnover_hi
      },
      summary_stats_unit_cost_per_kg: {
        n_simulations: stats.n,
        p5: +stats.p5.toFixed(2),
        p50: +stats.p50.toFixed(2),
        p95: +stats.p95.toFixed(2),
        prob_under_10: +stats.prob_10.toFixed(1),
        prob_under_25: +stats.prob_25.toFixed(1),
        prob_under_50: +stats.prob_50.toFixed(1),
        prob_under_100: +stats.prob_100.toFixed(1)
      },
      component_means_per_kg: {
        media: +mean(results.cost_media).toFixed(2),
        growth_factors: +mean(results.cost_recf).toFixed(2),
        other_voc: +mean(results.cost_other_var).toFixed(2),
        capex_annualized: +mean(results.cost_capex).toFixed(2),
        fixed_opex: +mean(results.cost_fixed).toFixed(2),
        downstream: +mean(results.cost_downstream).toFixed(2)
      },
      notes: {
        units: "USD per kg of pure cultured cell mass, wet weight",
        simulation_seed: 42,
        monte_carlo_samples: 30000
      }
    };
    downloadBlob(JSON.stringify(config, null, 2), `cm_pq_config_${stamp()}.json`, "application/json");
  }

  const btnStyle = "padding: 0.45rem 0.85rem; font-size: 0.85rem; border: 1px solid #5a7a5a; border-radius: 4px; background: #e8f4e8; color: #2d4a2d; cursor: pointer; white-space: nowrap; font-weight: 500;";

  const container = html`<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin: 0 0 1.25rem 0; padding: 0.6rem 0.85rem; background: #fbfcf9; border: 1px solid #d4e0d4; border-radius: 6px;">
    <strong style="font-size: 0.85rem; color: #2d4a2d; margin-right: 0.25rem;">Save / share this scenario:</strong>
    <button data-btn="copy" style="${btnStyle}" title="Copy a URL that captures every slider and toggle value. Paste it anywhere to restore the exact scenario.">📋 Copy shareable link</button>
    <button data-btn="csv" style="${btnStyle}" title="Download all 30,000 Monte Carlo samples as CSV (unit cost + every component).">⬇ Results CSV</button>
    <button data-btn="json" style="${btnStyle}" title="Download a compact JSON with your parameter values, summary percentiles, and component means.">⬇ Config + summary JSON</button>
    <span data-status="" style="font-size: 0.82rem; font-weight: 500;"></span>
  </div>`;

  const statusEl = container.querySelector("[data-status]");
  function flash(msg, ok = true) {
    statusEl.textContent = msg;
    statusEl.style.color = ok ? "#27ae60" : "#c0392b";
    setTimeout(() => { statusEl.textContent = ""; }, 2200);
  }

  container.querySelector("[data-btn=copy]").addEventListener("click", () => {
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(window.location.href)
        .then(() => flash("✓ Link copied to clipboard"))
        .catch(() => flash("× Clipboard denied — copy the URL from the address bar", false));
    } else {
      flash("× Clipboard API unavailable — copy the URL from the address bar", false);
    }
  });
  container.querySelector("[data-btn=csv]").addEventListener("click", () => {
    try { downloadCSV(); flash("✓ CSV downloaded"); }
    catch (e) { flash("× CSV export failed: " + e.message, false); }
  });
  container.querySelector("[data-btn=json]").addEventListener("click", () => {
    try { downloadJSON(); flash("✓ JSON downloaded"); }
    catch (e) { flash("× JSON export failed: " + e.message, false); }
  });

  return container;
}
```

*Tip: the URL in your address bar updates live as you move sliders, so the "Copy shareable link" button always reflects the scenario you're currently viewing.*

### Cost Distribution

```{ojs}
//| echo: false
Plot = import("https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/+esm")
```

```{ojs}
//| echo: false
{
  const uc = results.unit_cost;
  const clipVal = Math.min(quantile(uc, 0.98), 200);
  const clipped = uc.filter(x => x <= clipVal);

  return Plot.plot({
    width: 800,
    height: 400,
    marginLeft: 60,
    marginBottom: 50,
    x: {
      label: "Pure Cell Mass Cost ($/kg, wet weight)",
      domain: [0, clipVal * 1.05]
    },
    y: {
      label: "Frequency",
      grid: true
    },
    marks: [
      Plot.rectY(clipped, Plot.binX({y: "count"}, {x: d => d, fill: "steelblue", fillOpacity: 0.7})),
      Plot.ruleX([stats.p5], {stroke: "green", strokeWidth: 2, strokeDasharray: "5,5"}),
      Plot.ruleX([stats.p50], {stroke: "blue", strokeWidth: 3}),
      Plot.ruleX([stats.p95], {stroke: "red", strokeWidth: 2, strokeDasharray: "5,5"}),
      Plot.ruleX([10], {stroke: "darkgreen", strokeWidth: 2, strokeDasharray: "2,2", strokeOpacity: 0.6}),
      Plot.ruleX([25], {stroke: "orange", strokeWidth: 2, strokeDasharray: "2,2", strokeOpacity: 0.6}),
      Plot.text([
        {x: stats.p5 + 2, y: 2500, text: `p5: $${stats.p5.toFixed(1)}`},
        {x: stats.p50 + 2, y: 3000, text: `p50: $${stats.p50.toFixed(1)}`},
        {x: stats.p95 + 2, y: 2500, text: `p95: $${stats.p95.toFixed(1)}`}
      ], {x: "x", y: "y", text: "text", fontSize: 12})
    ],
    title: `Projected ${target_year} Cultured Chicken Production Cost Distribution`
  })
}
```

<details>
<summary>How to read this chart</summary>

**The histogram** shows the distribution of simulated production costs.

- **Blue bars**: Frequency of each cost level across 30,000 simulations
- **Green dashed line**: 5th percentile (optimistic scenario)
- **Blue solid line**: Median (50th percentile)
- **Red dashed line**: 95th percentile (pessimistic scenario)
- **Dark green dotted**: $10/kg reference (competitive with conventional chicken)
- **Orange dotted**: $25/kg reference (premium product viable)

The **width** of the distribution shows uncertainty. Narrow = confident. Wide = uncertain.
</details>

### Probability Thresholds

These percentages answer: "What's the chance that production costs fall below key price points?" — based on the parameter distributions you've set above. These are the decision-relevant outputs for evaluating whether cultured meat can compete with conventional chicken.

```{ojs}
//| echo: false
html`<div class="grid" style="grid-template-columns: repeat(4, 1fr); gap: 1rem; margin: 2rem 0;">

<div class="card" style="border: 2px solid ${stats.prob_10 > 25 ? '#27ae60' : '#ddd'}; padding: 1rem; border-radius: 8px; text-align: center;">
<h5>P(Cost < $10/kg)</h5>
<h2 style="color: #27ae60;">${stats.prob_10.toFixed(1)}%</h2>
<small>Pure cells competitive with conventional chicken (~$5-10/kg)</small>
</div>

<div class="card" style="border: 2px solid ${stats.prob_25 > 50 ? '#3498db' : '#ddd'}; padding: 1rem; border-radius: 8px; text-align: center;">
<h5>P(Cost < $25/kg)</h5>
<h2 style="color: #3498db;">${stats.prob_25.toFixed(1)}%</h2>
<small>Premium product viable</small>
</div>

<div class="card" style="border: 2px solid ${stats.prob_50 > 50 ? '#f39c12' : '#ddd'}; padding: 1rem; border-radius: 8px; text-align: center;">
<h5>P(Cost < $50/kg)</h5>
<h2 style="color: #f39c12;">${stats.prob_50.toFixed(1)}%</h2>
<small>Specialty/niche market</small>
</div>

<div class="card" style="border: 2px solid #ddd; padding: 1rem; border-radius: 8px; text-align: center;">
<h5>P(Cost < $100/kg)</h5>
<h2 style="color: #e74c3c;">${stats.prob_100.toFixed(1)}%</h2>
<small>Below early estimates</small>
</div>

</div>`
```

### Cost Breakdown

**Where does the cost come from?** This chart shows the average contribution of each cost component across all simulations. The largest bars are the cost drivers to focus on — these are where technological progress or parameter uncertainty has the most impact.

```{ojs}
//| echo: false
{
  const allComponents = [
    {name: "Media (incl. basal micros)", value: mean(results.cost_media), color: "#27ae60"},
    {name: "Growth Factors", value: mean(results.cost_recf), color: "#9b59b6"},
    {name: "Other VOC", value: mean(results.cost_other_var), color: "#7f8c8d"},
    {name: "CAPEX (annualized)", value: mean(results.cost_capex), color: "#e74c3c"},
    {name: "Plant overhead OPEX", value: mean(results.cost_fixed), color: "#f39c12"},
    {name: "Downstream", value: mean(results.cost_downstream), color: "#1abc9c"}
  ];
  // Filter out zero-value components (e.g., downstream when not included)
  const components = allComponents.filter(c => c.value > 0.001).sort((a, b) => b.value - a.value);

  const total = components.reduce((s, c) => s + c.value, 0);

  const chartContainer = document.createElement("div");
  chartContainer.style.position = "relative";

  // Expand/collapse button
  const expandBtn = document.createElement("button");
  expandBtn.textContent = "Expand Chart";
  expandBtn.style.cssText = "padding: 0.3rem 0.7rem; font-size: 0.8rem; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #f8f9fa; margin-bottom: 0.5rem;";
  let expanded = false;
  expandBtn.onclick = () => {
    expanded = !expanded;
    expandBtn.textContent = expanded ? "Collapse Chart" : "Expand Chart";
    chartEl.replaceWith(makeChart(expanded));
    chartEl = chartContainer.querySelector(".cost-breakdown-plot");
  };
  chartContainer.appendChild(expandBtn);

  function makeChart(large) {
    const w = large ? 1200 : 1000;
    const h = large ? 700 : 580;
    const fontSize = large ? 14 : 13;
    const p = Plot.plot({
      width: w,
      height: h,
      marginLeft: 200,
      marginRight: 140,
      x: {
        label: "Average Cost ($/kg)",
        grid: true
      },
      y: {
        label: null
      },
      marks: [
        Plot.barX(components, {
          y: "name",
          x: "value",
          fill: "color",
          sort: {y: "-x"}
        }),
        Plot.text(components, {
          y: "name",
          x: d => d.value + 0.5,
          text: d => `$${d.value.toFixed(2)} (${(d.value/total*100).toFixed(0)}%)`,
          textAnchor: "start",
          fontSize: fontSize
        })
      ],
      title: `Cost Breakdown by Component (Total: $${Math.round(total)}/kg)`
    });
    p.classList.add("cost-breakdown-plot");
    return p;
  }

  let chartEl = makeChart(false);
  chartContainer.appendChild(chartEl);
  return chartContainer;
}
```

<details>
<summary>Understanding the cost components</summary>

**Variable Operating Costs (VOC):**

- **[Media](learn.html#media-composition-40-70-of-total-cost)**: Nutrient broth for cell growth (amino acids, glucose, vitamins, minerals, trace salts) — basal micronutrients are included here, not as a separate line item
- **[Recombinant](learn.html#step-5-growth-factors-a-key-cost-driver)**: Growth factors (proteins signaling cell division)
- **Other VOC**: Utilities, consumables, waste disposal

**Capacity-anchored Costs (per-plant, scale sub-linearly with plant size):**

- **CAPEX**: Capital costs (reactors, facilities) annualized via Capital Recovery Factor
- **Plant overhead OPEX**: Labor, maintenance, plant overhead (conventionally called "Fixed OPEX" — total scales sub-linearly with plant size, so per-kg overhead falls as plants grow)

The relative sizes shift dramatically based on technology adoption assumptions.
</details>

### Component Distributions

Individual distributions for each cost driver. **These charts update dynamically** when you change the sidebar parameters. If you adjust plant capacity, the CAPEX and plant-overhead OPEX distributions will shift; if you change technology adoption probabilities, the media and growth factor distributions will change. (Note: some parameters, like plant capacity, primarily affect CAPEX and overhead rather than variable costs like media.)

```{ojs}
//| echo: false

// Format cost with appropriate precision
function formatCost(val) {
  if (val >= 30) return Math.round(val).toString();
  if (val >= 1) return val.toFixed(1);
  if (val >= 0.1) return val.toFixed(2);
  return val.toFixed(3);
}

{
  const allComponents = [
    {name: "Media (incl. basal micros)", data: results.cost_media, color: "#27ae60"},
    {name: "Growth Factors", data: results.cost_recf, color: "#9b59b6"},
    {name: "Other VOC", data: results.cost_other_var, color: "#7f8c8d"},
    {name: "CAPEX (annualized)", data: results.cost_capex, color: "#e74c3c"},
    {name: "Plant overhead OPEX", data: results.cost_fixed, color: "#f39c12"},
    {name: "Downstream", data: results.cost_downstream, color: "#1abc9c"}
  ];

  // Filter out components with all zeros (e.g., downstream when not included)
  const components = allComponents.filter(c => mean(c.data) > 0.001);

  const plotData = components.map(comp => {
    const p5 = quantile(comp.data, 0.05);
    const p50 = quantile(comp.data, 0.50);
    const p95 = quantile(comp.data, 0.95);
    const clipVal = Math.max(quantile(comp.data, 0.98), 0.1);
    const clipped = comp.data.filter(x => x <= clipVal && x >= 0);

    const plot = Plot.plot({
      width: 420,
      height: 180,
      marginLeft: 45,
      marginBottom: 35,
      marginTop: 10,
      x: {
        label: "$/kg",
        domain: [0, clipVal * 1.1]
      },
      y: {
        label: null,
        ticks: []
      },
      marks: [
        Plot.rectY(clipped, Plot.binX({y: "count"}, {x: d => d, fill: comp.color, fillOpacity: 0.7})),
        Plot.ruleX([p5], {stroke: "black", strokeWidth: 1.5, strokeDasharray: "3,3"}),
        Plot.ruleX([p50], {stroke: "black", strokeWidth: 2}),
        Plot.ruleX([p95], {stroke: "black", strokeWidth: 1.5, strokeDasharray: "3,3"})
      ]
    });

    const label = `${comp.name}: $${formatCost(p50)} (90% CI: ${formatCost(p5)} – ${formatCost(p95)})`;
    return {plot, label};
  });

  return html`<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; margin: 1rem 0;">
    ${plotData.map(d => html`<div style="font-size: 0.9em;">
      <div style="font-weight: normal; margin-bottom: 0.3rem; color: #333;">${d.label}</div>
      ${d.plot}
    </div>`)}
  </div>`;
}
```

<details>
<summary>Reading these distributions</summary>

Each mini-histogram shows the distribution for one cost component across 30,000 simulations:

- **Solid black line**: Median (p50) - the middle value
- **Dashed black lines**: 5th and 95th percentiles (90% confidence interval)
- **Width of distribution**: Uncertainty - wider means more uncertain

These distributions are generated from the simulation using the parameters you set in the sidebar above. When you adjust the sliders, these distributions update accordingly. They are analogous to Squiggle/Guesstimate visualizations -- showing the full range of possible values, not just a point estimate.
</details>

### Technology Adoption (Realized)

```{ojs}
//| echo: false
html`<div class="grid" style="grid-template-columns: repeat(2, 1fr); gap: 1rem; margin: 2rem 0;">

<div class="card" style="border: 1px solid #ddd; padding: 1rem; border-radius: 8px; text-align: center;">
<h5>Hydrolysates Adopted</h5>
<h2 style="color: #27ae60;">${(results.pct_hydro * 100).toFixed(0)}%</h2>
<small>of simulations use hydrolysates</small>
</div>

<div class="card" style="border: 1px solid #ddd; padding: 1rem; border-radius: 8px; text-align: center;">
<h5>Cheap Growth Factors</h5>
<h2 style="color: #9b59b6;">${(results.pct_recf_cheap * 100).toFixed(0)}%</h2>
<small>of simulations have cheap factors</small>
</div>

</div>`
```

### Sensitivity Analysis (Tornado Chart)

Which parameters have the most impact on the final cost? Each bar shows the **difference in mean unit cost ($/kg)** between simulations where the parameter is in its **top 10%** and simulations where it is in its **bottom 10%**.

```{ojs}
//| echo: false
{
  const uc = results.unit_cost;

  // Deduplicated parameter list.
  // Removed vs. previous version (see explainer below for details):
  //   • L/kg (volume)     — deterministic function of density × media-use multiplier
  //   • Uses Hydrolysates — regime-switch subsumed into Media $/L
  //   • Has Cheap GFs     — regime-switch subsumed into GF Price / GF Quantity
  const params = [
    {name: "Cell Density (g/L)",                 data: results.density_samples,        kind: "primitive"},
    {name: "Media-use multiplier (×)",           data: results.media_turnover_samples, kind: "primitive"},
    {name: "Media $/L (incl. hydrolysate regime)", data: results.media_cost_L_samples, kind: "mixture"},
    {name: "GF Price ($/g, incl. regime)",       data: results.price_recf_samples,     kind: "mixture"},
    {name: "GF Quantity (g/kg, incl. regime)",   data: results.g_recf_samples,         kind: "mixture"},
    {name: "Industry Maturity (latent — see note)", data: results.maturity_samples,    kind: "latent"},
    {name: "Plant Capacity (kTA)",               data: results.plant_kta_samples,      kind: "primitive"},
    {name: "Utilization Rate",                   data: results.uptime_samples,         kind: "primitive"}
  ];

  const swings = params.map(p => ({
    name: p.name,
    kind: p.kind,
    swing: conditionalSwing(p.data, uc, 0.10)
  }));

  const sorted = swings
    .map(s => ({...s, absSwing: Math.abs(s.swing)}))
    .sort((a, b) => b.absSwing - a.absSwing);

  const maxAbs = Math.max(...sorted.map(s => s.absSwing), 1);
  const pad = maxAbs * 0.30;

  const tornadoPlot = Plot.plot({
    width: 900,
    height: 440,
    marginLeft: 290,
    marginRight: 100,
    x: {
      label: "Δ mean unit cost ($/kg): top 10% − bottom 10% of parameter",
      domain: [-maxAbs - pad, maxAbs + pad],
      grid: true,
      labelOffset: 40,
      tickFormat: d => (d >= 0 ? "+$" : "−$") + Math.abs(d).toFixed(0)
    },
    y: {
      label: null,
      tickFormat: d => d,
      tickSize: 0
    },
    color: {
      domain: ["Increases cost", "Decreases cost"],
      range: ["#e74c3c", "#27ae60"]
    },
    style: {
      fontSize: "13px"
    },
    marks: [
      Plot.barX(sorted, {
        y: "name",
        x: "swing",
        fill: d => d.swing > 0 ? "Increases cost" : "Decreases cost",
        sort: {y: "-x", reduce: d => Math.abs(d)}
      }),
      Plot.ruleX([0], {stroke: "black", strokeWidth: 1}),
      Plot.text(sorted, {
        y: "name",
        x: d => d.swing > 0 ? d.swing + maxAbs * 0.025 : d.swing - maxAbs * 0.025,
        text: d => (d.swing > 0 ? "+$" : "−$") + Math.abs(d.swing).toFixed(1) + "/kg",
        textAnchor: d => d.swing > 0 ? "start" : "end",
        fontSize: 12,
        fontWeight: 500
      })
    ]
  });

  return html`<div style="font-size: 1em;">
    <div style="font-weight: normal; font-size: 1.05em; margin-bottom: 0.5rem; color: #333;">Parameter Sensitivity: Dollar Swing in Mean Unit Cost</div>
    ${tornadoPlot}
  </div>`;
}
```

<details>
<summary>How to read this chart — and what changed from the earlier rank-correlation version (click to expand)</summary>

**Scale.** Each bar is the **difference, in $/kg, between the mean unit cost among simulations where the parameter is in its top decile and the mean unit cost among simulations where the parameter is in its bottom decile.** With 30,000 Monte Carlo samples, each tail bin contains ~3,000 simulations. Red (positive) means the parameter increases cost as it increases; green (negative) means it decreases cost.

This is a real-dollar quantity on the same scale as every other cost figure on the page. It replaces an earlier version of this chart that used **Spearman rank correlation** — a unitless number in [-1, 1] — which could tell you the *direction* of a monotonic relationship but not the *magnitude* in any unit comparable to the rest of the model.

**Why three parameters from the old chart were dropped.** The previous chart listed eleven parameters; three of them were double-counting signals that other parameters already carried:

- **L/kg (volume)** was a pure deterministic function of Cell Density × Media-use multiplier — not an independent driver, just a third lens on the same two variables.
- **Uses Hydrolysates (binary)** chose which lognormal regime Media $/L was sampled from. The continuous Media $/L bar already captures the regime switch *plus* within-regime noise.
- **Has Cheap GFs (binary)** chose the regime for both GF Price and GF Quantity. Same issue: subsumed into those continuous bars.

**Industry Maturity is a latent driver — do not sum its bar with downstream bars.** Maturity is not sampled independently of the other parameters in the chart. It perturbs P(hydrolysate adoption), P(cheap GFs), WACC, and the custom-reactor share. So its bar captures the **total effect** of a shift in maturity propagating through all of those downstream channels, and the samples that sit in its "top decile" are *also* more likely to have cheaper media and cheaper GFs as a consequence. If you add Industry Maturity's bar to Media $/L and GF Price, you will double count.

**What the other bars mean.** The mixture bars (Media $/L, GF Price, GF Quantity) bundle the regime switch with the within-regime lognormal noise — the swing is the combined effect. The primitive bars (Cell Density, Media-use multiplier, Plant Capacity, Utilization Rate) are sampled close to independently, so their swings are close to honest marginal effects over the joint distribution.

**Technical caveats on the dollar-swing statistic.**

- This is a **total-effect, conditional-expectation** statistic — not a Sobol first-order index. It does not cleanly decompose variance and can overlap across correlated parameters (which is why the latent-variable caveat above matters).
- The tail width is 10%. Wider tails smooth more; narrower tails are noisier. At 10% × 30,000 samples the noise is small.
- Because the statistic is computed on the joint sampling distribution, changing slider values can move bars for parameters you didn't touch — a different joint distribution produces different conditional means.
- For a fully decomposed analysis (first-order + total-effect Sobol indices) we would need dedicated re-simulation passes, which is plausible follow-up work but overkill given the model is still pre-review.
</details>

### Industry Maturity Deep Dive

The **maturity factor** is a continuous variable ranging from 0 (nascent industry) to 1 (fully mature) that correlates multiple model inputs. It is not binary — intermediate values represent partial industry development. Higher maturity means:

- Higher technology adoption rates
- Lower reactor costs (more custom/food-grade)
- Lower financing costs (WACC)

```{ojs}
//| echo: false
{
  // Maturity distribution
  const matP5 = quantile(results.maturity_samples, 0.05);
  const matP50 = quantile(results.maturity_samples, 0.50);
  const matP95 = quantile(results.maturity_samples, 0.95);

  const maturityPlot = Plot.plot({
    width: 400,
    height: 220,
    marginLeft: 50,
    marginBottom: 40,
    marginTop: 10,
    x: {
      label: "Maturity Factor",
      domain: [0, 1]
    },
    y: {
      label: "Frequency",
      grid: true
    },
    marks: [
      Plot.rectY(results.maturity_samples, Plot.binX({y: "count"}, {x: d => d, fill: "#8e44ad", fillOpacity: 0.7})),
      Plot.ruleX([matP50], {stroke: "black", strokeWidth: 2}),
      Plot.ruleX([matP5], {stroke: "black", strokeWidth: 1.5, strokeDasharray: "3,3"}),
      Plot.ruleX([matP95], {stroke: "black", strokeWidth: 1.5, strokeDasharray: "3,3"})
    ]
  });
  const matLabel = `Maturity Distribution (median: ${matP50.toFixed(2)}, 90% CI: ${matP5.toFixed(2)} – ${matP95.toFixed(2)})`;

  // Cell density distribution
  const denP5 = quantile(results.density_samples, 0.05);
  const denP50 = quantile(results.density_samples, 0.50);
  const denP95 = quantile(results.density_samples, 0.95);

  const densityPlot = Plot.plot({
    width: 400,
    height: 220,
    marginLeft: 50,
    marginBottom: 40,
    marginTop: 10,
    x: {
      label: "Cell Density (g/L)",
      domain: [0, Math.min(quantile(results.density_samples, 0.98) * 1.1, 350)]
    },
    y: {
      label: "Frequency",
      grid: true
    },
    marks: [
      Plot.rectY(results.density_samples, Plot.binX({y: "count"}, {x: d => d, fill: "#16a085", fillOpacity: 0.7})),
      Plot.ruleX([denP50], {stroke: "black", strokeWidth: 2}),
      Plot.ruleX([denP5], {stroke: "black", strokeWidth: 1.5, strokeDasharray: "3,3"}),
      Plot.ruleX([denP95], {stroke: "black", strokeWidth: 1.5, strokeDasharray: "3,3"})
    ]
  });
  const denLabel = `Cell Density Distribution (median: ${denP50.toFixed(0)} g/L, 90% CI: ${denP5.toFixed(0)} – ${denP95.toFixed(0)})`;

  return html`<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; margin: 1rem 0;">
    <div style="font-size: 0.9em;">
      <div style="font-weight: normal; margin-bottom: 0.3rem; color: #333;">${matLabel}</div>
      ${maturityPlot}
    </div>
    <div style="font-size: 0.9em;">
      <div style="font-weight: normal; margin-bottom: 0.3rem; color: #333;">${denLabel}</div>
      ${densityPlot}
    </div>
  </div>`;
}
```

```{ojs}
//| echo: false
{
  // Sample subset for scatter plot (too many points otherwise)
  const sampleSize = 2000;
  const step = Math.floor(results.unit_cost.length / sampleSize);
  const scatterData = [];
  for (let i = 0; i < results.unit_cost.length; i += step) {
    scatterData.push({
      maturity: results.maturity_samples[i],
      cost: results.unit_cost[i],
      density: results.density_samples[i]
    });
  }

  const scatterPlot = Plot.plot({
    width: 800,
    height: 320,
    marginLeft: 60,
    marginBottom: 50,
    marginTop: 10,
    x: {
      label: "Industry Maturity Factor",
      domain: [0, 1]
    },
    y: {
      label: "Unit Cost ($/kg)",
      domain: [0, Math.min(quantile(results.unit_cost, 0.95), 150)],
      grid: true
    },
    color: {
      label: "Cell Density (g/L)",
      scheme: "viridis",
      legend: true
    },
    marks: [
      Plot.dot(scatterData, {
        x: "maturity",
        y: "cost",
        fill: "density",
        fillOpacity: 0.5,
        r: 3
      }),
      Plot.linearRegressionY(scatterData, {x: "maturity", y: "cost", stroke: "red", strokeWidth: 2})
    ]
  });

  return html`<div style="font-size: 0.95em;">
    <div style="font-weight: normal; margin-bottom: 0.3rem; color: #333;">Maturity vs Unit Cost (colored by cell density)</div>
    ${scatterPlot}
  </div>`;
}
```

<details>
<summary>Understanding the maturity correlation</summary>

The scatter plot shows how **maturity** (x-axis) relates to **unit cost** (y-axis), with points colored by **cell density**.

**Key patterns:**

- **Negative slope**: Higher maturity → lower costs (red trend line)
- **Color gradient**: Higher density (yellow) tends to cluster at lower costs
- **Spread**: Even at high maturity, costs vary due to other random factors

This correlation structure prevents unrealistic scenarios where technology succeeds but financing remains difficult, or vice versa.
</details>

:::

---

## Quick Start

1. **Explore the "Learn" sections** at the top to understand how cultured chicken production works and how this model handles uncertainty.

2. **Adjust parameters** in the left sidebar. Each parameter has an expandable explanation.

3. **Results update automatically** as you change the Basic Parameters sliders — the summary cards, cost distribution, and probability thresholds all recalculate. (The Component Distributions section shows the underlying distributions and updates when you change the relevant sliders.)

4. **Navigate the results** to see:
   - **Cost Distribution**: Full probability distribution with key percentiles
   - **Probability Thresholds**: Chances of hitting key price points
   - **Cost Breakdown**: Which components drive costs
   - **Technology Adoption**: Realized adoption rates

## Pivotal Questions Context

This model is part of [The Unjournal's **Pivotal Questions** initiative](https://globalimpact.gitbook.io/the-unjournal-project-and-communication-space/pivotal-questions) — a project to identify and quantify uncertainties that matter most for high-impact decisions.

::: {.callout-note collapse="true"}
## What is a "Pivotal Question"?

A **pivotal question** is one where resolving the uncertainty would significantly change optimal resource allocation. For cultured chicken:

- If costs reach **<$10/kg by 2036**: Large-scale displacement of conventional chicken becomes plausible → prioritize support for industry scale-up
- If costs remain **>$50/kg**: Focus shifts to alternative interventions (plant-based, policy, etc.)

The pivotal question framework helps prioritize research and forecasting efforts where they matter most.

See: [The Unjournal's Pivotal Questions documentation](https://globalimpact.gitbook.io/the-unjournal-project-and-communication-space/pivotal-questions)
:::

**The cultured chicken pivotal question asks:**

> What will be the production cost of cultured chicken by the projection year, and what does this imply for animal welfare interventions?

**Key links:**

| Resource | Description |
|----------|-------------|
| [Pivotal Questions overview](https://globalimpact.gitbook.io/the-unjournal-project-and-communication-space/pivotal-questions) | Full documentation on the PQ framework |
| [Cultured meat on Metaculus](https://www.metaculus.com/questions/?search=cultured%20meat) | Forecasting questions with community predictions |
| [The Unjournal](https://unjournal.org) | Independent evaluations of impactful research |
| [UC Davis ACBM Calculator](https://acbmcostcalculator.ucdavis.edu/) | Original academic cost model (Risner et al.) |
| [Good Food Institute](https://gfi.org/resource/cultivated-meat-eggs-and-dairy-state-of-the-industry-report/) | Industry reports and data |

---

## Model formulas

::: {.callout-note}
**[View Full Model Formulas page](docs.qmd)** — Complete model formulas, parameter definitions, code reference, and methodology.

Quick reference:

$\text{Unit Cost} = \text{VOC} + \text{CAPEX}_{/\text{kg}} + \text{Fixed OPEX}_{/\text{kg}} + \text{Downstream}_{/\text{kg}}$

The model runs **30,000 Monte Carlo simulations**, sampling from parameter distributions and technology adoption switches to produce cost distributions and probability thresholds.
:::

---

## Key Insights

The table below summarizes typical outcomes from this interactive model at different maturity settings. These are illustrative ranges -- the actual results shown in the charts above update dynamically as you adjust the sidebar parameters.

| Scenario | Typical Median Cost | Key Drivers |
|----------|---------------------|-------------|
| High maturity (0.7+) | $15-30/kg | All technologies adopted, low WACC |
| Baseline (0.5) | $25-50/kg | Mixed adoption, moderate uncertainty |
| Low maturity (0.3-) | $50-100+/kg | Pharma-grade inputs, high financing costs |

**Most sensitive parameters:**

1. Technology adoption (especially hydrolysates)
2. Cell density at harvest
3. Industry maturity (affects multiple factors)

---

::: {.callout-warning collapse="true"}
## Limitations & Caveats

**Model status (March 2026):** This model is largely AI-generated and we cannot yet vouch for all parameter values. It is provided to fix ideas, give a sense of the sort of modeling we're interested in, and present a framework for discussion and comparison — not a definitive cost estimate. We welcome scrutiny and suggestions via [Hypothesis annotations](https://hypothes.is/) or [email](mailto:contact@unjournal.org).

1. **Static snapshot model** — Projects costs for a single projection year (adjustable 2026-2050). Does not model year-over-year learning curves; instead, the "maturity" parameter serves as a proxy for cumulative industry development.

2. **Downstream is optional** — Scaffolding, texturization, and forming costs can be included via toggle (+$2-15/kg for structured products).

3. **No geography** — Assumes generic global costs, not region-specific labor, energy, or regulatory factors.

4. **Limited contamination modeling** — No batch failure or contamination event distributions.
:::

---

## Sources & How They Inform the Model

::: {.callout-warning collapse="true"}
## Honesty note on sourcing
This model was largely AI-generated. We have grounded key parameter ranges in published TEAs and recently revised the pharma-grade media cost range downward (from $1-4/L to $0.50-2.50/L) based on GFI's 2025 amino acid supplier data. However, **we have not yet systematically verified that every hardcoded range traces to a specific source.** Cycle days is the main remaining parameter that still lacks a direct citation; growth factor quantities and the media-use multiplier were both tightened in April 2026 based on GFI 2023 / Humbird / Pasitka primary sources. This is one reason we are seeking expert review.
:::

<a id="micronutrient-change-note"></a>
::: {.callout-note collapse="true"}
## Model change log — basal micronutrients folded into media (April 2026)
Earlier versions of this model carried a separate **food-grade micronutrient** cost line (vitamins, minerals, trace elements) with its own adoption toggle, usage range (0.1–10 g/kg), and price range ($0.02–20/g). External review flagged two problems:

1. **Category confusion / double counting.** The page already describes basal media as containing "amino acids, glucose, vitamins," and the model comments described media as "basal media, excluding growth factors." A separate line for vitamins/minerals was therefore adding a second charge for the same inputs.
2. **Ranges not coherent with the literature.** [O'Neill et al. (2021)](https://ift.onlinelibrary.wiley.com/doi/abs/10.1111/1541-4337.12678) describe vitamin sourcing as a "less pressing issue" and note required minerals can "generally be obtained relatively inexpensively." Meanwhile [Pasitka et al. (2024)](https://www.nature.com/articles/s43016-024-01022-w) report an ACF-medium supplement (vitamins + minerals) at roughly 0.151 kg per kg wet biomass — orders of magnitude above the slider's 0.1–2 g/kg range. So the old slider was not tracking any coherent category.

**What changed:** The separate slider and cost line were removed. Basal vitamins, minerals, and trace salts are now included inside media $/L along with amino acids and glucose, consistent with how the media-cost literature is reported. Supplemental **recombinant proteins** (insulin, transferrin, albumin) remain unmodeled as a standalone line for now; if we reintroduce a separate uncertainty term, it will be repurposed to these proteins where per-g prices and per-kg usage can be sourced.
:::

**Sources that directly informed model structure and parameter ranges:**

| Source | What it informed in the model | How |
|--------|------|-----|
| [Risner et al. (2021)](https://www.mdpi.com/2304-8158/10/1/3) | Reference plant scale (20 kTA), reactor cost structure | Default plant capacity; CAPEX scaling reference |
| [Humbird (2021)](https://doi.org/10.1002/bit.27848) | Pharma-grade media costs ($1-4/L), reactor costs ($50-500/L), scale exponents (0.6-0.9) | Directly used for pharma-grade parameter ranges in simulate() |
| [O'Neill et al. (2021)](https://ift.onlinelibrary.wiley.com/doi/abs/10.1111/1541-4337.12678) | Serum-free media cost benchmarks | Informed hydrolysate media cost range ($0.20-1.20/L) |
| [GFI amino acid report (2025)](https://gfi.org/resource/amino-acid-cost-and-supply-chain-analysis-for-cultivated-meat/) | Pharma-grade media cost revised downward ($1-4/L → $0.50-2.50/L) | Real supplier quotes found Humbird AA prices 2-10x too high; since AA dominates basal media cost, this lowers the pharma range |
| [GFI recombinant-protein cost analysis (2023)](https://gfi.org/wp-content/uploads/2023/01/GFI-report_Anticipated-growth-factor-and-recombinant-protein-costs-and-volumes-necessary-for-cost-competitive-cultivated-meat_2023-1.pdf) | **Growth-factor quantity range (tightened April 2026)** | Reproduces Humbird formulation (FGF 0.1 mg/L, TGFβ 0.002 mg/L) and cost-competitive media-use assumption (8–13 L/kg); drives the revised 0.0005–0.006 g/kg quantity range |
| [GFI State of Industry (2024)](https://gfi.org/resource/cultivated-meat-eggs-and-dairy-state-of-the-industry-report/) | Growth factor cost targets, technology status | Informed GF price regime bounds and adoption probability defaults |

**Sources cited for context but NOT directly integrated into parameter values:**

| Source | Relevance |
|--------|-----------|
| [Pasitka et al. (2024)](https://www.nature.com/articles/s43016-024-01022-w) | Optimistic TEA ($6/lb); also provides an empirical ACF chicken process figure of ~0.00187 g growth factor/kg wet biomass, which is used as a **cross-check** for the GFI-derived quantity range (April 2026) |
| [Goodwin et al. (2024)](https://www.nature.com/articles/s43016-024-01061-3) | Scoping review comparing TEAs — informed our understanding of where TEAs disagree |
| [CE Delft (2021)](https://cedelft.eu/publications/tea-of-cultivated-meat/) | European cost benchmarks — cited in documentation but not directly used for parameter ranges |
| [Lever VC (2025)](https://www.levervc.com/) | Industry claims on cell density (60-90 g/L) — cited as context for density range upper bound |
| [The Unjournal evaluations](https://unjournal.pubpub.org/pub/evalsumlimitedmeadprod/) | Independent evaluation of RP forecasting paper — informed our project framing |

**Parameters where source grounding is weakest** (review priority):

- Supplemental recombinant proteins (insulin, transferrin, albumin) are **not currently broken out** as a separate cost line — they are implicitly absorbed into "Other VOC" or into media $/L. A dedicated uncertainty term sourced from supplier quotes would be an improvement (see the [model change log](#micronutrient-change-note) on why the old "micronutrients" line was removed).
- Growth factor quantities — **tightened April 2026** from a very broad 0.0001–0.02 g/kg stress-test range to a cited 0.0005–0.006 g/kg default, derived from the Humbird medium formulation reproduced in [GFI 2023](https://gfi.org/wp-content/uploads/2023/01/GFI-report_Anticipated-growth-factor-and-recombinant-protein-costs-and-volumes-necessary-for-cost-competitive-cultivated-meat_2023-1.pdf) (FGF 0.1 mg/L + TGFβ 0.002 mg/L) × media-use assumptions of 8–60 L/kg, and cross-checked against [Pasitka et al. (2024)](https://www.nature.com/articles/s43016-024-01022-w)'s ~0.00187 g/kg empirical ACF figure. The remaining uncertainty lives in the **$/g** of growth factors, not in the grams/kg.
- Media-use multiplier — **renamed and tightened April 2026** from "Media turnover (1–10×)" to "Media-use multiplier (0.5–3.0×)". The rename flags that the parameter is a bundled ratio (fresh media / nominal reactor volume), not a literal count of reactor-volume changes, so values below 1 (recycling, fed-batch, harvest concentration) are now allowed. The new range lets the model represent GFI 2023 cost-competitive scenarios (8–13 L/kg ≈ 0.5–1.2× at 60–90 g/L); the old floor of 1.0 mechanically excluded those. See [Learn: media-use mechanisms](learn.html#media-use-mechanisms) for the derivation.
- Cycle days (0.5-5.0 days) — hardcoded without source attribution
- Plant factor (1.5-3.5×) — standard engineering multiplier range, not CM-specific

See the [Technical Documentation](docs.qmd) for detailed parameter definitions and the expandable details under each slider for parameter-specific discussion.
 

Built by The Unjournal | Source Code