Cultured Chicken Cost Model
  • Simplest Model
  • Advanced Model
  • Learn
  • TEA Comparison
  • Model formulas & metrics
  • Discuss
  • Limits/Critique
  • 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

↓ Go to parameters Simplest Model →
New to this model?

Start with the Simplest Model → — a shorter version focusing on some key levers with line-of-sight explanations. You can carry your settings over to this Advanced Model when you’re ready.

►Audio overviews(AI-generated · note)
How Cultured Meat is Made ~11 min
Download MP3View/annotate script
The Cost Model Explained ~10 min
Download MP3View/annotate script
Important: Model Status & Limitations

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.

An external methodological review identified several structural limitations — including the ad hoc dependence structure, missing supplemental protein costs (albumin/transferrin/insulin), and the sensitivity chart being a dollar-swing ranking rather than a variance decomposition. We are addressing these incrementally.

Read the full critique and our responses → Limits/Critique

We especially welcome expert review of: growth factor quantities and prices, bioreactor CAPEX ranges, and the maturity correlation structure — see below and the Sources section.

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, utilization rate (0–1 share). 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 methodological references.

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.

Caveat: This correlation structure is a modelling convenience, not an empirically calibrated design. The choice of a single latent variable, the specific sensitivity coefficients, and the functional form are all ad hoc. Results — especially in tail scenarios — may be sensitive to these choices. We encourage users to stress-test by varying the maturity slider widely and treat correlated-scenario outputs as illustrative rather than calibrated.

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;
}

// Like sampleLognormalP5P95 but takes an 80% CI (p10/p90) — matches the beliefs form.
function sampleLognormalP10P90(rng, p10, p90, n) {
  const Z_90 = 1.2815515655446004;
  const mu = (Math.log(p10) + Math.log(p90)) / 2;
  const sigma = (Math.log(p90) - Math.log(p10)) / (2 * Z_90);
  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) {
  // Guard: Beta is undefined at the boundaries (mean=0 or mean=1).
  // betaFromMeanStdev returns NaN params → sampleBeta returns NaN →
  // rng() < NaN is always false, silently flipping boolean outcomes.
  // E.g. p_recf_mean=1.0 (slider at 100%) → all treated as expensive GF.
  if (mean <= 0) return new Array(n).fill(0);
  if (mean >= 1) return new Array(n).fill(1);
  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
  // Mode-based sampling: each Monte Carlo run draws a process mode, then samples
  // density and media-use from that mode's physical range. This prevents incoherent
  // combinations (e.g., 200 g/L density in batch mode, which is biologically impossible).
  // Pure batch is excluded as not commercially viable at scale (Oana Kubinecz, Apr 2026).
  const cycle_days = sampleLognormalP5P95(rng, 0.5, 5.0, n);

  let density_gL, media_turnover, mode_labels;
  if (params.override_mode_constraints) {
    // Expert override: use manually specified density and turnover ranges directly.
    density_gL = sampleLognormalP5P95(rng, params.density_gL_p5, params.density_gL_p95, n);
    media_turnover = sampleLognormalP5P95(rng, params.media_turnover_p5, params.media_turnover_p95, n);
    mode_labels = new Array(n).fill("override");
  } else {
    // Default: sample process mode per run, then draw density and turnover from that
    // mode's range. Pre-generate all six arrays (vectorized) for performance.
    const p_total = params.p_fedbatch + params.p_perfusion + params.p_continuous;
    const p0  = params.p_fedbatch / p_total;
    const p01 = p0 + params.p_perfusion / p_total;
    const d_fb = sampleLognormalP5P95(rng, 5, 30, n);    // Fed-batch density
    const d_pf = sampleLognormalP5P95(rng, 30, 150, n);  // Perfusion density
    const d_ct = sampleLognormalP5P95(rng, 50, 200, n);  // Continuous density
    const t_fb = sampleLognormalP5P95(rng, 1.0, 2.0, n); // Fed-batch media-use multiplier
    const t_pf = sampleLognormalP5P95(rng, 1.0, 5.0, n); // Perfusion media-use multiplier
    const t_ct = sampleLognormalP5P95(rng, 0.5, 3.0, n); // Continuous media-use multiplier
    const mode_rand = sampleUniform(rng, 0, 1, n);
    mode_labels = mode_rand.map(r => r < p0 ? "fedbatch" : r < p01 ? "perfusion" : "continuous");
    density_gL    = mode_labels.map((m, i) =>
      m === "fedbatch" ? d_fb[i] : m === "perfusion" ? d_pf[i] : d_ct[i]);
    media_turnover = mode_labels.map((m, i) =>
      m === "fedbatch" ? t_fb[i] : m === "perfusion" ? t_pf[i] : t_ct[i]);
  }

  // Expert prior override: cell density — must precede L_per_kg so CAPEX also sees it
  if (params.ep_density_p10 && params.ep_density_p90 && params.ep_density_p10 < params.ep_density_p90) {
    density_gL = sampleLognormalP10P90(rng, params.ep_density_p10, params.ep_density_p90, 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);

  // Supplemental recombinant proteins: albumin, transferrin, insulin
  // These are distinct from growth factors (FGF/IGF/TGF-β) — they are structural/survival
  // proteins used as media additives, not signalling molecules. GFI 2024 supply-chain
  // analysis projects albumin alone to be 96.6% of anticipated recombinant protein
  // production volume for CM. Cost depends on whether food-grade production at scale
  // (recombinant albumin from yeast, etc.) replaces pharma-grade sourcing.
  // Cheap regime: food-grade recombinant at scale → p5=$0.03/kg, p95=$0.60/kg cell mass
  // Exp. regime:  pharma-grade → p5=$0.50/kg, p95=$4.00/kg cell mass
  let p_supp = sampleBetaMeanStdev(rng, params.p_supp_protein_mean, 0.12, n);
  p_supp = clip(add(p_supp, scale(maturity.map(m => m - 0.5), 0.20)), 0, 1);
  const is_supp_cheap = p_supp.map(p => rng() < p);
  const supp_cheap_cost = sampleLognormalP5P95(rng, 0.03, 0.60, n);
  const supp_exp_cost   = sampleLognormalP5P95(rng, 0.50, 4.00, n);
  const cost_supp_protein = is_supp_cheap.map((c, i) => c ? supp_cheap_cost[i] : supp_exp_cost[i]);

  // Other variable costs (utilities, consumables, small-molecule additives)
  // Reduced from p5=0.5/p95=5.0 — supplemental proteins now have their own term above
  const other_var = sampleLognormalP5P95(rng, 0.30, 3.0, n);

  // Bundled media override (advanced full-view mode): replaces the separable basal+GF
  // structure with a single complete-medium $/L figure, matching the convention used in
  // research-grade published TEAs (e.g., Humbird 2021 quotes $50-500/L complete media).
  // GF sampling above still runs to maintain RNG consistency regardless of bundled mode.
  let cost_media_eff = cost_media;
  let cost_recf_eff = cost_recf;
  let media_cost_L_eff = media_cost_L;
  if (params.bundled_media) {
    const complete_media_L = sampleLognormalP5P95(rng, params.bundled_media_p5, params.bundled_media_p95, n);
    cost_media_eff  = mul(L_per_kg, complete_media_L);
    cost_recf_eff   = new Array(n).fill(0);
    media_cost_L_eff = complete_media_L;
  }

  // Expert prior overrides: applied after bundled-media so they work in both modes.
  // These bypass the regime-switching logic and express direct beliefs in $/kg biomass.
  if (params.ep_media_p10 && params.ep_media_p90 && params.ep_media_p10 < params.ep_media_p90) {
    cost_media_eff = sampleLognormalP10P90(rng, params.ep_media_p10, params.ep_media_p90, n);
  }
  if (params.ep_gf_p10 && params.ep_gf_p90 && params.ep_gf_p10 < params.ep_gf_p90) {
    cost_recf_eff = sampleLognormalP10P90(rng, params.ep_gf_p10, params.ep_gf_p90, n);
  }

  // VOC total: media + growth factors + supplemental proteins + other
  const voc = add(add(add(cost_media_eff, cost_recf_eff), cost_supp_protein), other_var);

  // CDMO mode: replace CAPEX + Fixed OPEX with a contract toll fee ($/kg)
  // The toll covers the CDMO's amortized capital, labor, overhead, and margin.
  // VOC (media, GFs) remain as direct company costs.
  let cdmo_toll_perkg = new Array(n).fill(0);
  if (params.cdmo_mode) {
    cdmo_toll_perkg = sampleLognormalP5P95(rng, params.cdmo_toll_p5, params.cdmo_toll_p95, n);
  }

  // CAPEX calculation (skipped in CDMO mode — CDMO bears the capital)
  let capex_perkg = new Array(n).fill(0);
  // Pre-allocate WACC and asset-life sample arrays so they exist for the
  // tornado chart even when CAPEX is excluded (in those cases they're
  // sampled here purely for the sensitivity export, with no cost effect).
  let wacc_samples_out = sampleLognormalP5P95(rng, params.wacc_p5, params.wacc_p95, n);
  wacc_samples_out = clip(wacc_samples_out.map((w, i) => w - 0.03 * (maturity[i] - 0.5)), 0.03, 1);
  let asset_life_samples_out = sampleUniform(rng, params.asset_life_lo, params.asset_life_hi, n);
  if (!params.cdmo_mode && 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]
    );

    // Re-use the pre-sampled wacc/asset_life so the tornado chart has access
    // to the realized values regardless of CAPEX inclusion.
    const wacc = wacc_samples_out;
    const asset_life = asset_life_samples_out;
    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 (skipped in CDMO mode — CDMO bears overhead)
  let fixed_perkg = new Array(n).fill(0);
  if (!params.cdmo_mode && 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(add(voc, capex_perkg), fixed_perkg), cdmo_toll_perkg), downstream_perkg);

  return {
    unit_cost,
    cost_media: cost_media_eff,
    cost_recf: cost_recf_eff,
    cost_supp_protein,
    cost_other_var: other_var,
    cost_capex: capex_perkg,
    cost_fixed: fixed_perkg,
    cost_cdmo_toll: cdmo_toll_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,
    // Process mode realized shares — stored as scalars (not the full 30k string array)
    // to keep OJS reactive graph lightweight
    mode_is_override: mode_labels[0] === "override",
    pct_fedbatch:  mode_labels.filter(m => m === "fedbatch").length  / n,
    pct_perfusion: mode_labels.filter(m => m === "perfusion").length / n,
    pct_continuous: mode_labels.filter(m => m === "continuous").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_eff,
    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,
    wacc_samples: wacc_samples_out,
    asset_life_samples: asset_life_samples_out
  };
}

// 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 dollar-impact sensitivity analysis (tornado chart), each of these individually shifts the mean cost by a relatively small amount — typically under $15/kg — compared to $50–150+/kg for cell density and growth factor uncertainty. Note: this is a conditional-mean swing statistic, not a variance decomposition; see the tornado chart below for the full ranking.
<em>Switch off to adjust all parameters.</em>
</div>`
Code
// Reactive style block to hide/show full-mode-only and cdmo-only inputs
html`<style>
  .full-mode-only     { display: ${simpleMode ? 'none' : 'block'}; }
  .cdmo-only          { display: ${cdmo_mode ? 'block' : 'none'}; }
  .override-mode-only { display: ${override_mode_constraints ? 'block' : 'none'}; }
  .separable-only     { display: ${bundled_media ? 'none' : 'block'}; }
  .bundled-only       { display: ${bundled_media ? 'block' : 'none'}; }
  .blending-only      { display: ${include_blending ? 'block' : 'none'}; }
</style>`

Blended / Hybrid Product

Code
viewof include_blending = Inputs.toggle({label: html`Show blended/hybrid product cost <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="Most near-term cultured meat products will blend cells with plant-based filler to reduce cost. Enable this to see what a product at your chosen CM inclusion rate would cost — and whether it could compete with conventional chicken. The blended cost = (pure cell cost × CM share) + (filler cost × plant share).">(?)</abbr>`, 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 (%)"
  })
viewof filler_cost = Inputs.range([1, 10], {
    value: urlNum("filler_cost", 3), step: 0.5,
    label: "Filler cost ($/kg)"
  })

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.
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.

Media Accounting

Code
viewof bundled_media = Inputs.toggle({
  label: html`Bundled media pricing (TEA-comparable) <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="When ON: basal media + growth factors are replaced by a single 'complete media' $/L figure, matching the convention used in research-grade published TEAs (Humbird 2021 etc.). Default OFF = separable accounting, which is better for reasoning about future cost reduction.">(?)</abbr>`,
  value: urlBool("bundled_media", false)
})
Code
html`<div style="padding: 0.5rem 0.8rem; background: #f0f4fa; border-left: 3px solid #3498db; font-size: 0.84em; margin: 0.25rem 0 0.4rem 0; line-height: 1.5;">
<strong>Bundled mode:</strong> Complete media $/L replaces basal media + GF lines. GF sliders hidden.
Range ($50–$500/L default) reflects current research-grade complete media with pharma GFs.
For 2036 projection, consider lowering the range. &nbsp;
<a href="docs.html#bundled-media-pricing" target="_blank" style="white-space:nowrap;">Details ↗</a>
</div>`
Code
viewof bundled_media_p5 = Inputs.range([1, 200], {
  value: urlNum("bundled_media_p5", 50), step: 5,
  label: "Complete media p5 ($/L)"
})
viewof bundled_media_p95 = Inputs.range([50, 1000], {
  value: urlNum("bundled_media_p95", 500), step: 10,
  label: "Complete media p95 ($/L)"
})

Production Model

Code
viewof cdmo_mode = Inputs.toggle({
  label: html`CDMO / Contract Manufacturing mode <abbr style="cursor:help; text-decoration:underline dotted; font-size:0.85em; color:#888;" title="A CDMO (Contract Development &amp; Manufacturing Organization) owns the bioreactors and facility and charges a per-kg toll, replacing your CAPEX and plant overhead. Your VOC costs (media, growth factors) remain direct.">(?)</abbr>`,
  value: urlBool("cdmo_mode", false)
})
Code
html`<div style="padding: 0.5rem 0.8rem; background: #fef9e7; border-left: 3px solid #f39c12; font-size: 0.84em; margin: 0.25rem 0 0.5rem 0; line-height: 1.5;">
CAPEX and Plant Overhead OPEX replaced by a lognormal <em>CDMO toll fee</em>.
VOC (media, GFs, other variable) and downstream remain direct costs.
A side-by-side comparison appears in the results below. &nbsp;
<a href="docs.html#cdmo-production-model" target="_blank" style="white-space:nowrap;">Full explanation ↗</a>
</div>`
Code
viewof cdmo_toll_p5 = Inputs.range([1, 20], {
  value: urlNum("cdmo_toll_p5", 4), step: 1,
  label: "CDMO Toll p5 ($/kg)"
})
viewof cdmo_toll_p95 = Inputs.range([10, 100], {
  value: urlNum("cdmo_toll_p95", 40), step: 2,
  label: "CDMO Toll p95 ($/kg)"
})

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 synthetic latent variable (0 = nascent, 1 = mature) that correlates technology adoption, reactor costs, and financing. Not calibrated to observed historical trajectories — the slider represents your assumption about where the industry lands, not a fitted rate of change.

Slider Rough interpretation Implied annual cost reduction in expensive inputs (GFs + media)
0.1–0.2 Industry largely stalls — pharma-grade inputs, expensive GFs, high financing risk ~0–3%/yr — worse than most biotech supply chains
0.3–0.4 Slow progress — some adoption but major cost drivers remain expensive ~4–7%/yr
0.5 Continuation of current trajectory — pace observed 2020–2025, extrapolated to 2036 ~7–12%/yr — consistent with GFI 2025 amino acid data (~10%/yr for that input)
0.6–0.7 Accelerated progress — multiple technologies reaching commercial scale ~12–18%/yr
0.8–0.9 Optimistic convergence — most pathways succeed by 2036 ~18–25%/yr — “solar PV level”

Note: “expensive inputs” = growth factors + amino acid media, which dominate variable cost. The implied rate is approximate — the slider controls correlated adoption probabilities, not a single learning rate. GFI’s Dec 2025 amino acid data (Humbird’s 2021 prices appear 2–10× too high vs current quotes, implying ~10–20%/yr for that component) provides the main empirical anchor.

To approximate “today’s conditions” (minimal technical progress): set maturity to 0.1 and target year to 2026. Note that even then the parameter ranges reflect forward projections — the model has no explicitly calibrated 2024 cost baseline.

Full explanation, calibration caveats, and stress-test guidance →

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 effective maturity, less technology adoption. The model scales maturity by a factor of (0.5 + 0.5 × (year − 2024) / 20), so:

  • 2026 → maturity scaled to ~55% of slider value
  • 2036 (default) → maturity scaled to ~80% of slider value
  • 2044 → maturity at full slider value
This scaling is mechanical (linear interpolation), not fit to historical data. To approximate minimal progress from today’s state: combine year = 2026 with maturity = 0.1.

Probability of Each Process Mode (?)

Code
viewof p_fedbatch = Inputs.range([0, 1], {
  value: urlNum("p_fedbatch", 0.20), step: 0.05,
  label: html`P(Fed-batch) <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="Low density (5–30 g/L), moderate media use (1–2×). Nutrient-concentrated feeds added periodically. Simpler to operate but lower cell density than perfusion.">(?)</abbr>`
})
viewof p_perfusion = Inputs.range([0, 1], {
  value: urlNum("p_perfusion", 0.50), step: 0.05,
  label: html`P(Perfusion) <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="Medium-high density (30–150 g/L), higher media throughput (1–5×). Continuous media exchange with cell retention. Currently the industry standard for high-density CM production.">(?)</abbr>`
})
viewof p_continuous = Inputs.range([0, 1], {
  value: urlNum("p_continuous", 0.30), step: 0.05,
  label: html`P(Continuous) <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="Highest density (50–200 g/L), efficient media use (0.5–3×). Near-steady-state operation; cells grown and harvested continuously with optimized recycling.">(?)</abbr>`
})
Code
{
  const tot = p_fedbatch + p_perfusion + p_continuous;
  if (tot <= 0) return html``;
  const rawPct = (tot * 100).toFixed(0);
  const fb = (p_fedbatch / tot * 100).toFixed(0);
  const pf = (p_perfusion / tot * 100).toFixed(0);
  const ct = (p_continuous / tot * 100).toFixed(0);
  const sumExact = Math.abs(tot - 1) < 0.001;
  const sumColor = sumExact ? "#27ae60" : "#e67e22";
  const sumNote = sumExact
    ? ""
    : ` — weights sum to ${rawPct}%, normalized to 100% in simulation`;
  return html`<div style="font-size:0.84em; color:#555; margin: -0.2rem 0 0.4rem 0;">
    <span style="color:${sumColor}; font-weight:600;">Sum: ${rawPct}%${sumNote}</span><br>
    Using: Fed-batch <strong>${fb}%</strong> · Perfusion <strong>${pf}%</strong> · Continuous <strong>${ct}%</strong>
  </div>`;
}
What are these modes and why does it matter? (click to expand)

These weights set the probability of each bioreactor process mode being drawn per Monte Carlo simulation. Within each mode, cell density and media-use multiplier are sampled from mode-specific ranges — preventing physically incoherent combinations.

Mode Density (g/L) Media-use (×) Typical application
Fed-batch 5–30 1.0–2.0 Periodic nutrient addition; lower achievable densities
Perfusion 30–150 1.0–5.0 Continuous exchange with cell retention (hollow fiber / centrifuge)
Continuous 50–200 0.5–3.0 Near-steady-state with recycling; highest densities

Pure batch (single fill-and-dump) is excluded — not considered commercially viable for cultured meat at scale (expert reviewer feedback, April 2026).

Note: the default mix (20% / 50% / 30%) produces somewhat higher media usage per kg than the previous unconstrained defaults, because fed-batch and perfusion at medium density use more liquid than the prior lognormal(30–200 g/L) assumption. This is more physically realistic.

In full view, an “Override process mode constraints” toggle restores manual density and media-use sliders.

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}));
    viewof p_supp_protein.value = 0.70;
    viewof p_supp_protein.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.

Note on $/L vs $/kg output: The model uses $/L as an internal parameter, but the economically meaningful unit is $/kg of cell biomass produced — which equals ($/L) × (L consumed per kg output). L per kg = 1000/density(g/L) × media_turnover_multiplier. At the model’s default settings (~60 g/L median density, 1.5× turnover), this is ~25 L/kg: a $0.70/L hydrolysate medium costs ~$17.5/kg output; a $1.25/L pharma-grade medium costs ~$31/kg. At 200 g/L perfusion with 1.0× turnover (~5 L/kg), the same formulations cost $3.5/kg and $6.25/kg respectively — illustrating why cell density matters far more than formulation price at high-density processes. The “Media” bar in the cost breakdown chart already shows this $/kg result directly.

Note on correlation with density: $/L and cell density (g/L) are not fully independent — richer media is often needed to sustain higher densities, particularly in fed-batch systems where cells deplete the available nutrient pool. Combining a very low $/L with a very high density assumption in the same scenario may therefore overstate cost savings. The model partially addresses this via process-mode-specific density ranges, but does not impose explicit correlation between $/L and density within a given mode. See Model Limits for discussion.

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 basically 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 ↗ Indirect benefit: can reduce GF requirements by improving cell health; cannot replace GF signaling

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.

Growth factors (GFs) signal cells to proliferate — at current research-grade prices, they can dominate media costs. The slider below sets P(at least one scalable production route — e.g., autocrine cell lines, plant-based farming, or precision fermentation — reaches commercial scale by the projection year), switching between “expensive” and “cheap” GF price regimes. For background on what growth factors do, why they’re expensive, and which technologies might reduce costs, see Learn → Step 5: Growth Factors.

Code
viewof p_recfactors = Inputs.range([0.1, 0.9], {
  value: urlNum("p_recfactors", 0.5), step: 0.05,
  label: html`P(Scalable <abbr style="cursor:help;text-decoration:underline dotted;" title="Growth Factor — signaling proteins like FGF-2, IGF-1, TGF-β that tell cells to proliferate. Currently the most expensive media component.">Growth Factor (GF)</abbr> 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.

Supplemental Recombinant Proteins (?)

Code
viewof p_supp_protein = Inputs.range([0.20, 0.95], {
  value: urlNum("p_supp_protein", 0.70), step: 0.05,
  label: html`P(Albumin/transferrin/insulin affordable by ${target_year}) <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="Probability that food-grade recombinant production (from yeast, bacteria, or plant systems) brings albumin + transferrin + insulin costs to under ~$1/g by the target year, making per-kg-cell-mass contribution below ~$0.50/kg. Without breakthrough: pharma-grade sourcing could add $0.50-4/kg. Default 70%: considered more tractable than GF breakthrough because albumin at scale is closer to an engineering problem than a discovery problem.">(?)</abbr>`
})
About supplemental proteins and cost ranges (click to expand)

Albumin, transferrin, and insulin are media additives — structural/survival proteins distinct from growth factors. They are widely used in serum-free cell culture but rarely modeled explicitly in CM TEAs (usually bundled into “other VOC” or media cost).

Per GFI’s 2024 supply-chain analysis, albumin is expected to represent ~97% of anticipated recombinant protein production volume for CM. At typical cell-culture concentrations (~1 mg/mL albumin) and the model’s L/kg range, the per-kg cost contribution is:

Sourcing Albumin price Contribution to cell mass cost
Food-grade recombinant (yeast) $0.10–1/g ~$0.03–0.50/kg
Pharma-grade recombinant $5–50/g ~$0.50–4/kg
Albumin-free formulation $0/g ~$0/kg

Insulin and transferrin are used at much lower concentrations and contribute <$0.05/kg in all scenarios.


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)"
})
Code
{
  // Reactive diagnostic: show CAPEX share and explain why WACC might seem insensitive
  const capexMean = mean(results.cost_capex);
  const totalMean = mean(results.unit_cost);
  const capexPct = totalMean > 0 ? (capexMean / totalMean * 100).toFixed(0) : 0;
  const isOff = !include_capex && !simpleMode;
  const isCDMO = cdmo_mode;

  let msg = '';
  let color = '#27ae60';
  if (isOff) {
    msg = 'CAPEX is currently excluded — financing parameters have no effect. Re-enable in Model Structure above.';
    color = '#c0392b';
  } else if (isCDMO) {
    msg = 'CDMO mode is on — the CDMO toll replaces CAPEX/WACC. These financing sliders have no effect.';
    color = '#e67e22';
  } else if (capexPct < 15) {
    msg = `CAPEX is only ${capexPct}% of mean total cost right now — media costs dominate (often driven by fed-batch process mode). Large WACC changes will cause small visible shifts. Try reducing the fed-batch weight above to increase CAPEX sensitivity.`;
    color = '#e67e22';
  } else {
    msg = `CAPEX = ${capexPct}% of mean total cost. Changes to WACC and Asset Life will have visible effects.`;
    color = '#27ae60';
  }

  return html`<div style="font-size:0.82em; margin: 0.25rem 0 0.4rem; padding:0.4rem 0.7rem; background:#fafafa; border-left:3px solid ${color}; border-radius:0 4px 4px 0; line-height:1.5;">
    ${msg}
  </div>`;
}
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. These two sliders define a uniform distribution over the plausible range — not a single fixed value. The simulation draws a different asset life for each of the 30,000 Monte Carlo runs. Shorter life (8 years) means higher annual capital charges; longer life (20 years) spreads costs but assumes equipment remains productive. To set a specific value, set both sliders to the same number.

These two parameters together determine the Capital Recovery Factor (CRF).

Cell Density / Media-Use Override (?)

Code
viewof override_mode_constraints = Inputs.toggle({
  label: html`Override process mode constraints <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="When ON: process-mode sampling is bypassed and you can specify density and media-use ranges directly. Useful for experts wanting to model specific bioreactor configurations.">(?)</abbr>`,
  value: urlBool("override_mode_constraints", false)
})
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,
    cdmo_mode: false, cdmo_toll_p5: 4, cdmo_toll_p95: 40,
    bundled_media: false, bundled_media_p5: 50, bundled_media_p95: 500,
    plant_capacity: 20, uptime: 0.90, maturity: 0.5, target_year: 2036,
    p_fedbatch: 0.20, p_perfusion: 0.50, p_continuous: 0.30,
    override_mode_constraints: false,
    p_hydro: 0.75, p_recfactors: 0.5, gf_progress: 50, p_supp_protein: 0.70,
    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,
    cdmo_mode, cdmo_toll_p5, cdmo_toll_p95,
    bundled_media, bundled_media_p5, bundled_media_p95,
    plant_capacity, uptime, maturity, target_year,
    p_fedbatch, p_perfusion, p_continuous, override_mode_constraints,
    p_hydro, p_recfactors, gf_progress, p_supp_protein,
    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 an invisible empty element instead of `null` — when an OJS cell
  // returns null/undefined, the cell renders the literal text "null" in the
  // output. This is a no-op span used purely for its side effect (URL sync).
  return html`<span style="display:none;"></span>`;
}
Code
// Expert priors — collapsible panel for the three biggest cost drivers
viewof expert_priors_adv = {
  const inp = (name, placeholder, step) => {
    const el = document.createElement('input');
    Object.assign(el, {type:'number', name, min:0, step, placeholder});
    el.style.cssText = 'width:72px;padding:3px 5px;border:1px solid #b0c8c0;border-radius:4px;font-size:0.77rem;';
    el.addEventListener('input', () => container.dispatchEvent(new Event('input', {bubbles:true})));
    return el;
  };
  const resetBtn = (targets) => {
    const b = document.createElement('button');
    b.type='button'; b.textContent='✕'; b.title='Clear — revert to model default';
    b.style.cssText='padding:2px 6px;font-size:0.7rem;border:1px solid #ccc;border-radius:4px;background:#f9f9f9;color:#999;cursor:pointer;align-self:flex-end;';
    b.onclick = () => {
      targets.forEach(n => { const el=container.querySelector(`[name=${n}]`); if(el) el.value=''; });
      container.dispatchEvent(new Event('input', {bubbles:true}));
    };
    return b;
  };
  const row = (labelText, hint, names, placeholders, steps) => {
    const wrap = document.createElement('div');
    wrap.style.cssText='display:flex;flex-direction:column;gap:3px;';
    const head = document.createElement('div');
    head.style.cssText='display:flex;align-items:center;gap:5px;font-size:0.78rem;font-weight:600;color:#2d4a2d;';
    head.innerHTML = labelText + `<span title="${hint}" style="font-size:0.65rem;color:#aaa;cursor:help;border-bottom:1px dotted #ccc;">(?)</span>`;
    const inputs = document.createElement('div');
    inputs.style.cssText='display:flex;gap:8px;align-items:flex-end;';
    names.forEach((name, i) => {
      const col = document.createElement('div');
      col.style.cssText='display:flex;flex-direction:column;gap:1px;';
      const lbl = document.createElement('div'); lbl.style.cssText='font-size:0.62rem;color:#888;';
      lbl.textContent = i===0 ? 'p10 — optimistic' : 'p90 — pessimistic';
      col.append(lbl, inp(name, placeholders[i], steps[i]));
      inputs.appendChild(col);
    });
    inputs.appendChild(resetBtn(names));
    wrap.append(head, inputs);
    return wrap;
  };
  const container = document.createElement('div');
  const details = document.createElement('details');
  details.style.cssText='border:1.5px solid #3498db;border-radius:6px;overflow:hidden;margin:10px 0 4px;';
  const summary = document.createElement('summary');
  summary.style.cssText='padding:7px 10px;background:#f0f8ff;cursor:pointer;font-size:0.82rem;font-weight:600;color:#1a5276;list-style:none;display:flex;align-items:center;gap:6px;user-select:none;';
  summary.innerHTML = '◧ Set my own uncertainty ranges <span style="font-size:0.68rem;font-weight:400;color:#888;margin-left:auto;" title="Override the model\'s built-in uncertainty ranges for the three biggest cost drivers with your own 80% credible intervals (p10/p90) — the same format as the beliefs form (CM_13, CM_14, CM_16). Overrides apply after bundled-media mode; leave blank to use defaults.">(?)</span>';
  const body = document.createElement('div');
  body.style.cssText='padding:10px 12px;display:flex;flex-direction:column;gap:10px;';
  const intro = document.createElement('p');
  intro.style.cssText='margin:0;font-size:0.71rem;color:#555;line-height:1.45;';
  intro.innerHTML='Override built-in ranges with your <strong>80% credible interval</strong> for each key driver. Leave blank to use model defaults. <a href="docs.html#expert-priors" style="color:#3498db;" target="_blank">How this works →</a>';
  const activeNote = document.createElement('div');
  activeNote.id='ep-adv-active-note';
  activeNote.style.cssText='display:none;font-size:0.68rem;color:#c0392b;font-weight:600;padding:2px 4px;background:#fef9f9;border-radius:3px;';
  activeNote.textContent='⚠ Custom ranges active — results reflect your priors';
  body.append(
    intro, activeNote,
    row('Media cost ($/kg biomass)',
        'Total cell culture media cost per kg of harvested cell biomass. Overrides the $/L × L/kg calculation — applies in both standard and bundled-media modes. Connects to CM_14 in the beliefs form. Default model range roughly p10≈5, p90≈120.',
        ['media_p10','media_p90'], ['e.g. 5','e.g. 80'], [1,5]),
    row('Growth factor cost ($/kg biomass)',
        'Total growth factor cost per kg of biomass. Bypasses the breakthrough regime switch — express your net belief directly. Connects to CM_13. Default range varies with the GF slider.',
        ['gf_p10','gf_p90'], ['e.g. 2','e.g. 60'], [0.5,5]),
    row('Cell density (g/L at harvest)',
        'Wet-weight cell density at bioreactor harvest. Overrides mode-specific defaults; also affects bioreactor volume and CAPEX. Connects to CM_16. Default ranges: fed-batch ≈5–30, perfusion ≈30–150, continuous ≈50–200.',
        ['density_p10','density_p90'], ['e.g. 8','e.g. 60'], [1,5])
  );
  details.append(summary, body);
  container.appendChild(details);
  container.addEventListener('input', () => {
    const anyActive = ['media_p10','gf_p10','density_p10'].some(n => container.querySelector(`[name=${n}]`)?.value !== '');
    activeNote.style.display = anyActive ? 'block' : 'none';
  });
  function getVal(name) {
    const v = parseFloat(container.querySelector(`[name=${name}]`)?.value);
    return isNaN(v) || v <= 0 ? null : v;
  }
  Object.defineProperty(container, 'value', {
    get: () => ({
      media_p10: getVal('media_p10'), media_p90: getVal('media_p90'),
      gf_p10: getVal('gf_p10'), gf_p90: getVal('gf_p90'),
      density_p10: getVal('density_p10'), density_p90: getVal('density_p90')
    })
  });
  return container;
}
Code
// Shared parameter object — consumed by both the primary run and the comparison run
simParams = {
  const yearFactor = Math.max(0, Math.min(1, (target_year - 2024) / 20));
  const adjustedMaturity = maturity * (0.5 + 0.5 * yearFactor);
  return {
    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,
    p_supp_protein_mean: simpleMode ? 0.70 : p_supp_protein,
    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,
    cdmo_mode: cdmo_mode,
    cdmo_toll_p5: cdmo_toll_p5,
    cdmo_toll_p95: cdmo_toll_p95,
    // Process mode (always active; override only available in full view)
    p_fedbatch: p_fedbatch,
    p_perfusion: p_perfusion,
    p_continuous: p_continuous,
    override_mode_constraints: simpleMode ? false : override_mode_constraints,
    // Bundled media (full view only)
    bundled_media: simpleMode ? false : bundled_media,
    bundled_media_p5: bundled_media_p5,
    bundled_media_p95: bundled_media_p95,
    // Expert prior overrides — null means use model defaults
    ep_media_p10: expert_priors_adv.media_p10,
    ep_media_p90: expert_priors_adv.media_p90,
    ep_gf_p10: expert_priors_adv.gf_p10,
    ep_gf_p90: expert_priors_adv.gf_p90,
    ep_density_p10: expert_priors_adv.density_p10,
    ep_density_p90: expert_priors_adv.density_p90
  };
}
Code
results = simulate(30000, 42, simParams)

// Comparison run: when CDMO mode is on, also run the in-house baseline
// (same seed so VOC components are paired; only capital structure differs)
// Returns an invisible span (not null) when CDMO is off — returning null
// causes OJS to render the literal text "null" in the document.
inhouse_results = {
  if (!cdmo_mode) return html`<span style="display:none;"></span>`;
  return simulate(30000, 42, {...simParams, cdmo_mode: false});
}

// Calculate statistics (pure cell and blended product)
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));
  const pct = (arr, threshold) => arr.filter(x => x < threshold).length / arr.length * 100;
  return {
    n: uc.length,
    p5: quantile(uc, 0.05),
    p50: quantile(uc, 0.50),
    p95: quantile(uc, 0.95),
    // Pure cell thresholds
    prob_10:  pct(uc, 10),
    prob_25:  pct(uc, 25),
    prob_50:  pct(uc, 50),
    prob_100: pct(uc, 100),
    // Blended product thresholds (same $/kg price points, but for the blended product)
    bprob_10:  pct(blended, 10),
    bprob_25:  pct(blended, 25),
    bprob_50:  pct(blended, 50),
    bprob_100: pct(blended, 100),
    // Consumer-relevant blended thresholds ($5, $8, $12/kg)
    bprob_5:  pct(blended, 5),
    bprob_8:  pct(blended, 8),
    bprob_12: pct(blended, 12),
    // Summary stats
    blended_p50: quantile(blended, 0.50),
    blended_p5:  quantile(blended, 0.05),
    blended_p95: quantile(blended, 0.95),
    bs, fc
  };
}

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 manufacturing cost per kg of cultured chicken cell biomass (<span title="Wet weight, at the CM gate: the mass of cells as harvested from the bioreactor. Water content at harvest typically ~75–90% depending on cell line, density, and post-harvest dewatering; this model uses 80% as a reference assumption. Note: published TEAs (Humbird 2021, Pasitka 2024) rarely state their assumed hydration explicitly, which means cost comparisons across papers may embed subtle inconsistencies — a 10% hydration difference substantially affects $/kg wet weight. Includes: media, growth factors, bioreactor capital (annualised), plant overhead, utilities. Excludes: texturization, scaffolding, blending with plant-based fillers, packaging, distribution. This is the same accounting object as 'edible kg before mixture' used on the beliefs form — wet cell mass at harvest IS the edible component at this stage. For comparison: Humbird reports $37/kg; Pasitka $13.75/kg (large perfusion). The widely-cited ~$6/lb Pasitka figure is for a 50/50 hybrid product. See TEA Comparison for details." style="text-decoration: underline dotted; cursor: help;">wet weight, at harvest &#9432;</span>) in ${target_year}, based on ${stats.n.toLocaleString()} Monte Carlo simulations. <em style="font-size:0.88em;color:#888;">Wet-weight hydration assumed ~80% (range ~75–90% in practice; see note below on hydration assumptions).</em> This is the factory-gate manufacturing cost — not a consumer product price or retail cost. <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." style="text-decoration: underline dotted; cursor: help;">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 cell biomass (wet weight, at harvest) — 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_supp_protein", "cost_other_var", "cost_capex", "cost_fixed", "cost_cdmo_toll", "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,
        cdmo_mode, cdmo_toll_p5, cdmo_toll_p95,
        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),
        supplemental_proteins: +mean(results.cost_supp_protein).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),
        cdmo_toll: +mean(results.cost_cdmo_toll).toFixed(2),
        downstream: +mean(results.cost_downstream).toFixed(2)
      },
      notes: {
        units: "USD per kg of cultured chicken cell biomass, wet weight at harvest",
        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);
  const p20 = quantile(uc, 0.20);
  const p80 = quantile(uc, 0.80);

  // Helper: build the distribution chart at a given pixel width/height
  function makeDistChart(w, h, fontSize) {
    const yLabel1 = Math.round(h * 6.0);
    const yLabel2 = Math.round(h * 7.5);
    return Plot.plot({
      width: w,
      height: h,
      marginLeft: 60,
      marginBottom: 50,
      x: { label: "Cell Biomass Manufacturing Cost ($/kg, wet weight)", domain: [0, clipVal * 1.05] },
      y: { label: "Frequency", grid: true },
      style: { fontSize: fontSize || 13 },
      marks: [
        Plot.rectY(clipped, Plot.binX({y: "count"}, {x: d => d, fill: "steelblue", fillOpacity: 0.7})),
        // p5 / p50 / p95 coloured lines
        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"}),
        // p20 / p80 grey dashed
        Plot.ruleX([p20], {stroke: "#888", strokeWidth: 1.5, strokeDasharray: "4,4", strokeOpacity: 0.85}),
        Plot.ruleX([p80], {stroke: "#888", strokeWidth: 1.5, strokeDasharray: "4,4", strokeOpacity: 0.85}),
        // Reference cost thresholds
        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}),
        // Labels
        Plot.text([
          {x: stats.p5  + 1.5, y: yLabel1, text: `p5: $${stats.p5.toFixed(1)}`},
          {x: stats.p50 + 1.5, y: yLabel2, text: `p50: $${stats.p50.toFixed(1)}`},
          {x: stats.p95 + 1.5, y: yLabel1, text: `p95: $${stats.p95.toFixed(1)}`},
          {x: p20 + 1.5, y: Math.round(h * 4.5), text: `p20: $${p20.toFixed(1)}`, fill: "#666"},
          {x: p80 + 1.5, y: Math.round(h * 4.5), text: `p80: $${p80.toFixed(1)}`, fill: "#666"}
        ], {x: "x", y: "y", text: "text", fontSize: (fontSize || 12) - 1, fill: d => d.fill || "black"})
      ],
      title: `Projected ${target_year} Cultured Chicken Production Cost Distribution`
    });
  }

  const wrapper = document.createElement("div");
  wrapper.style.cssText = "position:relative; display:inline-block; width:100%;";

  // Fullscreen expand button
  const fsBtn = document.createElement("button");
  fsBtn.textContent = "⛶";
  fsBtn.title = "Expand to full screen (press Escape to close)";
  fsBtn.style.cssText = "position:absolute; top:6px; right:6px; z-index:10; padding:3px 7px; font-size:15px; cursor:pointer; border:1px solid #ccc; border-radius:4px; background:rgba(255,255,255,0.9); line-height:1;";

  // Fullscreen overlay
  const overlay = document.createElement("div");
  overlay.id = "dist-chart-overlay";
  overlay.style.cssText = "display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:white; z-index:9500; padding:2rem; box-sizing:border-box; overflow:auto;";
  const closeBtn = document.createElement("button");
  closeBtn.textContent = "✕ Close";
  closeBtn.style.cssText = "position:fixed; top:16px; right:20px; padding:6px 14px; font-size:14px; cursor:pointer; border:1px solid #ccc; border-radius:6px; background:#f8f9fa; z-index:9501;";
  closeBtn.onclick = () => { overlay.style.display = "none"; };
  overlay.appendChild(closeBtn);

  // Keyboard close
  document.addEventListener("keydown", e => {
    if (e.key === "Escape" && overlay.style.display !== "none") overlay.style.display = "none";
  });

  fsBtn.onclick = () => {
    overlay.style.display = "block";
    // Build large chart at near-full viewport size
    const w = Math.min(window.innerWidth - 80, 1600);
    const h = Math.min(window.innerHeight - 120, 900);
    // Clear old chart
    while (overlay.children.length > 2) overlay.removeChild(overlay.lastChild);
    overlay.appendChild(makeDistChart(w, h, 14));
  };

  document.body.appendChild(overlay);
  wrapper.appendChild(makeDistChart(800, 400, 12));
  wrapper.appendChild(fsBtn);
  return wrapper;
}
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.

Code
{
  if (!cdmo_mode || !inhouse_results) return html``;

  const uc_cdmo = results.unit_cost;
  const uc_inhouse = inhouse_results.unit_cost;

  const clipVal = Math.min(quantile(uc_inhouse, 0.97), 250);

  const cdmo_p5  = quantile(uc_cdmo, 0.05);
  const cdmo_p50 = quantile(uc_cdmo, 0.50);
  const cdmo_p95 = quantile(uc_cdmo, 0.95);
  const ih_p5    = quantile(uc_inhouse, 0.05);
  const ih_p50   = quantile(uc_inhouse, 0.50);
  const ih_p95   = quantile(uc_inhouse, 0.95);

  const diff_p50 = cdmo_p50 - ih_p50;
  const diffSign = diff_p50 >= 0 ? "+" : "−";
  const diffAbs  = Math.abs(diff_p50).toFixed(1);

  // Build plot data: subsample to 10k each for performance
  const step = Math.floor(30000 / 10000);
  const plotData = [];
  for (let i = 0; i < 30000; i += step) {
    if (uc_cdmo[i] <= clipVal)    plotData.push({x: uc_cdmo[i],    mode: "CDMO"});
    if (uc_inhouse[i] <= clipVal) plotData.push({x: uc_inhouse[i], mode: "In-House"});
  }

  const compPlot = Plot.plot({
    width: 820,
    height: 340,
    marginLeft: 60,
    marginBottom: 50,
    x: {label: "Manufacturing Cost ($/kg, wet weight at harvest)", domain: [0, clipVal * 1.02]},
    y: {label: "Frequency", grid: true},
    color: {
      domain: ["In-House", "CDMO"],
      range:  ["#3498db", "#e67e22"],
      legend: true
    },
    marks: [
      Plot.rectY(plotData.filter(d => d.mode === "In-House"),
        Plot.binX({y: "count"}, {x: "x", fill: "#3498db", fillOpacity: 0.55})),
      Plot.rectY(plotData.filter(d => d.mode === "CDMO"),
        Plot.binX({y: "count"}, {x: "x", fill: "#e67e22", fillOpacity: 0.55})),
      Plot.ruleX([ih_p50],   {stroke: "#3498db", strokeWidth: 2.5}),
      Plot.ruleX([cdmo_p50], {stroke: "#e67e22", strokeWidth: 2.5}),
      Plot.ruleX([10], {stroke: "darkgreen", strokeWidth: 1.5, strokeDasharray: "3,3", strokeOpacity: 0.6}),
      Plot.ruleX([25], {stroke: "orange",    strokeWidth: 1.5, strokeDasharray: "3,3", strokeOpacity: 0.6}),
      Plot.text([
        {x: ih_p50   + 1.5, y: 900, text: `In-House p50: $${ih_p50.toFixed(0)}`},
        {x: cdmo_p50 + 1.5, y: 750, text: `CDMO p50: $${cdmo_p50.toFixed(0)}`}
      ], {x: "x", y: "y", text: "text", fontSize: 12})
    ],
    title: `In-House vs. CDMO: Projected ${target_year} Cost`
  });

  const rows = [
    {label: "p5 (optimistic)",   ih: ih_p5,  cdmo: cdmo_p5},
    {label: "p50 (median)",      ih: ih_p50, cdmo: cdmo_p50},
    {label: "p95 (pessimistic)", ih: ih_p95, cdmo: cdmo_p95}
  ];

  const tbody = document.createElement("tbody");
  rows.forEach(r => {
    const d = r.cdmo - r.ih;
    const dStr = (d >= 0 ? "+" : "−") + "$" + Math.abs(d).toFixed(0);
    const dColor = d >= 0 ? "#c0392b" : "#27ae60";
    const tr = document.createElement("tr");
    tr.innerHTML = `<td style="padding:5px 8px;">${r.label}</td>` +
      `<td style="padding:5px 8px; text-align:right; color:#3498db;"><strong>$${r.ih.toFixed(0)}</strong></td>` +
      `<td style="padding:5px 8px; text-align:right; color:#e67e22;"><strong>$${r.cdmo.toFixed(0)}</strong></td>` +
      `<td style="padding:5px 8px; text-align:right; color:${dColor};"><strong>${dStr}</strong></td>`;
    tbody.appendChild(tr);
  });

  return html`<div style="margin: 1.5rem 0;">
    <h3 style="margin-bottom:0.3rem;">In-House vs. CDMO Comparison</h3>
    <p style="font-size:0.88em; color:#555; margin-top:0;">Overlapping distributions for the same VOC parameters. Difference = CDMO − In-House; negative means CDMO is cheaper.</p>
    ${compPlot}
    <table style="margin-top:1rem; font-size:0.9em; border-collapse:collapse; min-width:360px;">
      <thead><tr style="border-bottom:2px solid #ddd;">
        <th style="padding:5px 8px; text-align:left;"></th>
        <th style="padding:5px 8px; text-align:right; color:#3498db;">In-House</th>
        <th style="padding:5px 8px; text-align:right; color:#e67e22;">CDMO</th>
        <th style="padding:5px 8px; text-align:right;">Difference</th>
      </tr></thead>
      ${tbody}
    </table>
    <p style="font-size:0.82em; color:#777; margin-top:0.6rem;">
      Both runs use seed 42 and identical VOC parameters. The difference isolates the capital cost structure: in-house (sampled CAPEX + fixed OPEX) vs. CDMO toll (lognormal p5=$${cdmo_toll_p5}, p95=$${cdmo_toll_p95}/kg).
    </p>
  </div>`;
}

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.

Code
{
  // Helper: build one card, optionally with a blended product secondary row
  function card(threshold, pureProb, label, color, blendProb, bs, fc) {
    const borderColor = pureProb > 30 ? color : '#ddd';
    const blendLine = include_blending && blendProb !== undefined
      ? `<div style="margin-top:4px; font-size:0.82em; color:#1a5276; background:#f0f8ff; border-radius:4px; padding:3px 6px;">
           <strong>${blendProb.toFixed(1)}%</strong> chance blended product (${Math.round(bs*100)}% CM, $${fc}/kg filler) &lt; $${threshold}/kg
         </div>`
      : '';
    return `<div class="card" style="border: 2px solid ${borderColor}; padding: 1rem; border-radius: 8px; text-align: center;">
      <h5 style="margin-bottom:0.3rem;">P(Pure cells &lt; $${threshold}/kg)</h5>
      <h2 style="color: ${color}; margin:0.2rem 0;">${pureProb.toFixed(1)}%</h2>
      <small style="color:#555;">${label}</small>
      ${blendLine}
    </div>`;
  }

  const bs = stats.bs, fc = stats.fc;
  const grid = `<div class="grid" style="grid-template-columns: repeat(4, 1fr); gap: 1rem; margin: 1.5rem 0;">
    ${card(10,  stats.prob_10,  'could approach conventional chicken pricing (~$5-10/kg retail)',  '#27ae60', stats.bprob_10,  bs, fc)}
    ${card(25,  stats.prob_25,  'range where premium cultured products may be viable',              '#3498db', stats.bprob_25,  bs, fc)}
    ${card(50,  stats.prob_50,  'potential niche/specialty market segment',                         '#f39c12', stats.bprob_50,  bs, fc)}
    ${card(100, stats.prob_100, 'substantially cheaper than current lab-scale costs',               '#e74c3c', stats.bprob_100, bs, fc)}
  </div>`;

  // When blending is on, also show consumer-relevant blended thresholds
  const blendRow = include_blending ? `
    <p style="font-size:0.88em; color:#1a5276; margin:0.5rem 0 0.3rem; font-weight:500;">
      Blended product (${Math.round(bs*100)}% CM + ${Math.round((1-bs)*100)}% filler at $${fc}/kg) — consumer-relevant price points:
    </p>
    <div class="grid" style="grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1.5rem;">
      <div class="card" style="border: 2px solid ${stats.bprob_5  > 20 ? '#27ae60' : '#ddd'}; padding: 0.8rem; border-radius: 8px; text-align: center;">
        <h5 style="font-size:0.85em;">P(Blend &lt; $5/kg)</h5>
        <h2 style="color:#27ae60; margin:0.2rem 0;">${stats.bprob_5.toFixed(1)}%</h2>
        <small>competitive with conventional chicken</small>
      </div>
      <div class="card" style="border: 2px solid ${stats.bprob_8  > 30 ? '#3498db' : '#ddd'}; padding: 0.8rem; border-radius: 8px; text-align: center;">
        <h5 style="font-size:0.85em;">P(Blend &lt; $8/kg)</h5>
        <h2 style="color:#3498db; margin:0.2rem 0;">${stats.bprob_8.toFixed(1)}%</h2>
        <small>competitive with premium chicken/beef</small>
      </div>
      <div class="card" style="border: 2px solid ${stats.bprob_12 > 50 ? '#f39c12' : '#ddd'}; padding: 0.8rem; border-radius: 8px; text-align: center;">
        <h5 style="font-size:0.85em;">P(Blend &lt; $12/kg)</h5>
        <h2 style="color:#f39c12; margin:0.2rem 0;">${stats.bprob_12.toFixed(1)}%</h2>
        <small>affordable specialty market</small>
      </div>
    </div>
    <p style="font-size:0.82em; color:#777; margin-bottom:1rem; font-style:italic;">
      Key insight: even if pure cell costs remain high ($50-100/kg), a 20-30% CM blended product can still approach conventional chicken pricing — the blend ratio and filler cost ($${fc}/kg) matter as much as cell costs alone.
    </p>` : '';

  return html([grid + blendRow]);
}

Cost Breakdown

Where does the cost come from? This chart shows the mean contribution of each cost component across all 30,000 simulations (mean, not median — skewed components like growth factors will appear larger here than at the median). The largest bars are the cost drivers to focus on — these are where technological progress or parameter uncertainty has the most impact.

Code
{
  const mediaLabel = bundled_media ? "Complete Media (incl. GFs)" : "Media (incl. basal micros)";
  const allComponents = [
    {name: mediaLabel,               value: mean(results.cost_media),          color: "#27ae60"},
    {name: "Growth Factors",         value: mean(results.cost_recf),           color: "#9b59b6"},
    {name: "Supplemental Proteins",  value: mean(results.cost_supp_protein),   color: "#e67e22"},
    {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: "CDMO Toll",              value: mean(results.cost_cdmo_toll),     color: "#e67e22"},
    {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;

  // Chart wrapper: clear-and-append avoids replaceWith()+querySelector on SVG
  // which fails silently in Safari (classList.add on SVG unreliable in WebKit).
  const chartWrapper = document.createElement("div");

  expandBtn.onclick = () => {
    expanded = !expanded;
    expandBtn.textContent = expanded ? "Collapse Chart" : "Expand Chart";
    chartWrapper.innerHTML = "";
    chartWrapper.appendChild(makeChart(expanded));
  };
  chartContainer.appendChild(expandBtn);

  function makeChart(large) {
    const w = large ? 1200 : 1000;
    const h = large ? 700 : 580;
    const fontSize = large ? 14 : 13;
    return 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)`
    });
  }

  chartWrapper.appendChild(makeChart(false));
  chartContainer.appendChild(chartWrapper);
  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

Show per-component distributions (click to expand)

Individual distributions for each cost driver, updating dynamically as you adjust parameters.

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: bundled_media ? "Complete Media (incl. GFs)" : "Media (incl. basal micros)", data: results.cost_media, color: "#27ae60"},
    {name: "Growth Factors", data: results.cost_recf, color: "#9b59b6"},
    {name: "Supplemental Proteins (albumin/transferrin/insulin)", data: results.cost_supp_protein, color: "#e67e22"},
    {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: "CDMO Toll", data: results.cost_cdmo_toll, color: "#e67e22"},
    {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.
Realized adoption rates (click to expand)

Actual shares across the 30,000 draws. Near-identical to slider inputs at default maturity (0.5); deviates when maturity ≠ 0.5, because the maturity distribution shifts each draw’s adoption probability before sampling. Process mode shares always equal slider inputs exactly and are omitted here.

Code
html`<div style="display:flex; gap:2rem; flex-wrap:wrap; font-size:0.95em; padding: 0.5rem 0;">
  <span>Hydrolysates: <strong style="color:#27ae60;">${(results.pct_hydro * 100).toFixed(0)}%</strong> of draws</span>
  <span>Cheap GFs: <strong style="color:#9b59b6;">${(results.pct_recf_cheap * 100).toFixed(0)}%</strong> of draws</span>
</div>`

Sensitivity Analysis (Tornado Chart)

Which parameters have the most impact on the final cost? Each bar shows the dollar swing in mean unit cost between simulations where the parameter is in its top 10% versus its bottom 10%. Larger bars = bigger levers on cost.

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: "WACC (financing)",                   data: results.wacc_samples,           kind: "primitive"},
    {name: "Asset Life (years)",                 data: results.asset_life_samples,     kind: "primitive"},
    {name: "Plant Capacity (kTA)",               data: results.plant_kta_samples,      kind: "primitive"},
    {name: "Utilization Rate",                   data: results.uptime_samples,         kind: "primitive"}
  ];

  // Compute signed swings: positive = high param → higher cost (red); negative = high param → lower cost (green)
  const swingsRaw = params.map(p => ({
    name: p.name,
    kind: p.kind,
    rawSwing: conditionalSwing(p.data, uc, 0.10)
  }));
  const swings = swingsRaw.map(s => ({
    name: s.name,
    kind: s.kind,
    swing: Math.abs(s.rawSwing),
    // Red: high value raises cost; Green: high value lowers cost
    fill: s.rawSwing >= 0 ? "#c0392b" : "#27ae60"
  }));

  const sorted = [...swings].sort((a, b) => b.swing - a.swing);
  const maxAbs = Math.max(...sorted.map(s => s.swing), 1);
  const pad = maxAbs * 0.25;

  const tornadoPlot = Plot.plot({
    width: 900,
    height: 440,
    marginLeft: 290,
    marginRight: 120,
    x: {
      label: "Dollar swing in mean unit cost ($/kg) — larger bar = bigger lever on cost",
      domain: [0, maxAbs + pad],
      grid: true,
      labelOffset: 40,
      tickFormat: d => "$" + d.toFixed(0)
    },
    y: { label: null, tickFormat: d => d, tickSize: 0 },
    style: { fontSize: "13px" },
    marks: [
      Plot.barX(sorted, {
        y: "name",
        x: "swing",
        fill: d => d.fill,
        fillOpacity: 0.80,
        sort: {y: "-x"}
      }),
      Plot.text(sorted, {
        y: "name",
        x: d => d.swing + maxAbs * 0.02,
        text: d => "$" + d.swing.toFixed(1) + "/kg",
        textAnchor: "start",
        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;">Which parameters matter most? (dollar impact on mean cost)</div>
    ${tornadoPlot}
  </div>`;
}

Red bars: when this parameter is high, cost is higher (a cost driver). Green bars: when high, cost is lower (a cost reducer). Bar length = conditional-mean dollar swing between top 10% vs. bottom 10% of that parameter’s distribution across the 30,000 Monte Carlo draws. This is NOT a variance decomposition — bars for correlated parameters can overlap and should not be summed. Industry Maturity is a latent variable whose bar captures the bundled effect of correlated improvements. Full methodology →

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 (hydrolysates, scalable GFs, affordable supplemental proteins)
  • Lower reactor costs (more custom/food-grade equipment)
  • Lower financing costs (WACC)

Not calibrated to observed historical trajectories. The maturity slider is a forward-looking judgment parameter — it does not incorporate data on how fast CM costs have actually fallen since 2015. The model’s parameter ranges (e.g., media $/L, GF $/g) were set from TEA literature snapshots (Humbird 2021, Pasitka 2024, GFI 2025 amino acid report) — point estimates, not a fitted time series. The year → maturity scaling formula (linear from 2024 to 2044) is mechanical convenience, not a regression on industry data.

What this means: “maturity = 0.5” does not mean “the historical rate of progress continues.” It means “the model’s default assumption is that by 2036 the industry lands roughly in the middle of the plausible range.” Whether that default is consistent with observed progress since 2020 is a substantive question this model cannot answer on its own — and a good one to raise with experts at the workshop.

Correlation structure caveat (important for interpretation): The model uses a single shared latent factor to induce correlation across technology adoption, reactor costs, and financing. This is an ad hoc modelling convenience — not an empirically calibrated copula. The specific sensitivity coefficients (e.g., ±0.25 shifts to adoption probabilities, ±0.03 shifts to WACC) and the choice of a single dimension are choices made for tractability, not derived from data.

What this means for interpretation: - Scenarios where all dimensions co-move together (high maturity = everything goes well; low maturity = everything is hard) will be overrepresented relative to a fully independent model - Scenarios where, say, bioprocess technology matures rapidly but financing remains difficult are partly suppressed - Stress test: Set the maturity slider to 0.1 (pessimistic) and 0.9 (optimistic) and compare — the difference captures the full correlated scenario range. Then check whether P(cost < $25/kg) changes materially - The alternative — modeling technology adoption, reactor costs, and financing as fully independent — can be approximated by setting maturity to exactly 0.5 and reading the adoption probabilities as unconditional (no maturity adjustment)

We plan to offer explicit alternative correlation modes (independent / coupled) in a future update.

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 (click to expand)
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 (click to expand)
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 (click to expand)
  • 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>
---

```{=html}
<div style="margin:6px 0 10px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
  <a href="#adjustable-parameters" style="display:inline-block; padding:6px 14px; background:#2d4a2d; color:white; border-radius:6px; text-decoration:none; font-size:13px; font-weight:600;">↓ Go to parameters</a>
  <a href="simple.html" style="display:inline-block; padding:6px 14px; background:#f0f8ff; color:#1a5276; border:1px solid #3498db; border-radius:6px; text-decoration:none; font-size:13px;">Simplest Model →</a>
</div>
```

::: {.callout-note collapse="true"}
## New to this model?
Start with the [Simplest Model &rarr;](simple.qmd) — a shorter version focusing on some key levers with line-of-sight explanations. You can carry your settings over to this Advanced Model when you're ready.
:::

```{=html}
<details style="margin:10px 0 16px;border:1px solid #d4dfd4;border-radius:7px;background:#f8f9f5;padding:0;"><summary style="padding:9px 14px;cursor:pointer;font-size:13px;font-weight:600;color:#2d4a2d;list-style:none;display:flex;align-items:center;gap:8px;"><span style="font-size:10px;">&#9658;</span>Audio overviews<span style="font-weight:400;color:#888;font-size:12px;">(AI-generated · <span title="Generated by AI (OpenAI TTS-1-HD, May 2026) based on page content. Reviewed by The Unjournal team — generally accurate and useful, but may state some things more definitively than we'd prefer." style="cursor:help;border-bottom:1px dotted #aaa;">note</span>)</span></summary><div style="padding:0 12px 12px;display:grid;grid-template-columns:1fr 1fr;gap:10px;"><div style="padding:10px 12px;background:white;border:1px solid #dde8dd;border-radius:6px;"><div style="font-weight:600;font-size:13px;color:#2d4a2d;margin-bottom:4px;">How Cultured Meat is Made <span style="font-weight:400;color:#888;font-size:11px;">~11 min</span></div><audio controls style="width:100%;height:30px;display:block;margin-bottom:4px;"><source src="https://unjournal.github.io/cm_pq_modeling/process_overview.mp3" type="audio/mpeg"></audio><div style="font-size:11px;color:#777;display:flex;gap:10px;"><a href="https://unjournal.github.io/cm_pq_modeling/process_overview.mp3" download style="color:#5a7a5a;">Download MP3</a><a href="https://unjournal.github.io/cm_pq_modeling/process-overview-script.html" target="_blank" rel="noopener" style="color:#5a7a5a;">View/annotate script</a></div></div><div style="padding:10px 12px;background:white;border:1px solid #dde8dd;border-radius:6px;"><div style="font-weight:600;font-size:13px;color:#2d4a2d;margin-bottom:4px;">The Cost Model Explained <span style="font-weight:400;color:#888;font-size:11px;">~10 min</span></div><audio controls style="width:100%;height:30px;display:block;margin-bottom:4px;"><source src="https://unjournal.github.io/cm_pq_modeling/model_review_report.mp3" type="audio/mpeg"></audio><div style="font-size:11px;color:#777;display:flex;gap:10px;"><a href="https://unjournal.github.io/cm_pq_modeling/model_review_report.mp3" download style="color:#5a7a5a;">Download MP3</a><a href="https://unjournal.github.io/cm_pq_modeling/model-overview-script.html" target="_blank" rel="noopener" style="color:#5a7a5a;">View/annotate script</a></div></div></div></details>
```


::: {.callout-warning collapse="true"}
## Important: Model Status & Limitations
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.*

**An external methodological review identified several structural limitations** — including the ad hoc dependence structure, missing supplemental protein costs (albumin/transferrin/insulin), and the sensitivity chart being a dollar-swing ranking rather than a variance decomposition. We are addressing these incrementally.

**[Read the full critique and our responses → Limits/Critique](limits.html)**

We especially welcome expert review of: growth factor quantities and prices, bioreactor CAPEX ranges, and the maturity correlation structure — see below and the [Sources section](#sources--how-they-inform-the-model).
:::

::: {.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');
  // Track state via body class (avoids checking Hypothesis's internal display/transform state)
  const hidden = document.body.classList.contains('hyp-force-hidden');
  if (hidden) {
    document.body.classList.remove('hyp-force-hidden');
    if (btn) btn.textContent = '◉ Annotations';
  } else {
    document.body.classList.add('hyp-force-hidden');
    if (btn) btn.textContent = '▷ Annotations';
  }
}

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;

  const isHidden = window.getComputedStyle(sidebar).display === 'none';

  if (isHidden) {
    sidebar.classList.remove('params-hidden');
    sidebar.style.removeProperty('display');
    if (fill) { fill.style.removeProperty('grid-column'); fill.style.removeProperty('width'); }
    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');
    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'; }
  }
}

// Wide sidebar: turn the parameters panel into a fixed-position drawer overlay.
// Using position:fixed avoids all CSS grid conflicts — the sidebar lifts out of
// the document flow and floats on top. A dimmed backdrop lets users click outside
// to close. The OJS cells inside still work because the DOM nodes are the same.
function toggleWideSidebar() {
  const body = document.body;
  const sidebar = document.querySelector('.panel-sidebar');
  const backdrop = document.getElementById('params-backdrop');
  const btnV = document.getElementById('vc-wide-params');
  const btnI = document.getElementById('toggle-wide-params');

  if (body.classList.contains('sidebar-wide')) {
    // Close drawer
    body.classList.remove('sidebar-wide');
    const s = document.getElementById('sidebar-wide-style');
    if (s) s.remove();
    if (backdrop) backdrop.style.display = 'none';
    if (btnV) { btnV.textContent = '⟺ Wide params'; btnV.style.background = '#f8f9fa'; btnV.style.borderColor = '#ccc'; }
    if (btnI) btnI.textContent = '⟺ Wide view';
  } else {
    // Open drawer: make sidebar a fixed overlay panel
    body.classList.add('sidebar-wide');
    if (!document.getElementById('sidebar-wide-style')) {
      const s = document.createElement('style');
      s.id = 'sidebar-wide-style';
      s.textContent = `
        body.sidebar-wide .panel-sidebar {
          position: fixed !important;
          top: var(--quarto-navbar-height, 62px) !important;
          left: 0 !important;
          width: min(62vw, 820px) !important;
          max-width: 92vw !important;
          height: calc(100vh - var(--quarto-navbar-height, 62px)) !important;
          max-height: calc(100vh - var(--quarto-navbar-height, 62px)) !important;
          overflow-y: auto !important;
          overflow-x: hidden !important;
          z-index: 6000 !important;
          background: white !important;
          box-shadow: 8px 0 32px rgba(0,0,0,0.22) !important;
          padding: 0.5rem 2rem 5rem 1.5rem !important;
          border-right: 3px solid #3498db !important;
          scrollbar-width: thin !important;
        }
      `;
      document.head.appendChild(s);
    }
    if (backdrop) backdrop.style.display = 'block';
    if (btnV) { btnV.textContent = '↘ Narrow params'; btnV.style.background = '#e8f0fa'; btnV.style.borderColor = '#3498db'; }
    if (btnI) btnI.textContent = '↘ Narrow view';
    // Scroll drawer to top when opening
    if (sidebar) sidebar.scrollTop = 0;
  }
}

// Re-initialise Tippy tooltips for abbr[title] elements added by OJS
// (OJS renders after Quarto's DOMContentLoaded Tippy pass, so abbr elements
// inside OJS html`` templates are missed on first pass).
function initOjsTooltips() {
  if (typeof tippy === 'undefined') return;
  document.querySelectorAll('.panel-sidebar abbr[title], .panel-fill abbr[title]').forEach(function(el) {
    const t = el.getAttribute('title');
    if (!el._tippy && t) {
      el.removeAttribute('title');  // prevent duplicate native browser tooltip
      tippy(el, { content: t, placement: 'bottom', maxWidth: 340, allowHTML: false });
    }
  });
}
setTimeout(initOjsTooltips, 2500);  // first pass after OJS settles
// Re-run whenever sidebar content changes (OJS re-renders on slider change)
document.addEventListener('DOMContentLoaded', function() {
  var obs = new MutationObserver(function() { initOjsTooltips(); });
  var sidebar = document.querySelector('.panel-sidebar');
  if (sidebar) obs.observe(sidebar, { childList: true, subtree: true });
});

// Reset all parameters: clear URL state and reload (most reliable approach,
// since OJS viewof inputs can't be reset reliably from plain JS)
function resetAllDefaults() {
  window.location.href = window.location.pathname + window.location.hash;
}

var _expandAllActive = false;
var _expandAllObserver = null;

function collapseAllDetails() {
  _expandAllActive = false;
  if (_expandAllObserver) { _expandAllObserver.disconnect(); _expandAllObserver = null; }
  document.querySelectorAll('details[open]').forEach(function(d) { d.removeAttribute('open'); });
}

function expandAllDetails() {
  _expandAllActive = true;
  document.querySelectorAll('details').forEach(function(d) { d.setAttribute('open', ''); });
  // OJS re-renders reactive cells after every slider change, replacing <details> elements
  // with fresh closed ones. The MutationObserver re-applies 'open' to any new ones.
  if (!_expandAllObserver) {
    _expandAllObserver = new MutationObserver(function(mutations) {
      if (!_expandAllActive) return;
      mutations.forEach(function(m) {
        m.addedNodes.forEach(function(node) {
          if (node.nodeType !== 1) return;
          if (node.tagName === 'DETAILS') node.setAttribute('open', '');
          if (node.querySelectorAll) node.querySelectorAll('details').forEach(function(d) {
            d.setAttribute('open', '');
          });
        });
      });
    });
    _expandAllObserver.observe(document.body, { childList: true, subtree: true });
  }
}

function toggleViewControls() {
  const content = document.getElementById('view-controls-content');
  const icon = document.getElementById('vc-minimize');
  if (!content) return;
  const isHidden = content.style.display === 'none';
  content.style.display = isHidden ? 'flex' : 'none';
  if (icon) icon.textContent = isHidden ? '−' : '≡';
}

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() {
  document.body.classList.add('hyp-force-hidden');
  const btn = document.getElementById('toggle-hypothesis') || document.getElementById('vc-hyp');
  if (btn) btn.textContent = '▷ Annotations';
}
</script>

<!-- Backdrop for wide-sidebar drawer: click to close -->
<div id="params-backdrop" onclick="toggleWideSidebar()" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.35); z-index:5999; cursor:pointer;"></div>

<!-- Fixed floating view controls — lower RIGHT, elevated 90px above Hypothes.is badge.
     Collapsible: click the VIEW header row to minimize to a slim bar. -->
<div id="view-controls" style="position: fixed; bottom: 90px; right: 20px; z-index: 10001; background: white; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.15); font-size: 12px; min-width: 145px; overflow: hidden;">
  <div onclick="toggleViewControls()" title="Click to expand/collapse" style="display: flex; align-items: center; padding: 6px 9px; cursor: pointer; border-bottom: 1px solid #eee; user-select: none; background: #fafafa;">
    <span style="font-size: 11px; color: #888; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; flex: 1;">View</span>
    <span id="vc-minimize" style="font-size: 15px; color: #aaa; line-height: 1;">−</span>
  </div>
  <div id="view-controls-content" style="display: flex; flex-direction: column; gap: 5px; padding: 8px 9px;">
    <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="toggleWideSidebar()" id="vc-wide-params" style="padding: 5px 8px; font-size: 12px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #f8f9fa; text-align: left;">⟺ Wide params</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>
    <hr style="margin: 3px 0; border-color: #eee;">
    <button onclick="collapseAllDetails()" style="padding: 5px 8px; font-size: 12px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #f8f9fa; text-align: left;">▲ Collapse all</button>
    <button onclick="expandAllDetails()" style="padding: 5px 8px; font-size: 12px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #f8f9fa; text-align: left;">▼ Expand all</button>
  </div>
</div>

<style>
/* ── Fullwidth mode: hide parameter sidebar, let charts fill the page ── */
.fullwidth-mode .page-columns {
  grid-template-columns: 1fr !important;
  max-width: 100% !important;
  width: 100% !important;
}
.fullwidth-mode #quarto-content,
.fullwidth-mode main.content,
.fullwidth-mode main,
.fullwidth-mode .column-page,
.fullwidth-mode .column-body,
.fullwidth-mode .panel-fill {
  max-width: 100% !important;
  width: 100% !important;
  padding-left: 1rem !important;
  padding-right: 1rem !important;
}
/* Hide parameter sidebar and TOC in fullwidth mode */
.fullwidth-mode .panel-sidebar,
.fullwidth-mode #quarto-sidebar,
.fullwidth-mode #quarto-margin-sidebar,
.fullwidth-mode .sidebar {
  display: none !important;
}
/* Hide Hypothesis in fullwidth mode, and when manually toggled off */
.fullwidth-mode .annotator-frame,
.fullwidth-mode .hypothesis-sidebar,
.fullwidth-mode hypothesis-sidebar,
.hyp-force-hidden .annotator-frame,
.hyp-force-hidden .hypothesis-sidebar,
.hyp-force-hidden hypothesis-sidebar,
.hyp-force-hidden iframe[src*="hypothes.is"] {
  display: none !important;
  visibility: hidden !important;
  pointer-events: none !important;
}
/* Sidebar overflow fix — offset by Quarto navbar so top buttons aren't hidden */
.panel-sidebar {
  overflow-y: auto !important;
  overflow-x: hidden !important;
  position: sticky !important;
  top: var(--quarto-navbar-height, 62px) !important;
  max-height: calc(100vh - var(--quarto-navbar-height, 62px)) !important;
}
.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;
}
</style>
```

::: {.callout-note collapse="true"}
## 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, utilization rate (0–1 share). 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. <abbr title="Vose (2008), Risk Analysis: A Quantitative Guide (Wiley) — textbook treatment of correlated Monte Carlo sampling; Morgan &amp; Henrion (1990), Uncertainty (Cambridge UP) — foundational discussion of dependent uncertainties.">See methodological references.</abbr>

<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.

*Caveat:* This correlation structure is a modelling convenience, not an empirically calibrated design. The choice of a single latent variable, the specific sensitivity coefficients, and the functional form are all ad hoc. Results — especially in tail scenarios — may be sensitive to these choices. We encourage users to stress-test by varying the maturity slider widely and treat correlated-scenario outputs as illustrative rather than calibrated.
</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;
}

// Like sampleLognormalP5P95 but takes an 80% CI (p10/p90) — matches the beliefs form.
function sampleLognormalP10P90(rng, p10, p90, n) {
  const Z_90 = 1.2815515655446004;
  const mu = (Math.log(p10) + Math.log(p90)) / 2;
  const sigma = (Math.log(p90) - Math.log(p10)) / (2 * Z_90);
  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) {
  // Guard: Beta is undefined at the boundaries (mean=0 or mean=1).
  // betaFromMeanStdev returns NaN params → sampleBeta returns NaN →
  // rng() < NaN is always false, silently flipping boolean outcomes.
  // E.g. p_recf_mean=1.0 (slider at 100%) → all treated as expensive GF.
  if (mean <= 0) return new Array(n).fill(0);
  if (mean >= 1) return new Array(n).fill(1);
  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
  // Mode-based sampling: each Monte Carlo run draws a process mode, then samples
  // density and media-use from that mode's physical range. This prevents incoherent
  // combinations (e.g., 200 g/L density in batch mode, which is biologically impossible).
  // Pure batch is excluded as not commercially viable at scale (Oana Kubinecz, Apr 2026).
  const cycle_days = sampleLognormalP5P95(rng, 0.5, 5.0, n);

  let density_gL, media_turnover, mode_labels;
  if (params.override_mode_constraints) {
    // Expert override: use manually specified density and turnover ranges directly.
    density_gL = sampleLognormalP5P95(rng, params.density_gL_p5, params.density_gL_p95, n);
    media_turnover = sampleLognormalP5P95(rng, params.media_turnover_p5, params.media_turnover_p95, n);
    mode_labels = new Array(n).fill("override");
  } else {
    // Default: sample process mode per run, then draw density and turnover from that
    // mode's range. Pre-generate all six arrays (vectorized) for performance.
    const p_total = params.p_fedbatch + params.p_perfusion + params.p_continuous;
    const p0  = params.p_fedbatch / p_total;
    const p01 = p0 + params.p_perfusion / p_total;
    const d_fb = sampleLognormalP5P95(rng, 5, 30, n);    // Fed-batch density
    const d_pf = sampleLognormalP5P95(rng, 30, 150, n);  // Perfusion density
    const d_ct = sampleLognormalP5P95(rng, 50, 200, n);  // Continuous density
    const t_fb = sampleLognormalP5P95(rng, 1.0, 2.0, n); // Fed-batch media-use multiplier
    const t_pf = sampleLognormalP5P95(rng, 1.0, 5.0, n); // Perfusion media-use multiplier
    const t_ct = sampleLognormalP5P95(rng, 0.5, 3.0, n); // Continuous media-use multiplier
    const mode_rand = sampleUniform(rng, 0, 1, n);
    mode_labels = mode_rand.map(r => r < p0 ? "fedbatch" : r < p01 ? "perfusion" : "continuous");
    density_gL    = mode_labels.map((m, i) =>
      m === "fedbatch" ? d_fb[i] : m === "perfusion" ? d_pf[i] : d_ct[i]);
    media_turnover = mode_labels.map((m, i) =>
      m === "fedbatch" ? t_fb[i] : m === "perfusion" ? t_pf[i] : t_ct[i]);
  }

  // Expert prior override: cell density — must precede L_per_kg so CAPEX also sees it
  if (params.ep_density_p10 && params.ep_density_p90 && params.ep_density_p10 < params.ep_density_p90) {
    density_gL = sampleLognormalP10P90(rng, params.ep_density_p10, params.ep_density_p90, 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);

  // Supplemental recombinant proteins: albumin, transferrin, insulin
  // These are distinct from growth factors (FGF/IGF/TGF-β) — they are structural/survival
  // proteins used as media additives, not signalling molecules. GFI 2024 supply-chain
  // analysis projects albumin alone to be 96.6% of anticipated recombinant protein
  // production volume for CM. Cost depends on whether food-grade production at scale
  // (recombinant albumin from yeast, etc.) replaces pharma-grade sourcing.
  // Cheap regime: food-grade recombinant at scale → p5=$0.03/kg, p95=$0.60/kg cell mass
  // Exp. regime:  pharma-grade → p5=$0.50/kg, p95=$4.00/kg cell mass
  let p_supp = sampleBetaMeanStdev(rng, params.p_supp_protein_mean, 0.12, n);
  p_supp = clip(add(p_supp, scale(maturity.map(m => m - 0.5), 0.20)), 0, 1);
  const is_supp_cheap = p_supp.map(p => rng() < p);
  const supp_cheap_cost = sampleLognormalP5P95(rng, 0.03, 0.60, n);
  const supp_exp_cost   = sampleLognormalP5P95(rng, 0.50, 4.00, n);
  const cost_supp_protein = is_supp_cheap.map((c, i) => c ? supp_cheap_cost[i] : supp_exp_cost[i]);

  // Other variable costs (utilities, consumables, small-molecule additives)
  // Reduced from p5=0.5/p95=5.0 — supplemental proteins now have their own term above
  const other_var = sampleLognormalP5P95(rng, 0.30, 3.0, n);

  // Bundled media override (advanced full-view mode): replaces the separable basal+GF
  // structure with a single complete-medium $/L figure, matching the convention used in
  // research-grade published TEAs (e.g., Humbird 2021 quotes $50-500/L complete media).
  // GF sampling above still runs to maintain RNG consistency regardless of bundled mode.
  let cost_media_eff = cost_media;
  let cost_recf_eff = cost_recf;
  let media_cost_L_eff = media_cost_L;
  if (params.bundled_media) {
    const complete_media_L = sampleLognormalP5P95(rng, params.bundled_media_p5, params.bundled_media_p95, n);
    cost_media_eff  = mul(L_per_kg, complete_media_L);
    cost_recf_eff   = new Array(n).fill(0);
    media_cost_L_eff = complete_media_L;
  }

  // Expert prior overrides: applied after bundled-media so they work in both modes.
  // These bypass the regime-switching logic and express direct beliefs in $/kg biomass.
  if (params.ep_media_p10 && params.ep_media_p90 && params.ep_media_p10 < params.ep_media_p90) {
    cost_media_eff = sampleLognormalP10P90(rng, params.ep_media_p10, params.ep_media_p90, n);
  }
  if (params.ep_gf_p10 && params.ep_gf_p90 && params.ep_gf_p10 < params.ep_gf_p90) {
    cost_recf_eff = sampleLognormalP10P90(rng, params.ep_gf_p10, params.ep_gf_p90, n);
  }

  // VOC total: media + growth factors + supplemental proteins + other
  const voc = add(add(add(cost_media_eff, cost_recf_eff), cost_supp_protein), other_var);

  // CDMO mode: replace CAPEX + Fixed OPEX with a contract toll fee ($/kg)
  // The toll covers the CDMO's amortized capital, labor, overhead, and margin.
  // VOC (media, GFs) remain as direct company costs.
  let cdmo_toll_perkg = new Array(n).fill(0);
  if (params.cdmo_mode) {
    cdmo_toll_perkg = sampleLognormalP5P95(rng, params.cdmo_toll_p5, params.cdmo_toll_p95, n);
  }

  // CAPEX calculation (skipped in CDMO mode — CDMO bears the capital)
  let capex_perkg = new Array(n).fill(0);
  // Pre-allocate WACC and asset-life sample arrays so they exist for the
  // tornado chart even when CAPEX is excluded (in those cases they're
  // sampled here purely for the sensitivity export, with no cost effect).
  let wacc_samples_out = sampleLognormalP5P95(rng, params.wacc_p5, params.wacc_p95, n);
  wacc_samples_out = clip(wacc_samples_out.map((w, i) => w - 0.03 * (maturity[i] - 0.5)), 0.03, 1);
  let asset_life_samples_out = sampleUniform(rng, params.asset_life_lo, params.asset_life_hi, n);
  if (!params.cdmo_mode && 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]
    );

    // Re-use the pre-sampled wacc/asset_life so the tornado chart has access
    // to the realized values regardless of CAPEX inclusion.
    const wacc = wacc_samples_out;
    const asset_life = asset_life_samples_out;
    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 (skipped in CDMO mode — CDMO bears overhead)
  let fixed_perkg = new Array(n).fill(0);
  if (!params.cdmo_mode && 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(add(voc, capex_perkg), fixed_perkg), cdmo_toll_perkg), downstream_perkg);

  return {
    unit_cost,
    cost_media: cost_media_eff,
    cost_recf: cost_recf_eff,
    cost_supp_protein,
    cost_other_var: other_var,
    cost_capex: capex_perkg,
    cost_fixed: fixed_perkg,
    cost_cdmo_toll: cdmo_toll_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,
    // Process mode realized shares — stored as scalars (not the full 30k string array)
    // to keep OJS reactive graph lightweight
    mode_is_override: mode_labels[0] === "override",
    pct_fedbatch:  mode_labels.filter(m => m === "fedbatch").length  / n,
    pct_perfusion: mode_labels.filter(m => m === "perfusion").length / n,
    pct_continuous: mode_labels.filter(m => m === "continuous").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_eff,
    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,
    wacc_samples: wacc_samples_out,
    asset_life_samples: asset_life_samples_out
  };
}

// 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}
<div style="display: flex; gap: 5px; margin-bottom: 0.75rem; position: sticky; top: 0; background: white; padding: 0.5rem 0 0.5rem; border-bottom: 1px solid #eee; z-index: 5; margin-top: -0.5rem; flex-wrap: wrap;">
  <button id="toggle-params" onclick="toggleParams()" style="flex: 1; min-width: 90px; padding: 0.5rem; font-size: 0.82rem; cursor: pointer; border: 1px solid #5a7a5a; border-radius: 6px; background: #e8f4e8; color: #2d4a2d; font-weight: 500;">
    ◀ Hide
  </button>
  <button id="toggle-wide-params" onclick="toggleWideSidebar()" title="Expand sidebar for detailed reading" style="padding: 0.5rem 0.5rem; font-size: 0.82rem; cursor: pointer; border: 1px solid #3498db; border-radius: 6px; background: #f0f8ff; color: #1a5276; font-weight: 500; white-space: nowrap;">
    ⟺ Wide
  </button>
  <button id="reset-all-btn" onclick="resetAllDefaults()" title="Reset every slider and toggle back to its default value" style="padding: 0.5rem 0.5rem; font-size: 0.82rem; cursor: pointer; border: 1px solid #c0392b; border-radius: 6px; background: #fef9f9; color: #922b21; font-weight: 500; white-space: nowrap;">
    ↺ Reset all
  </button>
  <button onclick="expandAllDetails()" title="Expand all parameter detail sections" style="padding: 0.5rem 0.4rem; font-size: 0.82rem; cursor: pointer; border: 1px solid #aaa; border-radius: 6px; background: #f9f9f9; color: #555; white-space: nowrap;">▼ All</button>
  <button onclick="collapseAllDetails()" title="Collapse all parameter detail sections" style="padding: 0.5rem 0.4rem; font-size: 0.82rem; cursor: pointer; border: 1px solid #aaa; border-radius: 6px; background: #f9f9f9; color: #555; white-space: nowrap;">▲ All</button>
</div>
```

```{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 dollar-impact sensitivity analysis (tornado chart), each of these individually shifts the mean cost by a relatively small amount — typically under $15/kg — compared to $50–150+/kg for cell density and growth factor uncertainty. Note: this is a conditional-mean swing statistic, not a variance decomposition; see the tornado chart below for the full ranking.
<em>Switch off to adjust all parameters.</em>
</div>`
```

```{ojs}
//| echo: false
// Reactive style block to hide/show full-mode-only and cdmo-only inputs
html`<style>
  .full-mode-only     { display: ${simpleMode ? 'none' : 'block'}; }
  .cdmo-only          { display: ${cdmo_mode ? 'block' : 'none'}; }
  .override-mode-only { display: ${override_mode_constraints ? 'block' : 'none'}; }
  .separable-only     { display: ${bundled_media ? 'none' : 'block'}; }
  .bundled-only       { display: ${bundled_media ? 'block' : 'none'}; }
  .blending-only      { display: ${include_blending ? 'block' : 'none'}; }
</style>`
```

**Blended / Hybrid Product**

```{ojs}
//| echo: false
viewof include_blending = Inputs.toggle({label: html`Show blended/hybrid product cost <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="Most near-term cultured meat products will blend cells with plant-based filler to reduce cost. Enable this to see what a product at your chosen CM inclusion rate would cost — and whether it could compete with conventional chicken. The blended cost = (pure cell cost × CM share) + (filler cost × plant share).">(?)</abbr>`, value: urlBool("include_blending", false)})
```

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

```{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 (%)"
  })
viewof filler_cost = Inputs.range([1, 10], {
    value: urlNum("filler_cost", 3), step: 0.5,
    label: "Filler cost ($/kg)"
  })
```

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

```{=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.

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>

---

**Media Accounting**

```{ojs}
//| echo: false
viewof bundled_media = Inputs.toggle({
  label: html`Bundled media pricing (TEA-comparable) <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="When ON: basal media + growth factors are replaced by a single 'complete media' $/L figure, matching the convention used in research-grade published TEAs (Humbird 2021 etc.). Default OFF = separable accounting, which is better for reasoning about future cost reduction.">(?)</abbr>`,
  value: urlBool("bundled_media", false)
})
```

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

```{ojs}
//| echo: false
html`<div style="padding: 0.5rem 0.8rem; background: #f0f4fa; border-left: 3px solid #3498db; font-size: 0.84em; margin: 0.25rem 0 0.4rem 0; line-height: 1.5;">
<strong>Bundled mode:</strong> Complete media $/L replaces basal media + GF lines. GF sliders hidden.
Range ($50–$500/L default) reflects current research-grade complete media with pharma GFs.
For 2036 projection, consider lowering the range. &nbsp;
<a href="docs.html#bundled-media-pricing" target="_blank" style="white-space:nowrap;">Details ↗</a>
</div>`
```

```{ojs}
//| echo: false
viewof bundled_media_p5 = Inputs.range([1, 200], {
  value: urlNum("bundled_media_p5", 50), step: 5,
  label: "Complete media p5 ($/L)"
})
viewof bundled_media_p95 = Inputs.range([50, 1000], {
  value: urlNum("bundled_media_p95", 500), step: 10,
  label: "Complete media p95 ($/L)"
})
```

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

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

---

**Production Model**

```{ojs}
//| echo: false
viewof cdmo_mode = Inputs.toggle({
  label: html`CDMO / Contract Manufacturing mode <abbr style="cursor:help; text-decoration:underline dotted; font-size:0.85em; color:#888;" title="A CDMO (Contract Development &amp; Manufacturing Organization) owns the bioreactors and facility and charges a per-kg toll, replacing your CAPEX and plant overhead. Your VOC costs (media, growth factors) remain direct.">(?)</abbr>`,
  value: urlBool("cdmo_mode", false)
})
```

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

```{ojs}
//| echo: false
html`<div style="padding: 0.5rem 0.8rem; background: #fef9e7; border-left: 3px solid #f39c12; font-size: 0.84em; margin: 0.25rem 0 0.5rem 0; line-height: 1.5;">
CAPEX and Plant Overhead OPEX replaced by a lognormal <em>CDMO toll fee</em>.
VOC (media, GFs, other variable) and downstream remain direct costs.
A side-by-side comparison appears in the results below. &nbsp;
<a href="docs.html#cdmo-production-model" target="_blank" style="white-space:nowrap;">Full explanation ↗</a>
</div>`
```

```{ojs}
//| echo: false
viewof cdmo_toll_p5 = Inputs.range([1, 20], {
  value: urlNum("cdmo_toll_p5", 4), step: 1,
  label: "CDMO Toll p5 ($/kg)"
})
viewof cdmo_toll_p95 = Inputs.range([10, 100], {
  value: urlNum("cdmo_toll_p95", 40), step: 2,
  label: "CDMO Toll p95 ($/kg)"
})
```

```{=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 synthetic latent variable (0 = nascent, 1 = mature) that correlates technology adoption, reactor costs, and financing. **Not calibrated to observed historical trajectories** — the slider represents your assumption about where the industry lands, not a fitted rate of change.

| Slider | Rough interpretation | Implied annual cost reduction in expensive inputs (GFs + media) |
|---|---|---|
| 0.1–0.2 | Industry largely stalls — pharma-grade inputs, expensive GFs, high financing risk | ~0–3%/yr — worse than most biotech supply chains |
| 0.3–0.4 | Slow progress — some adoption but major cost drivers remain expensive | ~4–7%/yr |
| **0.5** | **Continuation of current trajectory** — pace observed 2020–2025, extrapolated to 2036 | **~7–12%/yr** — consistent with GFI 2025 amino acid data (~10%/yr for that input) |
| 0.6–0.7 | Accelerated progress — multiple technologies reaching commercial scale | ~12–18%/yr |
| 0.8–0.9 | Optimistic convergence — most pathways succeed by 2036 | ~18–25%/yr — "solar PV level" |

*Note: "expensive inputs" = growth factors + amino acid media, which dominate variable cost. The implied rate is approximate — the slider controls correlated adoption probabilities, not a single learning rate. GFI's Dec 2025 amino acid data (Humbird's 2021 prices appear 2–10× too high vs current quotes, implying ~10–20%/yr for that component) provides the main empirical anchor.*

**To approximate "today's conditions" (minimal technical progress):** set maturity to 0.1 and target year to 2026. Note that even then the parameter ranges reflect forward projections — the model has no explicitly calibrated 2024 cost baseline.

[Full explanation, calibration caveats, and stress-test guidance →](#industry-maturity-deep-dive)
</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 effective maturity, less technology adoption. The model scales maturity by a factor of (0.5 + 0.5 × (year − 2024) / 20), so:

- 2026 → maturity scaled to ~55% of slider value
- 2036 (default) → maturity scaled to ~80% of slider value
- 2044 → maturity at full slider value

This scaling is mechanical (linear interpolation), not fit to historical data. **To approximate minimal progress from today's state:** combine year = 2026 with maturity = 0.1.
</details>

---

**Probability of Each Process Mode** <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#555;" title="Each slider sets the relative probability that a randomly drawn simulated plant operates in that mode. The three values are normalized internally so they always sum to 100% — so only their ratios matter. E.g., Fed-batch 0.2, Perfusion 0.5, Continuous 0.3 means 20% of simulated plants use fed-batch, 50% perfusion, 30% continuous.">(?)</abbr>

```{ojs}
//| echo: false
viewof p_fedbatch = Inputs.range([0, 1], {
  value: urlNum("p_fedbatch", 0.20), step: 0.05,
  label: html`P(Fed-batch) <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="Low density (5–30 g/L), moderate media use (1–2×). Nutrient-concentrated feeds added periodically. Simpler to operate but lower cell density than perfusion.">(?)</abbr>`
})
viewof p_perfusion = Inputs.range([0, 1], {
  value: urlNum("p_perfusion", 0.50), step: 0.05,
  label: html`P(Perfusion) <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="Medium-high density (30–150 g/L), higher media throughput (1–5×). Continuous media exchange with cell retention. Currently the industry standard for high-density CM production.">(?)</abbr>`
})
viewof p_continuous = Inputs.range([0, 1], {
  value: urlNum("p_continuous", 0.30), step: 0.05,
  label: html`P(Continuous) <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="Highest density (50–200 g/L), efficient media use (0.5–3×). Near-steady-state operation; cells grown and harvested continuously with optimized recycling.">(?)</abbr>`
})
```

```{ojs}
//| echo: false
{
  const tot = p_fedbatch + p_perfusion + p_continuous;
  if (tot <= 0) return html``;
  const rawPct = (tot * 100).toFixed(0);
  const fb = (p_fedbatch / tot * 100).toFixed(0);
  const pf = (p_perfusion / tot * 100).toFixed(0);
  const ct = (p_continuous / tot * 100).toFixed(0);
  const sumExact = Math.abs(tot - 1) < 0.001;
  const sumColor = sumExact ? "#27ae60" : "#e67e22";
  const sumNote = sumExact
    ? ""
    : ` — weights sum to ${rawPct}%, normalized to 100% in simulation`;
  return html`<div style="font-size:0.84em; color:#555; margin: -0.2rem 0 0.4rem 0;">
    <span style="color:${sumColor}; font-weight:600;">Sum: ${rawPct}%${sumNote}</span><br>
    Using: Fed-batch <strong>${fb}%</strong> · Perfusion <strong>${pf}%</strong> · Continuous <strong>${ct}%</strong>
  </div>`;
}
```

<details><summary>What are these modes and why does it matter? (click to expand)</summary>

These weights set the probability of each bioreactor process mode being drawn per Monte Carlo simulation. Within each mode, cell density and media-use multiplier are sampled from mode-specific ranges — preventing physically incoherent combinations.

| Mode | Density (g/L) | Media-use (×) | Typical application |
|------|--------------|--------------|---------------------|
| Fed-batch | 5–30 | 1.0–2.0 | Periodic nutrient addition; lower achievable densities |
| Perfusion | 30–150 | 1.0–5.0 | Continuous exchange with cell retention (hollow fiber / centrifuge) |
| Continuous | 50–200 | 0.5–3.0 | Near-steady-state with recycling; highest densities |

Pure batch (single fill-and-dump) is excluded — not considered commercially viable for cultured meat at scale (expert reviewer feedback, April 2026).

*Note: the default mix (20% / 50% / 30%) produces somewhat higher media usage per kg than the previous unconstrained defaults, because fed-batch and perfusion at medium density use more liquid than the prior lognormal(30–200 g/L) assumption. This is more physically realistic.*

*In full view, an "Override process mode constraints" toggle restores manual density and media-use sliders.*
</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}));
    viewof p_supp_protein.value = 0.70;
    viewof p_supp_protein.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.

**Note on \$/L vs \$/kg output:** The model uses \$/L as an internal parameter, but the economically meaningful unit is **\$/kg of cell biomass produced** — which equals (\$/L) × (L consumed per kg output). L per kg = 1000/density(g/L) × media_turnover_multiplier. At the model's default settings (~60 g/L median density, 1.5× turnover), this is ~25 L/kg: a \$0.70/L hydrolysate medium costs ~\$17.5/kg output; a \$1.25/L pharma-grade medium costs ~\$31/kg. At 200 g/L perfusion with 1.0× turnover (~5 L/kg), the same formulations cost \$3.5/kg and \$6.25/kg respectively — illustrating why cell density matters far more than formulation price at high-density processes. The "Media" bar in the cost breakdown chart already shows this \$/kg result directly.

**Note on correlation with density:** \$/L and cell density (g/L) are not fully independent — richer media is often needed to sustain higher densities, particularly in fed-batch systems where cells deplete the available nutrient pool. Combining a very low $/L with a very high density assumption in the same scenario may therefore overstate cost savings. The model partially addresses this via process-mode-specific density ranges, but does not impose explicit correlation between $/L and density within a given mode. See [Model Limits](limits.qmd#parameter-grounding) for discussion.
:::
<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.

<abbr title="What hydrolysates CAN do for growth factors: (1) Replace amino acid/peptide nutritional content that serum also provides. (2) Possibly contain trace bioactive peptides with modest stimulatory effects. (3) Reduce the amount of GFs needed by improving overall cell health and nutrition — but they cannot replace GF signaling function itself.">**Hydrolysates**</abbr> 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 basically 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 | ↗ Indirect benefit: can reduce GF requirements by improving cell health; cannot replace GF signaling |

**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>

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

::: {.callout-note appearance="minimal" icon=false}
<abbr title="Growth factors (GFs) are signaling proteins — FGF-2, IGF-1, TGF-β, etc. — that tell cells to divide. At current research-grade prices they can dominate media costs (55–95% of media $/L).">**Growth factors (GFs)**</abbr> signal cells to proliferate — at current research-grade prices, they can dominate media costs. The slider below sets P(at least one scalable production route — e.g., autocrine cell lines, plant-based farming, or precision fermentation — reaches commercial scale by the projection year), switching between "expensive" and "cheap" GF price regimes. *For background on what growth factors do, why they're expensive, and which technologies might reduce costs, see [Learn → Step 5: Growth Factors](learn.html#step-5-growth-factors--a-key-cost-driver).*
:::

```{ojs}
//| echo: false
viewof p_recfactors = Inputs.range([0.1, 0.9], {
  value: urlNum("p_recfactors", 0.5), step: 0.05,
  label: html`P(Scalable <abbr style="cursor:help;text-decoration:underline dotted;" title="Growth Factor — signaling proteins like FGF-2, IGF-1, TGF-β that tell cells to proliferate. Currently the most expensive media component.">Growth Factor (GF)</abbr> 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/) |

---

<details>
<summary><strong>What triggers the "cheap" scenario? (Key breakthroughs)</strong></summary>

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>
</details>

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

---

**Supplemental Recombinant Proteins** <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="Albumin, transferrin, and insulin — used as media additives to support cell survival and growth. Distinct from growth factors (FGF/IGF/TGF-β). GFI 2024 projects albumin alone as 96.6% of anticipated CM recombinant protein production volume. Currently priced at pharma-grade ($10-200/g); food-grade recombinant production (from yeast) could reduce this dramatically.">(?)</abbr>

```{ojs}
//| echo: false
viewof p_supp_protein = Inputs.range([0.20, 0.95], {
  value: urlNum("p_supp_protein", 0.70), step: 0.05,
  label: html`P(Albumin/transferrin/insulin affordable by ${target_year}) <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="Probability that food-grade recombinant production (from yeast, bacteria, or plant systems) brings albumin + transferrin + insulin costs to under ~$1/g by the target year, making per-kg-cell-mass contribution below ~$0.50/kg. Without breakthrough: pharma-grade sourcing could add $0.50-4/kg. Default 70%: considered more tractable than GF breakthrough because albumin at scale is closer to an engineering problem than a discovery problem.">(?)</abbr>`
})
```

<details><summary>About supplemental proteins and cost ranges (click to expand)</summary>

**Albumin, transferrin, and insulin** are media additives — structural/survival proteins distinct from growth factors. They are widely used in serum-free cell culture but rarely modeled explicitly in CM TEAs (usually bundled into "other VOC" or media cost).

Per GFI's 2024 supply-chain analysis, albumin is expected to represent ~97% of anticipated recombinant protein production volume for CM. At typical cell-culture concentrations (~1 mg/mL albumin) and the model's L/kg range, the per-kg cost contribution is:

| Sourcing | Albumin price | Contribution to cell mass cost |
|----------|--------------|-------------------------------|
| Food-grade recombinant (yeast) | $0.10–1/g | ~$0.03–0.50/kg |
| Pharma-grade recombinant | $5–50/g | ~$0.50–4/kg |
| Albumin-free formulation | $0/g | ~$0/kg |

Insulin and transferrin are used at much lower concentrations and contribute <$0.05/kg in all scenarios.

</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)"
})
```
```{ojs}
//| echo: false
{
  // Reactive diagnostic: show CAPEX share and explain why WACC might seem insensitive
  const capexMean = mean(results.cost_capex);
  const totalMean = mean(results.unit_cost);
  const capexPct = totalMean > 0 ? (capexMean / totalMean * 100).toFixed(0) : 0;
  const isOff = !include_capex && !simpleMode;
  const isCDMO = cdmo_mode;

  let msg = '';
  let color = '#27ae60';
  if (isOff) {
    msg = 'CAPEX is currently excluded — financing parameters have no effect. Re-enable in Model Structure above.';
    color = '#c0392b';
  } else if (isCDMO) {
    msg = 'CDMO mode is on — the CDMO toll replaces CAPEX/WACC. These financing sliders have no effect.';
    color = '#e67e22';
  } else if (capexPct < 15) {
    msg = `CAPEX is only ${capexPct}% of mean total cost right now — media costs dominate (often driven by fed-batch process mode). Large WACC changes will cause small visible shifts. Try reducing the fed-batch weight above to increase CAPEX sensitivity.`;
    color = '#e67e22';
  } else {
    msg = `CAPEX = ${capexPct}% of mean total cost. Changes to WACC and Asset Life will have visible effects.`;
    color = '#27ae60';
  }

  return html`<div style="font-size:0.82em; margin: 0.25rem 0 0.4rem; padding:0.4rem 0.7rem; background:#fafafa; border-left:3px solid ${color}; border-radius:0 4px 4px 0; line-height:1.5;">
    ${msg}
  </div>`;
}
```

<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. These two sliders define a **uniform distribution** over the plausible range — not a single fixed value. The simulation draws a different asset life for each of the 30,000 Monte Carlo runs. Shorter life (8 years) means higher annual capital charges; longer life (20 years) spreads costs but assumes equipment remains productive. To set a specific value, set both sliders to the same number.

These two parameters together determine the **Capital Recovery Factor** (<abbr title="CRF = r(1+r)^n / ((1+r)^n - 1), where r = WACC and n = asset life in years. A higher WACC or shorter asset life means larger annual payments to service the capital, which increases cost/kg.">CRF</abbr>).
</details>

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

---

**Cell Density / Media-Use Override** <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#555;" title="In the default model, cell density is not a free slider — it is sampled automatically from mode-specific ranges (fed-batch: 5–30 g/L; perfusion: 30–150 g/L; continuous: 50–200 g/L) based on the process mode probabilities above. This reflects the fact that density is physically determined by operating mode, not independently chosen. Enable 'Override' below to set density ranges directly — useful if you have a specific bioreactor configuration in mind.">(?)</abbr>

```{ojs}
//| echo: false
viewof override_mode_constraints = Inputs.toggle({
  label: html`Override process mode constraints <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="When ON: process-mode sampling is bypassed and you can specify density and media-use ranges directly. Useful for experts wanting to model specific bioreactor configurations.">(?)</abbr>`,
  value: urlBool("override_mode_constraints", false)
})
```

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

```{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>
```

```{=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,
    cdmo_mode: false, cdmo_toll_p5: 4, cdmo_toll_p95: 40,
    bundled_media: false, bundled_media_p5: 50, bundled_media_p95: 500,
    plant_capacity: 20, uptime: 0.90, maturity: 0.5, target_year: 2036,
    p_fedbatch: 0.20, p_perfusion: 0.50, p_continuous: 0.30,
    override_mode_constraints: false,
    p_hydro: 0.75, p_recfactors: 0.5, gf_progress: 50, p_supp_protein: 0.70,
    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,
    cdmo_mode, cdmo_toll_p5, cdmo_toll_p95,
    bundled_media, bundled_media_p5, bundled_media_p95,
    plant_capacity, uptime, maturity, target_year,
    p_fedbatch, p_perfusion, p_continuous, override_mode_constraints,
    p_hydro, p_recfactors, gf_progress, p_supp_protein,
    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 an invisible empty element instead of `null` — when an OJS cell
  // returns null/undefined, the cell renders the literal text "null" in the
  // output. This is a no-op span used purely for its side effect (URL sync).
  return html`<span style="display:none;"></span>`;
}
```

```{ojs}
//| echo: false
// Expert priors — collapsible panel for the three biggest cost drivers
viewof expert_priors_adv = {
  const inp = (name, placeholder, step) => {
    const el = document.createElement('input');
    Object.assign(el, {type:'number', name, min:0, step, placeholder});
    el.style.cssText = 'width:72px;padding:3px 5px;border:1px solid #b0c8c0;border-radius:4px;font-size:0.77rem;';
    el.addEventListener('input', () => container.dispatchEvent(new Event('input', {bubbles:true})));
    return el;
  };
  const resetBtn = (targets) => {
    const b = document.createElement('button');
    b.type='button'; b.textContent='✕'; b.title='Clear — revert to model default';
    b.style.cssText='padding:2px 6px;font-size:0.7rem;border:1px solid #ccc;border-radius:4px;background:#f9f9f9;color:#999;cursor:pointer;align-self:flex-end;';
    b.onclick = () => {
      targets.forEach(n => { const el=container.querySelector(`[name=${n}]`); if(el) el.value=''; });
      container.dispatchEvent(new Event('input', {bubbles:true}));
    };
    return b;
  };
  const row = (labelText, hint, names, placeholders, steps) => {
    const wrap = document.createElement('div');
    wrap.style.cssText='display:flex;flex-direction:column;gap:3px;';
    const head = document.createElement('div');
    head.style.cssText='display:flex;align-items:center;gap:5px;font-size:0.78rem;font-weight:600;color:#2d4a2d;';
    head.innerHTML = labelText + `<span title="${hint}" style="font-size:0.65rem;color:#aaa;cursor:help;border-bottom:1px dotted #ccc;">(?)</span>`;
    const inputs = document.createElement('div');
    inputs.style.cssText='display:flex;gap:8px;align-items:flex-end;';
    names.forEach((name, i) => {
      const col = document.createElement('div');
      col.style.cssText='display:flex;flex-direction:column;gap:1px;';
      const lbl = document.createElement('div'); lbl.style.cssText='font-size:0.62rem;color:#888;';
      lbl.textContent = i===0 ? 'p10 — optimistic' : 'p90 — pessimistic';
      col.append(lbl, inp(name, placeholders[i], steps[i]));
      inputs.appendChild(col);
    });
    inputs.appendChild(resetBtn(names));
    wrap.append(head, inputs);
    return wrap;
  };
  const container = document.createElement('div');
  const details = document.createElement('details');
  details.style.cssText='border:1.5px solid #3498db;border-radius:6px;overflow:hidden;margin:10px 0 4px;';
  const summary = document.createElement('summary');
  summary.style.cssText='padding:7px 10px;background:#f0f8ff;cursor:pointer;font-size:0.82rem;font-weight:600;color:#1a5276;list-style:none;display:flex;align-items:center;gap:6px;user-select:none;';
  summary.innerHTML = '◧ Set my own uncertainty ranges <span style="font-size:0.68rem;font-weight:400;color:#888;margin-left:auto;" title="Override the model\'s built-in uncertainty ranges for the three biggest cost drivers with your own 80% credible intervals (p10/p90) — the same format as the beliefs form (CM_13, CM_14, CM_16). Overrides apply after bundled-media mode; leave blank to use defaults.">(?)</span>';
  const body = document.createElement('div');
  body.style.cssText='padding:10px 12px;display:flex;flex-direction:column;gap:10px;';
  const intro = document.createElement('p');
  intro.style.cssText='margin:0;font-size:0.71rem;color:#555;line-height:1.45;';
  intro.innerHTML='Override built-in ranges with your <strong>80% credible interval</strong> for each key driver. Leave blank to use model defaults. <a href="docs.html#expert-priors" style="color:#3498db;" target="_blank">How this works →</a>';
  const activeNote = document.createElement('div');
  activeNote.id='ep-adv-active-note';
  activeNote.style.cssText='display:none;font-size:0.68rem;color:#c0392b;font-weight:600;padding:2px 4px;background:#fef9f9;border-radius:3px;';
  activeNote.textContent='⚠ Custom ranges active — results reflect your priors';
  body.append(
    intro, activeNote,
    row('Media cost ($/kg biomass)',
        'Total cell culture media cost per kg of harvested cell biomass. Overrides the $/L × L/kg calculation — applies in both standard and bundled-media modes. Connects to CM_14 in the beliefs form. Default model range roughly p10≈5, p90≈120.',
        ['media_p10','media_p90'], ['e.g. 5','e.g. 80'], [1,5]),
    row('Growth factor cost ($/kg biomass)',
        'Total growth factor cost per kg of biomass. Bypasses the breakthrough regime switch — express your net belief directly. Connects to CM_13. Default range varies with the GF slider.',
        ['gf_p10','gf_p90'], ['e.g. 2','e.g. 60'], [0.5,5]),
    row('Cell density (g/L at harvest)',
        'Wet-weight cell density at bioreactor harvest. Overrides mode-specific defaults; also affects bioreactor volume and CAPEX. Connects to CM_16. Default ranges: fed-batch ≈5–30, perfusion ≈30–150, continuous ≈50–200.',
        ['density_p10','density_p90'], ['e.g. 8','e.g. 60'], [1,5])
  );
  details.append(summary, body);
  container.appendChild(details);
  container.addEventListener('input', () => {
    const anyActive = ['media_p10','gf_p10','density_p10'].some(n => container.querySelector(`[name=${n}]`)?.value !== '');
    activeNote.style.display = anyActive ? 'block' : 'none';
  });
  function getVal(name) {
    const v = parseFloat(container.querySelector(`[name=${name}]`)?.value);
    return isNaN(v) || v <= 0 ? null : v;
  }
  Object.defineProperty(container, 'value', {
    get: () => ({
      media_p10: getVal('media_p10'), media_p90: getVal('media_p90'),
      gf_p10: getVal('gf_p10'), gf_p90: getVal('gf_p90'),
      density_p10: getVal('density_p10'), density_p90: getVal('density_p90')
    })
  });
  return container;
}
```

:::

::: {.panel-fill}

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

// Shared parameter object — consumed by both the primary run and the comparison run
simParams = {
  const yearFactor = Math.max(0, Math.min(1, (target_year - 2024) / 20));
  const adjustedMaturity = maturity * (0.5 + 0.5 * yearFactor);
  return {
    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,
    p_supp_protein_mean: simpleMode ? 0.70 : p_supp_protein,
    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,
    cdmo_mode: cdmo_mode,
    cdmo_toll_p5: cdmo_toll_p5,
    cdmo_toll_p95: cdmo_toll_p95,
    // Process mode (always active; override only available in full view)
    p_fedbatch: p_fedbatch,
    p_perfusion: p_perfusion,
    p_continuous: p_continuous,
    override_mode_constraints: simpleMode ? false : override_mode_constraints,
    // Bundled media (full view only)
    bundled_media: simpleMode ? false : bundled_media,
    bundled_media_p5: bundled_media_p5,
    bundled_media_p95: bundled_media_p95,
    // Expert prior overrides — null means use model defaults
    ep_media_p10: expert_priors_adv.media_p10,
    ep_media_p90: expert_priors_adv.media_p90,
    ep_gf_p10: expert_priors_adv.gf_p10,
    ep_gf_p90: expert_priors_adv.gf_p90,
    ep_density_p10: expert_priors_adv.density_p10,
    ep_density_p90: expert_priors_adv.density_p90
  };
}
```

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

// Primary simulation run (uses whatever mode is selected)
// N=30,000: adequate precision for this model's decision-relevant outputs
// (SE on median < 0.3%, SE on P(cost<$10/kg) < 0.2pp at p≈0.1).
// Higher N (e.g. 200k) would reduce tail noise marginally but re-runs on every
// slider drag — 30k keeps reactive updates imperceptible (<100ms in most browsers).
results = simulate(30000, 42, simParams)

// Comparison run: when CDMO mode is on, also run the in-house baseline
// (same seed so VOC components are paired; only capital structure differs)
// Returns an invisible span (not null) when CDMO is off — returning null
// causes OJS to render the literal text "null" in the document.
inhouse_results = {
  if (!cdmo_mode) return html`<span style="display:none;"></span>`;
  return simulate(30000, 42, {...simParams, cdmo_mode: false});
}

// Calculate statistics (pure cell and blended product)
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));
  const pct = (arr, threshold) => arr.filter(x => x < threshold).length / arr.length * 100;
  return {
    n: uc.length,
    p5: quantile(uc, 0.05),
    p50: quantile(uc, 0.50),
    p95: quantile(uc, 0.95),
    // Pure cell thresholds
    prob_10:  pct(uc, 10),
    prob_25:  pct(uc, 25),
    prob_50:  pct(uc, 50),
    prob_100: pct(uc, 100),
    // Blended product thresholds (same $/kg price points, but for the blended product)
    bprob_10:  pct(blended, 10),
    bprob_25:  pct(blended, 25),
    bprob_50:  pct(blended, 50),
    bprob_100: pct(blended, 100),
    // Consumer-relevant blended thresholds ($5, $8, $12/kg)
    bprob_5:  pct(blended, 5),
    bprob_8:  pct(blended, 8),
    bprob_12: pct(blended, 12),
    // Summary stats
    blended_p50: quantile(blended, 0.50),
    blended_p5:  quantile(blended, 0.05),
    blended_p95: quantile(blended, 0.95),
    bs, fc
  };
}
```

### 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 manufacturing cost per kg of cultured chicken cell biomass (<span title="Wet weight, at the CM gate: the mass of cells as harvested from the bioreactor. Water content at harvest typically ~75–90% depending on cell line, density, and post-harvest dewatering; this model uses 80% as a reference assumption. Note: published TEAs (Humbird 2021, Pasitka 2024) rarely state their assumed hydration explicitly, which means cost comparisons across papers may embed subtle inconsistencies — a 10% hydration difference substantially affects $/kg wet weight. Includes: media, growth factors, bioreactor capital (annualised), plant overhead, utilities. Excludes: texturization, scaffolding, blending with plant-based fillers, packaging, distribution. This is the same accounting object as 'edible kg before mixture' used on the beliefs form — wet cell mass at harvest IS the edible component at this stage. For comparison: Humbird reports $37/kg; Pasitka $13.75/kg (large perfusion). The widely-cited ~$6/lb Pasitka figure is for a 50/50 hybrid product. See TEA Comparison for details." style="text-decoration: underline dotted; cursor: help;">wet weight, at harvest &#9432;</span>) in ${target_year}, based on ${stats.n.toLocaleString()} Monte Carlo simulations. <em style="font-size:0.88em;color:#888;">Wet-weight hydration assumed ~80% (range ~75–90% in practice; see note below on hydration assumptions).</em> This is the factory-gate manufacturing cost — not a consumer product price or retail cost. <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." style="text-decoration: underline dotted; cursor: help;">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 cell biomass (wet weight, at harvest) — 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_supp_protein", "cost_other_var", "cost_capex", "cost_fixed", "cost_cdmo_toll", "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,
        cdmo_mode, cdmo_toll_p5, cdmo_toll_p95,
        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),
        supplemental_proteins: +mean(results.cost_supp_protein).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),
        cdmo_toll: +mean(results.cost_cdmo_toll).toFixed(2),
        downstream: +mean(results.cost_downstream).toFixed(2)
      },
      notes: {
        units: "USD per kg of cultured chicken cell biomass, wet weight at harvest",
        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);
  const p20 = quantile(uc, 0.20);
  const p80 = quantile(uc, 0.80);

  // Helper: build the distribution chart at a given pixel width/height
  function makeDistChart(w, h, fontSize) {
    const yLabel1 = Math.round(h * 6.0);
    const yLabel2 = Math.round(h * 7.5);
    return Plot.plot({
      width: w,
      height: h,
      marginLeft: 60,
      marginBottom: 50,
      x: { label: "Cell Biomass Manufacturing Cost ($/kg, wet weight)", domain: [0, clipVal * 1.05] },
      y: { label: "Frequency", grid: true },
      style: { fontSize: fontSize || 13 },
      marks: [
        Plot.rectY(clipped, Plot.binX({y: "count"}, {x: d => d, fill: "steelblue", fillOpacity: 0.7})),
        // p5 / p50 / p95 coloured lines
        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"}),
        // p20 / p80 grey dashed
        Plot.ruleX([p20], {stroke: "#888", strokeWidth: 1.5, strokeDasharray: "4,4", strokeOpacity: 0.85}),
        Plot.ruleX([p80], {stroke: "#888", strokeWidth: 1.5, strokeDasharray: "4,4", strokeOpacity: 0.85}),
        // Reference cost thresholds
        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}),
        // Labels
        Plot.text([
          {x: stats.p5  + 1.5, y: yLabel1, text: `p5: $${stats.p5.toFixed(1)}`},
          {x: stats.p50 + 1.5, y: yLabel2, text: `p50: $${stats.p50.toFixed(1)}`},
          {x: stats.p95 + 1.5, y: yLabel1, text: `p95: $${stats.p95.toFixed(1)}`},
          {x: p20 + 1.5, y: Math.round(h * 4.5), text: `p20: $${p20.toFixed(1)}`, fill: "#666"},
          {x: p80 + 1.5, y: Math.round(h * 4.5), text: `p80: $${p80.toFixed(1)}`, fill: "#666"}
        ], {x: "x", y: "y", text: "text", fontSize: (fontSize || 12) - 1, fill: d => d.fill || "black"})
      ],
      title: `Projected ${target_year} Cultured Chicken Production Cost Distribution`
    });
  }

  const wrapper = document.createElement("div");
  wrapper.style.cssText = "position:relative; display:inline-block; width:100%;";

  // Fullscreen expand button
  const fsBtn = document.createElement("button");
  fsBtn.textContent = "⛶";
  fsBtn.title = "Expand to full screen (press Escape to close)";
  fsBtn.style.cssText = "position:absolute; top:6px; right:6px; z-index:10; padding:3px 7px; font-size:15px; cursor:pointer; border:1px solid #ccc; border-radius:4px; background:rgba(255,255,255,0.9); line-height:1;";

  // Fullscreen overlay
  const overlay = document.createElement("div");
  overlay.id = "dist-chart-overlay";
  overlay.style.cssText = "display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:white; z-index:9500; padding:2rem; box-sizing:border-box; overflow:auto;";
  const closeBtn = document.createElement("button");
  closeBtn.textContent = "✕ Close";
  closeBtn.style.cssText = "position:fixed; top:16px; right:20px; padding:6px 14px; font-size:14px; cursor:pointer; border:1px solid #ccc; border-radius:6px; background:#f8f9fa; z-index:9501;";
  closeBtn.onclick = () => { overlay.style.display = "none"; };
  overlay.appendChild(closeBtn);

  // Keyboard close
  document.addEventListener("keydown", e => {
    if (e.key === "Escape" && overlay.style.display !== "none") overlay.style.display = "none";
  });

  fsBtn.onclick = () => {
    overlay.style.display = "block";
    // Build large chart at near-full viewport size
    const w = Math.min(window.innerWidth - 80, 1600);
    const h = Math.min(window.innerHeight - 120, 900);
    // Clear old chart
    while (overlay.children.length > 2) overlay.removeChild(overlay.lastChild);
    overlay.appendChild(makeDistChart(w, h, 14));
  };

  document.body.appendChild(overlay);
  wrapper.appendChild(makeDistChart(800, 400, 12));
  wrapper.appendChild(fsBtn);
  return wrapper;
}
```

<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>

<a id="in-house-vs-cdmo-comparison"></a>

```{ojs}
//| echo: false
{
  if (!cdmo_mode || !inhouse_results) return html``;

  const uc_cdmo = results.unit_cost;
  const uc_inhouse = inhouse_results.unit_cost;

  const clipVal = Math.min(quantile(uc_inhouse, 0.97), 250);

  const cdmo_p5  = quantile(uc_cdmo, 0.05);
  const cdmo_p50 = quantile(uc_cdmo, 0.50);
  const cdmo_p95 = quantile(uc_cdmo, 0.95);
  const ih_p5    = quantile(uc_inhouse, 0.05);
  const ih_p50   = quantile(uc_inhouse, 0.50);
  const ih_p95   = quantile(uc_inhouse, 0.95);

  const diff_p50 = cdmo_p50 - ih_p50;
  const diffSign = diff_p50 >= 0 ? "+" : "−";
  const diffAbs  = Math.abs(diff_p50).toFixed(1);

  // Build plot data: subsample to 10k each for performance
  const step = Math.floor(30000 / 10000);
  const plotData = [];
  for (let i = 0; i < 30000; i += step) {
    if (uc_cdmo[i] <= clipVal)    plotData.push({x: uc_cdmo[i],    mode: "CDMO"});
    if (uc_inhouse[i] <= clipVal) plotData.push({x: uc_inhouse[i], mode: "In-House"});
  }

  const compPlot = Plot.plot({
    width: 820,
    height: 340,
    marginLeft: 60,
    marginBottom: 50,
    x: {label: "Manufacturing Cost ($/kg, wet weight at harvest)", domain: [0, clipVal * 1.02]},
    y: {label: "Frequency", grid: true},
    color: {
      domain: ["In-House", "CDMO"],
      range:  ["#3498db", "#e67e22"],
      legend: true
    },
    marks: [
      Plot.rectY(plotData.filter(d => d.mode === "In-House"),
        Plot.binX({y: "count"}, {x: "x", fill: "#3498db", fillOpacity: 0.55})),
      Plot.rectY(plotData.filter(d => d.mode === "CDMO"),
        Plot.binX({y: "count"}, {x: "x", fill: "#e67e22", fillOpacity: 0.55})),
      Plot.ruleX([ih_p50],   {stroke: "#3498db", strokeWidth: 2.5}),
      Plot.ruleX([cdmo_p50], {stroke: "#e67e22", strokeWidth: 2.5}),
      Plot.ruleX([10], {stroke: "darkgreen", strokeWidth: 1.5, strokeDasharray: "3,3", strokeOpacity: 0.6}),
      Plot.ruleX([25], {stroke: "orange",    strokeWidth: 1.5, strokeDasharray: "3,3", strokeOpacity: 0.6}),
      Plot.text([
        {x: ih_p50   + 1.5, y: 900, text: `In-House p50: $${ih_p50.toFixed(0)}`},
        {x: cdmo_p50 + 1.5, y: 750, text: `CDMO p50: $${cdmo_p50.toFixed(0)}`}
      ], {x: "x", y: "y", text: "text", fontSize: 12})
    ],
    title: `In-House vs. CDMO: Projected ${target_year} Cost`
  });

  const rows = [
    {label: "p5 (optimistic)",   ih: ih_p5,  cdmo: cdmo_p5},
    {label: "p50 (median)",      ih: ih_p50, cdmo: cdmo_p50},
    {label: "p95 (pessimistic)", ih: ih_p95, cdmo: cdmo_p95}
  ];

  const tbody = document.createElement("tbody");
  rows.forEach(r => {
    const d = r.cdmo - r.ih;
    const dStr = (d >= 0 ? "+" : "−") + "$" + Math.abs(d).toFixed(0);
    const dColor = d >= 0 ? "#c0392b" : "#27ae60";
    const tr = document.createElement("tr");
    tr.innerHTML = `<td style="padding:5px 8px;">${r.label}</td>` +
      `<td style="padding:5px 8px; text-align:right; color:#3498db;"><strong>$${r.ih.toFixed(0)}</strong></td>` +
      `<td style="padding:5px 8px; text-align:right; color:#e67e22;"><strong>$${r.cdmo.toFixed(0)}</strong></td>` +
      `<td style="padding:5px 8px; text-align:right; color:${dColor};"><strong>${dStr}</strong></td>`;
    tbody.appendChild(tr);
  });

  return html`<div style="margin: 1.5rem 0;">
    <h3 style="margin-bottom:0.3rem;">In-House vs. CDMO Comparison</h3>
    <p style="font-size:0.88em; color:#555; margin-top:0;">Overlapping distributions for the same VOC parameters. Difference = CDMO − In-House; negative means CDMO is cheaper.</p>
    ${compPlot}
    <table style="margin-top:1rem; font-size:0.9em; border-collapse:collapse; min-width:360px;">
      <thead><tr style="border-bottom:2px solid #ddd;">
        <th style="padding:5px 8px; text-align:left;"></th>
        <th style="padding:5px 8px; text-align:right; color:#3498db;">In-House</th>
        <th style="padding:5px 8px; text-align:right; color:#e67e22;">CDMO</th>
        <th style="padding:5px 8px; text-align:right;">Difference</th>
      </tr></thead>
      ${tbody}
    </table>
    <p style="font-size:0.82em; color:#777; margin-top:0.6rem;">
      Both runs use seed 42 and identical VOC parameters. The difference isolates the capital cost structure: in-house (sampled CAPEX + fixed OPEX) vs. CDMO toll (lognormal p5=$${cdmo_toll_p5}, p95=$${cdmo_toll_p95}/kg).
    </p>
  </div>`;
}
```

### 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.

```{ojs}
//| echo: false
{
  // Helper: build one card, optionally with a blended product secondary row
  function card(threshold, pureProb, label, color, blendProb, bs, fc) {
    const borderColor = pureProb > 30 ? color : '#ddd';
    const blendLine = include_blending && blendProb !== undefined
      ? `<div style="margin-top:4px; font-size:0.82em; color:#1a5276; background:#f0f8ff; border-radius:4px; padding:3px 6px;">
           <strong>${blendProb.toFixed(1)}%</strong> chance blended product (${Math.round(bs*100)}% CM, $${fc}/kg filler) &lt; $${threshold}/kg
         </div>`
      : '';
    return `<div class="card" style="border: 2px solid ${borderColor}; padding: 1rem; border-radius: 8px; text-align: center;">
      <h5 style="margin-bottom:0.3rem;">P(Pure cells &lt; $${threshold}/kg)</h5>
      <h2 style="color: ${color}; margin:0.2rem 0;">${pureProb.toFixed(1)}%</h2>
      <small style="color:#555;">${label}</small>
      ${blendLine}
    </div>`;
  }

  const bs = stats.bs, fc = stats.fc;
  const grid = `<div class="grid" style="grid-template-columns: repeat(4, 1fr); gap: 1rem; margin: 1.5rem 0;">
    ${card(10,  stats.prob_10,  'could approach conventional chicken pricing (~$5-10/kg retail)',  '#27ae60', stats.bprob_10,  bs, fc)}
    ${card(25,  stats.prob_25,  'range where premium cultured products may be viable',              '#3498db', stats.bprob_25,  bs, fc)}
    ${card(50,  stats.prob_50,  'potential niche/specialty market segment',                         '#f39c12', stats.bprob_50,  bs, fc)}
    ${card(100, stats.prob_100, 'substantially cheaper than current lab-scale costs',               '#e74c3c', stats.bprob_100, bs, fc)}
  </div>`;

  // When blending is on, also show consumer-relevant blended thresholds
  const blendRow = include_blending ? `
    <p style="font-size:0.88em; color:#1a5276; margin:0.5rem 0 0.3rem; font-weight:500;">
      Blended product (${Math.round(bs*100)}% CM + ${Math.round((1-bs)*100)}% filler at $${fc}/kg) — consumer-relevant price points:
    </p>
    <div class="grid" style="grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1.5rem;">
      <div class="card" style="border: 2px solid ${stats.bprob_5  > 20 ? '#27ae60' : '#ddd'}; padding: 0.8rem; border-radius: 8px; text-align: center;">
        <h5 style="font-size:0.85em;">P(Blend &lt; $5/kg)</h5>
        <h2 style="color:#27ae60; margin:0.2rem 0;">${stats.bprob_5.toFixed(1)}%</h2>
        <small>competitive with conventional chicken</small>
      </div>
      <div class="card" style="border: 2px solid ${stats.bprob_8  > 30 ? '#3498db' : '#ddd'}; padding: 0.8rem; border-radius: 8px; text-align: center;">
        <h5 style="font-size:0.85em;">P(Blend &lt; $8/kg)</h5>
        <h2 style="color:#3498db; margin:0.2rem 0;">${stats.bprob_8.toFixed(1)}%</h2>
        <small>competitive with premium chicken/beef</small>
      </div>
      <div class="card" style="border: 2px solid ${stats.bprob_12 > 50 ? '#f39c12' : '#ddd'}; padding: 0.8rem; border-radius: 8px; text-align: center;">
        <h5 style="font-size:0.85em;">P(Blend &lt; $12/kg)</h5>
        <h2 style="color:#f39c12; margin:0.2rem 0;">${stats.bprob_12.toFixed(1)}%</h2>
        <small>affordable specialty market</small>
      </div>
    </div>
    <p style="font-size:0.82em; color:#777; margin-bottom:1rem; font-style:italic;">
      Key insight: even if pure cell costs remain high ($50-100/kg), a 20-30% CM blended product can still approach conventional chicken pricing — the blend ratio and filler cost ($${fc}/kg) matter as much as cell costs alone.
    </p>` : '';

  return html([grid + blendRow]);
}
```

### Cost Breakdown

**Where does the cost come from?** This chart shows the **mean** contribution of each cost component across all 30,000 simulations (mean, not median — skewed components like growth factors will appear larger here than at the median). 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 mediaLabel = bundled_media ? "Complete Media (incl. GFs)" : "Media (incl. basal micros)";
  const allComponents = [
    {name: mediaLabel,               value: mean(results.cost_media),          color: "#27ae60"},
    {name: "Growth Factors",         value: mean(results.cost_recf),           color: "#9b59b6"},
    {name: "Supplemental Proteins",  value: mean(results.cost_supp_protein),   color: "#e67e22"},
    {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: "CDMO Toll",              value: mean(results.cost_cdmo_toll),     color: "#e67e22"},
    {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;

  // Chart wrapper: clear-and-append avoids replaceWith()+querySelector on SVG
  // which fails silently in Safari (classList.add on SVG unreliable in WebKit).
  const chartWrapper = document.createElement("div");

  expandBtn.onclick = () => {
    expanded = !expanded;
    expandBtn.textContent = expanded ? "Collapse Chart" : "Expand Chart";
    chartWrapper.innerHTML = "";
    chartWrapper.appendChild(makeChart(expanded));
  };
  chartContainer.appendChild(expandBtn);

  function makeChart(large) {
    const w = large ? 1200 : 1000;
    const h = large ? 700 : 580;
    const fontSize = large ? 14 : 13;
    return 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)`
    });
  }

  chartWrapper.appendChild(makeChart(false));
  chartContainer.appendChild(chartWrapper);
  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

<details>
<summary>Show per-component distributions (click to expand)</summary>

Individual distributions for each cost driver, updating dynamically as you adjust parameters.

```{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: bundled_media ? "Complete Media (incl. GFs)" : "Media (incl. basal micros)", data: results.cost_media, color: "#27ae60"},
    {name: "Growth Factors", data: results.cost_recf, color: "#9b59b6"},
    {name: "Supplemental Proteins (albumin/transferrin/insulin)", data: results.cost_supp_protein, color: "#e67e22"},
    {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: "CDMO Toll", data: results.cost_cdmo_toll, color: "#e67e22"},
    {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.
</details>

</details>

<details>
<summary style="cursor:pointer; color: #666; font-size: 0.9em; margin: 0.5rem 0 1.5rem;">Realized adoption rates (click to expand)</summary>

*Actual shares across the 30,000 draws. Near-identical to slider inputs at default maturity (0.5); deviates when maturity ≠ 0.5, because the maturity distribution shifts each draw's adoption probability before sampling. Process mode shares always equal slider inputs exactly and are omitted here.*

```{ojs}
//| echo: false
html`<div style="display:flex; gap:2rem; flex-wrap:wrap; font-size:0.95em; padding: 0.5rem 0;">
  <span>Hydrolysates: <strong style="color:#27ae60;">${(results.pct_hydro * 100).toFixed(0)}%</strong> of draws</span>
  <span>Cheap GFs: <strong style="color:#9b59b6;">${(results.pct_recf_cheap * 100).toFixed(0)}%</strong> of draws</span>
</div>`
```

</details>

### Sensitivity Analysis (Tornado Chart)

Which parameters have the most impact on the final cost? Each bar shows the **dollar swing** in mean unit cost between simulations where the parameter is in its top 10% versus its bottom 10%. Larger bars = bigger levers on cost.

```{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: "WACC (financing)",                   data: results.wacc_samples,           kind: "primitive"},
    {name: "Asset Life (years)",                 data: results.asset_life_samples,     kind: "primitive"},
    {name: "Plant Capacity (kTA)",               data: results.plant_kta_samples,      kind: "primitive"},
    {name: "Utilization Rate",                   data: results.uptime_samples,         kind: "primitive"}
  ];

  // Compute signed swings: positive = high param → higher cost (red); negative = high param → lower cost (green)
  const swingsRaw = params.map(p => ({
    name: p.name,
    kind: p.kind,
    rawSwing: conditionalSwing(p.data, uc, 0.10)
  }));
  const swings = swingsRaw.map(s => ({
    name: s.name,
    kind: s.kind,
    swing: Math.abs(s.rawSwing),
    // Red: high value raises cost; Green: high value lowers cost
    fill: s.rawSwing >= 0 ? "#c0392b" : "#27ae60"
  }));

  const sorted = [...swings].sort((a, b) => b.swing - a.swing);
  const maxAbs = Math.max(...sorted.map(s => s.swing), 1);
  const pad = maxAbs * 0.25;

  const tornadoPlot = Plot.plot({
    width: 900,
    height: 440,
    marginLeft: 290,
    marginRight: 120,
    x: {
      label: "Dollar swing in mean unit cost ($/kg) — larger bar = bigger lever on cost",
      domain: [0, maxAbs + pad],
      grid: true,
      labelOffset: 40,
      tickFormat: d => "$" + d.toFixed(0)
    },
    y: { label: null, tickFormat: d => d, tickSize: 0 },
    style: { fontSize: "13px" },
    marks: [
      Plot.barX(sorted, {
        y: "name",
        x: "swing",
        fill: d => d.fill,
        fillOpacity: 0.80,
        sort: {y: "-x"}
      }),
      Plot.text(sorted, {
        y: "name",
        x: d => d.swing + maxAbs * 0.02,
        text: d => "$" + d.swing.toFixed(1) + "/kg",
        textAnchor: "start",
        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;">Which parameters matter most? (dollar impact on mean cost)</div>
    ${tornadoPlot}
  </div>`;
}
```

*Red bars: when this parameter is high, cost is higher (a cost driver). Green bars: when high, cost is lower (a cost reducer). Bar length = conditional-mean dollar swing between top 10% vs. bottom 10% of that parameter's distribution across the 30,000 Monte Carlo draws. This is NOT a variance decomposition — bars for correlated parameters can overlap and should not be summed. Industry Maturity is a latent variable whose bar captures the bundled effect of correlated improvements.* [Full methodology →](docs.html#sensitivity-analysis-dollar-swing-metric){target="_blank"}

### 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 (hydrolysates, scalable GFs, affordable supplemental proteins)
- Lower reactor costs (more custom/food-grade equipment)
- Lower financing costs (WACC)

::: {.callout-warning appearance="minimal"}
**Not calibrated to observed historical trajectories.** The maturity slider is a forward-looking judgment parameter — it does not incorporate data on how fast CM costs have actually fallen since 2015. The model's parameter ranges (e.g., media $/L, GF $/g) were set from TEA literature snapshots (Humbird 2021, Pasitka 2024, GFI 2025 amino acid report) — point estimates, not a fitted time series. The year → maturity scaling formula (linear from 2024 to 2044) is mechanical convenience, not a regression on industry data.

**What this means:** "maturity = 0.5" does not mean "the historical rate of progress continues." It means "the model's default assumption is that by 2036 the industry lands roughly in the middle of the plausible range." Whether that default is consistent with observed progress since 2020 is a substantive question this model cannot answer on its own — and a good one to raise with experts at the workshop.

**Correlation structure caveat (important for interpretation):** The model uses a single shared latent factor to induce correlation across technology adoption, reactor costs, and financing. This is an *ad hoc modelling convenience* — not an empirically calibrated copula. The specific sensitivity coefficients (e.g., ±0.25 shifts to adoption probabilities, ±0.03 shifts to WACC) and the choice of a single dimension are choices made for tractability, not derived from data.

**What this means for interpretation:**
- Scenarios where all dimensions co-move together (high maturity = everything goes well; low maturity = everything is hard) will be overrepresented relative to a fully independent model
- Scenarios where, say, bioprocess technology matures rapidly but financing remains difficult are partly suppressed
- **Stress test:** Set the maturity slider to 0.1 (pessimistic) and 0.9 (optimistic) and compare — the difference captures the full correlated scenario range. Then check whether P(cost < $25/kg) changes materially
- The alternative — modeling technology adoption, reactor costs, and financing as fully *independent* — can be approximated by setting maturity to exactly 0.5 and reading the adoption probabilities as unconditional (no maturity adjustment)

We plan to offer explicit alternative correlation modes (independent / coupled) in a future update.
:::

```{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.
:::

<details>
<summary>Sources that directly informed model structure and parameter ranges (click to expand)</summary>

| 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 |

</details>

<details>
<summary>Sources cited for context but not directly integrated into parameter values (click to expand)</summary>

| 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 |

</details>

<details>
<summary>Parameters where source grounding is weakest — review priority (click to expand)</summary>

- 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.
</details>
 

Built by The Unjournal | Source Code