13

Competitive Exclusion: Tilman's R*

drag Y to tune species 1 growth, click to reset

Tilman (1982): when two consumer species compete for a single limiting resource , the species with the lower โ€” the minimum resource concentration at which its per-capita growth is zero โ€” wins. Each species has Monod growth , giving . Resource dynamics combine abiotic supply and combined consumption: . Parameters here are tuned so species 2 has the smaller half-saturation and therefore the lower even when species 1 has the higher maximum growth rate . Watch the dynamics: falls from its abiotic equilibrium, species 1 grows fast at high resource, then crashes once drops below where its growth rate goes negative; species 2 keeps pulling down until the system rests at . Drag the mouse vertically to scrub : at the top of the canvas species 1's falls below species 2's and the winner flips โ€” that's the regime boundary the -rule predicts exactly.

idle
211 lines ยท vanilla
view source
// Tilman's R*-rule and competitive exclusion.
// Two species N1, N2 compete for one limiting resource R.
//   g_i(R) = mu_i * R / (K_i + R) - m_i
//   R_i*   = K_i * m_i / (mu_i - m_i)    (solve g_i(R*) = 0)
//   dN_i/dt = N_i * g_i(R)
//   dR/dt   = S - r R - sum_i N_i * mu_i * R / (K_i + R) * q_i
// The species with the LOWER R* wins competitive exclusion.

const S_IN = 1.5;       // resource supply rate
const R_LOSS = 0.10;    // abiotic resource loss
const MU2 = 0.85;       // species 2 max growth rate (fixed)
const K1 = 1.20;
const K2 = 0.55;
const M1 = 0.18;
const M2 = 0.18;
const Q1 = 0.018;       // per-capita resource consumption coeff (species 1)
const Q2 = 0.018;
const DT_SIM = 0.05;
const SUBSTEPS = 4;
const HISTORY = 600;
const RESET_T = 240;    // auto reset after this much sim time

let W = 0, H = 0;
let N1 = 0, N2 = 0, R = 0;
let mu1Mult = 1.6;      // mouseY-controlled multiplier on mu1 baseline
const MU1_BASE = MU2;   // baseline equals MU2; multiplier scales it
let frame = 0;
let simTime = 0;
let hist;               // {N1, N2, R, t}
let pendingClicks = 0;

function rStar(mu, K, m) {
  if (mu <= m) return Infinity;
  return (K * m) / (mu - m);
}

function clamp(x, lo, hi) { return x < lo ? lo : x > hi ? hi : x; }

function reset() {
  N1 = 1.0;
  N2 = 1.0;
  R = 1.0;
  simTime = 0;
  hist.length = 0;
}

function derivs(n1, n2, r, mu1) {
  const uptake1 = mu1 * r / (K1 + r);
  const uptake2 = MU2 * r / (K2 + r);
  const dn1 = n1 * (uptake1 - M1);
  const dn2 = n2 * (uptake2 - M2);
  const dr  = S_IN - R_LOSS * r - n1 * uptake1 * Q1 - n2 * uptake2 * Q2;
  return [dn1, dn2, dr];
}

function step(mu1) {
  // RK4 on (N1, N2, R)
  const k1 = derivs(N1, N2, R, mu1);
  const k2 = derivs(N1 + 0.5 * DT_SIM * k1[0], N2 + 0.5 * DT_SIM * k1[1], R + 0.5 * DT_SIM * k1[2], mu1);
  const k3 = derivs(N1 + 0.5 * DT_SIM * k2[0], N2 + 0.5 * DT_SIM * k2[1], R + 0.5 * DT_SIM * k2[2], mu1);
  const k4 = derivs(N1 + DT_SIM * k3[0], N2 + DT_SIM * k3[1], R + DT_SIM * k3[2], mu1);
  N1 = Math.max(0, N1 + (DT_SIM / 6) * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]));
  N2 = Math.max(0, N2 + (DT_SIM / 6) * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]));
  R  = Math.max(0, R  + (DT_SIM / 6) * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2]));
  simTime += DT_SIM;
}

function init({ width, height }) {
  W = width; H = height;
  hist = [];
  reset();
}

function tick({ ctx, dt, width, height, input }) {
  W = width; H = height;
  frame++;

  // input -> mu1 multiplier (mouseY top = stronger species 1, bottom = weaker)
  if (input && typeof input.mouseY === 'number' && input.mouseY >= 0 && input.mouseY <= H) {
    const yn = clamp(input.mouseY / H, 0, 1);
    // top of canvas => high mu1 (advantage), bottom => low mu1 (disadvantage)
    mu1Mult = 2.2 - yn * 1.7;   // range ~[0.5, 2.2]
  }
  const mu1 = MU1_BASE * mu1Mult;

  // clicks reset
  if (input && typeof input.consumeClicks === 'function') {
    pendingClicks += input.consumeClicks();
  }
  if (pendingClicks > 0) {
    reset();
    pendingClicks = 0;
  }

  // integrate
  for (let s = 0; s < SUBSTEPS; s++) step(mu1);

  // record
  if ((frame & 1) === 0) {
    hist.push({ N1, N2, R, t: simTime });
    if (hist.length > HISTORY) hist.shift();
  }

  // auto-loop: once a steady state has held, restart
  if (simTime > RESET_T) reset();

  // ---- render ----
  ctx.fillStyle = '#07090d';
  ctx.fillRect(0, 0, W, H);

  // layout: top panel = N1, N2 lines; bottom panel = R with R1*, R2* dashed lines
  const padL = 56, padR = 18, padT = 36, padB = 92;
  const gap  = 14;
  const plotW = W - padL - padR;
  const plotH = (H - padT - padB - gap) * 0.55;
  const rPlotH = (H - padT - padB - gap) - plotH;
  const popY0 = padT;
  const popY1 = popY0 + plotH;
  const rY0   = popY1 + gap;
  const rY1   = rY0 + rPlotH;

  const R1s = rStar(mu1, K1, M1);
  const R2s = rStar(MU2, K2, M2);

  // dynamic ranges
  let nMax = 50;
  let rMax = 4;
  for (const h of hist) {
    if (h.N1 > nMax) nMax = h.N1;
    if (h.N2 > nMax) nMax = h.N2;
    if (h.R  > rMax) rMax = h.R;
  }
  if (Number.isFinite(R1s)) rMax = Math.max(rMax, R1s * 1.15);
  if (Number.isFinite(R2s)) rMax = Math.max(rMax, R2s * 1.15);
  nMax *= 1.1;
  const tMax = Math.max(simTime, RESET_T * 0.25);

  // panel frames
  ctx.strokeStyle = '#1d2530';
  ctx.lineWidth = 1;
  ctx.strokeRect(padL, popY0, plotW, plotH);
  ctx.strokeRect(padL, rY0,   plotW, rPlotH);

  // axes labels
  ctx.fillStyle = '#7a8494';
  ctx.font = '11px monospace';
  ctx.fillText('populations N1, N2', padL + 6, popY0 + 14);
  ctx.fillText('resource R', padL + 6, rY0 + 14);
  ctx.fillText('time โ†’', padL + plotW - 60, rY1 + 14);

  // y-axis ticks (N)
  ctx.strokeStyle = '#141a23';
  ctx.beginPath();
  for (let k = 1; k <= 3; k++) {
    const y = popY0 + plotH * (1 - k / 4);
    ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y);
  }
  for (let k = 1; k <= 3; k++) {
    const y = rY0 + rPlotH * (1 - k / 4);
    ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y);
  }
  ctx.stroke();

  // N axis label numbers
  ctx.fillStyle = '#5a6473';
  ctx.font = '10px monospace';
  for (let k = 0; k <= 4; k++) {
    const v = (nMax * k / 4);
    const y = popY0 + plotH * (1 - k / 4);
    ctx.fillText(v.toFixed(0), 8, y + 3);
  }
  for (let k = 0; k <= 4; k++) {
    const v = (rMax * k / 4);
    const y = rY0 + rPlotH * (1 - k / 4);
    ctx.fillText(v.toFixed(2), 8, y + 3);
  }

  // R1* and R2* dashed lines (only valid in the resource panel)
  function drawDashedY(value, color, label) {
    if (!Number.isFinite(value) || value < 0 || value > rMax) return;
    const y = rY0 + rPlotH * (1 - value / rMax);
    ctx.save();
    ctx.setLineDash([5, 4]);
    ctx.strokeStyle = color;
    ctx.lineWidth = 1.2;
    ctx.beginPath();
    ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y);
    ctx.stroke();
    ctx.setLineDash([]);
    ctx.fillStyle = color;
    ctx.font = '11px monospace';
    ctx.fillText(label, padL + plotW - 78, y - 4);
    ctx.restore();
  }
  drawDashedY(R1s, 'rgba(255,140,90,0.85)',  `R1* = ${Number.isFinite(R1s) ? R1s.toFixed(2) : 'โˆž'}`);
  drawDashedY(R2s, 'rgba(120,200,255,0.85)', `R2* = ${R2s.toFixed(2)}`);

  // plot histories
  function plotSeries(panelY0, panelH, key, yMax, stroke, lw) {
    if (hist.length < 2) return;
    ctx.strokeStyle = stroke;
    ctx.lineWidth = lw;
    ctx.beginPath();
    for (let i = 0; i < hist.length; i++) {
      const h = hist[i];
      const px = padL + (h.t / tMax) * plotW;
      const v = h[key];
      const py = panelY0 + panelH * (1 - clamp(v / yMax, 0, 1));
      if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
    }
    ctx.stroke();
  }
  plotSeries(popY0, plotH, 'N1', nMax, '#ff8c5a', 1.8);
  plotSeries(popY0, plotH, 'N2', nMax, '#78c8ff', 1.8);
  plotSeries(rY0,   rPlotH, 'R',  rMax, '#e8d27a', 1.6);

  // legend dots at last value
  if (hist.length) {
    const last = hist[hist.length - 1];
    const lx = padL + (last.t / tMax) * plotW;
    ctx.fillStyle = '#ff8c5a';
    ctx.beginPath();
    ctx.arc(lx, popY0 + plotH * (1 - clamp(last.N1 / nMax, 0, 1)), 3, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#78c8ff';
    ctx.beginPath();
    ctx.arc(lx, popY0 + plotH * (1 - clamp(last.N2 / nMax, 0, 1)), 3, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#e8d27a';
    ctx.beginPath();
    ctx.arc(lx, rY0 + rPlotH * (1 - clamp(last.R / rMax, 0, 1)), 3, 0, Math.PI * 2);
    ctx.fill();
  }

  // title
  ctx.fillStyle = '#d8dee9';
  ctx.font = '14px monospace';
  ctx.fillText("Tilman's R*-rule โ€” competitive exclusion", padL, 20);

  // big R* readout, predicted winner
  const winner = R1s < R2s ? 1 : (R2s < R1s ? 2 : 0);
  const winnerStr = winner === 0 ? 'tie' : (winner === 1 ? 'species 1 wins' : 'species 2 wins');
  const winnerCol = winner === 1 ? '#ff8c5a' : winner === 2 ? '#78c8ff' : '#cfd6e0';

  // bottom HUD
  const hudY = rY1 + 22;
  ctx.fillStyle = 'rgba(255,140,90,0.95)';
  ctx.font = '14px monospace';
  ctx.fillText(`R1* = ${Number.isFinite(R1s) ? R1s.toFixed(3) : 'โˆž'}`, padL, hudY);
  ctx.fillStyle = 'rgba(120,200,255,0.95)';
  ctx.fillText(`R2* = ${R2s.toFixed(3)}`, padL + 160, hudY);
  ctx.fillStyle = winnerCol;
  ctx.fillText(`predicted: ${winnerStr}`, padL + 310, hudY);

  ctx.fillStyle = '#7a8494';
  ctx.font = '11px monospace';
  ctx.fillText(`mu1 = ${mu1.toFixed(2)}   mu2 = ${MU2.toFixed(2)}   N1 = ${N1.toFixed(2)}   N2 = ${N2.toFixed(2)}   R = ${R.toFixed(3)}   t = ${simTime.toFixed(1)}`, padL, hudY + 18);
  ctx.fillStyle = '#5a6473';
  ctx.fillText('drag Y to tune mu1   โ€ข   click to reset', padL, hudY + 36);

  // mouseY indicator bar on the right
  if (input && typeof input.mouseY === 'number' && input.mouseY >= 0 && input.mouseY <= H) {
    const bx = W - padR + 4;
    ctx.strokeStyle = '#1f2733';
    ctx.strokeRect(bx, padT, 4, H - padT - padB);
    ctx.fillStyle = '#cfd6e0';
    ctx.fillRect(bx, clamp(input.mouseY, padT, H - padB) - 1, 4, 3);
  }
}

Comments (0)

Log in to comment.