Cultured Chicken Cost Model
  • Simplest Model
  • Advanced Model
  • Learn
  • TEA Comparison
  • Model formulas & metrics
  • Discuss
  • Limits/Critique
  • About
  • an Unjournal project ↗

Simplest Model

  • Show All Code
  • Hide All Code

  • View Source
Preliminary model — for exploration only

This model is largely AI-generated and has not been fully validated. It is provided to fix ideas, illustrate the modeling approach, and enable exploration — not as authoritative cost estimates. For a more detailed exploration with many more parameters, use the Advanced Model.

How to use this page

This Simplest Model focuses on a few key levers on cultured chicken production cost. Each parameter has an inline explanation — no further reading required to understand what you’re adjusting.

Once you’ve explored here, click → Advanced Model at the bottom of the sidebar to carry your settings over to a fuller parameter set.

Parameters exposed here: Projection Year, P(Growth Factor Breakthrough), P(Hydrolysates adopted), Process Mode Mix, Blended Product

Everything else (WACC, plant size, cell density, media-use multiplier, asset life, downstream costs) is held at reasonable defaults — see the “Background parameters” section in the sidebar.

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
// ============================================================
function boxMuller(rng) {
  const u1 = rng(); const u2 = rng();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}
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;  // qnorm(0.90)
  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;
}
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;
}
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;
  return [Math.max(mean * t, 0.01), Math.max((1 - mean) * t, 0.01)];
}
function sampleBeta(rng, a, b) {
  const gammaA = sampleGamma(rng, a); const gammaB = sampleGamma(rng, b);
  return gammaA / (gammaA + gammaB);
}
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;
  }
}
function sampleBetaMeanStdev(rng, mean, stdev, n) {
  // Guard: Beta is undefined at the boundaries — return degenerate arrays
  // Without this, betaFromMeanStdev(0 or 1, ...) produces NaN parameters,
  // sampleBeta returns NaN, and rng() < NaN is always false, flipping
  // boolean outcomes to the wrong regime (e.g. p_recf=100% → all expensive).
  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;
}
function crf(wacc, nYears) {
  return wacc * Math.pow(1 + wacc, nYears) / (Math.pow(1 + wacc, nYears) - 1);
}
function clip(arr, min, max) { return arr.map(v => Math.min(Math.max(v, min), max)); }
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 (identical to Advanced Model)
// ============================================================
function simulate(n, seed, params) {
  const rng = mulberry32(seed);
  // Year-based maturity: 0.30 at 2026, 0.50 at 2036, 0.70 at 2050
  // Later years -> higher industry maturity -> lower CAPEX (via WACC and custom-reactor share)
  // and higher effective tech-adoption probabilities.
  const effectiveMaturityMean = params.maturity_mean
    + (params.target_year ? (params.target_year - 2036) / 14 * 0.20 : 0);
  const clampedMaturityMean = Math.min(0.85, Math.max(0.15, effectiveMaturityMean));
  const maturity = sampleBetaMeanStdev(rng, clampedMaturityMean, 0.20, n);
  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);
  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);
  const is_hydro = p_hydro.map(p => rng() < p);
  const is_recf_cheap = p_recf.map(p => rng() < p);
  const cycle_days = sampleLognormalP5P95(rng, 0.5, 5.0, n);
  let density_gL, media_turnover, mode_labels;
  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);
  const d_pf = sampleLognormalP5P95(rng, 30, 150, n);
  const d_ct = sampleLognormalP5P95(rng, 50, 200, n);
  const t_fb = sampleLognormalP5P95(rng, 1.0, 2.0, n);
  const t_pf = sampleLognormalP5P95(rng, 1.0, 5.0, n);
  const t_ct = sampleLognormalP5P95(rng, 0.5, 3.0, n);
  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]);
  // Cell density override — must come before L_per_kg so CAPEX calculation also sees the new density
  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);
  const media_cost_hydro = sampleLognormalP5P95(rng, 0.2, 1.2, n);
  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]);
  // Media cost override: if user specified their own $/kg biomass p10/p90, use that directly
  const cost_media = (params.ep_media_p10 && params.ep_media_p90 && params.ep_media_p10 < params.ep_media_p90)
    ? sampleLognormalP10P90(rng, params.ep_media_p10, params.ep_media_p90, n)
    : mul(L_per_kg, media_cost_L);
  const g_recf_exp = sampleLognormalP5P95(rng, 1e-3, 6e-3, n);
  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]);
  const progress = params.gf_progress / 100;
  const cheap_p5 = 100 * Math.pow(0.01, progress);
  const cheap_p95 = 10000 * Math.pow(0.01, progress);
  const price_recf_cheap = sampleLognormalP5P95(rng, cheap_p5, cheap_p95, n);
  const exp_p5 = 5000 * Math.pow(0.01, progress);
  const exp_p95 = 500000 * Math.pow(0.01, progress);
  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]);
  // GF cost override: if user specified their own $/kg biomass p10/p90, bypass the regime model
  const cost_recf = (params.ep_gf_p10 && params.ep_gf_p90 && params.ep_gf_p10 < params.ep_gf_p90)
    ? sampleLognormalP10P90(rng, params.ep_gf_p10, params.ep_gf_p90, n)
    : mul(g_recf, price_recf);
  const other_var = sampleLognormalP5P95(rng, 0.5, 5.0, n);
  const voc = add(add(cost_media, cost_recf), other_var);
  let cdmo_toll_perkg = new Array(n).fill(0);
  let capex_perkg = new Array(n).fill(0);
  if (params.include_capex) {
    const prod_kg_L_day = div(scale(density_gL, 1/1000), cycle_days);
    const total_working_volume_L = div(output_kgpy, scale(prod_kg_L_day, 365));
    const reactor_cost_L_pharma = sampleLognormalP5P95(rng, 50, 500, n);
    const custom_ratio = sampleUniform(rng, 0.35, 0.85, n);
    let custom_share = sampleBetaMeanStdev(rng, 0.55, 0.15, n);
    custom_share = clip(add(custom_share, scale(maturity.map(m => m - 0.5), 0.30)), 0, 1);
    const reactor_cost_L_avg = reactor_cost_L_pharma.map((p, i) =>
      p * (custom_share[i] * custom_ratio[i] + (1 - custom_share[i])));
    const capex_s = sampleUniform(rng, 0.6, 0.9, n);
    const plant_factor = sampleLognormalP5P95(rng, 1.5, 3.5, n);
    const V_ref = 1e6;
    const capex_total = reactor_cost_L_avg.map((r, i) =>
      r * total_working_volume_L[i] * Math.pow(total_working_volume_L[i] / V_ref, capex_s[i] - 1) * plant_factor[i]);
    let wacc = sampleLognormalP5P95(rng, params.wacc_p5, params.wacc_p95, n);
    wacc = clip(wacc.map((w, i) => w - 0.03 * (maturity[i] - 0.5)), 0.03, 1);
    const asset_life = sampleUniform(rng, params.asset_life_lo, params.asset_life_hi, n);
    const crf_val = wacc.map((w, i) => crf(w, asset_life[i]));
    capex_perkg = capex_total.map((c, i) => (c * crf_val[i]) / output_kgpy[i]);
  }
  let fixed_perkg = new Array(n).fill(0);
  if (params.include_fixed_opex) {
    const ref_output = 20e6 * 0.9;
    const fixed_perkg_ref = sampleUniform(rng, 1.0, 6.0, n);
    const fixed_annual_ref = scale(fixed_perkg_ref, ref_output);
    const fixed_scale = sampleUniform(rng, 0.6, 1.0, n);
    const fixed_annual = fixed_annual_ref.map((f, i) =>
      f * Math.pow(output_kgpy[i] / ref_output, fixed_scale[i]));
    fixed_perkg = div(fixed_annual, output_kgpy);
  }
  const unit_cost = add(add(add(voc, capex_perkg), fixed_perkg), cdmo_toll_perkg);
  return {
    unit_cost,
    pct_hydro: is_hydro.filter(x => x).length / n,
    pct_recf_cheap: is_recf_cheap.filter(x => x).length / n,
    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,
  };
}

// ============================================================
// STATISTICS 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);
  return sorted[lo] * (1 - (idx - lo)) + sorted[hi] * (idx - lo);
}
function mean(arr) { return arr.reduce((a, b) => a + b, 0) / arr.length; }
Code
urlParams_s = window.__CM_URL_STATE__ || {}
urlNum_s = function(key, def) {
  const v = urlParams_s[key];
  if (v === undefined) return def;
  const n = Number(v); return Number.isFinite(n) ? n : def;
}
urlBool_s = function(key, def) {
  const v = urlParams_s[key];
  if (v === undefined) return def;
  return v === "1" || v === "true";
}
Code
// Reactive CSS for blending-only visibility
html`<style>
  .blending-only-s { display: ${include_blending_s ? 'block' : 'none'}; }
</style>`

Adjustable Parameters

Advanced Model →

Projection Year

Code
viewof target_year_s = Inputs.range([2026, 2050], {
  value: urlNum_s("target_year", 2036), step: 1,
  label: "Projection year"
})

Further-out years give more time for cost reductions and industry scale-up.


Will a Growth Factor (GF) breakthrough happen?

Code
viewof p_recfactors_s = Inputs.range([0, 100], {
  value: Math.round(urlNum_s("p_recfactors", 0.50) * 100), step: 5,
  label: "P(GF breakthrough) %"
})

Growth factors (FGF-2, IGF-1, TGF-β) are the most expensive media ingredient — often 55-95% of media cost at current research-grade prices. A “breakthrough” means at least one of these reaches commercial scale cheaply: autocrine cell lines (cells make their own), plant molecular farming, or precision fermentation. If no breakthrough: GF costs could dominate the total.


Will hydrolysates replace pharma-grade amino acids?

Code
viewof p_hydro_s = Inputs.range([0, 100], {
  value: Math.round(urlNum_s("p_hydro", 0.75) * 100), step: 5,
  label: "P(Hydrolysates adopted) %"
})

The nutrient broth cells grow in (basal media) requires amino acids. “Hydrolysates” are cheap plant/yeast protein digests that replace expensive pharmaceutical-grade amino acids. Hydrolysates: ~$0.20-1.20/L vs pharma-grade: ~$0.50-2.50/L — a ~70% cost reduction for media.


Blended Product (?)

Code
viewof include_blending_s = Inputs.toggle({
  label: "Show blended product analysis",
  value: urlBool_s("include_blending", false)
})
Code
viewof blending_share_s = Inputs.range([5, 95], {
  value: Math.round(urlNum_s("blending_share", 0.25) * 100), step: 5,
  label: "CM inclusion rate (%)"
})

Most commercial products blend cultured cells with plant-based filler. E.g., 25% CM cells + 75% plant protein at ~$3/kg filler. Even if pure cells are expensive, a blended product can be price-competitive.


Probability of each process mode

Set all three; the simulation normalizes them internally so they always sum to 100%. The indicator below shows whether your raw inputs already total 100%.

Code
viewof p_fedbatch_s = Inputs.range([0, 100], {
  value: Math.round(urlNum_s("p_fedbatch", 0.20) * 100), step: 5,
  label: "Fed-batch %"
})
viewof p_perfusion_s = Inputs.range([0, 100], {
  value: Math.round(urlNum_s("p_perfusion", 0.50) * 100), step: 5,
  label: "Perfusion %"
})
viewof p_continuous_s = Inputs.range([0, 100], {
  value: Math.round(urlNum_s("p_continuous", 0.30) * 100), step: 5,
  label: "Continuous %"
})
Code
{
  const sum = p_fedbatch_s + p_perfusion_s + p_continuous_s;
  const exact = Math.abs(sum - 100) < 1;
  const color = exact ? "#27ae60" : "#e67e22";
  return html`<div style="font-size:0.85em; padding:0.3rem 0.5rem; background:#fafafa; border-radius:4px; margin-bottom:0.3rem;">
    Sum: <strong style="color:${color}">${sum}%</strong>
    ${exact
      ? html` <span style="color:#27ae60;">(adds to 100%)</span>`
      : html` <span style="color:${color};">— simulation will normalize to 100%</span>`}
  </div>`;
}
Mode Density Media use Cost implication
Fed-batch 5–30 g/L 1–2× Higher cost (less dense)
Perfusion 30–150 g/L 1–5× Medium cost
Continuous 50–200 g/L 0.5–3× Lower cost (denser)

Pure batch (single fill-and-dump) is excluded — not considered commercially viable at scale.


Background parameters held constant in this model
Parameter Value Why fixed
Industry Maturity 0.5 (neutral) At exactly 0.5, the maturity adjustment term (±0.25 × (m−0.5)) is zero — so technology adoption probabilities are set purely by your sliders, and financing costs use the midpoint WACC. The Projection Year slider then nudges the effective maturity toward 0.30 at 2026 (early industry) and 0.70 at 2050 (mature industry).
WACC (cost of capital) 8–20% range Sampled from lognormal distribution; p5=8%, p95=20%. Food/biotech industry range from Humbird (2021) and CE Delft (2021).
Asset life 8–20 years (uniform) Typical bioreactor/facility lifecycle; Risner et al. and Humbird use 10–15 yr.
Plant capacity 10–40 kTA (lognormal) Ranges from small-scale (10 kTA) to large (40 kTA) commercial facilities.
CAPEX Included Bioreactor and facility capital costs, annualised via CRF.
Fixed overhead Included. $1–6/kg at reference 20 kTA scale; scales sub-linearly. Labour, maintenance, plant overhead.
Downstream costs Not included (pure cell-mass basis). Downstream costs (scaffolding, texturization) are available in the Advanced Model. Output here is unstructured cell mass at the bioreactor gate.
GF cost progress 50% Midpoint toward industry price targets
Filler cost $3/kg Plant protein / mycoprotein estimate

Full parameter definitions → Model formulas & metrics

Code
// Expert priors panel — collapsible, allows overriding the 3 biggest cost-driver distributions
viewof expert_priors = {
  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:8px 0 2px;';
  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. Uses 80% credible intervals (p10/p90) — the same format as the beliefs form. Leave blank to use model 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 = 'Replace the model\'s built-in ranges with your own <strong>80% credible interval</strong> for each key cost driver. Leave blank to use defaults. <a href="docs.html#expert-priors" style="color:#3498db;" target="_blank">How this works →</a>';

  const activeNote = document.createElement('div');
  activeNote.id = 'ep-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, not model defaults';

  body.append(
    intro,
    activeNote,
    row('Media cost ($/kg biomass)',
        'Total cell culture media cost per kg of harvested cell biomass — combines $/L cost × liters consumed. Default model range roughly p10≈5, p90≈120. CM_14 in the beliefs form.',
        ['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 — combining quantity (g/kg) × price ($/g). Overriding this bypasses the breakthrough-regime model. CM_13 in the beliefs form.',
        ['gf_p10','gf_p90'], ['e.g. 2','e.g. 60'], [0.5, 5]),
    row('Cell density (g/L at harvest)',
        'Wet-weight cell density in the bioreactor at harvest. Higher density → fewer liters per kg → lower media and CAPEX costs. Default model ranges: fed-batch ≈5–30, perfusion ≈30–150. CM_16 in the beliefs form.',
        ['density_p10','density_p90'], ['e.g. 8','e.g. 50'], [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
// "→ Advanced Model" carry-over link
{
  const tot = Math.max(p_fedbatch_s + p_perfusion_s + p_continuous_s, 1);
  const p = new URLSearchParams({
    target_year: target_year_s,
    p_hydro: (p_hydro_s / 100).toFixed(2),
    p_recfactors: (p_recfactors_s / 100).toFixed(2),
    p_fedbatch: (p_fedbatch_s / tot).toFixed(2),
    p_perfusion: (p_perfusion_s / tot).toFixed(2),
    p_continuous: (p_continuous_s / tot).toFixed(2),
    include_blending: include_blending_s ? 1 : 0,
    blending_share: (blending_share_s / 100).toFixed(2)
  });
  return html`<div style="margin-top:1rem; padding-top:0.8rem; border-top:2px solid #eee;">
    <a href="index.html?${p.toString()}" style="display:block; text-align:center; padding:0.7rem; background:#2980b9; color:white; border-radius:6px; text-decoration:none; font-weight:600; font-size:0.95rem;">
      → Advanced Model (adapt these settings)
    </a>
    <div style="font-size:0.78em; color:#888; margin-top:0.4rem; text-align:center;">These parameters will be pre-set in the Advanced Model, where many more parameters become adjustable</div>
  </div>`;
}
Code
// Build params object for simulation — all background params hardcoded
simParams_simple = {
  // All three process-mode probabilities are user-set; we normalize internally
  // so the weights always sum to 1, even if the raw slider values don't sum to 100.
  const tot = Math.max(p_fedbatch_s + p_perfusion_s + p_continuous_s, 1);
  return {
    maturity_mean: 0.5,
    target_year: target_year_s,
    p_hydro_mean: p_hydro_s / 100,
    p_recfactors_mean: p_recfactors_s / 100,
    gf_progress: 50,
    p_fedbatch: p_fedbatch_s / tot,
    p_perfusion: p_perfusion_s / tot,
    p_continuous: p_continuous_s / tot,
    override_mode_constraints: false,
    plant_kta_p5: 10, plant_kta_p95: 40,
    uptime_mean: 0.90,
    wacc_p5: 0.08, wacc_p95: 0.20,
    asset_life_lo: 8, asset_life_hi: 20,
    density_gL_p5: 30, density_gL_p95: 200,
    media_turnover_p5: 0.5, media_turnover_p95: 3.0,
    include_capex: true, include_fixed_opex: true, include_downstream: false,
    cdmo_mode: false, bundled_media: false,
    bundled_media_p5: 50, bundled_media_p95: 500,
    cdmo_toll_p5: 4, cdmo_toll_p95: 40,
    // Expert prior overrides — null means use model defaults
    ep_media_p10: expert_priors.media_p10,
    ep_media_p90: expert_priors.media_p90,
    ep_gf_p10: expert_priors.gf_p10,
    ep_gf_p90: expert_priors.gf_p90,
    ep_density_p10: expert_priors.density_p10,
    ep_density_p90: expert_priors.density_p90
  };
}
Code
results_s = simulate(30000, 42, simParams_simple)
Code
stats_s = {
  const uc = results_s.unit_cost;
  const bs = blending_share_s / 100;
  const fc = 3.0;
  const blended = uc.map(c => c * bs + fc * (1 - bs));
  const pct = (arr, t) => arr.filter(x => x < t).length / arr.length * 100;
  return {
    p5: quantile(uc, 0.05), p20: quantile(uc, 0.20),
    p50: quantile(uc, 0.50), p80: quantile(uc, 0.80), p95: quantile(uc, 0.95),
    prob_10: pct(uc, 10), prob_25: pct(uc, 25), prob_50: pct(uc, 50), prob_100: pct(uc, 100),
    bprob_5: pct(blended, 5), bprob_8: pct(blended, 8), bprob_12: pct(blended, 12),
    bprob_10: pct(blended, 10), bprob_25: pct(blended, 25),
    blended_p50: quantile(blended, 0.50),
    blended_p5: quantile(blended, 0.05), blended_p95: quantile(blended, 0.95),
    bs, n: uc.length
  };
}
Code
Plot_s = import("https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/+esm")

Results

Code
html`<div style="background:#f8f9fa; padding:0.8rem 1rem; border-left:4px solid #3498db; margin-bottom:1.5rem; font-size:0.9em; line-height:1.6;">
All values are <strong>manufacturing cost per kg of cultured chicken cell biomass (wet weight, at harvest)</strong> — the factory-gate cost, not a consumer product price. Based on ${stats_s.n.toLocaleString()} Monte Carlo simulations.
${include_blending_s ? html` Blended product estimates use ${stats_s.bs*100 | 0}% CM + ${(1-stats_s.bs)*100 | 0}% plant-based filler at $3/kg.` : ''}
</div>`
Code
html`<div class="grid" style="grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 2rem;">

<div style="background: linear-gradient(135deg, #3498db, #2980b9); color: white; padding: 1.5rem; border-radius: 8px;">
  <h4 style="margin:0; opacity:0.9; font-size:0.9rem;">Median Cost (p50)</h4>
  <h2 style="margin:0.5rem 0;">$${Math.round(stats_s.p50)}/kg</h2>
  <small>Half of simulations above, half below</small>
  ${include_blending_s ? html`<div style="margin-top:0.5rem; font-size:0.85em; opacity:0.9;">Blended: $${stats_s.blended_p50.toFixed(1)}/kg</div>` : ''}
</div>

<div style="background: linear-gradient(135deg, #27ae60, #1e8449); color: white; padding: 1.5rem; border-radius: 8px;">
  <h4 style="margin:0; opacity:0.9; font-size:0.9rem;">Optimistic (p5)</h4>
  <h2 style="margin:0.5rem 0;">$${Math.round(stats_s.p5)}/kg</h2>
  <small>Only 5% of simulations cheaper</small>
  ${include_blending_s ? html`<div style="margin-top:0.5rem; font-size:0.85em; opacity:0.9;">Blended p5: $${stats_s.blended_p5.toFixed(1)}/kg</div>` : ''}
</div>

<div style="background: linear-gradient(135deg, #e74c3c, #c0392b); color: white; padding: 1.5rem; border-radius: 8px;">
  <h4 style="margin:0; opacity:0.9; font-size:0.9rem;">Pessimistic (p95)</h4>
  <h2 style="margin:0.5rem 0;">$${Math.round(stats_s.p95)}/kg</h2>
  <small>95% of simulations cheaper</small>
  ${include_blending_s ? html`<div style="margin-top:0.5rem; font-size:0.85em; opacity:0.9;">Blended p95: $${stats_s.blended_p95.toFixed(1)}/kg</div>` : ''}
</div>

</div>`

Probability Thresholds

Code
{
  // Pure-cell-mass cards: a single set of thresholds. When blending is enabled,
  // the blended-product probabilities are shown ONLY in the dedicated blend
  // row below — never embedded inside the pure-cell cards — to avoid showing
  // overlapping but slightly different threshold sets in the same place.
  function card(thresh, prob, label, color) {
    const bc = prob > 30 ? color : '#ddd';
    return `<div style="border:2px solid ${bc}; padding:0.9rem; border-radius:8px; text-align:center;">
      <h5 style="margin:0 0 0.2rem;">P(Pure cells &lt; $${thresh}/kg)</h5>
      <h2 style="color:${color}; margin:0.2rem 0;">${prob.toFixed(1)}%</h2>
      <small style="color:#666;">${label}</small>
    </div>`;
  }
  const grid = `<div class="grid" style="grid-template-columns:repeat(4,1fr); gap:0.75rem; margin-bottom:1.5rem;">
    ${card(10,  stats_s.prob_10,  'could approach conventional chicken (~$5-10/kg retail)', '#27ae60')}
    ${card(25,  stats_s.prob_25,  'range where premium cultured products may be viable',    '#3498db')}
    ${card(50,  stats_s.prob_50,  'potential niche/specialty market',                       '#f39c12')}
    ${card(100, stats_s.prob_100, 'substantially below current lab-scale costs',             '#e74c3c')}
  </div>`;

  const blendRow = include_blending_s ? `
    <p style="font-size:0.88em; color:#1a5276; font-weight:500; margin:0.5rem 0 0.3rem;">
      Blended product (${stats_s.bs*100|0}% CM + ${((1-stats_s.bs)*100)|0}% filler at $3/kg) — consumer-relevant prices:
    </p>
    <div class="grid" style="grid-template-columns:repeat(3,1fr); gap:0.6rem; margin-bottom:1.5rem;">
      <div style="border:2px solid ${stats_s.bprob_5>20?'#27ae60':'#ddd'}; padding:0.8rem; border-radius:8px; text-align:center;">
        <h5 style="font-size:0.85em; margin:0 0 0.2rem;">P(Blend &lt; $5/kg)</h5>
        <h2 style="color:#27ae60; margin:0.2rem 0;">${stats_s.bprob_5.toFixed(1)}%</h2>
        <small>competitive with conventional chicken</small>
      </div>
      <div style="border:2px solid ${stats_s.bprob_8>30?'#3498db':'#ddd'}; padding:0.8rem; border-radius:8px; text-align:center;">
        <h5 style="font-size:0.85em; margin:0 0 0.2rem;">P(Blend &lt; $8/kg)</h5>
        <h2 style="color:#3498db; margin:0.2rem 0;">${stats_s.bprob_8.toFixed(1)}%</h2>
        <small>competitive with premium chicken/beef</small>
      </div>
      <div style="border:2px solid ${stats_s.bprob_12>50?'#f39c12':'#ddd'}; padding:0.8rem; border-radius:8px; text-align:center;">
        <h5 style="font-size:0.85em; margin:0 0 0.2rem;">P(Blend &lt; $12/kg)</h5>
        <h2 style="color:#f39c12; margin:0.2rem 0;">${stats_s.bprob_12.toFixed(1)}%</h2>
        <small>affordable specialty market</small>
      </div>
    </div>` : '';

  return html([grid + blendRow]);
}

Cost Distribution

Code
{
  const uc = results_s.unit_cost;
  const clipVal = Math.min(quantile(uc, 0.98), 200);
  const clipped = uc.filter(x => x <= clipVal);
  const p20 = stats_s.p20; const p80 = stats_s.p80;

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

  function makeChart(w, h) {
    return Plot_s.plot({
      width: w, height: h, marginLeft: 60, marginBottom: 45,
      x: { label: "Cell Biomass Manufacturing Cost ($/kg, wet weight)", domain: [0, clipVal * 1.05] },
      y: { label: "Frequency", grid: true },
      marks: [
        Plot_s.rectY(clipped, Plot_s.binX({y: "count"}, {x: d => d, fill: "steelblue", fillOpacity: 0.7})),
        Plot_s.ruleX([stats_s.p5],  {stroke: "green", strokeWidth: 2, strokeDasharray: "5,5"}),
        Plot_s.ruleX([stats_s.p50], {stroke: "blue",  strokeWidth: 3}),
        Plot_s.ruleX([stats_s.p95], {stroke: "red",   strokeWidth: 2, strokeDasharray: "5,5"}),
        Plot_s.ruleX([p20], {stroke: "#888", strokeWidth: 1.5, strokeDasharray: "4,4", strokeOpacity: 0.85}),
        Plot_s.ruleX([p80], {stroke: "#888", strokeWidth: 1.5, strokeDasharray: "4,4", strokeOpacity: 0.85}),
        Plot_s.ruleX([10], {stroke: "darkgreen", strokeWidth: 2, strokeDasharray: "2,2", strokeOpacity: 0.6}),
        Plot_s.ruleX([25], {stroke: "orange",    strokeWidth: 2, strokeDasharray: "2,2", strokeOpacity: 0.6}),
        Plot_s.text([
          {x: stats_s.p5+1.5, y: h*6, text: `p5: $${stats_s.p5.toFixed(0)}`},
          {x: stats_s.p50+1.5, y: h*7.5, text: `p50: $${stats_s.p50.toFixed(0)}`},
          {x: stats_s.p95+1.5, y: h*6, text: `p95: $${stats_s.p95.toFixed(0)}`},
          {x: p20+1.5, y: h*4.5, text: `p20: $${p20.toFixed(0)}`, fill: "#666"},
          {x: p80+1.5, y: h*4.5, text: `p80: $${p80.toFixed(0)}`, fill: "#666"}
        ], {x:"x", y:"y", text:"text", fontSize: 11, fill: d => d.fill || "black"})
      ],
      title: `Projected ${target_year_s} Cost Distribution`
    });
  }

  const overlay = document.createElement("div");
  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;";
  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);
  document.addEventListener("keydown", e => { if (e.key==="Escape") overlay.style.display="none"; });
  fsBtn.onclick = () => {
    overlay.style.display = "block";
    while (overlay.children.length > 2) overlay.removeChild(overlay.lastChild);
    overlay.appendChild(makeChart(Math.min(window.innerWidth-80, 1600), Math.min(window.innerHeight-120, 900)));
  };
  document.body.appendChild(overlay);

  const wrapper = document.createElement("div");
  wrapper.style.cssText = "position:relative; display:inline-block; width:100%;";
  wrapper.appendChild(makeChart(780, 360));
  wrapper.appendChild(fsBtn);
  return wrapper;
}
How is this cost calculated?

\[\text{Unit Cost} = \underbrace{\text{Media}}_{\text{amino acids + nutrients}} + \underbrace{\text{Growth Factors}}_{\text{FGF-2, IGF-1, etc.}} + \underbrace{\text{Other VOC}}_{\text{utilities, consumables}} + \underbrace{\text{CAPEX/kg}}_{\text{bioreactors, annualised}} + \underbrace{\text{Overhead/kg}}_{\text{labour, maintenance}}\]

The model draws 30,000 random samples for each uncertain parameter (cell density, media price, growth factor quantity/price, reactor costs, asset life, WACC, plant capacity, uptime, etc.) and computes a unit cost for each draw. The histogram above shows the resulting distribution of unit costs; the cards above summarize what fraction of those samples fall below each threshold.

  • Media cost depends on cell density (g/L) and media-use multiplier (× of reactor volume), both determined by process mode.
  • Growth factor cost depends on quantity (g/kg meat) and price ($/g), with a binary regime switch based on P(GF breakthrough).
  • CAPEX is annualised via the Capital Recovery Factor: CRF = r(1+r)^n / ((1+r)^n − 1).

Full formula documentation → Model formulas & metrics (the formulas are the same as the Advanced Model — only the background parameters listed in the sidebar are held constant here.)

Code
html`<div style="margin-top:1.5rem; padding:0.8rem; background:#f0f8ff; border:1px solid #3498db; border-radius:6px; font-size:0.88em;">
<strong>Want more control?</strong> The <a href="index.html">Advanced Model</a> exposes many more parameters: financing (<abbr title="Weighted Average Cost of Capital: the expected return investors require, blending equity and debt financing costs. Higher WACC = more expensive capital = higher CAPEX per kg.">WACC</abbr>, asset life), plant capacity, cell density, media-use multiplier, CDMO mode, bundled media pricing, and more.
<div style="margin-top:0.5rem;">
<a href="${(() => { const tot=Math.max(p_fedbatch_s+p_perfusion_s+p_continuous_s,1); const p=new URLSearchParams({target_year:target_year_s,p_hydro:(p_hydro_s/100).toFixed(2),p_recfactors:(p_recfactors_s/100).toFixed(2),p_fedbatch:(p_fedbatch_s/tot).toFixed(2),p_perfusion:(p_perfusion_s/tot).toFixed(2),p_continuous:(p_continuous_s/tot).toFixed(2),include_blending:include_blending_s?1:0,blending_share:(blending_share_s/100).toFixed(2)}); return 'index.html?'+p.toString(); })()}" style="font-weight:600;">→ Advanced Model (adapt these settings)</a>
</div>
</div>`
Source Code
---
title: "Simplest Model"
format:
  html:
    page-layout: full
    css: styles.css
    include-in-header:
      text: |
        <script>
        // Strip URL query params before Hypothes.is loads so annotations
        // anchor to the canonical bare URL, while OJS gets params via 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) {}
        })();
        </script>
    include-after-body:
      text: |
        <script src="https://hypothes.is/embed.js" async></script>
---

::: {.callout-warning}
## Preliminary model — for exploration only
This model is *largely AI-generated* and has not been fully validated. It is provided to **fix ideas, illustrate the modeling approach, and enable exploration** — not as authoritative cost estimates. For a more detailed exploration with many more parameters, use the [Advanced Model](index.qmd).
:::

::: {.callout-note collapse="true"}
## How to use this page

This Simplest Model focuses on a few key levers on cultured chicken production cost. Each parameter has an inline explanation — no further reading required to understand what you're adjusting.

Once you've explored here, click **→ Advanced Model** at the bottom of the sidebar to carry your settings over to a fuller parameter set.

**Parameters exposed here:** Projection Year, P(Growth Factor Breakthrough), P(Hydrolysates adopted), Process Mode Mix, Blended Product

**Everything else** (WACC, plant size, cell density, media-use multiplier, asset life, downstream costs) is held at reasonable defaults — see the "Background parameters" section in the sidebar.
:::

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

// ============================================================
// SEEDED RANDOM NUMBER GENERATOR
// ============================================================
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
// ============================================================
function boxMuller(rng) {
  const u1 = rng(); const u2 = rng();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}
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;  // qnorm(0.90)
  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;
}
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;
}
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;
  return [Math.max(mean * t, 0.01), Math.max((1 - mean) * t, 0.01)];
}
function sampleBeta(rng, a, b) {
  const gammaA = sampleGamma(rng, a); const gammaB = sampleGamma(rng, b);
  return gammaA / (gammaA + gammaB);
}
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;
  }
}
function sampleBetaMeanStdev(rng, mean, stdev, n) {
  // Guard: Beta is undefined at the boundaries — return degenerate arrays
  // Without this, betaFromMeanStdev(0 or 1, ...) produces NaN parameters,
  // sampleBeta returns NaN, and rng() < NaN is always false, flipping
  // boolean outcomes to the wrong regime (e.g. p_recf=100% → all expensive).
  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;
}
function crf(wacc, nYears) {
  return wacc * Math.pow(1 + wacc, nYears) / (Math.pow(1 + wacc, nYears) - 1);
}
function clip(arr, min, max) { return arr.map(v => Math.min(Math.max(v, min), max)); }
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 (identical to Advanced Model)
// ============================================================
function simulate(n, seed, params) {
  const rng = mulberry32(seed);
  // Year-based maturity: 0.30 at 2026, 0.50 at 2036, 0.70 at 2050
  // Later years -> higher industry maturity -> lower CAPEX (via WACC and custom-reactor share)
  // and higher effective tech-adoption probabilities.
  const effectiveMaturityMean = params.maturity_mean
    + (params.target_year ? (params.target_year - 2036) / 14 * 0.20 : 0);
  const clampedMaturityMean = Math.min(0.85, Math.max(0.15, effectiveMaturityMean));
  const maturity = sampleBetaMeanStdev(rng, clampedMaturityMean, 0.20, n);
  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);
  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);
  const is_hydro = p_hydro.map(p => rng() < p);
  const is_recf_cheap = p_recf.map(p => rng() < p);
  const cycle_days = sampleLognormalP5P95(rng, 0.5, 5.0, n);
  let density_gL, media_turnover, mode_labels;
  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);
  const d_pf = sampleLognormalP5P95(rng, 30, 150, n);
  const d_ct = sampleLognormalP5P95(rng, 50, 200, n);
  const t_fb = sampleLognormalP5P95(rng, 1.0, 2.0, n);
  const t_pf = sampleLognormalP5P95(rng, 1.0, 5.0, n);
  const t_ct = sampleLognormalP5P95(rng, 0.5, 3.0, n);
  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]);
  // Cell density override — must come before L_per_kg so CAPEX calculation also sees the new density
  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);
  const media_cost_hydro = sampleLognormalP5P95(rng, 0.2, 1.2, n);
  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]);
  // Media cost override: if user specified their own $/kg biomass p10/p90, use that directly
  const cost_media = (params.ep_media_p10 && params.ep_media_p90 && params.ep_media_p10 < params.ep_media_p90)
    ? sampleLognormalP10P90(rng, params.ep_media_p10, params.ep_media_p90, n)
    : mul(L_per_kg, media_cost_L);
  const g_recf_exp = sampleLognormalP5P95(rng, 1e-3, 6e-3, n);
  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]);
  const progress = params.gf_progress / 100;
  const cheap_p5 = 100 * Math.pow(0.01, progress);
  const cheap_p95 = 10000 * Math.pow(0.01, progress);
  const price_recf_cheap = sampleLognormalP5P95(rng, cheap_p5, cheap_p95, n);
  const exp_p5 = 5000 * Math.pow(0.01, progress);
  const exp_p95 = 500000 * Math.pow(0.01, progress);
  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]);
  // GF cost override: if user specified their own $/kg biomass p10/p90, bypass the regime model
  const cost_recf = (params.ep_gf_p10 && params.ep_gf_p90 && params.ep_gf_p10 < params.ep_gf_p90)
    ? sampleLognormalP10P90(rng, params.ep_gf_p10, params.ep_gf_p90, n)
    : mul(g_recf, price_recf);
  const other_var = sampleLognormalP5P95(rng, 0.5, 5.0, n);
  const voc = add(add(cost_media, cost_recf), other_var);
  let cdmo_toll_perkg = new Array(n).fill(0);
  let capex_perkg = new Array(n).fill(0);
  if (params.include_capex) {
    const prod_kg_L_day = div(scale(density_gL, 1/1000), cycle_days);
    const total_working_volume_L = div(output_kgpy, scale(prod_kg_L_day, 365));
    const reactor_cost_L_pharma = sampleLognormalP5P95(rng, 50, 500, n);
    const custom_ratio = sampleUniform(rng, 0.35, 0.85, n);
    let custom_share = sampleBetaMeanStdev(rng, 0.55, 0.15, n);
    custom_share = clip(add(custom_share, scale(maturity.map(m => m - 0.5), 0.30)), 0, 1);
    const reactor_cost_L_avg = reactor_cost_L_pharma.map((p, i) =>
      p * (custom_share[i] * custom_ratio[i] + (1 - custom_share[i])));
    const capex_s = sampleUniform(rng, 0.6, 0.9, n);
    const plant_factor = sampleLognormalP5P95(rng, 1.5, 3.5, n);
    const V_ref = 1e6;
    const capex_total = reactor_cost_L_avg.map((r, i) =>
      r * total_working_volume_L[i] * Math.pow(total_working_volume_L[i] / V_ref, capex_s[i] - 1) * plant_factor[i]);
    let wacc = sampleLognormalP5P95(rng, params.wacc_p5, params.wacc_p95, n);
    wacc = clip(wacc.map((w, i) => w - 0.03 * (maturity[i] - 0.5)), 0.03, 1);
    const asset_life = sampleUniform(rng, params.asset_life_lo, params.asset_life_hi, n);
    const crf_val = wacc.map((w, i) => crf(w, asset_life[i]));
    capex_perkg = capex_total.map((c, i) => (c * crf_val[i]) / output_kgpy[i]);
  }
  let fixed_perkg = new Array(n).fill(0);
  if (params.include_fixed_opex) {
    const ref_output = 20e6 * 0.9;
    const fixed_perkg_ref = sampleUniform(rng, 1.0, 6.0, n);
    const fixed_annual_ref = scale(fixed_perkg_ref, ref_output);
    const fixed_scale = sampleUniform(rng, 0.6, 1.0, n);
    const fixed_annual = fixed_annual_ref.map((f, i) =>
      f * Math.pow(output_kgpy[i] / ref_output, fixed_scale[i]));
    fixed_perkg = div(fixed_annual, output_kgpy);
  }
  const unit_cost = add(add(add(voc, capex_perkg), fixed_perkg), cdmo_toll_perkg);
  return {
    unit_cost,
    pct_hydro: is_hydro.filter(x => x).length / n,
    pct_recf_cheap: is_recf_cheap.filter(x => x).length / n,
    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,
  };
}

// ============================================================
// STATISTICS 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);
  return sorted[lo] * (1 - (idx - lo)) + sorted[hi] * (idx - lo);
}
function mean(arr) { return arr.reduce((a, b) => a + b, 0) / arr.length; }
```

```{ojs}
//| echo: false
urlParams_s = window.__CM_URL_STATE__ || {}
urlNum_s = function(key, def) {
  const v = urlParams_s[key];
  if (v === undefined) return def;
  const n = Number(v); return Number.isFinite(n) ? n : def;
}
urlBool_s = function(key, def) {
  const v = urlParams_s[key];
  if (v === undefined) return def;
  return v === "1" || v === "true";
}
```

```{ojs}
//| echo: false
// Reactive CSS for blending-only visibility
html`<style>
  .blending-only-s { display: ${include_blending_s ? 'block' : 'none'}; }
</style>`
```

::: {.panel-sidebar}

### Adjustable Parameters

```{=html}
<div style="display: flex; gap: 5px; margin-bottom: 0.75rem; position: sticky; top: var(--quarto-navbar-height, 62px); background: white; padding: 0.5rem 0; border-bottom: 1px solid #eee; z-index: 5; margin-top: -0.5rem;">
  <a href="index.html" style="flex:1; text-align:center; padding:0.4rem 0.3rem; font-size:0.82rem; border:1px solid #3498db; border-radius:6px; background:#f0f8ff; color:#1a5276; font-weight:500; text-decoration:none;">
    Advanced Model →
  </a>
  <button onclick="window.location.href=window.location.pathname" title="Reset all to defaults" style="padding:0.4rem 0.5rem; font-size:0.82rem; cursor:pointer; border:1px solid #c0392b; border-radius:6px; background:#fef9f9; color:#922b21; font-weight:500;">
    ↺ Reset
  </button>
  <button onclick="document.querySelectorAll('details').forEach(function(d){d.setAttribute('open','')})" title="Expand all sections" style="padding:0.4rem 0.4rem; font-size:0.82rem; cursor:pointer; border:1px solid #aaa; border-radius:6px; background:#f9f9f9; color:#555;">▼ All</button>
  <button onclick="document.querySelectorAll('details[open]').forEach(function(d){d.removeAttribute('open')})" title="Collapse all sections" style="padding:0.4rem 0.4rem; font-size:0.82rem; cursor:pointer; border:1px solid #aaa; border-radius:6px; background:#f9f9f9; color:#555;">▲ All</button>
</div>
```

---

**Projection Year**

```{ojs}
//| echo: false
viewof target_year_s = Inputs.range([2026, 2050], {
  value: urlNum_s("target_year", 2036), step: 1,
  label: "Projection year"
})
```

*Further-out years give more time for cost reductions and industry scale-up.*

---

**Will a Growth Factor (GF) breakthrough happen?**

```{ojs}
//| echo: false
viewof p_recfactors_s = Inputs.range([0, 100], {
  value: Math.round(urlNum_s("p_recfactors", 0.50) * 100), step: 5,
  label: "P(GF breakthrough) %"
})
```

*Growth factors (FGF-2, IGF-1, TGF-β) are the most expensive media ingredient — often 55-95% of media cost at current research-grade prices. A "breakthrough" means at least one of these reaches commercial scale cheaply: autocrine cell lines (cells make their own), plant molecular farming, or precision fermentation. If no breakthrough: GF costs could dominate the total.*

---

**Will hydrolysates replace pharma-grade amino acids?**

```{ojs}
//| echo: false
viewof p_hydro_s = Inputs.range([0, 100], {
  value: Math.round(urlNum_s("p_hydro", 0.75) * 100), step: 5,
  label: "P(Hydrolysates adopted) %"
})
```

*The nutrient broth cells grow in (basal media) requires amino acids. "Hydrolysates" are cheap plant/yeast protein digests that replace expensive pharmaceutical-grade amino acids. Hydrolysates: ~$0.20-1.20/L vs pharma-grade: ~$0.50-2.50/L — a ~70% cost reduction for media.*

---

**Blended Product** <abbr style="cursor:help;text-decoration:underline dotted;font-size:0.85em;color:#888;" title="Show blended product costs: cultured meat mixed with plant-based filler to lower the per-kg cost. The toggle adds a second set of probability/cost cards based on a CM-plus-filler product, which is the form most consumer-facing cultured meat is expected to take.">(?)</abbr>

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

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

```{ojs}
//| echo: false
viewof blending_share_s = Inputs.range([5, 95], {
  value: Math.round(urlNum_s("blending_share", 0.25) * 100), step: 5,
  label: "CM inclusion rate (%)"
})
```

*Most commercial products blend cultured cells with plant-based filler. E.g., 25% CM cells + 75% plant protein at ~$3/kg filler. Even if pure cells are expensive, a blended product can be price-competitive.*

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

---

**Probability of each process mode**

*Set all three; the simulation normalizes them internally so they always sum to 100%. The indicator below shows whether your raw inputs already total 100%.*

```{ojs}
//| echo: false
viewof p_fedbatch_s = Inputs.range([0, 100], {
  value: Math.round(urlNum_s("p_fedbatch", 0.20) * 100), step: 5,
  label: "Fed-batch %"
})
viewof p_perfusion_s = Inputs.range([0, 100], {
  value: Math.round(urlNum_s("p_perfusion", 0.50) * 100), step: 5,
  label: "Perfusion %"
})
viewof p_continuous_s = Inputs.range([0, 100], {
  value: Math.round(urlNum_s("p_continuous", 0.30) * 100), step: 5,
  label: "Continuous %"
})
```

```{ojs}
//| echo: false
{
  const sum = p_fedbatch_s + p_perfusion_s + p_continuous_s;
  const exact = Math.abs(sum - 100) < 1;
  const color = exact ? "#27ae60" : "#e67e22";
  return html`<div style="font-size:0.85em; padding:0.3rem 0.5rem; background:#fafafa; border-radius:4px; margin-bottom:0.3rem;">
    Sum: <strong style="color:${color}">${sum}%</strong>
    ${exact
      ? html` <span style="color:#27ae60;">(adds to 100%)</span>`
      : html` <span style="color:${color};">— simulation will normalize to 100%</span>`}
  </div>`;
}
```

```{=html}
<table style="width:100%; font-size:0.82em; border-collapse:collapse; margin-bottom:0.5rem;">
<thead><tr style="border-bottom:1px solid #ddd; color:#555;">
  <th style="padding:3px 4px; text-align:left;">Mode</th>
  <th style="padding:3px 4px; text-align:left;">Density</th>
  <th style="padding:3px 4px; text-align:left;">Media use</th>
  <th style="padding:3px 4px; text-align:left;">Cost implication</th>
</tr></thead>
<tbody>
<tr style="border-bottom:1px solid #f0f0f0;">
  <td style="padding:3px 4px;"><strong>Fed-batch</strong></td>
  <td style="padding:3px 4px;">5–30 g/L</td>
  <td style="padding:3px 4px;">1–2×</td>
  <td style="padding:3px 4px; color:#c0392b;">Higher cost (less dense)</td>
</tr>
<tr style="border-bottom:1px solid #f0f0f0;">
  <td style="padding:3px 4px;"><strong>Perfusion</strong></td>
  <td style="padding:3px 4px;">30–150 g/L</td>
  <td style="padding:3px 4px;">1–5×</td>
  <td style="padding:3px 4px; color:#e67e22;">Medium cost</td>
</tr>
<tr>
  <td style="padding:3px 4px;"><strong>Continuous</strong></td>
  <td style="padding:3px 4px;">50–200 g/L</td>
  <td style="padding:3px 4px;">0.5–3×</td>
  <td style="padding:3px 4px; color:#27ae60;">Lower cost (denser)</td>
</tr>
</tbody>
</table>
```

*Pure batch (single fill-and-dump) is excluded — not considered commercially viable at scale.*

---

<details>
<summary><a href="docs.html">Background parameters</a> held constant in this model</summary>

| Parameter | Value | Why fixed |
|-----------|-------|-----------|
| Industry Maturity | 0.5 (neutral) | At exactly 0.5, the maturity adjustment term (±0.25 × (m−0.5)) is zero — so technology adoption probabilities are set purely by your sliders, and financing costs use the midpoint WACC. The Projection Year slider then nudges the effective maturity toward 0.30 at 2026 (early industry) and 0.70 at 2050 (mature industry). |
| <abbr title="Weighted Average Cost of Capital: the expected return investors require, blending equity and debt financing costs. Higher WACC = more expensive capital = higher CAPEX per kg.">WACC</abbr> (cost of capital) | 8–20% range | Sampled from lognormal distribution; p5=8%, p95=20%. Food/biotech industry range from Humbird (2021) and CE Delft (2021). |
| Asset life | 8–20 years (uniform) | Typical bioreactor/facility lifecycle; Risner et al. and Humbird use 10–15 yr. |
| Plant capacity | 10–40 kTA (lognormal) | Ranges from small-scale (10 kTA) to large (40 kTA) commercial facilities. |
| CAPEX | Included | Bioreactor and facility capital costs, annualised via CRF. |
| Fixed overhead | Included. \$1–6/kg at reference 20 kTA scale; scales sub-linearly. | Labour, maintenance, plant overhead. |
| Downstream costs | Not included (pure cell-mass basis). Downstream costs (scaffolding, texturization) are available in the Advanced Model. | Output here is unstructured cell mass at the bioreactor gate. |
| GF cost progress | 50% | Midpoint toward industry price targets |
| Filler cost | $3/kg | Plant protein / mycoprotein estimate |

[Full parameter definitions → Model formulas & metrics](docs.html)

</details>

```{ojs}
//| echo: false
// Expert priors panel — collapsible, allows overriding the 3 biggest cost-driver distributions
viewof expert_priors = {
  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:8px 0 2px;';
  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. Uses 80% credible intervals (p10/p90) — the same format as the beliefs form. Leave blank to use model 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 = 'Replace the model\'s built-in ranges with your own <strong>80% credible interval</strong> for each key cost driver. Leave blank to use defaults. <a href="docs.html#expert-priors" style="color:#3498db;" target="_blank">How this works →</a>';

  const activeNote = document.createElement('div');
  activeNote.id = 'ep-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, not model defaults';

  body.append(
    intro,
    activeNote,
    row('Media cost ($/kg biomass)',
        'Total cell culture media cost per kg of harvested cell biomass — combines $/L cost × liters consumed. Default model range roughly p10≈5, p90≈120. CM_14 in the beliefs form.',
        ['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 — combining quantity (g/kg) × price ($/g). Overriding this bypasses the breakthrough-regime model. CM_13 in the beliefs form.',
        ['gf_p10','gf_p90'], ['e.g. 2','e.g. 60'], [0.5, 5]),
    row('Cell density (g/L at harvest)',
        'Wet-weight cell density in the bioreactor at harvest. Higher density → fewer liters per kg → lower media and CAPEX costs. Default model ranges: fed-batch ≈5–30, perfusion ≈30–150. CM_16 in the beliefs form.',
        ['density_p10','density_p90'], ['e.g. 8','e.g. 50'], [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;
}
```

```{ojs}
//| echo: false
// "→ Advanced Model" carry-over link
{
  const tot = Math.max(p_fedbatch_s + p_perfusion_s + p_continuous_s, 1);
  const p = new URLSearchParams({
    target_year: target_year_s,
    p_hydro: (p_hydro_s / 100).toFixed(2),
    p_recfactors: (p_recfactors_s / 100).toFixed(2),
    p_fedbatch: (p_fedbatch_s / tot).toFixed(2),
    p_perfusion: (p_perfusion_s / tot).toFixed(2),
    p_continuous: (p_continuous_s / tot).toFixed(2),
    include_blending: include_blending_s ? 1 : 0,
    blending_share: (blending_share_s / 100).toFixed(2)
  });
  return html`<div style="margin-top:1rem; padding-top:0.8rem; border-top:2px solid #eee;">
    <a href="index.html?${p.toString()}" style="display:block; text-align:center; padding:0.7rem; background:#2980b9; color:white; border-radius:6px; text-decoration:none; font-weight:600; font-size:0.95rem;">
      → Advanced Model (adapt these settings)
    </a>
    <div style="font-size:0.78em; color:#888; margin-top:0.4rem; text-align:center;">These parameters will be pre-set in the Advanced Model, where many more parameters become adjustable</div>
  </div>`;
}
```

:::

::: {.panel-fill}

```{ojs}
//| echo: false
// Build params object for simulation — all background params hardcoded
simParams_simple = {
  // All three process-mode probabilities are user-set; we normalize internally
  // so the weights always sum to 1, even if the raw slider values don't sum to 100.
  const tot = Math.max(p_fedbatch_s + p_perfusion_s + p_continuous_s, 1);
  return {
    maturity_mean: 0.5,
    target_year: target_year_s,
    p_hydro_mean: p_hydro_s / 100,
    p_recfactors_mean: p_recfactors_s / 100,
    gf_progress: 50,
    p_fedbatch: p_fedbatch_s / tot,
    p_perfusion: p_perfusion_s / tot,
    p_continuous: p_continuous_s / tot,
    override_mode_constraints: false,
    plant_kta_p5: 10, plant_kta_p95: 40,
    uptime_mean: 0.90,
    wacc_p5: 0.08, wacc_p95: 0.20,
    asset_life_lo: 8, asset_life_hi: 20,
    density_gL_p5: 30, density_gL_p95: 200,
    media_turnover_p5: 0.5, media_turnover_p95: 3.0,
    include_capex: true, include_fixed_opex: true, include_downstream: false,
    cdmo_mode: false, bundled_media: false,
    bundled_media_p5: 50, bundled_media_p95: 500,
    cdmo_toll_p5: 4, cdmo_toll_p95: 40,
    // Expert prior overrides — null means use model defaults
    ep_media_p10: expert_priors.media_p10,
    ep_media_p90: expert_priors.media_p90,
    ep_gf_p10: expert_priors.gf_p10,
    ep_gf_p90: expert_priors.gf_p90,
    ep_density_p10: expert_priors.density_p10,
    ep_density_p90: expert_priors.density_p90
  };
}
```

```{ojs}
//| echo: false
results_s = simulate(30000, 42, simParams_simple)
```

```{ojs}
//| echo: false
stats_s = {
  const uc = results_s.unit_cost;
  const bs = blending_share_s / 100;
  const fc = 3.0;
  const blended = uc.map(c => c * bs + fc * (1 - bs));
  const pct = (arr, t) => arr.filter(x => x < t).length / arr.length * 100;
  return {
    p5: quantile(uc, 0.05), p20: quantile(uc, 0.20),
    p50: quantile(uc, 0.50), p80: quantile(uc, 0.80), p95: quantile(uc, 0.95),
    prob_10: pct(uc, 10), prob_25: pct(uc, 25), prob_50: pct(uc, 50), prob_100: pct(uc, 100),
    bprob_5: pct(blended, 5), bprob_8: pct(blended, 8), bprob_12: pct(blended, 12),
    bprob_10: pct(blended, 10), bprob_25: pct(blended, 25),
    blended_p50: quantile(blended, 0.50),
    blended_p5: quantile(blended, 0.05), blended_p95: quantile(blended, 0.95),
    bs, n: uc.length
  };
}
```

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

### Results

```{ojs}
//| echo: false
html`<div style="background:#f8f9fa; padding:0.8rem 1rem; border-left:4px solid #3498db; margin-bottom:1.5rem; font-size:0.9em; line-height:1.6;">
All values are <strong>manufacturing cost per kg of cultured chicken cell biomass (wet weight, at harvest)</strong> — the factory-gate cost, not a consumer product price. Based on ${stats_s.n.toLocaleString()} Monte Carlo simulations.
${include_blending_s ? html` Blended product estimates use ${stats_s.bs*100 | 0}% CM + ${(1-stats_s.bs)*100 | 0}% plant-based filler at $3/kg.` : ''}
</div>`
```

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

<div style="background: linear-gradient(135deg, #3498db, #2980b9); color: white; padding: 1.5rem; border-radius: 8px;">
  <h4 style="margin:0; opacity:0.9; font-size:0.9rem;">Median Cost (p50)</h4>
  <h2 style="margin:0.5rem 0;">$${Math.round(stats_s.p50)}/kg</h2>
  <small>Half of simulations above, half below</small>
  ${include_blending_s ? html`<div style="margin-top:0.5rem; font-size:0.85em; opacity:0.9;">Blended: $${stats_s.blended_p50.toFixed(1)}/kg</div>` : ''}
</div>

<div style="background: linear-gradient(135deg, #27ae60, #1e8449); color: white; padding: 1.5rem; border-radius: 8px;">
  <h4 style="margin:0; opacity:0.9; font-size:0.9rem;">Optimistic (p5)</h4>
  <h2 style="margin:0.5rem 0;">$${Math.round(stats_s.p5)}/kg</h2>
  <small>Only 5% of simulations cheaper</small>
  ${include_blending_s ? html`<div style="margin-top:0.5rem; font-size:0.85em; opacity:0.9;">Blended p5: $${stats_s.blended_p5.toFixed(1)}/kg</div>` : ''}
</div>

<div style="background: linear-gradient(135deg, #e74c3c, #c0392b); color: white; padding: 1.5rem; border-radius: 8px;">
  <h4 style="margin:0; opacity:0.9; font-size:0.9rem;">Pessimistic (p95)</h4>
  <h2 style="margin:0.5rem 0;">$${Math.round(stats_s.p95)}/kg</h2>
  <small>95% of simulations cheaper</small>
  ${include_blending_s ? html`<div style="margin-top:0.5rem; font-size:0.85em; opacity:0.9;">Blended p95: $${stats_s.blended_p95.toFixed(1)}/kg</div>` : ''}
</div>

</div>`
```

### Probability Thresholds

```{ojs}
//| echo: false
{
  // Pure-cell-mass cards: a single set of thresholds. When blending is enabled,
  // the blended-product probabilities are shown ONLY in the dedicated blend
  // row below — never embedded inside the pure-cell cards — to avoid showing
  // overlapping but slightly different threshold sets in the same place.
  function card(thresh, prob, label, color) {
    const bc = prob > 30 ? color : '#ddd';
    return `<div style="border:2px solid ${bc}; padding:0.9rem; border-radius:8px; text-align:center;">
      <h5 style="margin:0 0 0.2rem;">P(Pure cells &lt; $${thresh}/kg)</h5>
      <h2 style="color:${color}; margin:0.2rem 0;">${prob.toFixed(1)}%</h2>
      <small style="color:#666;">${label}</small>
    </div>`;
  }
  const grid = `<div class="grid" style="grid-template-columns:repeat(4,1fr); gap:0.75rem; margin-bottom:1.5rem;">
    ${card(10,  stats_s.prob_10,  'could approach conventional chicken (~$5-10/kg retail)', '#27ae60')}
    ${card(25,  stats_s.prob_25,  'range where premium cultured products may be viable',    '#3498db')}
    ${card(50,  stats_s.prob_50,  'potential niche/specialty market',                       '#f39c12')}
    ${card(100, stats_s.prob_100, 'substantially below current lab-scale costs',             '#e74c3c')}
  </div>`;

  const blendRow = include_blending_s ? `
    <p style="font-size:0.88em; color:#1a5276; font-weight:500; margin:0.5rem 0 0.3rem;">
      Blended product (${stats_s.bs*100|0}% CM + ${((1-stats_s.bs)*100)|0}% filler at $3/kg) — consumer-relevant prices:
    </p>
    <div class="grid" style="grid-template-columns:repeat(3,1fr); gap:0.6rem; margin-bottom:1.5rem;">
      <div style="border:2px solid ${stats_s.bprob_5>20?'#27ae60':'#ddd'}; padding:0.8rem; border-radius:8px; text-align:center;">
        <h5 style="font-size:0.85em; margin:0 0 0.2rem;">P(Blend &lt; $5/kg)</h5>
        <h2 style="color:#27ae60; margin:0.2rem 0;">${stats_s.bprob_5.toFixed(1)}%</h2>
        <small>competitive with conventional chicken</small>
      </div>
      <div style="border:2px solid ${stats_s.bprob_8>30?'#3498db':'#ddd'}; padding:0.8rem; border-radius:8px; text-align:center;">
        <h5 style="font-size:0.85em; margin:0 0 0.2rem;">P(Blend &lt; $8/kg)</h5>
        <h2 style="color:#3498db; margin:0.2rem 0;">${stats_s.bprob_8.toFixed(1)}%</h2>
        <small>competitive with premium chicken/beef</small>
      </div>
      <div style="border:2px solid ${stats_s.bprob_12>50?'#f39c12':'#ddd'}; padding:0.8rem; border-radius:8px; text-align:center;">
        <h5 style="font-size:0.85em; margin:0 0 0.2rem;">P(Blend &lt; $12/kg)</h5>
        <h2 style="color:#f39c12; margin:0.2rem 0;">${stats_s.bprob_12.toFixed(1)}%</h2>
        <small>affordable specialty market</small>
      </div>
    </div>` : '';

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

### Cost Distribution

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

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

  function makeChart(w, h) {
    return Plot_s.plot({
      width: w, height: h, marginLeft: 60, marginBottom: 45,
      x: { label: "Cell Biomass Manufacturing Cost ($/kg, wet weight)", domain: [0, clipVal * 1.05] },
      y: { label: "Frequency", grid: true },
      marks: [
        Plot_s.rectY(clipped, Plot_s.binX({y: "count"}, {x: d => d, fill: "steelblue", fillOpacity: 0.7})),
        Plot_s.ruleX([stats_s.p5],  {stroke: "green", strokeWidth: 2, strokeDasharray: "5,5"}),
        Plot_s.ruleX([stats_s.p50], {stroke: "blue",  strokeWidth: 3}),
        Plot_s.ruleX([stats_s.p95], {stroke: "red",   strokeWidth: 2, strokeDasharray: "5,5"}),
        Plot_s.ruleX([p20], {stroke: "#888", strokeWidth: 1.5, strokeDasharray: "4,4", strokeOpacity: 0.85}),
        Plot_s.ruleX([p80], {stroke: "#888", strokeWidth: 1.5, strokeDasharray: "4,4", strokeOpacity: 0.85}),
        Plot_s.ruleX([10], {stroke: "darkgreen", strokeWidth: 2, strokeDasharray: "2,2", strokeOpacity: 0.6}),
        Plot_s.ruleX([25], {stroke: "orange",    strokeWidth: 2, strokeDasharray: "2,2", strokeOpacity: 0.6}),
        Plot_s.text([
          {x: stats_s.p5+1.5, y: h*6, text: `p5: $${stats_s.p5.toFixed(0)}`},
          {x: stats_s.p50+1.5, y: h*7.5, text: `p50: $${stats_s.p50.toFixed(0)}`},
          {x: stats_s.p95+1.5, y: h*6, text: `p95: $${stats_s.p95.toFixed(0)}`},
          {x: p20+1.5, y: h*4.5, text: `p20: $${p20.toFixed(0)}`, fill: "#666"},
          {x: p80+1.5, y: h*4.5, text: `p80: $${p80.toFixed(0)}`, fill: "#666"}
        ], {x:"x", y:"y", text:"text", fontSize: 11, fill: d => d.fill || "black"})
      ],
      title: `Projected ${target_year_s} Cost Distribution`
    });
  }

  const overlay = document.createElement("div");
  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;";
  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);
  document.addEventListener("keydown", e => { if (e.key==="Escape") overlay.style.display="none"; });
  fsBtn.onclick = () => {
    overlay.style.display = "block";
    while (overlay.children.length > 2) overlay.removeChild(overlay.lastChild);
    overlay.appendChild(makeChart(Math.min(window.innerWidth-80, 1600), Math.min(window.innerHeight-120, 900)));
  };
  document.body.appendChild(overlay);

  const wrapper = document.createElement("div");
  wrapper.style.cssText = "position:relative; display:inline-block; width:100%;";
  wrapper.appendChild(makeChart(780, 360));
  wrapper.appendChild(fsBtn);
  return wrapper;
}
```

<details>
<summary>How is this cost calculated?</summary>

$$\text{Unit Cost} = \underbrace{\text{Media}}_{\text{amino acids + nutrients}} + \underbrace{\text{Growth Factors}}_{\text{FGF-2, IGF-1, etc.}} + \underbrace{\text{Other VOC}}_{\text{utilities, consumables}} + \underbrace{\text{CAPEX/kg}}_{\text{bioreactors, annualised}} + \underbrace{\text{Overhead/kg}}_{\text{labour, maintenance}}$$

The model draws **30,000 random samples** for each uncertain parameter (cell density, media price, growth factor quantity/price, reactor costs, asset life, WACC, plant capacity, uptime, etc.) and computes a unit cost for each draw. The histogram above shows the resulting distribution of unit costs; the cards above summarize what fraction of those samples fall below each threshold.

- **Media cost** depends on cell density (g/L) and media-use multiplier (× of reactor volume), both determined by process mode.
- **Growth factor cost** depends on quantity (g/kg meat) and price ($/g), with a binary regime switch based on P(GF breakthrough).
- **CAPEX** is annualised via the Capital Recovery Factor: CRF = r(1+r)^n / ((1+r)^n − 1).

[Full formula documentation → Model formulas & metrics](docs.html) (the formulas are the same as the Advanced Model — only the background parameters listed in the sidebar are held constant here.)

</details>

```{ojs}
//| echo: false
html`<div style="margin-top:1.5rem; padding:0.8rem; background:#f0f8ff; border:1px solid #3498db; border-radius:6px; font-size:0.88em;">
<strong>Want more control?</strong> The <a href="index.html">Advanced Model</a> exposes many more parameters: financing (<abbr title="Weighted Average Cost of Capital: the expected return investors require, blending equity and debt financing costs. Higher WACC = more expensive capital = higher CAPEX per kg.">WACC</abbr>, asset life), plant capacity, cell density, media-use multiplier, CDMO mode, bundled media pricing, and more.
<div style="margin-top:0.5rem;">
<a href="${(() => { const tot=Math.max(p_fedbatch_s+p_perfusion_s+p_continuous_s,1); const p=new URLSearchParams({target_year:target_year_s,p_hydro:(p_hydro_s/100).toFixed(2),p_recfactors:(p_recfactors_s/100).toFixed(2),p_fedbatch:(p_fedbatch_s/tot).toFixed(2),p_perfusion:(p_perfusion_s/tot).toFixed(2),p_continuous:(p_continuous_s/tot).toFixed(2),include_blending:include_blending_s?1:0,blending_share:(blending_share_s/100).toFixed(2)}); return 'index.html?'+p.toString(); })()}" style="font-weight:600;">→ Advanced Model (adapt these settings)</a>
</div>
</div>`
```

:::
 

Built by The Unjournal | Source Code