Skip to the content

Capsid menu

app/lib/phage-tools.mjs

795 lines and 48 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of phage-tools.mjs
FunctionLinesComplexityCoveredCRAPMutation
parseNumber75 to 886100%WatchCRAP 6Watch64.9%
roundSig91 to 943100%OkCRAP 3Critical57.1%
trim97 to 992100%OkCRAP 2Ok100%
formatNumber107 to 1186100%WatchCRAP 6Watch75%
formatScientific121 to 1252100%OkCRAP 2Watch62.5%
formatDilution128 to 1302100%OkCRAP 2No data: changed since the last mutation run
textParts139 to 1514100%OkCRAP 4Ok94.7%
<anonymous:150>150 to 1501100%OkCRAP 1Ok100%
must160 to 1632100%OkCRAP 2Watch60%
count167 to 1692100%OkCRAP 2Critical0%
titer179 to 1841100%OkCRAP 1Ok100%
readCount194 to 2109100%WatchCRAP 9Ok86%
runTiter213 to 241597%OkCRAP 5Critical26.3%
planDilution264 to 3073100%OkCRAP 3Ok91.7%
<anonymous:269>269 to 2691100%OkCRAP 1Ok100%
round273 to 2731100%OkCRAP 1Ok100%
<anonymous:276>276 to 2761100%OkCRAP 1Critical0%
<anonymous:291>291 to 3051100%OkCRAP 1Ok100%
stepLine310 to 3143100%OkCRAP 3Critical0%
planLines323 to 3365100%OkCRAP 5Critical5.3%
runDilution339 to 3711694%WatchCRAP 16.1Critical33.7%
<anonymous:357>357 to 3572100%OkCRAP 2Critical0%
lysatePerPlate380 to 3821100%OkCRAP 1Ok100%
floodYield389 to 3911100%OkCRAP 1Ok100%
platesForLysate399 to 4011100%OkCRAP 1Ok100%
runWebbedPlate404 to 4551396%WatchCRAP 13Critical42.6%
runFlood458 to 4777100%WatchCRAP 7Critical15.6%
moi488 to 4931100%OkCRAP 1Ok100%
runMoi496 to 5249100%WatchCRAP 9Watch78.6%
eop533 to 5351100%OkCRAP 1Ok100%
eopSection543 to 5603100%OkCRAP 3Critical59.1%
runEop563 to 5695100%OkCRAP 5Ok100%
countLine572 to 5752100%OkCRAP 2Watch71.4%
runEopCounts578 to 5994100%OkCRAP 4Ok96.2%
lysateForPlates609 to 6131100%OkCRAP 1Ok100%
runLysateVolume616 to 65614100%WatchCRAP 14Ok81.1%
toolsOnPage775 to 7771100%OkCRAP 1Ok100%
<anonymous:776>776 to 7761100%OkCRAP 1Ok83.3%
toolValues786 to 7965100%OkCRAP 5Watch73.3%
runTool799 to 8032100%OkCRAP 2Watch60%
lineNodes812 to 8141100%OkCRAP 1Critical0%
<anonymous:813>813 to 8132100%OkCRAP 2Critical0%
resultView824 to 8532100%OkCRAP 2Watch78.6%
<anonymous:829>829 to 8291100%OkCRAP 1Ok100%
<anonymous:830>830 to 8342100%OkCRAP 2Critical0%
<anonymous:835>835 to 8512100%OkCRAP 2Critical0%
<anonymous:843>843 to 8431100%OkCRAP 1Critical0%
<anonymous:845>845 to 849140%OkCRAP 1.2Critical0%

Imports

It imports no other module of this repo. Imported by app/components/phage-tool.tsx, app/enhance/tools.ts, app/kb/form.server.ts, app/kb/procedures/library.mjs, app/kb/procedures/render.mjs, app/kb/procedures/validate.mjs, app/lib/pages/invariants.mjs, app/routes/content-page.tsx.

Source

/**
 * The phage lab calculators at /research/tools: titer, serial dilution, webbed plate, MOI, efficiency of
 * plating and lysate volume. One pure module,
 * imported by the server-rendered calculator (app/components/phage-tool.tsx), which renders the worked
 * example with script off, and by the browser module that recomputes on input (app/enhance/tools.ts).
 * test/phage-tools.test.mjs holds it against hand arithmetic and the numbers on the FAQ and the
 * isolation protocol, and holds each page's worked example to what this module prints.
 *
 * Every lab default here names the page that states it (`source`), and the page shows that source.
 * Volumes are in µl and titers in pfu/ml throughout, as on the FAQ.
 */

/** @typedef {{ href: string, label: string }} Source */

/**
 * @typedef {object} ToolField
 * @property {string} name the query parameter and the input's name
 * @property {string} label
 * @property {string} [unit]
 * @property {string} value the value the page opens with
 * @property {string} [hint]
 * @property {Source} [source] set when `value` is a lab default rather than the worked example's number
 */

/** @typedef {{ text: string, sup?: false } | { text: string, sup: true }} TextPart */

/**
 * @typedef {object} ToolSection
 * @property {string} heading
 * @property {string[]} steps the arithmetic, one line each, in the order it is done
 * @property {string} [result] the answer, set apart from the steps
 * @property {string[]} [notes] cautions from the lab's pages, shown after the steps
 */

/** @typedef {{ ok: true, sections: ToolSection[] } | { ok: false, error: string }} ToolResult */

const FAQ = "/teaching/virus-isolation/faq";
const PROTOCOL = "/research/protocols/phage-isolation";

/** @type {Record<string, Source>} */
const SOURCES = {
  fullPlate: { href: `${PROTOCOL}#full-plate-titer`, label: "Phage Isolation and Purification Protocol: full plate titer" },
  spot: { href: `${PROTOCOL}#spot-titer`, label: "Phage Isolation and Purification Protocol: spot titer" },
  tenfold: { href: FAQ, label: "Lab Calculations and Common Questions: 10-fold serial dilution" },
  volumeLimit: { href: `${PROTOCOL}#the-bracketing-shortcut`, label: "Phage Isolation and Purification Protocol: volume limit" },
  yields: { href: `${PROTOCOL}#yields`, label: "Phage Isolation and Purification Protocol: yields" },
};

/** The lab's numbers, each with its page. Nothing here is a value no page states. */
export const LAB = {
  /** Full plate titer: 10 µl of each dilution with 250 µl host. */
  ulPlated: 10,
  /** Spot titer: the Guide's Protocol 6.4, 3 µl spots, as the lab runs it. */
  ulSpot: 3,
  /** A 10-fold step: 10 µl phage + 90 µl phage buffer. */
  transferUl: 10,
  /** No more lysate than 10% of the 250 µl of host, or the cells lyse. */
  maxLysateUl: 25,
  /** Each plate flooded with 8 ml of phage buffer returns about 5 to 7 ml. */
  floodMl: 8,
  yieldLowMl: 5,
  yieldHighMl: 7,
  /** Plan about 6 webbed plates for 10 ml to archive plus 10 ml for DNA extraction. */
  plates: 6,
};

/* ---------------------------------------------------------------- numbers */

/**
 * A number as the bench writes it: `1.1e10`, `1.1 x 10^10`, `1.1×10^10`, `11,100` or `10^-3`.
 * Returns NaN for anything else, including an empty field, so the caller names the field.
 *
 * @param {unknown} raw
 */
export function parseNumber(raw) {
  const text = String(raw ?? "")
    .trim()
    .replace(/[\s,]/g, "")
    .replace(/[×xX*]/g, "x")
    .replace(/−/g, "-");
  if (text === "") return Number.NaN;
  const sci = /^([+-]?\d*\.?\d+)x10\^([+-]?\d+)$/.exec(text);
  if (sci) return Number(sci[1]) * 10 ** Number(sci[2]);
  const power = /^10\^([+-]?\d+)$/.exec(text);
  if (power) return 10 ** Number(power[1]);
  if (!/^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(text)) return Number.NaN;
  return Number(text);
}

/** @param {number} value @param {number} digits */
function roundSig(value, digits) {
  if (value === 0 || !Number.isFinite(value)) return value;
  return Number(value.toPrecision(digits));
}

/** Trailing zeros off a fixed-point string: 1.50 to 1.5, 2.00 to 2. @param {string} text */
function trim(text) {
  return text.includes(".") ? text.replace(/0+$/, "").replace(/\.$/, "") : text;
}

/**
 * Three significant figures, never more than the value has. Plain with thousands separators from 0.01
 * up to a million, and `a x 10^n` outside that, the way the FAQ writes titers.
 *
 * @param {number} value
 */
export function formatNumber(value) {
  if (!Number.isFinite(value)) return String(value);
  if (value === 0) return "0";
  const rounded = roundSig(value, 3);
  const abs = Math.abs(rounded);
  if (abs >= 0.01 && abs < 1e6) {
    const [whole = "", fraction] = trim(String(rounded)).split(".");
    const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
    return fraction ? `${grouped}.${fraction}` : grouped;
  }
  return formatScientific(rounded);
}

/** Always `a x 10^n`, three significant figures: 1.11 x 10^10, 1 x 10^-3. @param {number} value */
export function formatScientific(value) {
  if (value === 0) return "0";
  const [mantissa = "", exponent] = roundSig(value, 3).toExponential(2).split("e");
  return `${trim(mantissa)} x 10^${Number(exponent)}`;
}

/** `10^-6`, and `undiluted` for 10^0. @param {number} n */
function formatDilution(n) {
  return n === 0 ? "undiluted" : `10^-${n}`;
}

/**
 * A line split into plain runs and superscripts, so both renderers show `10^6` as 10 with a raised 6
 * and neither parses markup.
 *
 * @param {string} line
 * @returns {TextPart[]}
 */
export function textParts(line) {
  /** @type {TextPart[]} */
  const parts = [];
  let last = 0;
  for (const match of line.matchAll(/10\^(-?\d+)/g)) {
    const at = match.index ?? 0;
    parts.push({ text: `${line.slice(last, at)}10` });
    parts.push({ text: (match[1] ?? "").replace("-", "−"), sup: true });
    last = at + match[0].length;
  }
  parts.push({ text: line.slice(last) });
  return parts.filter((part) => part.text !== "");
}

/**
 * A value the arithmetic guarantees, checked anyway: an index past the end is a bug here, not input.
 *
 * @template T
 * @param {T | undefined} value
 * @returns {T}
 */
function must(value) {
  if (value === undefined) throw new Error("phage-tools: a planned step is missing.");
  return value;
}

const WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
/** @param {number} n */
function count(n) {
  return WORDS[n] ?? String(n);
}

/* ---------------------------------------------------------------- titer */

/**
 * titer (pfu/ml) = plaques counted / µl plated x 1,000 µl/ml x dilution factor, the FAQ's formula.
 * A spot titer is the same with the spot volume.
 *
 * @param {{ plaques: number, volumeUl: number, dilutionExponent: number }} input
 */
export function titer({ plaques, volumeUl, dilutionExponent }) {
  const perUl = plaques / volumeUl;
  const perMlDiluted = perUl * 1000;
  const pfuPerMl = perMlDiluted * 10 ** dilutionExponent;
  return { perUl, perMlDiluted, pfuPerMl };
}

/**
 * A plaque count, the volume plated and the dilution's exponent, read and checked. Shared by the titer
 * calculator and the EOP-from-counts calculator, so both refuse the same inputs in the same words.
 * `where` names the plate in the message ("" for one plate, " on the test host" for EOP).
 *
 * @param {unknown} rawPlaques @param {unknown} rawVolume @param {unknown} rawDilution @param {string} [where]
 * @returns {{ ok: true, plaques: number, volumeUl: number, n: number } | { ok: false, error: string }}
 */
function readCount(rawPlaques, rawVolume, rawDilution, where = "") {
  const plaques = parseNumber(rawPlaques);
  const volumeUl = parseNumber(rawVolume);
  const rawN = parseNumber(rawDilution);
  if (!Number.isFinite(plaques) || plaques < 0 || !Number.isInteger(plaques)) {
    return { ok: false, error: `Enter the plaques counted${where} as a whole number, 0 or more.` };
  }
  if (!Number.isFinite(volumeUl) || volumeUl <= 0) {
    return { ok: false, error: `Enter the volume plated or spotted${where}, in µl, greater than 0.` };
  }
  // A dilution is never more than 1, so 6 and -6 both mean 10^-6.
  const n = Math.abs(rawN);
  if (!Number.isFinite(rawN) || !Number.isInteger(n) || n > 20) {
    return { ok: false, error: `Enter the dilution's exponent${where} as a whole number from 0 to 20: 6 for 10^-6, 0 if undiluted.` };
  }
  return { ok: true, plaques, volumeUl, n };
}

/** @param {Record<string, string | undefined>} values @returns {ToolResult} */
function runTiter(values) {
  const read = readCount(values.plaques, values.volume, values.dilution);
  if (!read.ok) return read;
  const { plaques, volumeUl, n } = read;
  const { perUl, perMlDiluted, pfuPerMl } = titer({ plaques, volumeUl, dilutionExponent: n });
  const of = n === 0 ? "in the undiluted lysate" : `in the ${formatDilution(n)} dilution`;
  const steps = [
    `Plaques per µl: ${formatNumber(plaques)} / ${formatNumber(volumeUl)} µl = ${formatNumber(perUl)} pfu/µl.`,
    `Per ml: ${formatNumber(perUl)} x 1,000 = ${formatNumber(perMlDiluted)} pfu/ml ${of}.`,
  ];
  if (n > 0) {
    steps.push(`Undo the dilution: ${formatNumber(perMlDiluted)} x 10^${n} = ${formatScientific(pfuPerMl)} pfu/ml.`);
  }
  /** @type {string[]} */
  const notes = [];
  if (plaques === 0) {
    notes.push("No plaques: the titer is below what this plate can measure. Count a less dilute plate.");
  } else {
    const log = Math.log10(perUl);
    const exponent = Math.floor(log + 3 + n + 1e-9);
    steps.push(
      `Check the exponent: log10(${formatNumber(perUl)}) + 3 + ${n} = ${log.toFixed(2)} + 3 + ${n} = ${(log + 3 + n).toFixed(2)}, so the titer is in the 10^${exponent} range.`,
    );
  }
  return {
    ok: true,
    sections: [{ heading: "Titer", steps, result: `${formatScientific(pfuPerMl)} pfu/ml`, notes }],
  };
}

/* ---------------------------------------------------------------- dilution */

/**
 * @typedef {object} DilutionStep
 * @property {number} tube 1-based
 * @property {number} transferUl from the tube before (tube 1 takes from the lysate)
 * @property {number} bufferUl
 * @property {number} fold this step's own dilution
 * @property {number} cumulativeFold from the lysate to this tube
 * @property {number} pfuPerMl in this tube
 */

/**
 * The tubes that take a lysate from `startPfuPerMl` down to `targetPfuPerMl`: as many 10-fold steps as
 * fit (transfer + 9 x transfer of buffer), then one smaller step for whatever is left over when the
 * total is not a power of ten. Volumes are rounded to 0.1 µl and the tube concentrations are computed
 * from the rounded volumes, so they are what the tubes will hold. When `neededUl` is set, the last tube
 * holds at least that much, and each tube before it at least what the next one takes.
 *
 * @param {{ startPfuPerMl: number, targetPfuPerMl: number, transferUl?: number, neededUl?: number }} input
 */
export function planDilution({ startPfuPerMl, targetPfuPerMl, transferUl = LAB.transferUl, neededUl = 0 }) {
  const totalFold = startPfuPerMl / targetPfuPerMl;
  const tenfold = Math.floor(Math.log10(totalFold) + 1e-9);
  const remainder = totalFold / 10 ** tenfold;
  /** @type {number[]} */
  const folds = Array.from({ length: tenfold }, () => 10);
  // A remainder within 1% of 1 is a power of ten that floating point missed by a hair.
  if (remainder > 1.01) folds.push(remainder);

  const round = (/** @type {number} */ v) => Math.round(v * 10) / 10;
  // From the last tube back: each tube must hold what the next one takes, and the last what is needed.
  /** @type {number[]} */
  const totals = Array.from({ length: folds.length }, () => 0);
  // Filled from the end, so each tube's total is known before the tube that supplies it.
  let required = neededUl;
  for (let i = folds.length - 1; i >= 0; i -= 1) {
    const fold = must(folds[i]);
    // Never a volume under 1 µl: a 1.05-fold step at 10 µl would need 0.5 µl of buffer.
    const smallest = Math.max(fold, fold / (fold - 1));
    const total = Math.max(transferUl * fold, smallest, required);
    totals[i] = total;
    required = total / fold;
  }

  /** @type {DilutionStep[]} */
  const steps = [];
  let cumulativeFold = 1;
  folds.forEach((fold, i) => {
    const total = must(totals[i]);
    const transfer = round(total / fold);
    const buffer = round(total - total / fold);
    const actualFold = (transfer + buffer) / transfer;
    cumulativeFold *= actualFold;
    steps.push({
      tube: i + 1,
      transferUl: transfer,
      bufferUl: buffer,
      fold: actualFold,
      cumulativeFold,
      pfuPerMl: startPfuPerMl / cumulativeFold,
    });
  });
  return { totalFold, tenfold, remainder, steps, finalPfuPerMl: startPfuPerMl / cumulativeFold };
}

/** @param {DilutionStep} step @param {number} tenfoldCount */
function stepLine(step, tenfoldCount) {
  const from = step.tube === 1 ? "lysate" : `tube ${step.tube - 1}`;
  const label = step.tube <= tenfoldCount ? formatDilution(step.tube) : `1/${formatNumber(step.cumulativeFold)}`;
  return `Tube ${step.tube}: ${formatNumber(step.transferUl)} µl ${from} + ${formatNumber(step.bufferUl)} µl phage buffer = ${label}, ${formatScientific(step.pfuPerMl)} pfu/ml.`;
}

/**
 * The steps shared by the dilution and webbed-plate calculators: the fold, how it splits, the tubes.
 *
 * @param {ReturnType<typeof planDilution>} plan
 * @param {number} startPfuPerMl
 * @param {number} targetPfuPerMl
 */
function planLines(plan, startPfuPerMl, targetPfuPerMl) {
  const lines = [
    `Total dilution: ${formatScientific(startPfuPerMl)} / ${formatScientific(targetPfuPerMl)} = ${formatNumber(plan.totalFold)}-fold.`,
  ];
  const split =
    plan.steps.length === plan.tenfold
      ? `That is 10^${plan.tenfold}: ${count(plan.tenfold)} 10-fold steps.`
      : plan.tenfold === 0
        ? `That is less than 10-fold: one ${formatNumber(plan.remainder)}-fold step.`
        : `That is 10^${plan.tenfold} x ${formatNumber(plan.remainder)}: ${count(plan.tenfold)} 10-fold ${plan.tenfold === 1 ? "step" : "steps"}, then one ${formatNumber(plan.remainder)}-fold step.`;
  lines.push(split);
  for (const step of plan.steps) lines.push(stepLine(step, plan.tenfold));
  return lines;
}

/** @param {Record<string, string | undefined>} values @returns {ToolResult} */
function runDilution(values) {
  const start = parseNumber(values.start);
  const target = parseNumber(values.target);
  const transfer = parseNumber(values.transfer);
  const needed = values.needed === undefined || String(values.needed).trim() === "" ? 0 : parseNumber(values.needed);
  if (!Number.isFinite(start) || start <= 0) return { ok: false, error: "Enter the starting titer in pfu/ml, greater than 0." };
  if (!Number.isFinite(target) || target <= 0) return { ok: false, error: "Enter the target concentration in pfu/ml, greater than 0." };
  if (target >= start) return { ok: false, error: "The target is not below the starting titer, so there is nothing to dilute." };
  if (start / target > 1e20) return { ok: false, error: "That is more than 20 10-fold steps. Check the exponents." };
  if (!Number.isFinite(transfer) || transfer <= 0) return { ok: false, error: "Enter the volume carried into each tube, in µl, greater than 0." };
  if (!Number.isFinite(needed) || needed < 0) return { ok: false, error: "Enter the volume needed from the last tube in µl, or leave it empty." };
  const plan = planDilution({ startPfuPerMl: start, targetPfuPerMl: target, transferUl: transfer, neededUl: needed });
  const steps = planLines(plan, start, target);
  const last = must(plan.steps.at(-1));
  const lastVolume = last.transferUl + last.bufferUl;
  if (needed > 0) steps.push(`The last tube holds ${formatNumber(lastVolume)} µl, enough for the ${formatNumber(needed)} µl needed.`);
  /** @type {string[]} */
  const notes = [];
  if (plan.steps.some((step) => step.transferUl < 1 || step.bufferUl < 1)) {
    notes.push("A volume is under 1 µl. Raise the volume carried into each tube so every volume can be pipetted.");
  }
  return {
    ok: true,
    sections: [
      {
        heading: "Dilution series",
        steps,
        result: `${plan.steps.length} ${plan.steps.length === 1 ? "tube" : "tubes"}, ending at ${formatScientific(plan.finalPfuPerMl)} pfu/ml`,
        notes,
      },
    ],
  };
}

/* ---------------------------------------------------------------- webbed plate */

/**
 * µl of lysate per plate = pfu wanted per plate / titer (pfu/ml) x 1,000 µl/ml, the FAQ's formula.
 *
 * @param {{ titerPfuPerMl: number, pfuPerPlate: number }} input
 */
export function lysatePerPlate({ titerPfuPerMl, pfuPerPlate }) {
  return (pfuPerPlate / titerPfuPerMl) * 1000;
}

/**
 * Lysate back from flooding: plates x the lab's 5 to 7 ml per plate.
 *
 * @param {{ plates: number, lowMl?: number, highMl?: number }} input
 */
export function floodYield({ plates, lowMl = LAB.yieldLowMl, highMl = LAB.yieldHighMl }) {
  return { lowMl: plates * lowMl, highMl: plates * highMl };
}

/**
 * Plates to flood for a lysate volume: ml wanted / 5 to 7 ml per plate, rounded up. Fewest plates at
 * the high yield, most at the low one.
 *
 * @param {{ mlWanted: number, lowMl?: number, highMl?: number }} input
 */
export function platesForLysate({ mlWanted, lowMl = LAB.yieldLowMl, highMl = LAB.yieldHighMl }) {
  return { fewest: Math.ceil(mlWanted / highMl - 1e-9), most: Math.ceil(mlWanted / lowMl - 1e-9) };
}

/** @param {Record<string, string | undefined>} values @returns {ToolResult} */
function runWebbedPlate(values) {
  const titerValue = parseNumber(values.titer);
  const pfu = parseNumber(values.pfu);
  const volume = parseNumber(values.volume);
  const plates = parseNumber(values.plates);
  if (!Number.isFinite(titerValue) || titerValue <= 0) return { ok: false, error: "Enter the lysate's titer in pfu/ml, greater than 0." };
  if (!Number.isFinite(pfu) || pfu <= 0) return { ok: false, error: "Enter the pfu wanted on each plate, greater than 0." };
  if (!Number.isFinite(volume) || volume <= 0) return { ok: false, error: "Enter the volume plated on each plate, in µl, greater than 0." };
  if (!Number.isFinite(plates) || plates < 1 || !Number.isInteger(plates) || plates > 100) {
    return { ok: false, error: "Enter the number of plates as a whole number from 1 to 100." };
  }
  const lysateUl = lysatePerPlate({ titerPfuPerMl: titerValue, pfuPerPlate: pfu });
  const neededUl = plates * volume;
  const plate = [
    `Lysate per plate: ${formatNumber(pfu)} / ${formatScientific(titerValue)} pfu/ml x 1,000 µl/ml = ${formatNumber(lysateUl)} µl.`,
  ];
  /** @type {string[]} */
  const notes = [];
  let result;
  const targetPfuPerMl = (pfu / volume) * 1000;
  if (targetPfuPerMl >= titerValue) {
    plate.push(`That is more than ${formatNumber(volume)} µl, so plate undiluted lysate: ${formatNumber(lysateUl)} µl on each plate, ${formatNumber(lysateUl * plates)} µl for ${plates} plates.`);
    result = `${formatNumber(lysateUl)} µl of undiluted lysate per plate`;
    if (lysateUl > LAB.maxLysateUl) {
      notes.push(`${formatNumber(lysateUl)} µl is over the lab's limit of ${LAB.maxLysateUl} µl on 250 µl of host (10% of the cell volume), which lyses the cells. Plate ${LAB.maxLysateUl} µl, or use a lysate with a higher titer.`);
    }
  } else {
    plate.push(`To plate that in ${formatNumber(volume)} µl, the tube must hold ${formatNumber(pfu)} pfu / ${formatNumber(volume)} µl x 1,000 = ${formatScientific(targetPfuPerMl)} pfu/ml.`);
    const plan = planDilution({ startPfuPerMl: titerValue, targetPfuPerMl, transferUl: LAB.transferUl, neededUl });
    plate.push(...planLines(plan, titerValue, targetPfuPerMl));
    plate.push(`Enough of the last tube: ${plates} plates x ${formatNumber(volume)} µl = ${formatNumber(neededUl)} µl.`);
    const last = must(plan.steps.at(-1));
    result = `${formatNumber(volume)} µl of tube ${last.tube} (${formatNumber(volume * (last.pfuPerMl / 1000))} pfu) on each of ${plates} plates`;
  }
  const { lowMl, highMl } = floodYield({ plates });
  const flood = [
    `Flooded with ${LAB.floodMl} ml of phage buffer each, a plate returns about ${LAB.yieldLowMl} to ${LAB.yieldHighMl} ml.`,
    `${plates} plates x ${LAB.yieldLowMl} to ${LAB.yieldHighMl} ml = ${formatNumber(lowMl)} to ${formatNumber(highMl)} ml of lysate.`,
  ];
  return {
    ok: true,
    sections: [
      { heading: "Webbed plates", steps: plate, result, notes },
      {
        heading: "Lysate from flooding",
        steps: flood,
        result: `About ${formatNumber(lowMl)} to ${formatNumber(highMl)} ml`,
        notes: ["Dry or aged plates return less; one returned only 3.5 ml of an 8 ml flood."],
      },
    ],
  };
}

/** @param {Record<string, string | undefined>} values @returns {ToolResult} */
function runFlood(values) {
  const ml = parseNumber(values.ml);
  if (!Number.isFinite(ml) || ml <= 0 || ml > 10000) return { ok: false, error: "Enter the lysate wanted in ml, greater than 0." };
  const { fewest, most } = platesForLysate({ mlWanted: ml });
  const range = fewest === most ? `${most}` : `${fewest} to ${most}`;
  return {
    ok: true,
    sections: [
      {
        heading: "Plates to flood",
        steps: [
          `At ${LAB.yieldHighMl} ml per plate: ${formatNumber(ml)} / ${LAB.yieldHighMl} = ${formatNumber(ml / LAB.yieldHighMl)}, so ${fewest} ${fewest === 1 ? "plate" : "plates"}.`,
          `At ${LAB.yieldLowMl} ml per plate: ${formatNumber(ml)} / ${LAB.yieldLowMl} = ${formatNumber(ml / LAB.yieldLowMl)}, so ${most} ${most === 1 ? "plate" : "plates"}.`,
        ],
        result: `${range} webbed plates`,
        notes: ["Pour a plate or two more than this: some plates do not web or yield less."],
      },
    ],
  };
}

/* ---------------------------------------------------------------- MOI */

/**
 * MOI = pfu added / cells, with pfu = titer (pfu/ml) x µl added / 1,000 µl/ml and cells = cells/ml x µl
 * of culture / 1,000 µl/ml. `infected` is the share of cells that receive at least one phage when phages
 * land at random (Poisson): 1 - exp(-MOI). The site gives no OD-to-cells conversion, so cells are counted.
 *
 * @param {{ titerPfuPerMl: number, phageUl: number, cellsPerMl: number, hostUl: number }} input
 */
export function moi({ titerPfuPerMl, phageUl, cellsPerMl, hostUl }) {
  const pfu = (titerPfuPerMl * phageUl) / 1000;
  const cells = (cellsPerMl * hostUl) / 1000;
  const ratio = pfu / cells;
  return { pfu, cells, moi: ratio, infected: -Math.expm1(-ratio) };
}

/** @param {Record<string, string | undefined>} values @returns {ToolResult} */
function runMoi(values) {
  const titerValue = parseNumber(values.titer);
  const phageUl = parseNumber(values.phage);
  const cellsPerMl = parseNumber(values.cells);
  const hostUl = parseNumber(values.host);
  if (!Number.isFinite(titerValue) || titerValue <= 0) return { ok: false, error: "Enter the phage titer in pfu/ml, greater than 0." };
  if (!Number.isFinite(phageUl) || phageUl <= 0) return { ok: false, error: "Enter the volume of phage added, in µl, greater than 0." };
  if (!Number.isFinite(cellsPerMl) || cellsPerMl <= 0) return { ok: false, error: "Enter the host cells per ml, greater than 0." };
  if (!Number.isFinite(hostUl) || hostUl <= 0) return { ok: false, error: "Enter the volume of host culture, in µl, greater than 0." };
  const result = moi({ titerPfuPerMl: titerValue, phageUl, cellsPerMl, hostUl });
  return {
    ok: true,
    sections: [
      {
        heading: "MOI",
        steps: [
          `pfu added: ${formatScientific(titerValue)} pfu/ml x ${formatNumber(phageUl)} µl / 1,000 µl/ml = ${formatNumber(result.pfu)} pfu.`,
          `Cells: ${formatScientific(cellsPerMl)} cells/ml x ${formatNumber(hostUl)} µl / 1,000 µl/ml = ${formatNumber(result.cells)} cells.`,
          `MOI: ${formatNumber(result.pfu)} pfu / ${formatNumber(result.cells)} cells = ${formatNumber(result.moi)}.`,
          `Cells that get at least one phage: 1 - exp(-${formatNumber(result.moi)}) = ${formatNumber(result.infected)}, about ${formatNumber(result.infected * 100)}%.`,
        ],
        result: `${formatNumber(result.moi)} pfu per cell`,
        notes: [
          "The last step assumes phages meet cells at random and every pfu adsorbs. Slow or incomplete adsorption infects fewer cells than it says.",
        ],
      },
    ],
  };
}

/* ---------------------------------------------------------------- EOP */

/**
 * EOP = titer on the test host / titer on the reference host.
 *
 * @param {{ testPfuPerMl: number, referencePfuPerMl: number }} input
 */
export function eop({ testPfuPerMl, referencePfuPerMl }) {
  return testPfuPerMl / referencePfuPerMl;
}

/**
 * The EOP steps shared by both EOP calculators. `boundPfuPerMl`, when the test host had no plaques, is
 * the titer one plaque on that plate would have meant, so the note can say how low the EOP is.
 *
 * @param {number} test @param {number} reference @param {number} [boundPfuPerMl]
 */
function eopSection(test, reference, boundPfuPerMl) {
  const ratio = eop({ testPfuPerMl: test, referencePfuPerMl: reference });
  /** @type {string[]} */
  const notes = [];
  if (test === 0) {
    const bound = boundPfuPerMl === undefined ? "" : ` It is below ${formatNumber(eop({ testPfuPerMl: boundPfuPerMl, referencePfuPerMl: reference }))}, what one plaque on that plate would give.`;
    notes.push(`No plaques on the test host: the phage did not plate at this dilution, which is not the same as an EOP of exactly 0.${bound} Plate a less dilute sample to measure it.`);
  }
  return {
    heading: "Efficiency of plating",
    steps: [
      `EOP: ${formatScientific(test)} pfu/ml on the test host / ${formatScientific(reference)} pfu/ml on the reference host = ${formatNumber(ratio)}.`,
      `As a percentage of the reference titer: ${formatNumber(ratio)} x 100 = ${formatNumber(ratio * 100)}%.`,
    ],
    result: `${formatNumber(ratio)} (${formatNumber(ratio * 100)}% of the reference titer)`,
    notes,
  };
}

/** @param {Record<string, string | undefined>} values @returns {ToolResult} */
function runEop(values) {
  const test = parseNumber(values.test);
  const reference = parseNumber(values.reference);
  if (!Number.isFinite(test) || test < 0) return { ok: false, error: "Enter the titer on the test host in pfu/ml, 0 or more." };
  if (!Number.isFinite(reference) || reference <= 0) return { ok: false, error: "Enter the titer on the reference host in pfu/ml, greater than 0." };
  return { ok: true, sections: [eopSection(test, reference)] };
}

/** One host's titer line for the EOP-from-counts steps. @param {string} host @param {{ plaques: number, volumeUl: number, n: number }} count @param {number} pfuPerMl */
function countLine(host, { plaques, volumeUl, n }, pfuPerMl) {
  const undo = n === 0 ? "" : ` x 10^${n}`;
  return `Titer on the ${host}: ${formatNumber(plaques)} / ${formatNumber(volumeUl)} µl x 1,000${undo} = ${formatScientific(pfuPerMl)} pfu/ml.`;
}

/** @param {Record<string, string | undefined>} values @returns {ToolResult} */
function runEopCounts(values) {
  const test = readCount(values.testPlaques, values.testVolume, values.testDilution, " on the test host");
  if (!test.ok) return test;
  const reference = readCount(values.refPlaques, values.refVolume, values.refDilution, " on the reference host");
  if (!reference.ok) return reference;
  if (reference.plaques === 0) {
    return { ok: false, error: "No plaques on the reference host, so there is no titer to divide by. Count a less dilute plate." };
  }
  const testTiter = titer({ plaques: test.plaques, volumeUl: test.volumeUl, dilutionExponent: test.n }).pfuPerMl;
  const refTiter = titer({ plaques: reference.plaques, volumeUl: reference.volumeUl, dilutionExponent: reference.n }).pfuPerMl;
  const bound = titer({ plaques: 1, volumeUl: test.volumeUl, dilutionExponent: test.n }).pfuPerMl;
  const section = eopSection(testTiter, refTiter, bound);
  return {
    ok: true,
    sections: [
      {
        ...section,
        steps: [countLine("test host", test, testTiter), countLine("reference host", reference, refTiter), ...section.steps],
      },
    ],
  };
}

/* ---------------------------------------------------------------- lysate volume */

/**
 * Lysate for a run of plates: (pfu per plate x plates) / titer (pfu/ml) x 1,000 µl/ml, the webbed
 * plate formula for all the plates at once.
 *
 * @param {{ titerPfuPerMl: number, pfuPerPlate: number, plates: number }} input
 */
export function lysateForPlates({ titerPfuPerMl, pfuPerPlate, plates }) {
  const totalPfu = pfuPerPlate * plates;
  const totalUl = lysatePerPlate({ titerPfuPerMl, pfuPerPlate: totalPfu });
  return { totalPfu, totalUl, perPlateUl: totalUl / plates };
}

/** @param {Record<string, string | undefined>} values @returns {ToolResult} */
function runLysateVolume(values) {
  const titerValue = parseNumber(values.titer);
  const pfu = parseNumber(values.pfu);
  const plates = parseNumber(values.plates);
  const volume = parseNumber(values.volume);
  if (!Number.isFinite(titerValue) || titerValue <= 0) return { ok: false, error: "Enter the lysate's titer in pfu/ml, greater than 0." };
  if (!Number.isFinite(pfu) || pfu <= 0) return { ok: false, error: "Enter the pfu wanted on each plate, greater than 0." };
  if (!Number.isFinite(plates) || plates < 1 || !Number.isInteger(plates) || plates > 100) {
    return { ok: false, error: "Enter the number of plates as a whole number from 1 to 100." };
  }
  if (!Number.isFinite(volume) || volume <= 0) return { ok: false, error: "Enter the volume plated on each plate, in µl, greater than 0." };
  const { totalPfu, totalUl, perPlateUl } = lysateForPlates({ titerPfuPerMl: titerValue, pfuPerPlate: pfu, plates });
  const steps = [
    `pfu for all plates: ${formatNumber(pfu)} x ${plates} = ${formatNumber(totalPfu)} pfu.`,
    `Lysate for all plates: ${formatNumber(totalPfu)} / ${formatScientific(titerValue)} pfu/ml x 1,000 µl/ml = ${formatNumber(totalUl)} µl.`,
    `Per plate: ${formatNumber(totalUl)} µl / ${plates} = ${formatNumber(perPlateUl)} µl.`,
  ];
  /** @type {string[]} */
  const notes = [];
  let result;
  if (perPlateUl >= volume * (1 - 1e-9)) {
    steps.push(`That is at least the ${formatNumber(volume)} µl plated per plate, so plate the lysate undiluted: ${formatNumber(perPlateUl)} µl on each plate.`);
    result = `${formatNumber(totalUl)} µl of undiluted lysate, ${formatNumber(perPlateUl)} µl on each of ${plates} plates`;
    if (perPlateUl > LAB.maxLysateUl) {
      notes.push(`${formatNumber(perPlateUl)} µl is over the lab's limit of ${LAB.maxLysateUl} µl on 250 µl of host (10% of the cell volume), which lyses the cells. Use a lysate with a higher titer.`);
    }
  } else {
    const mixUl = plates * volume;
    if (totalUl >= 1) {
      steps.push(
        `To plate ${formatNumber(volume)} µl on each: ${plates} plates x ${formatNumber(volume)} µl = ${formatNumber(mixUl)} µl, so mix ${formatNumber(totalUl)} µl lysate + ${formatNumber(mixUl - totalUl)} µl phage buffer.`,
      );
      result = `${formatNumber(totalUl)} µl of lysate in ${formatNumber(mixUl)} µl, ${formatNumber(volume)} µl on each of ${plates} plates`;
    } else {
      steps.push(`${formatNumber(totalUl)} µl is under 1 µl, too little to pipette, so dilute the lysate first and plate ${formatNumber(volume)} µl of the last tube on each plate.`);
      result = `${formatNumber(totalUl)} µl of lysate for ${plates} plates, made by dilution`;
      notes.push("The webbed plate calculator plans the dilution tubes for these numbers.");
    }
  }
  return { ok: true, sections: [{ heading: "Lysate needed", steps, result, notes }] };
}

/* ---------------------------------------------------------------- registry */

/**
 * @typedef {object} ToolDefinition
 * @property {string} path the page it is on
 * @property {string} title the form's accessible name
 * @property {ToolField[]} fields
 * @property {(values: Record<string, string | undefined>) => ToolResult} run
 */

/** @type {Record<string, ToolDefinition>} */
export const TOOLS = {
  titer: {
    path: "/research/tools/titer",
    title: "Titer calculator",
    fields: [
      { name: "plaques", label: "Plaques counted", value: "111", hint: "On one plate, or in one spot." },
      {
        name: "volume",
        label: "Volume plated or spotted",
        unit: "µl",
        value: String(LAB.ulPlated),
        hint: `The lab plates ${LAB.ulPlated} µl for a full plate titer and spots ${LAB.ulSpot} µl for a spot titer; enter 10 for a 10 µl spot.`,
        source: SOURCES.fullPlate,
      },
      { name: "dilution", label: "Dilution, 10 to the minus", value: "6", hint: "6 for the 10^-6 tube, 0 if undiluted." },
    ],
    run: runTiter,
  },
  dilution: {
    path: "/research/tools/dilution",
    title: "Serial dilution planner",
    fields: [
      { name: "start", label: "Starting titer", unit: "pfu/ml", value: "1.11e10", hint: "1.11e10 or 1.11 x 10^10." },
      { name: "target", label: "Target concentration", unit: "pfu/ml", value: "1.11e6" },
      {
        name: "transfer",
        label: "Volume carried into each tube",
        unit: "µl",
        value: String(LAB.transferUl),
        hint: "Each 10-fold tube gets 9 times this of phage buffer: 10 µl + 90 µl.",
        source: SOURCES.tenfold,
      },
      { name: "needed", label: "Volume needed from the last tube (optional)", unit: "µl", value: "", hint: "Plates x µl per plate. Earlier tubes are scaled up to supply it." },
    ],
    run: runDilution,
  },
  "webbed-plate": {
    path: "/research/tools/webbed-plate",
    title: "Webbed plate calculator",
    fields: [
      { name: "titer", label: "Lysate titer", unit: "pfu/ml", value: "1.11e10", hint: "1.11e10 or 1.11 x 10^10." },
      { name: "pfu", label: "pfu wanted per plate", value: "11100", hint: "What webs depends on plaque size; see the notes below." },
      { name: "volume", label: "Volume plated per plate", unit: "µl", value: String(LAB.ulPlated), source: SOURCES.fullPlate },
      { name: "plates", label: "Plates to pour", value: String(LAB.plates), source: SOURCES.yields },
    ],
    run: runWebbedPlate,
  },
  flood: {
    path: "/research/tools/webbed-plate",
    title: "Plates for a lysate volume",
    fields: [{ name: "ml", label: "Lysate wanted", unit: "ml", value: "20", hint: "10 ml to archive plus 10 ml for DNA extraction is 20 ml." }],
    run: runFlood,
  },
  moi: {
    path: "/research/tools/moi",
    title: "MOI calculator",
    fields: [
      { name: "titer", label: "Phage titer", unit: "pfu/ml", value: "1e9", hint: "1e9 or 1 x 10^9." },
      { name: "phage", label: "Phage volume added", unit: "µl", value: String(LAB.ulPlated), source: SOURCES.fullPlate },
      {
        name: "cells",
        label: "Host cells",
        unit: "cells/ml",
        value: "1e8",
        hint: "Counted, not read off an OD: the site gives no OD-to-cells factor for the lab's hosts. 1e8 is only the worked example's number.",
      },
      { name: "host", label: "Host culture volume", unit: "µl", value: "250", source: SOURCES.fullPlate },
    ],
    run: runMoi,
  },
  eop: {
    path: "/research/tools/eop",
    title: "EOP from titers",
    fields: [
      { name: "test", label: "Titer on the test host", unit: "pfu/ml", value: "2e8", hint: "2e8 or 2 x 10^8." },
      { name: "reference", label: "Titer on the reference host", unit: "pfu/ml", value: "1e10", hint: "Usually the host the phage was isolated on." },
    ],
    run: runEop,
  },
  "eop-counts": {
    path: "/research/tools/eop",
    title: "EOP from plaque counts",
    fields: [
      { name: "testPlaques", label: "Plaques on the test host", value: "20" },
      { name: "testVolume", label: "Volume plated on the test host", unit: "µl", value: String(LAB.ulPlated), source: SOURCES.fullPlate },
      { name: "testDilution", label: "Test host dilution, 10 to the minus", value: "5", hint: "5 for the 10^-5 tube, 0 if undiluted." },
      { name: "refPlaques", label: "Plaques on the reference host", value: "100" },
      { name: "refVolume", label: "Volume plated on the reference host", unit: "µl", value: String(LAB.ulPlated), source: SOURCES.fullPlate },
      { name: "refDilution", label: "Reference host dilution, 10 to the minus", value: "6", hint: "6 for the 10^-6 tube, 0 if undiluted." },
    ],
    run: runEopCounts,
  },
  "lysate-volume": {
    path: "/research/tools/lysate-volume",
    title: "Lysate volume planner",
    fields: [
      { name: "titer", label: "Lysate titer", unit: "pfu/ml", value: "1e7", hint: "1e7 or 1 x 10^7." },
      { name: "pfu", label: "pfu wanted per plate", value: "10000", hint: "What webs depends on plaque size and host; see the notes below." },
      { name: "plates", label: "Plates to pour", value: String(LAB.plates), source: SOURCES.yields },
      { name: "volume", label: "Volume plated per plate", unit: "µl", value: String(LAB.ulPlated), source: SOURCES.fullPlate },
    ],
    run: runLysateVolume,
  },
};

/** The calculators on a page, in page order. @param {string} path */
export function toolsOnPage(path) {
  return Object.keys(TOOLS).filter((id) => TOOLS[id]?.path === path);
}

/**
 * A tool's values: the query string's where it has them, the field's opening value where not.
 *
 * @param {string} id
 * @param {URLSearchParams} [params]
 * @returns {Record<string, string>}
 */
export function toolValues(id, params) {
  const tool = TOOLS[id];
  if (!tool) throw new Error(`No calculator named ${id}.`);
  /** @type {Record<string, string>} */
  const values = {};
  for (const field of tool.fields) {
    const given = params?.get(field.name);
    values[field.name] = given === null || given === undefined ? field.value : given.slice(0, 40);
  }
  return values;
}

/** @param {string} id @param {Record<string, string | undefined>} values @returns {ToolResult} */
export function runTool(id, values) {
  const tool = TOOLS[id];
  if (!tool) throw new Error(`No calculator named ${id}.`);
  return tool.run(values);
}

/* ---------------------------------------------------------------- view */

/**
 * @typedef {{ tag: "div" | "h3" | "ol" | "li" | "p" | "strong" | "sup", className?: string, children: Array<ViewNode | string> }} ViewNode
 */

/** @param {string} line @returns {Array<ViewNode | string>} */
function lineNodes(line) {
  return textParts(line).map((part) => (part.sup ? { tag: "sup", children: [part.text] } : part.text));
}

/**
 * What a result looks like, as plain nodes, so the server component and the browser module build the
 * same markup from one description and neither parses HTML. `summary` is the one line per section the
 * status region announces; `detail` is the worked steps under it.
 *
 * @param {ToolResult} result
 * @returns {{ summary: Array<ViewNode | string>, detail: ViewNode[] }}
 */
export function resultView(result) {
  if (!result.ok) {
    return { summary: [{ tag: "p", className: "phage-tool-error", children: [result.error] }], detail: [] };
  }
  const summary = result.sections
    .filter((section) => section.result)
    .map((section) => ({
      tag: /** @type {const} */ ("p"),
      className: "phage-tool-answer",
      children: [`${section.heading}: `, { tag: /** @type {const} */ ("strong"), children: lineNodes(section.result ?? "") }],
    }));
  const detail = result.sections.map((section) => ({
    tag: /** @type {const} */ ("div"),
    className: "phage-tool-section",
    children: [
      { tag: /** @type {const} */ ("h3"), children: [section.heading] },
      {
        tag: /** @type {const} */ ("ol"),
        className: "phage-tool-steps",
        children: section.steps.map((step) => ({ tag: /** @type {const} */ ("li"), children: lineNodes(step) })),
      },
      ...(section.notes ?? []).map((note) => ({
        tag: /** @type {const} */ ("p"),
        className: "phage-tool-note",
        children: lineNodes(note),
      })),
    ],
  }));
  return { summary, detail };
}