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

Simplest Model

  • Show All Code
  • Hide All Code

  • View Source
Model versions

This page runs the current model (engine 2026-09-07.1). See the September 2026 review and correction log. For comparisons with beliefs recorded at the May 8 workshop, use the hosted workshop-era model or its tagged source. The archived model is preserved as it was deployed and includes errors corrected here.

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
costModel = import(new URL("./cost-model.mjs", window.location.href).href)
simulate = costModel.simulate
quantile = costModel.quantile
mean = costModel.mean
conditionalSwing = costModel.conditionalSwing
spearmanCorr = costModel.spearmanCorr
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 Base index 0.5 The shared year mapping gives a mean of 0.275 in 2026, 0.40 in 2036, and 0.50 from 2044 onward. Individual draws still vary and correlate adoption with financing. This is a scenario mapping, not an estimated learning curve.
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 {
    ...costModel.DEFAULT_PARAMS,
    maturity_mean: costModel.maturityForYear(0.5, target_year_s),
    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)
scenarioNotes = html`<div role="status">${results_s.warnings.map(w => html`<p><strong>Scenario note:</strong> ${w}</p>`)}</div>`
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 on the model's wet-biomass basis</strong> — a factory-gate ingredient cost, not a consumer-product price. The code treats density as wet-biomass g/L; it does not separately standardize hydration, dry matter, protein content, or recovery yield. Source dollars are not yet normalized to one real-dollar year, and the model does not separate P(commercial scale exists) from cost conditional on success. 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,  'illustrative manufacturing-cost threshold', '#27ae60')}
    ${card(25,  stats_s.prob_25,  'illustrative manufacturing-cost threshold',    '#3498db')}
    ${card(50,  stats_s.prob_50,  'illustrative manufacturing-cost threshold',                       '#f39c12')}
    ${card(100, stats_s.prob_100, 'illustrative manufacturing-cost threshold',             '#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) — ingredient costs only:
    </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>ingredients only; excludes retail costs</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>ingredients only; excludes retail costs</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>ingredients only; excludes retail costs</small>
      </div>
    </div>` : '';

  return html([grid + blendRow]);
}

Cost Distribution

Code
{
  const uc = results_s.unit_cost;
  const clipVal = quantile(uc, 0.98);
  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));
  const tailNote = document.createElement("p");
  tailNote.style.cssText = "font-size:0.85em;color:#666;";
  tailNote.textContent = "Histogram shows the lowest 98% of draws. Summary statistics and threshold probabilities use every draw.";
  wrapper.appendChild(tailNote);
  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.}} + \text{Supplemental proteins} + \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-note}
## Model versions

This page runs the **current model** (engine `2026-09-07.1`). See the [September 2026 review and correction log](review-2026-09.qmd). For comparisons with beliefs recorded at the May 8 workshop, use the [hosted workshop-era model](archive/workshop-2026-05-08/) or its [tagged source](https://github.com/unjournal/cm_pq_modeling/tree/workshop-2026-05-08). The archived model is preserved as it was deployed and includes errors corrected here.
:::

::: {.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
// ============================================================
costModel = import(new URL("./cost-model.mjs", window.location.href).href)
simulate = costModel.simulate
quantile = costModel.quantile
mean = costModel.mean
conditionalSwing = costModel.conditionalSwing
spearmanCorr = costModel.spearmanCorr

```

```{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 | Base index 0.5 | The shared year mapping gives a mean of 0.275 in 2026, 0.40 in 2036, and 0.50 from 2044 onward. Individual draws still vary and correlate adoption with financing. This is a scenario mapping, not an estimated learning curve. |
| <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 {
    ...costModel.DEFAULT_PARAMS,
    maturity_mean: costModel.maturityForYear(0.5, target_year_s),
    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)
scenarioNotes = html`<div role="status">${results_s.warnings.map(w => html`<p><strong>Scenario note:</strong> ${w}</p>`)}</div>`
```

```{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 on the model's wet-biomass basis</strong> — a factory-gate ingredient cost, not a consumer-product price. The code treats density as wet-biomass g/L; it does not separately standardize hydration, dry matter, protein content, or recovery yield. Source dollars are not yet normalized to one real-dollar year, and the model does not separate P(commercial scale exists) from cost conditional on success. 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,  'illustrative manufacturing-cost threshold', '#27ae60')}
    ${card(25,  stats_s.prob_25,  'illustrative manufacturing-cost threshold',    '#3498db')}
    ${card(50,  stats_s.prob_50,  'illustrative manufacturing-cost threshold',                       '#f39c12')}
    ${card(100, stats_s.prob_100, 'illustrative manufacturing-cost threshold',             '#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) — ingredient costs only:
    </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>ingredients only; excludes retail costs</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>ingredients only; excludes retail costs</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>ingredients only; excludes retail costs</small>
      </div>
    </div>` : '';

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

### Cost Distribution

```{ojs}
//| echo: false
{
  const uc = results_s.unit_cost;
  const clipVal = quantile(uc, 0.98);
  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));
  const tailNote = document.createElement("p");
  tailNote.style.cssText = "font-size:0.85em;color:#666;";
  tailNote.textContent = "Histogram shows the lowest 98% of draws. Summary statistics and threshold probabilities use every draw.";
  wrapper.appendChild(tailNote);
  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.}} + \text{Supplemental proteins} + \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