From 9d5bc39bbfd1def185ef4d07794f85e40d9851d5 Mon Sep 17 00:00:00 2001 From: Dylan Stechmann Date: Fri, 30 Oct 2026 22:55:35 +0000 Subject: [PATCH 1/7] Plan a layered wet-lab practice track and protocol simulator Co-Authored-By: Claude Opus 5.5 --- docs/WET_LAB_PRACTICE_PLAN.md | 83 +++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/WET_LAB_PRACTICE_PLAN.md diff --git a/docs/WET_LAB_PRACTICE_PLAN.md b/docs/WET_LAB_PRACTICE_PLAN.md new file mode 100644 index 0000000..804335d --- /dev/null +++ b/docs/WET_LAB_PRACTICE_PLAN.md @@ -0,0 +1,83 @@ +# Wet-lab practice track: plan + +Status: plan only, drafted with AI assistance on 2026-10-30. Nothing here is built yet, and nothing here has been reviewed by a laboratory professional, a biosafety officer or a certifying body. + +## Goal + +A layered bench-practice track that lets a learner rehearse real laboratory procedures, with their real volumes, speeds, times, temperatures and acceptance criteria, before doing them at a physical bench, and that covers the written scope of recognised laboratory credentials closely enough to make later study for them short. + +What it can and cannot do: + +- It can teach the knowledge, calculations, decision-making and order of operations that written exams and supervised bench training test. +- It cannot train hand skill (pipetting technique, aseptic handling, cell morphology by eye), and it confers no credential. Certifications such as the ASCP Board of Certification exams set their own eligibility routes, which include education and documented laboratory experience; simulator time does not count toward them. +- The track is not affiliated with or endorsed by ASCP, CDC, NIH, OSHA, ISSCR or any other body named below. Their outlines and documents set scope only, cited by link, except where a source is a US federal work in the public domain. + +## Layers + +Each layer is a course package in `content/courses/` with lessons, practice items and cards, like the existing packages, plus protocol simulations described below. Later layers assume the earlier ones. + +| Layer | Package (working name) | Scope set by | What it covers | +| --- | --- | --- | --- | +| 0 | `lab-measurement` | General Chemistry I solutions lesson; manufacturer-neutral pipette and balance practice | Units, molarity and percent solutions, serial and parallel dilutions, micropipette selection and gravimetric verification, balances, pH meter calibration, significant figures and propagated error at the bench | +| 1 | `lab-safety-biosafety` | CDC/NIH *BMBL* 6th ed. (2020); OSHA 29 CFR 1910.1030 (bloodborne pathogens), 1910.1450 (laboratory standard), 1910.1200 (hazard communication) | Risk assessment, BSL-1/BSL-2 practices, biosafety cabinet classes, set-up, work and certification, PPE, spills and exposures, decontamination and autoclave verification, sharps, waste streams, chemical hygiene, SDS and GHS labels, awareness of Division 6.2 shipping rules | +| 2 | `cell-culture-practice` | BMBL; ISSCR *Standards for Human Stem Cell Use in Research* (2023, link only) | Aseptic technique, media and reagent preparation, passaging adherent and suspension cells, counting with a hemocytometer and trypan blue, freezing and thawing, contamination diagnosis, mycoplasma testing, cell-line authentication, feeder-free human pluripotent stem cell culture and its quality checks | +| 3 | `molecular-lab-practice` | ASCP BOC MB(ASCP) examination content guideline (molecular science, molecular techniques, laboratory operations, applications of molecular testing) | Nucleic acid isolation and quality control, gel and capillary electrophoresis, blotting and hybridization, PCR design and optimization, qPCR and RT-qPCR, melt curves, Sanger and next-generation sequencing basics, restriction analysis, contamination control and one-way workflow, assay validation and troubleshooting | +| 4 | `lab-quality-systems` | ASCP MB laboratory-operations section; 21 CFR Part 58 (GLP); CLIA (42 CFR Part 493) | Documentation and notebooks, SOP control, controls and proficiency testing, equipment calibration and maintenance records, deviations and corrective action, competency assessment, test-system categories (RUO, ASR, IVD, LDT) | +| 5 (optional extensions) | as needed | to be scoped per topic | Protein methods (SDS-PAGE, western blot, ELISA), microscopy and flow cytometry, a CRISPR editing workflow from guide design to genotyping, organoid culture, and an awareness lesson on cell-therapy manufacturing rules (21 CFR Part 1271 and Part 211) | + +The applications section of the MB outline (infectious disease, oncology, genetic disorders, histocompatibility, identity, engraftment and pharmacogenomics) is about a third of that exam. It would be a later package after layer 3, reusing the genetics course where it can. + +## Sources and reuse + +- **Public domain (US federal works):** BMBL 6th edition, OSHA and FDA regulations, and CLIA regulations can be quoted and adapted with attribution. Each gets a registry record with `reuse_mode` set to allow adaptation, and the URL and access date. +- **Link only:** ASCP content guidelines and reading lists, ISSCR standards (the society reserves reproduction rights), ATCC and vendor manuals, textbooks. These set scope and are cited, never pasted. +- **Openly licensed protocols:** protocols from protocols.io or other repositories may be adapted only under the license each one carries, recorded per protocol in the registry. +- Every numeric protocol parameter (a speed, a time, a temperature, a tolerance) needs a cited sentence or table in its source, checked as the citation-check skill describes, or a label saying it is a typical value chosen for the simulation. + +## The protocol simulator + +The repository's existing labs are data-analysis exercises. Practising a procedure needs a new piece: an interactive bench state that the learner changes by choosing actions. + +**Protocol definitions** live with each package as JSON (`protocols/*.json`), validated by a new schema. A definition holds: + +- the bench state: containers with volumes and contents, instrument settings, a clock, temperatures, and hidden variables such as sterility and nucleic-acid carry-over; +- the actions available at each point, with parameters (a volume, a speed, a time); +- effects: how each action changes the state, including realistic error models (for example pipetting inaccuracy, or contamination when a tip is reused); +- checkpoints: rules on the state or the action history, each marked critical, major or minor, with an explanation and a citation; +- readouts the learner sees: a gel image, an absorbance ratio, a cell count, a standard curve, a colony on a sterility plate. + +**Engine.** A deterministic Python module in the backend applies actions to state with a seeded random generator, so every run can be replayed and tested. The full definition, including checkpoints and expected values, stays on the server, in line with the rule that solutions never reach learners. The browser sends actions and receives the new visible state and any consequences. + +**Modes:** + +- *Guided*: each step is prompted and explained as it is done. +- *Practice*: no prompts. Consequences appear when they would at a real bench: a contaminated culture is found days later, and a failed PCR shows up on the gel. +- *Assessed run*: the protocol is run without help, then a debrief lists every checkpoint met or missed. Results are formative-only, like the rest of the site. + +**Scenario variation.** Seeds vary sample concentrations, instrument faults and starting conditions, so a protocol cannot be passed by memorising one run. + +**Mock exams.** Item banks are tagged to the outline headings of each scope source. A mock exam for layer 3 mirrors the published MB format (100 single-best-answer questions in 2 h 30 min, adaptive). The adaptive selection is only approximate, because the items are not calibrated on real examinees. + +**Interface.** Version 1 is a two-dimensional React bench view: a bench, racks, instruments and a timeline. Three-dimensional assets (for example glTF models made in Blender and rendered with three.js) can come later. They make the bench more realistic to look at but add no knowledge, and Blender cannot run in this container. + +## First milestone + +1. Protocol schema, engine, API routes and engine tests with fixed seeds. +2. Three protocols from layers 0–1: gravimetric verification of a micropipette, a tenfold serial dilution with a plate count, and setting up and working in a Class II biosafety cabinet, including a spill. +3. A minimal React bench view for those three. +4. Layer 0 as a course package with lessons, items and cards that pass the content validator. + +Later milestones follow the layer order. Each one ends with a scope crosswalk against its source, built like the existing `scope-crosswalk.md` files, saying what is covered, shallow or absent. + +## Open questions + +- Package names and whether layers 0 and 1 should be one package. +- Which openly licensed protocols to adapt for cell culture and molecular work, checked one by one for license and parameters. +- Whether to add a printable competency checklist per protocol for a supervisor to sign off at a real bench. This would be a practice aid only, not a record of competency. + +## References (scope; accessed 2026-10-30) + +- ASCP Board of Certification, *Technologist and International Technologist in Molecular Biology, MB(ASCP) and MB(ASCPi): Examination Content Guideline*, revised March 2023, read from an archived copy ([current page](https://www.ascp.org/boc/explore-credentials/view-all-credentials/MB)). A later revision exists and must be compared before layer 3 is scoped. +- CDC and NIH, [*Biosafety in Microbiological and Biomedical Laboratories*, 6th edition](https://www.cdc.gov/labs/bmbl/index.html), 2020. +- ISSCR, [*Standards for Human Stem Cell Use in Research*](https://www.isscr.org/standards-document), 2023. +- OSHA, 29 CFR 1910.1030, 1910.1450 and 1910.1200; FDA, 21 CFR Part 58; CMS, 42 CFR Part 493. From fe8f5851c5592f95d770f837c1c8f9590f34ef72 Mon Sep 17 00:00:00 2001 From: Dylan Stechmann Date: Fri, 30 Oct 2026 23:51:18 +0000 Subject: [PATCH 2/7] Add a bench-practice simulator with three real-protocol simulations A deterministic replay engine (courselab.bench) and stateless API run practice simulations of bench procedures: the browser sends a seed, a mode and the action list; the server replays it and returns only what a person at the bench could see, with a checkpoint debrief when the run ends. Hidden faults, checkpoint rules and model parameters stay on the server. Protocols, each a JSON definition validated by a new schema and paired with a Python model: - micropipette gravimetric verification (water density per Tanaka et al. 2001; acceptance limits are labelled practice values) - decimal dilutions and the aerobic plate count, adapted from FDA BAM Chapter 3 (January 2026 edition) - a Class II biosafety cabinet session with a spill, adapted from BMBL 6th edition Appendix A Also adds a Bench practice page in the React app, registry records for the three sources, a content-validator check for bench protocols, and 51 backend and 2 frontend tests. Formative practice only; unreviewed. Co-Authored-By: Claude Opus 5.5 --- backend/courselab/bench/__init__.py | 160 +++ backend/courselab/bench/bsc.py | 516 ++++++++++ backend/courselab/bench/engine.py | 232 +++++ backend/courselab/bench/pipette.py | 288 ++++++ backend/courselab/bench/plate_count.py | 406 ++++++++ backend/courselab/bench_routes.py | 56 ++ backend/courselab/main.py | 4 + backend/tests/test_bench.py | 523 ++++++++++ .../class-ii-bsc-session-with-spill.json | 931 ++++++++++++++++++ .../decimal-dilution-aerobic-plate-count.json | 723 ++++++++++++++ ...micropipette-gravimetric-verification.json | 75 ++ content/schemas/bench-protocol.schema.json | 72 ++ content/sources/registry.json | 75 ++ docs/ARCHITECTURE.md | 2 + docs/ROADMAP.md | 2 + docs/WET_LAB_PRACTICE_PLAN.md | 4 +- frontend/src/App.tsx | 29 +- frontend/src/api.ts | 18 + .../src/components/BenchPractice.test.tsx | 162 +++ frontend/src/components/BenchPractice.tsx | 454 +++++++++ frontend/src/styles.css | 61 ++ frontend/src/types.ts | 87 ++ tools/validate_content.py | 30 + 23 files changed, 4900 insertions(+), 10 deletions(-) create mode 100644 backend/courselab/bench/__init__.py create mode 100644 backend/courselab/bench/bsc.py create mode 100644 backend/courselab/bench/engine.py create mode 100644 backend/courselab/bench/pipette.py create mode 100644 backend/courselab/bench/plate_count.py create mode 100644 backend/courselab/bench_routes.py create mode 100644 backend/tests/test_bench.py create mode 100644 content/bench/protocols/class-ii-bsc-session-with-spill.json create mode 100644 content/bench/protocols/decimal-dilution-aerobic-plate-count.json create mode 100644 content/bench/protocols/micropipette-gravimetric-verification.json create mode 100644 content/schemas/bench-protocol.schema.json create mode 100644 frontend/src/components/BenchPractice.test.tsx create mode 100644 frontend/src/components/BenchPractice.tsx diff --git a/backend/courselab/bench/__init__.py b/backend/courselab/bench/__init__.py new file mode 100644 index 0000000..7091c62 --- /dev/null +++ b/backend/courselab/bench/__init__.py @@ -0,0 +1,160 @@ +"""Bench-procedure practice: protocol definitions, models and deterministic replay.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + +from .bsc import BiosafetyCabinetSession +from .engine import ActionRejected, ProtocolInvalid, ProtocolModel, replay +from .pipette import PipetteVerification +from .plate_count import AerobicPlateCount + +MODELS: dict[str, type[ProtocolModel]] = { + model.protocol_id: model for model in (PipetteVerification, AerobicPlateCount, BiosafetyCabinetSession) +} + +__all__ = ["ActionRejected", "BenchLibrary", "ProtocolInvalid", "replay"] + + +class _Strict(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class Source(_Strict): + kind: Literal["cited", "simulation-typical", "simulation-model"] + source_id: str | None = None + location: str | None = None + note: str | None = None + + @model_validator(mode="after") + def cited_needs_source(self): + if self.kind == "cited" and not (self.source_id and self.location): + raise ValueError("A cited value needs a source_id and a location") + return self + + +class Option(_Strict): + value: str + label: str + + +class Param(_Strict): + name: str + label: str + type: Literal["number", "integer", "boolean", "choice"] + min: float | None = None + max: float | None = None + unit: str | None = None + options: list[Option] | None = None + + @model_validator(mode="after") + def complete(self): + if self.type in ("number", "integer") and (self.min is None or self.max is None or self.min > self.max): + raise ValueError(f"{self.name}: numeric parameters need min <= max") + if self.type == "choice" and not self.options: + raise ValueError(f"{self.name}: choice parameters need options") + return self + + +class Action(_Strict): + name: str = Field(pattern=r"^[a-z][a-z0-9_]*$") + label: str + params: list[Param] + + +class Parameter(_Strict): + name: str + value: float | int | str | list[float] + unit: str + label: str + source: Source + + +class Checkpoint(_Strict): + id: str = Field(pattern=r"^[a-z0-9-]+$") + severity: Literal["critical", "major", "minor"] + description: str + rationale: str + source: Source + + +class SourceUse(_Strict): + source_id: str + use: str + + +class ProtocolDefinition(_Strict): + schema_version: Literal["1.0"] + id: str = Field(pattern=r"^[a-z0-9-]+$") + version: str = Field(pattern=r"^\d+\.\d+\.\d+$") + title: str + layer: int = Field(ge=0, le=5) + summary: str + scenario: str + guided_steps: list[str] = Field(min_length=1) + parameters: list[Parameter] + actions: list[Action] = Field(min_length=1) + checkpoints: list[Checkpoint] = Field(min_length=1) + sources: list[SourceUse] + limitations: list[str] = Field(min_length=1) + + @model_validator(mode="after") + def unique_names(self): + for label, names in (("parameter", [p.name for p in self.parameters]), + ("action", [a.name for a in self.actions]), + ("checkpoint", [c.id for c in self.checkpoints])): + if len(names) != len(set(names)): + raise ValueError(f"Duplicate {label} name in {self.id}") + return self + + +class BenchLibrary: + """Loads every protocol under content/bench/protocols and pairs it with its model.""" + + def __init__(self, content_root: Path): + self.directory = Path(content_root) / "bench" / "protocols" + self._models: dict[str, ProtocolModel] | None = None + + def models(self) -> dict[str, ProtocolModel]: + if self._models is None: + models = {} + if self.directory.is_dir(): + for path in sorted(self.directory.glob("*.json")): + try: + raw = json.loads(path.read_text(encoding="utf-8")) + ProtocolDefinition.model_validate(raw) + except (ValueError, ValidationError) as exc: + raise ProtocolInvalid(f"{path.name}: {exc}") from exc + if raw["id"] != path.stem: + raise ProtocolInvalid(f"{path.name}: file name must match the protocol id") + model = MODELS.get(raw["id"]) + if model is None: + raise ProtocolInvalid(f"{path.name}: no simulation model for {raw['id']}") + models[raw["id"]] = model(raw) + self._models = models + return self._models + + def get(self, protocol_id: str) -> ProtocolModel | None: + return self.models().get(protocol_id) + + def summaries(self) -> list[dict]: + return [public_summary(model.definition) for model in self.models().values()] + + +def public_summary(definition: dict) -> dict: + return {key: definition[key] for key in ("id", "version", "title", "layer", "summary")} + + +def public_protocol(definition: dict) -> dict: + """What the browser may see before a run ends: no checkpoints, no hidden-model parameters.""" + return { + **public_summary(definition), + "scenario": definition["scenario"], + "actions": definition["actions"], + "limitations": definition["limitations"], + "sources": definition["sources"], + } diff --git a/backend/courselab/bench/bsc.py b/backend/courselab/bench/bsc.py new file mode 100644 index 0000000..8b2144a --- /dev/null +++ b/backend/courselab/bench/bsc.py @@ -0,0 +1,516 @@ +"""Working in a Class II biological safety cabinet, including a spill during the work.""" + +from __future__ import annotations + +from .engine import ActionRejected, Event, ProtocolModel, coach + +NEEDED = {"culture", "media", "plates", "pipettes", "pipette_aid", "discard_tray", "biohazard_bag"} +BULKY = {"discard_tray", "biohazard_bag"} +NAMES = { + "culture": "BSL-2 culture", "media": "media bottle", "plates": "fresh culture plates", + "pipettes": "sterile serological pipettes", "pipette_aid": "pipette aid", + "discard_tray": "horizontal discard tray with disinfectant", "biohazard_bag": "biohazard bag", + "vortex": "vortex mixer", "extra_gloves": "spare box of gloves", "upright_canister": "upright pipette canister", +} +WORK_STEPS = 3 + + +class BiosafetyCabinetSession(ProtocolModel): + protocol_id = "class-ii-bsc-session-with-spill" + + def checkpoint_ids(self): + return [ + "certification-checked", "certification-current", "ppe", "purge-before-loading", + "interior-disinfected", "bleach-rinsed", "drain-valve-closed", "toweling-placement", + "items-wiped", "biological-last", "only-needed-items", "bulky-to-side", "aerosol-equipment-rear", + "no-outside-bag", "sash-at-certified-height", "arms-slow", "arms-settle", "work-four-inches", + "no-open-flame", "uv-not-sole", "spill-covered-outside-in", "spill-contact-time", + "spill-towels-edge-to-center", "spill-biohazard-disposal", "spill-gloves-changed", + "large-spill-drain", "teardown-blower-running", "teardown-biological-first", "final-wipe", + "ppe-off-handwash", + ] + + def start(self, rng): + p = self.parameters + expired = rng.random() < p["expired_certification_probability"] + months = rng.randint(13, 20) if expired else rng.randint(2, 11) + return { + "hidden": { + "months_since_certification": months, "expired": months > p["certification_interval_months"], + "drain_open": rng.random() < 0.5, + "spill_size": "large" if rng.random() < p["large_spill_probability"] else "small", + "spill_step": rng.randint(1, 2), "product_risk": [], "personnel_risk": [], + }, + "inspected": False, "out_of_service": False, "ppe": None, "blower_on_at": None, "blower_off_events": 0, + "sash": "closed", "sash_during_work": set(), "surface_agent": None, "bleach_unrinsed": False, + "toweling": None, "items": {}, "load_order": [], "loaded_before_purge": False, + "arms_in_at": None, "arm_motions": [], "settled_each_entry": [], "flame": False, "uv_minutes": 0.0, + "work_done": 0, "work_distances": [], "spill": None, "removed": [], "final_wipe": None, + "ppe_removed": False, "hands_washed": False, "ppe_removed_before_wash": None, + } + + # Helpers ------------------------------------------------------------------- + + def _purged(self, state): + return state["blower_on_at"] is not None and state["clock"] - state["blower_on_at"] >= self.parameters["purge_min"] + + def _spill_open(self, state): + spill = state["spill"] + return spill is not None and not spill.get("resolved") + + def _resolve_if_done(self, state): + spill = state["spill"] + if spill and spill.get("discarded") and spill.get("wiped") and ( + spill["size"] == "small" or spill.get("drained")): + spill["resolved"] = True + + # Set-up -------------------------------------------------------------------- + + def act_inspect_cabinet(self, state, rng): + state["inspected"] = True + hidden = state["hidden"] + state["clock"] += 1 + return [Event("readout", f"Certification label: last field-certified {hidden['months_since_certification']} " + f"months ago. Drain valve: {'open' if hidden['drain_open'] else 'closed'}. " + f"Sash: {state['sash']}.", + {"months_since_certification": hidden["months_since_certification"], + "drain_valve": "open" if hidden["drain_open"] else "closed"})] + + def act_report_out_of_service(self, state, rng): + state["out_of_service"] = True + state["finished"] = True + return [Event("info", "The cabinet is tagged out of service and the biosafety officer is notified.")] + + def act_don_ppe(self, state, rng, coat_buttoned, gloves): + state["ppe"] = {"coat": coat_buttoned, "gloves": gloves} + state["clock"] += 1 + events = [Event("info", "PPE put on.")] + if not (coat_buttoned and gloves): + coach(state, events, "Wear a buttoned laboratory coat and gloves for BSL-2 work.") + return events + + def act_blower(self, state, rng, on): + if on and state["blower_on_at"] is None: + state["blower_on_at"] = state["clock"] + return [Event("info", "The cabinet blower is switched on.")] + if not on and state["blower_on_at"] is not None: + state["blower_on_at"] = None + state["blower_off_events"] += 1 + return [Event("info", "The cabinet blower is switched off.")] + raise ActionRejected("The blower is already in that state") + + def act_set_sash(self, state, rng, position): + state["sash"] = position + events = [Event("info", f"Sash moved to the {position} position.")] + if position == "raised" and state["blower_on_at"] is not None: + events.append(Event("warning", "The airflow alarm sounds: the sash is above its certified height.")) + return events + + def act_wait(self, state, rng, minutes): + state["clock"] += minutes + text = f"{minutes:g} min pass." + if self._spill_open(state) and state["spill"].get("applied_at") is not None: + text += f" The spill has had {self._contact(state):.0f} min of contact with disinfectant." + return [Event("info", text)] + + def _contact(self, state): + spill = state["spill"] + return 0.0 if spill.get("applied_at") is None else state["clock"] - spill["applied_at"] + + def act_close_drain_valve(self, state, rng): + state["hidden"]["drain_open"] = False + return [Event("info", "The drain valve under the work surface is closed.")] + + def act_disinfect_interior(self, state, rng, agent): + if state["items"]: + raise ActionRejected("Disinfect the empty cabinet before loading it") + state["surface_agent"] = agent + state["bleach_unrinsed"] = agent == "bleach" + state["clock"] += 3 + events = [Event("info", "Work surface, interior walls and the inside of the window wiped down.")] + if agent == "water": + coach(state, events, "Non-sterile water is not a disinfectant and may recontaminate the surfaces.") + return events + + def act_rinse_sterile_water(self, state, rng): + state["bleach_unrinsed"] = False + state["clock"] += 2 + return [Event("info", "Surfaces wiped a second time with sterile water.")] + + def act_lay_toweling(self, state, rng, location): + state["toweling"] = location + events = [Event("info", f"Plastic-backed absorbent toweling laid on the {location.replace('_', ' ')}.")] + if location == "front_grille": + state["hidden"]["product_risk"].append("toweling over the front grille") + state["hidden"]["personnel_risk"].append("toweling over the front grille") + coach(state, events, "Toweling over the grille blocks the air that keeps the cabinet contained.") + return events + + def act_load_item(self, state, rng, item, zone, wiped): + if item in state["items"]: + raise ActionRejected(f"The {NAMES[item]} is already in place") + if state["blower_on_at"] is None: + raise ActionRejected("Switch the blower on before loading the cabinet") + if self._spill_open(state): + raise ActionRejected("Deal with the spill first") + events = [Event("info", f"The {NAMES[item]} is placed at the {zone.replace('_', ' ')}.")] + if not self._purged(state): + state["loaded_before_purge"] = True + coach(state, events, f"Run the blower at least {self.parameters['purge_min']:g} min before starting work.") + if not wiped: + state["hidden"]["product_risk"].append(f"unwiped {NAMES[item]}") + coach(state, events, "Wipe each item with disinfectant before it goes in.") + if zone == "front_grille": + state["hidden"]["personnel_risk"].append(f"{NAMES[item]} on the front grille") + coach(state, events, "Nothing may rest on the front grille.") + state["items"][item] = {"zone": zone, "wiped": wiped} + state["load_order"].append(item) + state["clock"] += 0.5 + return events + + def act_insert_arms(self, state, rng, motion): + state["arms_in_at"] = state["clock"] + state["arm_motions"].append(motion) + state["settled_each_entry"].append(False) + events = [Event("info", f"Arms moved into the cabinet ({motion.replace('_', ' ')}).")] + if motion == "sweeping": + state["hidden"]["product_risk"].append("sweeping arm movement") + coach(state, events, "Move arms in and out slowly, perpendicular to the opening.") + return events + + def act_settle(self, state, rng, seconds): + if state["arms_in_at"] is None: + raise ActionRejected("Your arms are not in the cabinet") + state["clock"] += seconds / 60 + if seconds >= self.parameters["arm_settle_s"]: + state["settled_each_entry"][-1] = True + return [Event("info", f"You hold still for {seconds:g} s while the airflow stabilizes.")] + + def act_withdraw_arms(self, state, rng): + if state["arms_in_at"] is None: + raise ActionRejected("Your arms are not in the cabinet") + state["arms_in_at"] = None + return [Event("info", "Arms withdrawn slowly.")] + + def act_light_burner(self, state, rng): + state["flame"] = True + state["hidden"]["product_risk"].append("open flame disrupting the airflow") + events = [Event("info", "A Bunsen burner is lit inside the cabinet.")] + coach(state, events, "Open flames are not recommended in a BSC: they disrupt the HEPA-filtered airflow.") + return events + + def act_uv_lamp(self, state, rng, minutes): + if state["arms_in_at"] is not None: + raise ActionRejected("Withdraw your arms before using the UV lamp") + state["uv_minutes"] += minutes + state["clock"] += minutes + return [Event("info", f"The UV lamp runs for {minutes:g} min with the sash closed.")] + + # Work and spill ------------------------------------------------------------ + + def act_work_step(self, state, rng, distance_in, lids_over_plates): + p = self.parameters + if state["arms_in_at"] is None: + raise ActionRejected("Put your arms in the cabinet first") + if not {"culture", "plates", "pipettes", "pipette_aid"} <= set(self._remaining(state)): + raise ActionRejected("The culture, plates, pipettes and pipette aid must be in the cabinet") + if self._spill_open(state): + raise ActionRejected("Deal with the spill first") + if state["work_done"] >= WORK_STEPS: + raise ActionRejected("The subculture is complete") + if state["work_done"] == 0: + state["drain_open_at_start"] = state["hidden"]["drain_open"] + state["work_done"] += 1 + state["work_distances"].append(distance_in) + state["sash_during_work"].add(state["sash"]) + state["clock"] += 4 + events = [Event("info", f"Subculture step {state['work_done']} of {WORK_STEPS} done " + f"{distance_in:g} in behind the front grille.")] + if distance_in < p["min_distance_in"]: + state["hidden"]["personnel_risk"].append("work too close to the front grille") + coach(state, events, f"Work at least {p['min_distance_in']:g} in behind the front grille.") + if state["sash"] != "certified": + state["hidden"]["personnel_risk"].append(f"work with the sash {state['sash']}") + coach(state, events, "Work only with the sash at its certified height.") + if not lids_over_plates: + state["hidden"]["product_risk"].append("open plates exposed to the downflow") + coach(state, events, "Hold lids above open plates to shield them from the downward air.") + if state["work_done"] == state["hidden"]["spill_step"] and state["spill"] is None: + size = state["hidden"]["spill_size"] + state["spill"] = {"size": size} + text = ("A tube slips: about 2 mL of culture splashes onto the work surface." if size == "small" else + "The culture flask tips over: about 50 mL of culture floods the work surface and runs " + "through the front and rear grilles.") + events.append(Event("consequence", text, {"spill": size})) + if size == "large" and state["hidden"]["drain_open"]: + events.append(Event("consequence", "Liquid drips from under the cabinet: the drain valve is open.")) + state["hidden"]["personnel_risk"].append("culture leaking through the open drain valve") + return events + + def _need_spill(self, state): + if state["spill"] is None or state["spill"].get("resolved"): + raise ActionRejected("There is no spill to deal with") + return state["spill"] + + def act_cover_spill(self, state, rng): + spill = self._need_spill(state) + spill["covered"] = True + state["clock"] += 1 + return [Event("info", "The spill is covered with paper towels.")] + + def act_apply_disinfectant(self, state, rng, direction): + spill = self._need_spill(state) + if spill.get("applied_at") is None: + spill["applied_at"] = state["clock"] + spill["direction"] = direction + spill["covered_first"] = spill.get("covered", False) + state["clock"] += 1 + events = [Event("info", f"Disinfectant poured over the spill, working {direction.replace('_', ' ')}.")] + if direction == "center_out": + coach(state, events, "Start at the outside of the spill so you do not spread it.") + return events + + def act_push_towels(self, state, rng, direction): + spill = self._need_spill(state) + if spill.get("applied_at") is None: + raise ActionRejected("Apply disinfectant first") + spill["contact_min"] = self._contact(state) + spill["push_direction"] = direction + state["clock"] += 1 + events = [Event("info", f"Towels gathered {direction.replace('_', ' ')}.")] + if spill["contact_min"] < self.parameters["spill_contact_min"][0]: + coach(state, events, f"Only {spill['contact_min']:.0f} min of contact; leave the disinfectant " + "20–30 min first.") + return events + + def act_discard_towels(self, state, rng, into): + spill = self._need_spill(state) + if spill.get("push_direction") is None: + raise ActionRejected("Gather the towels first") + spill["discarded"] = into + state["clock"] += 0.5 + events = [Event("info", f"Towels discarded into the {into.replace('_', ' ')}.")] + if into != "biohazard_bag": + state["hidden"]["personnel_risk"].append("spill towels in ordinary waste") + coach(state, events, "Spill towels go into the biohazard bag.") + self._resolve_if_done(state) + return events + + def act_wipe_down_interior(self, state, rng): + spill = self._need_spill(state) + spill["wiped"] = True + spill["wiped_at"] = state["clock"] + state["clock"] += 2 + self._resolve_if_done(state) + return [Event("info", "Cabinet interior and the items inside wiped with a disinfectant-dampened towel.")] + + def act_flood_drain_pan(self, state, rng): + spill = self._need_spill(state) + if spill["size"] != "large": + raise ActionRejected("The spill has not reached the grilles") + if self._remaining(state): + raise ActionRejected("Surface-decontaminate and remove every item before flooding the drain pan") + spill["flooded_with_drain_open"] = state["hidden"]["drain_open"] + spill["flooded"] = True + state["clock"] += 2 + events = [Event("info", "Disinfectant poured onto the work surface and through the grilles into the drain pan.")] + if state["hidden"]["drain_open"]: + state["hidden"]["personnel_risk"].append("disinfectant and culture draining onto the floor") + events.append(Event("consequence", "Liquid runs out of the open drain valve onto the floor.")) + return events + + def act_drain_to_disinfectant(self, state, rng): + spill = self._need_spill(state) + if not spill.get("flooded"): + raise ActionRejected("Flood the drain pan with disinfectant first") + spill["drained"] = True + state["clock"] += 5 + self._resolve_if_done(state) + return [Event("info", "Drain pan emptied through a tube submerged in a collection vessel of disinfectant, " + "then flushed with water and the valve closed.")] + + def act_change_gloves(self, state, rng): + spill = state["spill"] + if spill is not None and spill.get("wiped") and spill.get("gloves_changed_after") is None: + spill["gloves_changed_after"] = True + state["clock"] += 0.5 + return [Event("info", "Gloves changed.")] + + # Teardown ------------------------------------------------------------------ + + def _remaining(self, state): + return [item for item in state["items"] if item not in {r["item"] for r in state["removed"]}] + + def act_remove_item(self, state, rng, item, decontaminated): + if item not in self._remaining(state): + raise ActionRejected(f"The {NAMES[item]} is not in the cabinet") + state["removed"].append({"item": item, "decontaminated": decontaminated, + "blower_on": state["blower_on_at"] is not None}) + state["clock"] += 0.5 + events = [Event("info", f"The {NAMES[item]} is removed.")] + if not decontaminated: + state["hidden"]["personnel_risk"].append(f"{NAMES[item]} removed without surface decontamination") + coach(state, events, "Surface-decontaminate items before taking them out.") + return events + + def act_final_wipe(self, state, rng, agent): + if self._remaining(state): + raise ActionRejected("Remove everything from the cabinet first") + state["final_wipe"] = agent + state["clock"] += 3 + return [Event("info", "Work surface, sides, back and the inside of the glass wiped down.")] + + def act_remove_ppe(self, state, rng): + state["ppe_removed"] = True + state["clock"] += 1 + return [Event("info", "Gloves and coat removed without touching the outer surfaces.")] + + def act_wash_hands(self, state, rng): + state["hands_washed"] = True + state["ppe_removed_before_wash"] = state["ppe_removed"] + state["clock"] += 1 + return [Event("info", "Hands washed.")] + + def act_end_session(self, state, rng): + state["finished"] = True + events = [Event("info", "The session is closed.")] + risk = state["hidden"]["product_risk"] + if state["work_done"]: + if len(risk) >= 2 or (risk and rng.random() < 0.5): + events.append(Event("consequence", "Two days later, mould and bacterial colonies appear on several " + "of the new plates.")) + else: + events.append(Event("consequence", "Two days later, the new plates show only the expected growth.")) + return events + + # Views and evaluation ------------------------------------------------------ + + def visible(self, state): + blower = "off" if state["blower_on_at"] is None else f"on for {state['clock'] - state['blower_on_at']:.1f} min" + cabinet = [ + ["Blower", blower], ["Sash", state["sash"]], + ["Surfaces", "not yet disinfected" if state["surface_agent"] is None else f"wiped with {state['surface_agent']}"], + ["Toweling", "none" if state["toweling"] is None else state["toweling"].replace("_", " ")], + ["Arms", "in the cabinet" if state["arms_in_at"] is not None else "outside"], + ["Subculture", f"{state['work_done']} of {WORK_STEPS} steps done"], + ] + if state["inspected"]: + cabinet.insert(0, ["Certification label", f"{state['hidden']['months_since_certification']} months ago"]) + items = [[NAMES[name], f"{info['zone'].replace('_', ' ')}{'' if info['wiped'] else ', not wiped'}"] + for name, info in state["items"].items() if name not in {r["item"] for r in state["removed"]}] + panels = [{"title": "Cabinet", "rows": cabinet}, {"title": "Inside the cabinet", "rows": items or [["—", "empty"]]}] + spill = state["spill"] + if spill is not None and not spill.get("resolved"): + status = [] + if spill.get("covered"): + status.append("covered with towels") + if spill.get("applied_at") is not None: + status.append(f"disinfectant contact {self._contact(state):.0f} min") + panels.append({"title": "Spill", "rows": [["Size", spill["size"]], ["Status", ", ".join(status) or "untreated"]]}) + return panels + + def evaluate(self, state): + p = self.parameters + hidden = state["hidden"] + r: dict[str, tuple[bool | None, str]] = {} + r["certification-checked"] = (state["inspected"], "The certification label was read before use." + if state["inspected"] else "The certification label was never checked.") + if hidden["expired"]: + ok = state["out_of_service"] and state["work_done"] == 0 + r["certification-current"] = (ok, f"The cabinet was {hidden['months_since_certification']} months past " + "certification; it must not be used until recertified.") + else: + r["certification-current"] = (not state["out_of_service"], + f"The cabinet was certified {hidden['months_since_certification']} months ago " + "and was fit for use.") + if state["out_of_service"]: + for key in self.checkpoint_ids(): + r.setdefault(key, (None, "Not applicable: the cabinet was taken out of service.")) + return r + ppe = state["ppe"] + r["ppe"] = (bool(ppe) and ppe["coat"] and ppe["gloves"], "A buttoned coat and gloves are required.") + r["purge-before-loading"] = (bool(state["items"]) and not state["loaded_before_purge"], + f"Run the blower at least {p['purge_min']:g} min before loading.") + r["interior-disinfected"] = (state["surface_agent"] in ("ethanol70", "bleach"), + f"Interior wiped with: {state['surface_agent'] or 'nothing'}.") + r["bleach-rinsed"] = ((not state["bleach_unrinsed"]) if state["surface_agent"] == "bleach" else None, + "Bleach needs a second wipe with sterile water to remove residual chlorine.") + opened = state.get("drain_open_at_start") + r["drain-valve-closed"] = ((not opened) if state["work_done"] else None, + "The drain valve should be closed before work begins.") + r["toweling-placement"] = (state["toweling"] != "front_grille", + "Toweling may cover the work surface but never the grilles.") + items = state["items"] + r["items-wiped"] = (bool(items) and all(info["wiped"] for info in items.values()), + "Every item should be wiped with disinfectant before it goes in.") + order = state["load_order"] + r["biological-last"] = (("culture" in order and order[-1] == "culture") if order else False, + "Biological material is placed in the cabinet last.") + extra = sorted(set(items) - NEEDED - {"vortex"}) + r["only-needed-items"] = (not extra, "Only what the work needs goes in." if not extra + else f"Not needed inside: {', '.join(NAMES[e] for e in extra)}.") + bulky = [name for name in BULKY if name in items] + r["bulky-to-side"] = ((all(items[name]["zone"] == "side" for name in bulky)) if bulky else None, + "The discard tray and biohazard bag belong to one side of the interior.") + r["aerosol-equipment-rear"] = ((items["vortex"]["zone"] == "rear") if "vortex" in items else None, + "Aerosol-generating equipment goes toward the rear.") + bag = items.get("biohazard_bag") + r["no-outside-bag"] = ((bag["zone"] != "outside_taped") if bag else None, + "The biohazard bag must not be taped to the outside of the cabinet.") + r["sash-at-certified-height"] = (bool(state["work_done"]) and state["sash_during_work"] == {"certified"}, + f"Sash positions during work: {sorted(state['sash_during_work']) or ['no work']}.") + motions = state["arm_motions"] + r["arms-slow"] = (bool(motions) and all(m == "slow_perpendicular" for m in motions), + "Arms should move in and out slowly, perpendicular to the opening.") + settled = state["settled_each_entry"] + r["arms-settle"] = (bool(settled) and all(settled), + f"Wait about {p['arm_settle_s']:g} s after putting your arms in before working.") + dist = state["work_distances"] + r["work-four-inches"] = (bool(dist) and min(dist) >= p["min_distance_in"], + f"Closest work: {min(dist) if dist else 0:g} in from the front grille; at least " + f"{p['min_distance_in']:g} in is required.") + r["no-open-flame"] = (not state["flame"], "No open flame was used." if not state["flame"] + else "An open flame was used inside the cabinet.") + r["uv-not-sole"] = ((state["surface_agent"] in ("ethanol70", "bleach")) if state["uv_minutes"] else None, + "UV may supplement but never replace chemical disinfection.") + spill = state["spill"] + if spill is None: + for key in ("spill-covered-outside-in", "spill-contact-time", "spill-towels-edge-to-center", + "spill-biohazard-disposal", "spill-gloves-changed", "large-spill-drain"): + r[key] = (None, "No spill occurred in this run.") + else: + r["spill-covered-outside-in"] = (bool(spill.get("covered_first")) and spill.get("direction") == "outside_in", + "Cover with towels, then pour disinfectant starting from the outside.") + low, high = p["spill_contact_min"] + contact = spill.get("contact_min") + r["spill-contact-time"] = (contact is not None and contact >= low, + f"Contact time before gathering: {contact or 0:.0f} min; usually {low}–{high} min.") + r["spill-towels-edge-to-center"] = (spill.get("push_direction") == "edge_to_center", + "Push towels from the edge of the spill to the centre.") + r["spill-biohazard-disposal"] = (spill.get("discarded") == "biohazard_bag", + "Spill towels go into the biohazard bag.") + r["spill-gloves-changed"] = (bool(spill.get("gloves_changed_after")), + "Change gloves after the surface is decontaminated.") + if spill["size"] == "large": + r["large-spill-drain"] = (bool(spill.get("drained")) and not spill.get("flooded_with_drain_open"), + "With the drain valve closed, flood the drain pan with disinfectant and " + "empty it into a vessel of disinfectant.") + else: + r["large-spill-drain"] = (None, "The spill stayed on the work surface.") + removed = state["removed"] + r["teardown-blower-running"] = (bool(removed) and all(x["blower_on"] and x["decontaminated"] for x in removed), + "Decontaminate and remove items with the blower running.") + r["teardown-biological-first"] = ((removed[0]["item"] == "culture") if removed and "culture" in items else None, + "Biological materials come out first.") + r["final-wipe"] = (state["final_wipe"] in ("ethanol70", "bleach"), + "End with a wipe of the work surface, sides, back and glass.") + r["ppe-off-handwash"] = (state["hands_washed"] and bool(state["ppe_removed_before_wash"]), + "Remove gloves and coat, then wash hands as the final step.") + return r + + def reveal(self, state): + hidden = state["hidden"] + notes = [f"Spill planned for step {hidden['spill_step']} ({hidden['spill_size']})."] + notes.append("Risks to the culture: " + ("; ".join(hidden["product_risk"]) or "none recorded") + ".") + notes.append("Risks to you and the room: " + ("; ".join(hidden["personnel_risk"]) or "none recorded") + ".") + return notes diff --git a/backend/courselab/bench/engine.py b/backend/courselab/bench/engine.py new file mode 100644 index 0000000..a7014fd --- /dev/null +++ b/backend/courselab/bench/engine.py @@ -0,0 +1,232 @@ +"""Deterministic replay of bench-procedure practice runs. + +A run is a protocol, a seed, a mode and the ordered list of actions the learner has +taken. The server replays the whole list on every request, so no run state is stored +and every run can be reproduced exactly. Hidden variables (a pipette's true bias, a +contaminated diluent) and checkpoint rules stay on the server; the browser receives +only what a person at the bench could see, and the checkpoint debrief once the run ends. +""" + +from __future__ import annotations + +import math +import random +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +MODES = ("guided", "practice") +MAX_ACTIONS = 400 + + +class ActionRejected(Exception): + """An action that is physically impossible in the current state (not a mistake).""" + + +class ProtocolInvalid(Exception): + """A protocol definition that does not match its model.""" + + +@dataclass +class Event: + kind: str # "info", "readout", "warning" or "consequence" + text: str + data: dict[str, Any] = field(default_factory=dict) + + def view(self) -> dict: + return {"kind": self.kind, "text": self.text, "data": self.data} + + +def coach(state: dict, events: list[Event], text: str) -> None: + """Immediate feedback on a mistake, given only in guided mode. + + In practice mode the learner finds out the way they would at a bench: through a + later readout, a failed result, or the debrief. + """ + if state.get("mode") == "guided": + events.append(Event("warning", text)) + + +def check_parameters(spec: dict, params: Any) -> dict: + """Validate one action's parameters against the action spec from the definition.""" + if not isinstance(params, dict): + raise ActionRejected("Action parameters must be an object") + declared = {item["name"]: item for item in spec.get("params", [])} + unknown = set(params) - set(declared) + if unknown: + raise ActionRejected(f"Unknown parameter for {spec['name']}: {sorted(unknown)[0]}") + clean = {} + for name, item in declared.items(): + if name not in params: + raise ActionRejected(f"{spec['label']} needs a value for {item['label'].lower()}") + value = params[name] + kind = item["type"] + if kind == "boolean": + if not isinstance(value, bool): + raise ActionRejected(f"{item['label']} must be true or false") + elif kind in ("number", "integer"): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ActionRejected(f"{item['label']} must be a number") + if kind == "integer" and int(value) != value: + raise ActionRejected(f"{item['label']} must be a whole number") + if not item["min"] <= value <= item["max"]: + raise ActionRejected( + f"{item['label']} must be between {item['min']} and {item['max']} {item.get('unit', '')}".rstrip() + ) + value = int(value) if kind == "integer" else float(value) + elif kind == "choice": + if value not in [option["value"] for option in item["options"]]: + raise ActionRejected(f"{item['label']} must be one of the listed options") + else: # pragma: no cover - the schema forbids other kinds + raise ProtocolInvalid(f"Unknown parameter type {kind}") + clean[name] = value + return clean + + +class ProtocolModel(ABC): + """Physics, error models and checkpoint rules for one protocol definition.""" + + protocol_id: str + + def __init__(self, definition: dict): + if definition["id"] != self.protocol_id: + raise ProtocolInvalid(f"Definition {definition['id']} does not match model {self.protocol_id}") + self.definition = definition + self.parameters = {item["name"]: item["value"] for item in definition["parameters"]} + self.action_specs = {item["name"]: item for item in definition["actions"]} + handled = {name[len("act_"):] for name in dir(self) if name.startswith("act_")} + if handled != set(self.action_specs): + raise ProtocolInvalid( + f"{self.protocol_id}: actions in definition {sorted(self.action_specs)} " + f"do not match handlers {sorted(handled)}" + ) + declared = {item["id"] for item in definition["checkpoints"]} + if declared != set(self.checkpoint_ids()): + raise ProtocolInvalid( + f"{self.protocol_id}: checkpoints in definition {sorted(declared)} " + f"do not match model {sorted(self.checkpoint_ids())}" + ) + + @abstractmethod + def start(self, rng: random.Random) -> dict: + """Initial state, including hidden variables drawn from the seeded generator.""" + + @abstractmethod + def visible(self, state: dict) -> list[dict]: + """Panels of what the learner can see now: [{"title": str, "rows": [[label, value], ...]}].""" + + @abstractmethod + def checkpoint_ids(self) -> list[str]: + """Every checkpoint the model evaluates; must equal the definition's list.""" + + @abstractmethod + def evaluate(self, state: dict) -> dict[str, tuple[bool | None, str]]: + """Checkpoint id -> (met, detail). None means the checkpoint did not apply in this run.""" + + def reveal(self, state: dict) -> list[str]: + """Hidden facts shown in the debrief once the run has finished.""" + return [] + + def apply(self, state: dict, name: str, params: dict, rng: random.Random) -> list[Event]: + if state.get("finished"): + raise ActionRejected("This run has finished; start a new run to practise again") + spec = self.action_specs.get(name) + if spec is None: + raise ActionRejected(f"Unknown action {name}") + clean = check_parameters(spec, params) + return getattr(self, f"act_{name}")(state, rng, **clean) + + +@dataclass +class RunView: + protocol_id: str + protocol_version: str + seed: int + mode: str + finished: bool + clock_minutes: float + panels: list[dict] + log: list[dict] + guidance: list[str] | None + debrief: dict | None + + def view(self) -> dict: + return { + "protocol_id": self.protocol_id, "protocol_version": self.protocol_version, + "seed": self.seed, "mode": self.mode, "finished": self.finished, + "clock_minutes": round(self.clock_minutes, 2), "panels": self.panels, + "log": self.log, "guidance": self.guidance, "debrief": self.debrief, + } + + +def debrief(model: ProtocolModel, state: dict) -> dict: + results = model.evaluate(state) + rows = [] + for checkpoint in model.definition["checkpoints"]: + met, detail = results[checkpoint["id"]] + rows.append({ + "id": checkpoint["id"], "severity": checkpoint["severity"], + "description": checkpoint["description"], "rationale": checkpoint["rationale"], + "source": checkpoint["source"], "met": met, "detail": detail, + }) + applicable = [row for row in rows if row["met"] is not None] + missed = {severity: sum(1 for row in applicable if row["severity"] == severity and not row["met"]) + for severity in ("critical", "major", "minor")} + return { + "checkpoints": rows, + "missed": missed, + "met_count": sum(1 for row in applicable if row["met"]), + "applicable_count": len(applicable), + "all_critical_met": missed["critical"] == 0, + "revealed": model.reveal(state), + "note": "Formative practice only. This debrief is not a grade, a competency record or a certification.", + } + + +def replay(model: ProtocolModel, seed: int, mode: str, actions: list[dict]) -> RunView: + """Rebuild a run from its seed and action list. Raises ActionRejected with the failing index.""" + state, log = run_state(model, seed, mode, actions) + guidance = model.definition["guided_steps"] if mode == "guided" else None + return RunView( + protocol_id=model.protocol_id, protocol_version=model.definition["version"], seed=seed, + mode=mode, finished=state["finished"], clock_minutes=state["clock"], + panels=model.visible(state), log=log, guidance=guidance, + debrief=debrief(model, state) if state["finished"] else None, + ) + + +def run_state(model: ProtocolModel, seed: int, mode: str, actions: list[dict]) -> tuple[dict, list[dict]]: + """The full server-side state after replaying the actions, including hidden variables.""" + if mode not in MODES: + raise ActionRejected(f"Mode must be one of {', '.join(MODES)}") + if len(actions) > MAX_ACTIONS: + raise ActionRejected(f"A run is limited to {MAX_ACTIONS} actions") + rng = random.Random(f"{model.protocol_id}:{model.definition['version']}:{seed}") + state = model.start(rng) + state["mode"] = mode + state.setdefault("clock", 0.0) + state.setdefault("finished", False) + log = [] + for index, action in enumerate(actions): + if not isinstance(action, dict) or not isinstance(action.get("name"), str): + raise ActionRejected(f"Action {index + 1} is malformed") + try: + events = model.apply(state, action["name"], action.get("params", {}), rng) + except ActionRejected as exc: + rejected = ActionRejected(f"Action {index + 1} ({action['name']}): {exc}") + rejected.index = index + raise rejected from None + log.append({ + "index": index, "name": action["name"], + "label": model.action_specs[action["name"]]["label"], + "params": action.get("params", {}), + "clock_minutes": round(state["clock"], 2), + "events": [event.view() for event in events], + }) + return state, log + + +def round_significant(value: float, digits: int) -> float: + if value == 0: + return 0.0 + return round(value, digits - 1 - int(math.floor(math.log10(abs(value))))) diff --git a/backend/courselab/bench/pipette.py b/backend/courselab/bench/pipette.py new file mode 100644 index 0000000..bd1ad23 --- /dev/null +++ b/backend/courselab/bench/pipette.py @@ -0,0 +1,288 @@ +"""Gravimetric verification of an air-displacement micropipette.""" + +from __future__ import annotations + +import random +import statistics + +from .engine import ActionRejected, Event, ProtocolModel, coach + +AIR_DENSITY = 0.0012 # g/mL, a typical laboratory value +WEIGHT_DENSITY = 8.0 # g/mL, conventional density of balance reference weights + + +def water_density(celsius: float) -> float: + """Density of air-free water in g/mL, using the formula tabulated by Tanaka et al. (2001).""" + a1, a2, a3, a4, a5 = -3.983035, 301.797, 522528.9, 69.34881, 999.974950 + t = celsius + return a5 * (1 - (t + a1) ** 2 * (t + a2) / (a3 * (t + a4))) / 1000 + + +def z_factor(celsius: float) -> float: + """Conversion from balance reading (mg) to delivered volume (uL), with air buoyancy.""" + return (1 / (water_density(celsius) - AIR_DENSITY)) * (1 - AIR_DENSITY / WEIGHT_DENSITY) + + +class PipetteVerification(ProtocolModel): + protocol_id = "micropipette-gravimetric-verification" + + def checkpoint_ids(self): + return [ + "temperature-recorded", "nominal-volume", "tip-prewetted", "immersion-depth", + "vertical-aspiration", "touch-off", "tare-before-each", "replicate-count", + "report-mean", "report-systematic", "report-random", "report-verdict", + ] + + def start(self, rng: random.Random) -> dict: + p = self.parameters + faulty = rng.random() < p["fault_probability"] + if faulty: + kind = rng.choice(["bias", "seal"]) + bias = rng.choice([-1, 1]) * rng.uniform(1.1, 2.2) / 100 if kind == "bias" else rng.uniform(-0.3, 0.3) / 100 + sd = rng.uniform(0.45, 0.8) / 100 if kind == "seal" else rng.uniform(0.08, 0.18) / 100 + else: + kind = "none" + bias = rng.uniform(-0.4, 0.4) / 100 + sd = rng.uniform(0.06, 0.18) / 100 + return { + "hidden": {"bias": bias, "sd": sd, "fault": kind, + "water_c": round(rng.uniform(19.5, 23.5), 1)}, + "volume_ul": p["nominal_volume_ul"] / 2, "tip": None, "loaded": None, + "temperature_read": None, "balance_mg": 0.0, "since_tare": [], + "replicates": [], "deliveries": [], "aspirations": [], "report": None, + "tared_before_dispense": [], + } + + def visible(self, state): + p = self.parameters + tip = state["tip"] + rows = [ + ["Pipette", f"{p['nominal_volume_ul']:g} µL adjustable air-displacement pipette"], + ["Volume setting", f"{state['volume_ul']:g} µL"], + ["Tip", "none" if tip is None else ("fitted, pre-wetted" if tip["prewet"] else "fitted, dry")], + ["In tip", "nothing" if state["loaded"] is None else f"water (about {state['volume_ul']:g} µL)"], + ] + bench = [ + ["Balance", f"{state['balance_mg']:.1f} mg displayed (readability {p['balance_readability_mg']:g} mg)"], + ["Water temperature", "not measured" if state["temperature_read"] is None + else f"{state['temperature_read']:.1f} °C"], + ["Readings recorded", str(len(state["replicates"]))], + ] + panels = [{"title": "Pipette", "rows": rows}, {"title": "Bench", "rows": bench}] + if state["replicates"]: + panels.append({"title": "Your balance readings after each dispense (mg)", "rows": [ + [f"Reading {i + 1}", f"{item['display_mg']:.1f}"] for i, item in enumerate(state["replicates"]) + ]}) + return panels + + # Actions ------------------------------------------------------------------- + + def act_measure_water_temperature(self, state, rng): + state["clock"] += 1 + state["temperature_read"] = state["hidden"]["water_c"] + return [Event("readout", f"Test water reads {state['temperature_read']:.1f} °C.", + {"water_c": state["temperature_read"]})] + + def act_set_volume(self, state, rng, volume_ul): + if state["loaded"] is not None: + raise ActionRejected("Dispense the liquid in the tip before changing the volume") + state["volume_ul"] = volume_ul + state["clock"] += 0.2 + return [Event("info", f"Volume set to {volume_ul:g} µL.")] + + def act_fit_tip(self, state, rng): + if state["tip"] is not None: + raise ActionRejected("A tip is already fitted") + state["tip"] = {"prewet": False, "used": False} + state["clock"] += 0.2 + return [Event("info", "A new tip is fitted.")] + + def act_eject_tip(self, state, rng): + if state["tip"] is None: + raise ActionRejected("There is no tip to eject") + state["tip"] = None + state["loaded"] = None + state["clock"] += 0.1 + return [Event("info", "The tip is ejected into the waste.")] + + def act_prewet(self, state, rng, cycles): + if state["tip"] is None: + raise ActionRejected("Fit a tip before pre-wetting") + if state["loaded"] is not None: + raise ActionRejected("Dispense the liquid in the tip first") + if cycles >= 1: + state["tip"]["prewet"] = True + state["clock"] += 0.15 * cycles + return [Event("info", f"The tip is filled and emptied {cycles} times in the reservoir and discarded to waste.")] + + def act_tare(self, state, rng): + state["balance_mg"] = 0.0 + state["since_tare"] = [] + state["clock"] += 0.3 + return [Event("info", "The balance is tared with the weighing vessel in place.")] + + def act_aspirate(self, state, rng, immersion_mm, vertical): + p = self.parameters + if state["tip"] is None: + raise ActionRejected("Fit a tip before aspirating") + if state["loaded"] is not None: + raise ActionRejected("The tip already holds liquid") + events = [] + relative = 0.0 + low, high = p["immersion_mm_range"] + if immersion_mm < 1: + relative -= rng.uniform(0.02, 0.08) + coach(state, events, "The tip barely touched the surface and drew in air.") + elif immersion_mm < low: + relative -= 0.002 + coach(state, events, f"Immerse the tip {low:g}–{high:g} mm; shallower risks drawing air.") + elif immersion_mm > high: + relative += 0.0015 * (immersion_mm - high) + coach(state, events, f"The tip went deeper than {high:g} mm, so liquid clings to the outside.") + if not vertical: + relative -= 0.003 + coach(state, events, "Aspirate with the pipette held vertical; tilting changes the volume drawn.") + if not state["tip"]["prewet"] and not state["tip"]["used"]: + relative -= 0.003 + coach(state, events, "The first aspiration into a dry tip delivers less: pre-wet it first.") + state["aspirations"].append({"immersion_mm": immersion_mm, "vertical": vertical, + "prewet": state["tip"]["prewet"] or state["tip"]["used"]}) + state["loaded"] = {"relative": relative, "volume_ul": state["volume_ul"]} + state["tip"]["used"] = True + state["clock"] += 0.15 + events.insert(0, Event("info", "Water is aspirated from the reservoir.")) + return events + + def act_dispense(self, state, rng, touch_off): + hidden = state["hidden"] + if state["loaded"] is None: + raise ActionRejected("The tip is empty") + if len(state["deliveries"]) > len(state["replicates"]): + raise ActionRejected("Record the balance reading for the previous dispense first") + events = [Event("info", "The water is dispensed into the weighing vessel.")] + loaded = state["loaded"] + relative = loaded["relative"] + if not touch_off: + relative -= 0.002 + coach(state, events, "Touch the tip against the vessel wall so the last drop is delivered.") + volume = loaded["volume_ul"] * (1 + hidden["bias"] + relative + rng.gauss(0, hidden["sd"])) + mass = volume / z_factor(hidden["water_c"]) + state["tared_before_dispense"].append(not state["since_tare"]) + state["since_tare"].append(mass) + state["balance_mg"] = sum(state["since_tare"]) + state["deliveries"].append({"volume_ul": loaded["volume_ul"], "touch_off": touch_off}) + state["loaded"] = None + state["clock"] += 0.15 + return events + + def act_read_balance(self, state, rng): + p = self.parameters + if not state["deliveries"] or len(state["replicates"]) >= len(state["deliveries"]): + raise ActionRejected("Record a reading after each dispense") + state["clock"] += 0.3 + step = p["balance_readability_mg"] + display = round(state["balance_mg"] / step) * step + mass = state["since_tare"][-1] + state["replicates"].append({ + "display_mg": display, "mass_mg": mass, + "volume_ul": state["deliveries"][-1]["volume_ul"], + "cumulative": len(state["since_tare"]) > 1, + }) + return [Event("readout", f"The balance shows {display:.1f} mg.", {"mg": display})] + + def act_submit_report(self, state, rng, mean_volume_ul, systematic_error_pct, random_error_cv_pct, verdict): + if len(state["replicates"]) < 2: + raise ActionRejected("Record at least two readings before reporting") + state["report"] = { + "mean": mean_volume_ul, "systematic": systematic_error_pct, + "cv": random_error_cv_pct, "verdict": verdict, + } + state["finished"] = True + return [Event("info", "The verification record is submitted.")] + + # Evaluation ---------------------------------------------------------------- + + def expected(self, state) -> dict | None: + """What a correct analyst computes from the readings this learner recorded.""" + p = self.parameters + temperature = state["temperature_read"] + if temperature is None or len(state["replicates"]) < 2: + return None + masses, previous = [], 0.0 + for item in state["replicates"]: + # A learner who did not tare must difference successive readings. + masses.append(item["display_mg"] - previous if item["cumulative"] else item["display_mg"]) + previous = item["display_mg"] + volumes = [m * z_factor(temperature) for m in masses] + mean = statistics.fmean(volumes) + nominal = p["nominal_volume_ul"] + systematic = (mean - nominal) / nominal * 100 + cv = statistics.stdev(volumes) / mean * 100 + passes = abs(systematic) <= p["systematic_limit_pct"] and cv <= p["random_limit_pct"] + return {"mean": mean, "systematic": systematic, "cv": cv, "verdict": "pass" if passes else "fail", + "z": z_factor(temperature)} + + def evaluate(self, state): + p = self.parameters + reps = state["replicates"] + low, high = p["immersion_mm_range"] + asp = state["aspirations"] + results = {} + results["temperature-recorded"] = ( + state["temperature_read"] is not None, + "Measured the water temperature, which sets the conversion from mass to volume." + if state["temperature_read"] is not None else "The water temperature was never measured, so the " + "density correction could not be chosen.") + at_nominal = bool(reps) and all(item["volume_ul"] == p["nominal_volume_ul"] for item in reps) + results["nominal-volume"] = (at_nominal, f"Readings were taken at {sorted({r['volume_ul'] for r in reps})} µL; " + f"the test volume is the nominal {p['nominal_volume_ul']:g} µL.") + dry = sum(1 for a in asp if not a["prewet"]) + results["tip-prewetted"] = (bool(asp) and dry == 0, + "Every tip was pre-wetted before its first measured aspiration." if asp and not dry + else f"{dry} measured aspiration(s) went into a dry tip.") + depth_ok = bool(asp) and all(low <= a["immersion_mm"] <= high for a in asp) + results["immersion-depth"] = (depth_ok, f"Immersion depths used: {sorted({a['immersion_mm'] for a in asp})} mm.") + results["vertical-aspiration"] = (bool(asp) and all(a["vertical"] for a in asp), + "Every aspiration was vertical." if asp and all(a["vertical"] for a in asp) + else "At least one aspiration was tilted.") + touched = bool(state["deliveries"]) and all(d["touch_off"] for d in state["deliveries"]) + results["touch-off"] = (touched, "Every dispense ended with a touch-off." if touched + else "At least one dispense left the last drop on the tip.") + tared = bool(state["tared_before_dispense"]) and all(state["tared_before_dispense"]) + results["tare-before-each"] = (tared, "The balance was tared before each dispense." if tared + else "Some readings were cumulative; they must be differenced.") + results["replicate-count"] = (len(reps) >= p["replicates_required"], + f"{len(reps)} readings recorded; the procedure asks for " + f"{p['replicates_required']}.") + expected = self.expected(state) + report = state["report"] + if expected is None or report is None: + for key in ("report-mean", "report-systematic", "report-random", "report-verdict"): + results[key] = (False, "No result can be checked without a temperature and at least two readings.") + return results + results["report-mean"] = (abs(report["mean"] - expected["mean"]) <= p["mean_tolerance_ul"], + f"From your readings and Z = {expected['z']:.4f} µL/mg, the mean is " + f"{expected['mean']:.2f} µL; you reported {report['mean']:g} µL.") + results["report-systematic"] = ( + abs(report["systematic"] - expected["systematic"]) <= p["percent_tolerance"], + f"Systematic error = (mean − nominal)/nominal × 100 = {expected['systematic']:.2f}%; " + f"you reported {report['systematic']:g}%.") + results["report-random"] = ( + abs(report["cv"] - expected["cv"]) <= p["percent_tolerance"], + f"Random error as a coefficient of variation = s/mean × 100 = {expected['cv']:.3f}%; " + f"you reported {report['cv']:g}%.") + results["report-verdict"] = ( + report["verdict"] == expected["verdict"], + f"Against the limits of ±{p['systematic_limit_pct']:g}% systematic and {p['random_limit_pct']:g}% " + f"random, your data give '{expected['verdict']}'; you reported '{report['verdict']}'.") + return results + + def reveal(self, state): + hidden = state["hidden"] + fault = {"none": "no fault", "bias": "a calibration offset", "seal": "a worn piston seal"}[hidden["fault"]] + return [ + f"This pipette had {fault}: true bias {hidden['bias'] * 100:+.2f}% and random spread " + f"{hidden['sd'] * 100:.2f}% before any technique error.", + "Technique errors (dry tip, depth, angle, missed touch-off) add to what the pipette itself does, " + "so a sound pipette can fail a check run carelessly.", + ] diff --git a/backend/courselab/bench/plate_count.py b/backend/courselab/bench/plate_count.py new file mode 100644 index 0000000..93aec2b --- /dev/null +++ b/backend/courselab/bench/plate_count.py @@ -0,0 +1,406 @@ +"""Decimal dilutions and the conventional aerobic plate count (pour plates).""" + +from __future__ import annotations + +import math +import random + +from .engine import ActionRejected, Event, ProtocolModel, coach + +BLANKS = ("B1", "B2", "B3", "B4", "B5", "B6") + + +def poisson(rng: random.Random, mean: float) -> int: + if mean <= 0: + return 0 + if mean > 500: + return max(0, round(rng.gauss(mean, math.sqrt(mean)))) + limit, k, product = math.exp(-mean), 0, rng.random() + while product > limit: + k += 1 + product *= rng.random() + return k + + +def bam_round(value: float) -> int: + """Two significant figures with the third-digit rule written in BAM Chapter 3, section 3.""" + if value <= 0: + return 0 + exponent = int(math.floor(math.log10(value))) + if exponent < 2: + return int(round(value)) + three = int(math.floor(value / 10 ** (exponent - 2) + 1e-9)) + first_two, third = divmod(three, 10) + if third >= 6 or (third == 5 and first_two % 2 == 1): + first_two += 1 + return first_two * 10 ** (exponent - 1) + + +class AerobicPlateCount(ProtocolModel): + protocol_id = "decimal-dilution-aerobic-plate-count" + + def checkpoint_ids(self): + return [ + "decimal-dilutions", "separate-pipets", "shake-before-sampling", "reshake-after-standing", + "plate-volume", "duplicate-plates", "agar-temperature", "agar-volume", "agar-within-window", + "controls", "mix-and-solidify", "incubation", "report-apc", "report-flags", + ] + + def start(self, rng): + p = self.parameters + log_cfu = rng.uniform(3.6, 6.4) + contaminated = rng.random() < p["diluent_contamination_probability"] + diluent_cfu_ml = rng.uniform(2.0, 6.0) if contaminated else 0.0 + bottles = {"H": {"label": "Homogenate (10⁻¹)", "volume": 450.0, "nominal_volume": 450.0, + "target": 10 ** log_cfu * 0.1, "contam": 0.0, "factor": 0.1, + "shaken_at": 0.0, "shake_ok": True, "received": True}} + for name in BLANKS: + volume = rng.uniform(89.0, 91.0) + bottles[name] = {"label": f"Dilution blank {name}", "volume": volume, "nominal_volume": 90.0, + "target": 0.0, "contam": diluent_cfu_ml, "factor": 0.0, + "shaken_at": None, "shake_ok": False, "received": False} + return { + "hidden": {"log_cfu_per_g": log_cfu, "diluent_cfu_ml": diluent_cfu_ml, "window_factor": None}, + "bottles": bottles, "plates": [], "transfers": [], "samplings": [], + "pipet_last": None, "pipet_reuses": 0, "incubation": None, "counted": False, + "report": None, "inverted_early": False, + } + + # Helpers ------------------------------------------------------------------- + + def _draw(self, state, rng, events, name, volume, fresh_pipet): + """Remove a sample from a bottle; return its (target, contaminant) cells and nominal sample grams.""" + p = self.parameters + bottle = state["bottles"].get(name) + if bottle is None: + raise ActionRejected(f"There is no bottle {name}") + if volume > bottle["volume"]: + raise ActionRejected(f"{name} holds only {bottle['volume']:.0f} mL") + if not bottle["received"]: + coach(state, events, f"{name} is still plain diluent.") + mixed = bottle["shaken_at"] is not None and bottle["shake_ok"] + stood = None if bottle["shaken_at"] is None else state["clock"] - bottle["shaken_at"] + state["samplings"].append({"bottle": name, "shaken": mixed, + "stood_too_long": stood is not None and stood > p["reshake_after_min"]}) + factor = 1.0 + if not mixed: + factor = math.exp(rng.gauss(-0.3, 0.35)) + coach(state, events, f"{name} was not shaken properly since liquid was last added, so cells " + "are unevenly distributed.") + elif stood > p["reshake_after_min"]: + factor = math.exp(rng.gauss(-0.1, 0.15)) + coach(state, events, f"{name} stood {stood:.1f} min after shaking; reshake it if it stands " + f"more than {p['reshake_after_min']:g} min.") + target = bottle["target"] * volume * factor + contam = bottle["contam"] * volume + if not fresh_pipet and state["pipet_last"] not in (None, name): + previous = state["bottles"][state["pipet_last"]] + carry = p["carryover_ml"] + target += previous["target"] * carry + contam += previous["contam"] * carry + state["pipet_reuses"] += 1 + coach(state, events, f"The pipet still carries liquid from {state['pipet_last']}; use a " + "separate sterile pipet for each transfer.") + elif not fresh_pipet and state["pipet_last"] is not None: + state["pipet_reuses"] += 1 + coach(state, events, "Use a separate sterile pipet for each transfer and plate.") + state["pipet_last"] = name + bottle["volume"] -= volume + grams = bottle["factor"] * volume + return target, contam, grams + + # Actions ------------------------------------------------------------------- + + def act_shake(self, state, rng, bottle, times, arc_cm, seconds): + p = self.parameters + if bottle not in state["bottles"]: + raise ActionRejected(f"There is no bottle {bottle}") + item = state["bottles"][bottle] + ok = times >= p["shake_times"] and arc_cm >= p["shake_arc_cm"] and seconds <= p["shake_max_seconds"] + item["shaken_at"] = state["clock"] + seconds / 60 + item["shake_ok"] = ok + state["clock"] += seconds / 60 + events = [Event("info", f"{bottle} shaken {times} times through about {arc_cm:g} cm in {seconds:g} s.")] + if not ok: + coach(state, events, f"Shake {p['shake_times']} times in a {p['shake_arc_cm']:g} cm arc within " + f"{p['shake_max_seconds']:g} s.") + return events + + def act_transfer(self, state, rng, source, destination, volume_ml, fresh_pipet): + if destination == source or destination not in state["bottles"]: + raise ActionRejected("Choose a different destination bottle") + if destination == "H": + raise ActionRejected("Do not add liquid back into the homogenate") + events = [] + target, contam, grams = self._draw(state, rng, events, source, volume_ml, fresh_pipet) + dest = state["bottles"][destination] + total = dest["volume"] + volume_ml + nominal_total = dest["nominal_volume"] + volume_ml + dest["target"] = (dest["target"] * dest["volume"] + target) / total + dest["contam"] = (dest["contam"] * dest["volume"] + contam) / total + dest["factor"] = (dest["factor"] * dest["nominal_volume"] + grams) / nominal_total + dest["volume"], dest["nominal_volume"] = total, nominal_total + dest["received"], dest["shaken_at"], dest["shake_ok"] = True, None, False + state["transfers"].append({"source": source, "destination": destination, "volume": volume_ml, + "decimal": abs(volume_ml - 10) < 1e-9 and abs(nominal_total - 100) < 1e-9}) + state["clock"] += 0.5 + events.insert(0, Event("info", f"{volume_ml:g} mL moved from {source} to {destination}.")) + return events + + def act_plate(self, state, rng, bottle, volume_ml, fresh_pipet): + if state["incubation"] is not None: + raise ActionRejected("Plates are already incubating") + events = [] + target, contam, grams = self._draw(state, rng, events, bottle, volume_ml, fresh_pipet) + plate_id = f"P{len(state['plates']) + 1}" + state["plates"].append({"id": plate_id, "kind": "sample", "bottle": bottle, "volume": volume_ml, + "grams": grams, "target": target, "contam": contam, "agar": None, + "mixed": False, "count": None}) + state["clock"] += 0.3 + events.insert(0, Event("info", f"{volume_ml:g} mL from {bottle} pipetted into plate {plate_id}.")) + return events + + def act_add_control(self, state, rng, kind): + if state["incubation"] is not None: + raise ActionRejected("Plates are already incubating") + plate_id = f"P{len(state['plates']) + 1}" + contam = state["hidden"]["diluent_cfu_ml"] if kind == "diluent" else 0.0 + state["plates"].append({"id": plate_id, "kind": f"{kind}-control", "bottle": None, "volume": 1.0, + "grams": 0.0, "target": 0.0, "contam": contam, "agar": None, + "mixed": False, "count": None}) + state["clock"] += 0.3 + text = "1 mL of sterile diluent" if kind == "diluent" else "no inoculum" + return [Event("info", f"Control plate {plate_id} prepared with {text}.")] + + def act_pour_agar(self, state, rng, volume_ml, temperature_c): + p = self.parameters + waiting = [plate for plate in state["plates"] if plate["agar"] is None] + if not waiting: + raise ActionRejected("No plates are waiting for agar") + events = [Event("info", f"{volume_ml:g} mL of plate count agar at {temperature_c:g} °C poured into " + f"{len(waiting)} plate(s).")] + low, high = p["agar_temperature_c"] + if temperature_c > high: + coach(state, events, "Agar above 46 °C injures or kills heat-sensitive cells.") + elif temperature_c < low: + coach(state, events, "Agar below 44 °C begins to set and will not mix evenly.") + if state["clock"] > p["agar_within_min"]: + coach(state, events, f"More than {p['agar_within_min']:g} min have passed since the original " + "dilution; cells in diluent may have multiplied or died.") + for plate in waiting: + plate["agar"] = {"volume": volume_ml, "temperature": temperature_c, "clock": state["clock"]} + state["clock"] += 0.3 * len(waiting) + return events + + def act_mix_plates(self, state, rng): + poured = [plate for plate in state["plates"] if plate["agar"] is not None and not plate["mixed"]] + if not poured: + raise ActionRejected("There are no freshly poured plates to mix") + for plate in poured: + plate["mixed"] = True + plate["agar"]["mixed_at"] = state["clock"] + state["clock"] += 0.2 * len(poured) + return [Event("info", f"{len(poured)} plate(s) mixed by alternate rotation and back-and-forth motion.")] + + def act_wait(self, state, rng, minutes): + state["clock"] += minutes + return [Event("info", f"{minutes:g} min pass.")] + + def act_incubate(self, state, rng, temperature_c, hours, inverted): + p = self.parameters + if not state["plates"]: + raise ActionRejected("There are no plates to incubate") + if any(plate["agar"] is None for plate in state["plates"]): + raise ActionRejected("Every plate needs agar before incubation") + if state["incubation"] is not None: + raise ActionRejected("The plates have already been incubated") + events = [] + solid = all(state["clock"] - plate["agar"]["clock"] >= p["solidify_min"] for plate in state["plates"]) + if inverted and not solid: + state["inverted_early"] = True + coach(state, events, "The agar had not solidified; inverting it now can slide or tear it.") + state["incubation"] = {"temperature": temperature_c, "hours": hours, "inverted": inverted, "solid": solid} + state["clock"] += hours * 60 + events.insert(0, Event("info", f"Plates incubated {'inverted' if inverted else 'upright'} at " + f"{temperature_c:g} °C for {hours:g} h.")) + self._grow(state, rng) + return events + + def _grow(self, state, rng): + p = self.parameters + incubation = state["incubation"] + t_low, t_high = p["incubation_temperature_c"] + h_low, h_high = p["incubation_hours"] + growth = 1.0 + if not t_low <= incubation["temperature"] <= t_high: + growth *= max(0.2, 1 - 0.12 * abs(incubation["temperature"] - 35)) + if incubation["hours"] < h_low: + growth *= (incubation["hours"] / 48) ** 1.5 + hidden = state["hidden"] + for plate in state["plates"]: + agar = plate["agar"] + survival = 1.0 + if agar["temperature"] > p["agar_temperature_c"][1]: + survival = math.exp(-0.25 * (agar["temperature"] - 46)) + if agar["clock"] > p["agar_within_min"]: + if hidden["window_factor"] is None: + hidden["window_factor"] = rng.uniform(0.7, 1.4) + survival *= hidden["window_factor"] + if not plate["mixed"]: + survival *= 0.85 + count = poisson(rng, plate["target"] * survival * growth) + poisson(rng, plate["contam"] * growth) + plate["count"] = count + plate["spreaders"] = (not incubation["inverted"]) and rng.random() < 0.5 + plate["damaged"] = state["inverted_early"] and rng.random() < 0.3 + + def act_count_plates(self, state, rng): + if state["incubation"] is None: + raise ActionRejected("Incubate the plates before counting") + state["counted"] = True + state["clock"] += 2 * len(state["plates"]) + return [Event("readout", "Colonies counted on every plate.", { + plate["id"]: self._shown(plate) for plate in state["plates"]})] + + def _shown(self, plate): + if plate.get("damaged"): + return "agar torn" + if plate["count"] > self.parameters["tntc_display_above"]: + return "TNTC" + return plate["count"] + + def act_submit_result(self, state, rng, apc_cfu_per_g, estimated, laboratory_accident): + if not state["counted"]: + raise ActionRejected("Count the plates before reporting") + state["report"] = {"apc": apc_cfu_per_g, "estimated": estimated, "accident": laboratory_accident} + state["finished"] = True + return [Event("info", "The aerobic plate count is reported.")] + + # Views and evaluation ------------------------------------------------------ + + def visible(self, state): + clock = state["clock"] + bottles = [] + for name, item in state["bottles"].items(): + if item["shaken_at"] is None: + shake = "not shaken since last addition" + else: + shake = f"last shaken {max(0.0, clock - item['shaken_at']):.1f} min ago" + contents = item["label"] if not item["received"] or name == "H" else f"{name}, mixed" + bottles.append([name, f"{contents}; about {item['volume']:.0f} mL; {shake}"]) + panels = [ + {"title": "Clock", "rows": [["Minutes since the original dilution", f"{clock:.1f}"]]}, + {"title": "Bottles", "rows": bottles}, + ] + if state["transfers"]: + panels.append({"title": "Transfers", "rows": [ + [f"{i + 1}", f"{t['volume']:g} mL {t['source']} → {t['destination']}"] + for i, t in enumerate(state["transfers"])]}) + if state["plates"]: + rows = [] + for plate in state["plates"]: + source = plate["kind"] if plate["kind"] != "sample" else f"{plate['volume']:g} mL of {plate['bottle']}" + agar = "no agar" if plate["agar"] is None else f"agar {plate['agar']['volume']:g} mL" + result = "" if not state["counted"] else f"; count {self._shown(plate)}" + if state["counted"] and plate.get("spreaders"): + result += " (spreading growth)" + rows.append([plate["id"], f"{source}; {agar}{result}"]) + panels.append({"title": "Plates", "rows": rows}) + return panels + + def expected(self, state): + p = self.parameters + low, high = p["countable_range"] + diluent_positive = any(plate["kind"] == "diluent-control" and plate["count"] for plate in state["plates"]) + usable = [plate for plate in state["plates"] if plate["kind"] == "sample" and not plate.get("damaged")] + countable = [plate for plate in usable if low <= plate["count"] <= high and plate["grams"] > 0] + if countable: + value = sum(plate["count"] for plate in countable) / sum(plate["grams"] for plate in countable) + return {"apc": bam_round(value), "raw": value, "estimated": False, "accident": diluent_positive, + "plates": [plate["id"] for plate in countable]} + return {"apc": None, "raw": None, "estimated": True, "accident": diluent_positive, "plates": []} + + def evaluate(self, state): + p = self.parameters + results = {} + transfers = state["transfers"] + results["decimal-dilutions"] = (bool(transfers) and all(t["decimal"] for t in transfers), + "Each step should move 10 mL into a 90 mL blank, a tenfold dilution.") + results["separate-pipets"] = (state["pipet_reuses"] == 0, + f"A pipet was reused {state['pipet_reuses']} time(s)." if state["pipet_reuses"] + else "A separate sterile pipet was used for every transfer and plate.") + samplings = state["samplings"] + unshaken = [s["bottle"] for s in samplings if not s["shaken"]] + results["shake-before-sampling"] = (bool(samplings) and not unshaken, + "Every bottle was shaken before sampling." if not unshaken + else f"Sampled without a proper shake: {', '.join(unshaken)}.") + stood = [s["bottle"] for s in samplings if s["shaken"] and s["stood_too_long"]] + results["reshake-after-standing"] = (not stood, "No bottle stood more than 3 min before sampling." + if not stood else f"Sampled after standing: {', '.join(stood)}.") + samples = [plate for plate in state["plates"] if plate["kind"] == "sample"] + results["plate-volume"] = (bool(samples) and all(plate["volume"] == p["plate_volume_ml"] for plate in samples), + f"Plate {p['plate_volume_ml']:g} mL of each dilution.") + per_bottle: dict[str, int] = {} + for plate in samples: + per_bottle[plate["bottle"]] = per_bottle.get(plate["bottle"], 0) + 1 + single = [name for name, n in per_bottle.items() if n < 2] + results["duplicate-plates"] = (bool(samples) and not single, + "Every plated dilution has duplicate plates." if samples and not single + else f"Single plates only for: {', '.join(single) or 'no plates poured'}.") + poured = [plate["agar"] for plate in state["plates"] if plate["agar"] is not None] + low, high = p["agar_temperature_c"] + results["agar-temperature"] = (bool(poured) and all(low <= a["temperature"] <= high for a in poured), + f"Agar temperatures used: {sorted({a['temperature'] for a in poured})} °C.") + v_low, v_high = p["agar_volume_ml"] + results["agar-volume"] = (bool(poured) and all(v_low <= a["volume"] <= v_high for a in poured), + f"Agar volumes used: {sorted({a['volume'] for a in poured})} mL.") + late = [a for a in poured if a["clock"] > p["agar_within_min"]] + results["agar-within-window"] = (bool(poured) and not late, + f"Last agar added at {max((a['clock'] for a in poured), default=0):.1f} min " + "after the original dilution.") + kinds = {plate["kind"] for plate in state["plates"]} + results["controls"] = ({"agar-control", "diluent-control"} <= kinds, + "Agar and dilution-water control plates were poured." + if {"agar-control", "diluent-control"} <= kinds + else "Without both controls a contaminated agar or diluent cannot be detected.") + results["mix-and-solidify"] = ( + bool(state["plates"]) and all(plate["mixed"] for plate in state["plates"]) + and state["incubation"] is not None and state["incubation"]["solid"], + "Plates should be mixed at once and left to solidify before they are inverted.") + inc = state["incubation"] + t_low, t_high = p["incubation_temperature_c"] + h_low, h_high = p["incubation_hours"] + ok = inc is not None and inc["inverted"] and t_low <= inc["temperature"] <= t_high and h_low <= inc["hours"] <= h_high + results["incubation"] = (ok, "Incubate inverted for 48 ± 2 h at 35 °C." if inc is None else + f"Incubated {'inverted' if inc['inverted'] else 'upright'} at {inc['temperature']:g} °C " + f"for {inc['hours']:g} h.") + report = state["report"] + expected = self.expected(state) if state["counted"] else None + if report is None or expected is None: + results["report-apc"] = (False, "No result was reported.") + results["report-flags"] = (False, "No result was reported.") + return results + if expected["apc"] is None: + results["report-apc"] = (None, "No plate fell in the countable range, so only an estimated count " + "could be reported.") + else: + results["report-apc"] = ( + round(report["apc"]) == expected["apc"], + f"Countable plates {', '.join(expected['plates'])}: total colonies ÷ total grams of sample plated " + f"= {expected['raw']:.0f} CFU/g, reported to two significant figures as {expected['apc']:,} CFU/g; " + f"you reported {report['apc']:,.0f}.") + flags_ok = report["estimated"] == expected["estimated"] and report["accident"] == expected["accident"] + results["report-flags"] = ( + flags_ok, + f"Estimated count: {'yes' if expected['estimated'] else 'no'}; laboratory accident (a positive " + f"diluent control): {'yes' if expected['accident'] else 'no'}.") + return results + + def reveal(self, state): + hidden = state["hidden"] + notes = [f"The sample held about {10 ** hidden['log_cfu_per_g']:,.0f} CFU/g."] + if hidden["diluent_cfu_ml"]: + notes.append(f"The diluent was contaminated at about {hidden['diluent_cfu_ml']:.1f} CFU/mL; only a " + "dilution-water control plate reveals this.") + else: + notes.append("The diluent and agar were sterile in this run.") + return notes diff --git a/backend/courselab/bench_routes.py b/backend/courselab/bench_routes.py new file mode 100644 index 0000000..5acae59 --- /dev/null +++ b/backend/courselab/bench_routes.py @@ -0,0 +1,56 @@ +"""Bench-procedure practice runs: stateless, replayed from a seed and an action list.""" + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import Field + +from .bench import ActionRejected, ProtocolInvalid, replay +from .bench import public_protocol as public_view +from .bench.engine import MAX_ACTIONS +from .schemas import StrictModel + +router = APIRouter(prefix="/api/v1/bench") + + +class BenchAction(StrictModel): + name: str = Field(min_length=1, max_length=60) + params: dict[str, bool | int | float | str] = Field(default_factory=dict, max_length=12) + + +class ReplayRequest(StrictModel): + seed: int = Field(ge=0, le=2**31 - 1) + mode: str = Field(pattern=r"^(guided|practice)$") + actions: list[BenchAction] = Field(default_factory=list, max_length=MAX_ACTIONS) + + +def library(request: Request): + try: + request.app.state.bench.models() + except ProtocolInvalid as exc: + raise HTTPException(503, "Bench protocols are unavailable") from exc + return request.app.state.bench + + +@router.get("/protocols") +def protocols(request: Request): + return library(request).summaries() + + +@router.get("/protocols/{protocol_id}") +def protocol(protocol_id: str, request: Request): + model = library(request).get(protocol_id) + if model is None: + raise HTTPException(404, "Protocol not found") + return public_view(model.definition) + + +@router.post("/protocols/{protocol_id}/replay") +def replay_run(protocol_id: str, body: ReplayRequest, request: Request): + model = library(request).get(protocol_id) + if model is None: + raise HTTPException(404, "Protocol not found") + actions = [action.model_dump() for action in body.actions] + try: + return replay(model, body.seed, body.mode, actions).view() + except ActionRejected as exc: + return JSONResponse({"detail": str(exc), "rejected_index": getattr(exc, "index", None)}, status_code=422) diff --git a/backend/courselab/main.py b/backend/courselab/main.py index 6feb6e1..210fbd5 100644 --- a/backend/courselab/main.py +++ b/backend/courselab/main.py @@ -12,6 +12,8 @@ from .assessment_plans import AssessmentPlanService from .authentication import new_dummy_password_hash from .authentication import router as auth_router +from .bench import BenchLibrary +from .bench_routes import router as bench_router from .case_studio import router as case_studio_router from .catalog import router as catalog_router from .config import Settings @@ -45,6 +47,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: app.state.engine = engine app.state.sessions = session_factory app.state.content = content + app.state.bench = BenchLibrary(settings.content_root) app.state.assessment_plans = assessment_plans app.state.course_archives = course_archives app.state.graded_assessments = graded_assessments @@ -60,6 +63,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: app.include_router(learning_router) app.include_router(learner_state_router) app.include_router(case_studio_router) + app.include_router(bench_router) app.include_router(practice_attempts_router) app.include_router(retrieval_router) app.include_router(instructor_appeals_router) diff --git a/backend/tests/test_bench.py b/backend/tests/test_bench.py new file mode 100644 index 0000000..200bd8e --- /dev/null +++ b/backend/tests/test_bench.py @@ -0,0 +1,523 @@ +import json +import random +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from courselab.bench import MODELS, BenchLibrary, ProtocolInvalid +from courselab.bench.engine import ActionRejected, check_parameters, replay, run_state +from courselab.bench.pipette import water_density, z_factor +from courselab.bench.plate_count import bam_round +from courselab.config import Settings +from courselab.main import create_app + +ROOT = Path(__file__).resolve().parents[2] +CONTENT = ROOT / "content" +ORIGIN = "http://localhost:8080" +PIPETTE = "micropipette-gravimetric-verification" +APC = "decimal-dilution-aerobic-plate-count" +BSC = "class-ii-bsc-session-with-spill" + + +@pytest.fixture(scope="module") +def library(): + return BenchLibrary(CONTENT) + + +def act(name, **params): + return {"name": name, "params": params} + + +def hidden(model, seed): + rng = random.Random(f"{model.protocol_id}:{model.definition['version']}:{seed}") + return model.start(rng)["hidden"] + + +def seed_where(model, test, limit=500): + for seed in range(limit): + if test(hidden(model, seed)): + return seed + raise AssertionError("no seed found") + + +def checkpoint_map(view): + return {row["id"]: row["met"] for row in view["debrief"]["checkpoints"]} + + +# Library and definitions ------------------------------------------------------ + +def test_every_protocol_file_has_a_model_and_loads(library): + models = library.models() + assert set(models) == {path.stem for path in (CONTENT / "bench/protocols").glob("*.json")} + assert set(models) == set(MODELS) + + +def test_cited_sources_exist_in_registry(library): + registry = {item["id"] for item in json.loads((CONTENT / "sources/registry.json").read_text())["sources"]} + for model in library.models().values(): + for use in model.definition["sources"]: + assert use["source_id"] in registry + + +def test_definition_and_model_must_agree(tmp_path): + directory = tmp_path / "bench" / "protocols" + directory.mkdir(parents=True) + raw = json.loads((CONTENT / f"bench/protocols/{PIPETTE}.json").read_text()) + raw["checkpoints"] = raw["checkpoints"][1:] + (directory / f"{PIPETTE}.json").write_text(json.dumps(raw)) + with pytest.raises(ProtocolInvalid, match="checkpoints"): + BenchLibrary(tmp_path).models() + + +def test_cited_value_needs_location(tmp_path): + directory = tmp_path / "bench" / "protocols" + directory.mkdir(parents=True) + raw = json.loads((CONTENT / f"bench/protocols/{APC}.json").read_text()) + del raw["parameters"][0]["source"]["location"] + (directory / f"{APC}.json").write_text(json.dumps(raw)) + with pytest.raises(ProtocolInvalid): + BenchLibrary(tmp_path).models() + + +# Engine ----------------------------------------------------------------------- + +def test_replay_is_deterministic_and_seed_dependent(library): + model = library.get(PIPETTE) + actions = [act("measure_water_temperature"), act("set_volume", volume_ul=1000), act("fit_tip"), + act("prewet", cycles=2), act("tare"), act("aspirate", immersion_mm=3, vertical=True), + act("dispense", touch_off=True), act("read_balance")] + first = replay(model, 11, "practice", actions).view() + assert first == replay(model, 11, "practice", actions).view() + readings = {replay(model, seed, "practice", actions).view()["log"][-1]["events"][0]["data"]["mg"] + for seed in range(8)} + assert len(readings) > 1 + + +@pytest.mark.parametrize("params, message", [ + ({"volume_ul": 50}, "between"), + ({"volume_ul": "1000"}, "number"), + ({"volume_ul": True}, "number"), + ({"volume_ul": float("nan")}, "number"), + ({}, "needs a value"), + ({"volume_ul": 500, "speed": 1}, "Unknown parameter"), +]) +def test_parameter_validation(library, params, message): + spec = library.get(PIPETTE).action_specs["set_volume"] + with pytest.raises(ActionRejected, match=message): + check_parameters(spec, params) + + +def test_integer_and_choice_parameters(library): + model = library.get(PIPETTE) + with pytest.raises(ActionRejected, match="whole number"): + check_parameters(model.action_specs["prewet"], {"cycles": 1.5}) + report = model.action_specs["submit_report"] + with pytest.raises(ActionRejected, match="listed options"): + check_parameters(report, {"mean_volume_ul": 1000, "systematic_error_pct": 0, + "random_error_cv_pct": 0.1, "verdict": "maybe"}) + + +def test_rejected_action_reports_its_index(library): + model = library.get(PIPETTE) + with pytest.raises(ActionRejected) as caught: + replay(model, 1, "practice", [act("fit_tip"), act("dispense", touch_off=True)]) + assert caught.value.index == 1 + assert "tip is empty" in str(caught.value) + + +def test_finished_runs_accept_no_more_actions(library): + model = library.get(BSC) + with pytest.raises(ActionRejected, match="finished"): + replay(model, 1, "practice", [act("report_out_of_service"), act("inspect_cabinet")]) + + +def test_guidance_and_coaching_only_in_guided_mode(library): + model = library.get(PIPETTE) + actions = [act("fit_tip"), act("aspirate", immersion_mm=8, vertical=False)] + guided = replay(model, 3, "guided", actions).view() + practice = replay(model, 3, "practice", actions).view() + assert guided["guidance"] and practice["guidance"] is None + assert any(e["kind"] == "warning" for e in guided["log"][-1]["events"]) + assert not any(e["kind"] == "warning" for e in practice["log"][-1]["events"]) + + +# Pipette verification ----------------------------------------------------------- + +def test_water_density_and_z_factor(): + assert water_density(20) == pytest.approx(0.9982067, abs=2e-7) + assert water_density(4) == pytest.approx(0.99997, abs=1e-5) + assert z_factor(20) == pytest.approx(1.00285, abs=2e-5) + assert z_factor(25) > z_factor(20) + + +def pipette_run(technique=None, tare=True, n=10): + technique = technique or {} + actions = [act("measure_water_temperature"), act("set_volume", volume_ul=1000), act("fit_tip"), + act("prewet", cycles=technique.get("prewet", 3))] + for _ in range(n): + if tare: + actions.append(act("tare")) + actions += [act("aspirate", immersion_mm=technique.get("depth", 3), vertical=technique.get("vertical", True)), + act("dispense", touch_off=technique.get("touch", True)), act("read_balance")] + return actions + + +def finish_pipette(model, seed, actions, override=None): + state, _ = run_state(model, seed, "practice", actions) + expected = model.expected(state) + report = {"mean_volume_ul": round(expected["mean"], 2), "systematic_error_pct": round(expected["systematic"], 3), + "random_error_cv_pct": round(expected["cv"], 3), "verdict": expected["verdict"]} + report.update(override or {}) + return replay(model, seed, "practice", actions + [act("submit_report", **report)]).view(), expected + + +def test_pipette_correct_run_meets_every_checkpoint(library): + model = library.get(PIPETTE) + view, _ = finish_pipette(model, 5, pipette_run()) + assert all(checkpoint_map(view).values()) + assert view["debrief"]["all_critical_met"] + + +def test_pipette_faulty_instrument_fails_with_good_technique(library): + model = library.get(PIPETTE) + seed = seed_where(model, lambda h: h["fault"] == "bias") + view, expected = finish_pipette(model, seed, pipette_run()) + assert expected["verdict"] == "fail" + assert checkpoint_map(view)["report-verdict"] + assert "calibration offset" in view["debrief"]["revealed"][0] + + +def test_pipette_sound_instrument_reports_pass(library): + model = library.get(PIPETTE) + seed = seed_where(model, lambda h: h["fault"] == "none" and abs(h["bias"]) < 0.002 and h["sd"] < 0.0015) + _, expected = finish_pipette(model, seed, pipette_run()) + assert expected["verdict"] == "pass" + + +def test_pipette_wrong_verdict_and_wrong_mean_are_flagged(library): + model = library.get(PIPETTE) + view, expected = finish_pipette(model, 5, pipette_run(), {"verdict": "fail" if True else "pass"}) + if expected["verdict"] == "pass": + assert checkpoint_map(view)["report-verdict"] is False + view, expected = finish_pipette(model, 5, pipette_run(), {"mean_volume_ul": round(expected["mean"] + 3, 2)}) + assert checkpoint_map(view)["report-mean"] is False + # Forgetting the buoyancy and density correction (Z = 1) is a near miss that must fail. + state, _ = run_state(model, 5, "practice", pipette_run()) + raw_mean = sum(item["display_mg"] for item in state["replicates"]) / len(state["replicates"]) + view, _ = finish_pipette(model, 5, pipette_run(), {"mean_volume_ul": round(raw_mean, 2)}) + assert checkpoint_map(view)["report-mean"] is False + + +def test_pipette_technique_errors_are_recorded(library): + model = library.get(PIPETTE) + view, _ = finish_pipette(model, 5, pipette_run({"prewet": 0, "depth": 7, "vertical": False, "touch": False})) + met = checkpoint_map(view) + assert not met["tip-prewetted"] and not met["immersion-depth"] + assert not met["vertical-aspiration"] and not met["touch-off"] + # The analysis is still checked against the learner's own readings. + assert met["report-mean"] and met["report-systematic"] + + +def test_pipette_cumulative_readings_must_be_differenced(library): + model = library.get(PIPETTE) + actions = [act("tare")] + pipette_run(tare=False) + state, _ = run_state(model, 9, "practice", actions) + assert state["replicates"][-1]["display_mg"] > 9000 + view, expected = finish_pipette(model, 9, actions) + assert 990 < expected["mean"] < 1010 + met = checkpoint_map(view) + assert met["tare-before-each"] is False and met["report-mean"] + + +def test_pipette_too_few_replicates_and_no_temperature(library): + model = library.get(PIPETTE) + actions = pipette_run(n=4)[1:] + view = replay(model, 2, "practice", actions + [act("submit_report", mean_volume_ul=1000, systematic_error_pct=0, + random_error_cv_pct=0.1, verdict="pass")]).view() + met = checkpoint_map(view) + assert not met["replicate-count"] and not met["temperature-recorded"] and not met["report-mean"] + + +def test_pipette_dispense_requires_reading_previous(library): + model = library.get(PIPETTE) + actions = [act("fit_tip"), act("aspirate", immersion_mm=3, vertical=True), act("dispense", touch_off=True), + act("aspirate", immersion_mm=3, vertical=True), act("dispense", touch_off=True)] + with pytest.raises(ActionRejected, match="previous dispense"): + replay(model, 1, "practice", actions) + + +# Aerobic plate count ------------------------------------------------------------ + +@pytest.mark.parametrize("value, expected", [ + (12700, 13000), (12400, 12000), (15500, 16000), (14500, 14000), + (308000 / 22, 14000), (143000 / 11, 13000), (3900, 3900), (2700, 2700), (99, 99), (0, 0), +]) +def test_bam_rounding_examples(value, expected): + assert bam_round(value) == expected + + +def apc_actions(steps=5, fresh=True, agar_temp=45, wait_before_pour=0.0, controls=True, inverted=True, + hours=48, shake=True): + actions = [] + bottles = ["H"] + [f"B{i}" for i in range(1, steps + 1)] + for source, dest in zip(bottles, bottles[1:]): + if shake: + actions.append(act("shake", bottle=source, times=25, arc_cm=30, seconds=7)) + actions.append(act("transfer", source=source, destination=dest, volume_ml=10, fresh_pipet=fresh)) + for bottle in bottles[1:]: + if shake: + actions.append(act("shake", bottle=bottle, times=25, arc_cm=30, seconds=7)) + actions += [act("plate", bottle=bottle, volume_ml=1, fresh_pipet=fresh)] * 2 + if controls: + actions += [act("add_control", kind="diluent"), act("add_control", kind="agar")] + if wait_before_pour: + actions.append(act("wait", minutes=wait_before_pour)) + actions += [act("pour_agar", volume_ml=14, temperature_c=agar_temp), act("mix_plates"), act("wait", minutes=15), + act("incubate", temperature_c=35, hours=hours, inverted=inverted), act("count_plates")] + return actions + + +def finish_apc(model, seed, actions, override=None): + state, _ = run_state(model, seed, "practice", actions) + expected = model.expected(state) + report = {"apc_cfu_per_g": expected["apc"] or 0, "estimated": expected["estimated"], + "laboratory_accident": expected["accident"]} + report.update(override or {}) + view = replay(model, seed, "practice", actions + [act("submit_result", **report)]).view() + return view, expected, state + + +def test_apc_correct_run(library): + model = library.get(APC) + seed = seed_where(model, lambda h: not h["diluent_cfu_ml"]) + view, expected, state = finish_apc(model, seed, apc_actions()) + met = checkpoint_map(view) + assert all(value for value in met.values() if value is not None), met + true = 10 ** hidden(model, seed)["log_cfu_per_g"] + assert 0.6 < expected["raw"] / true < 1.6 + assert state["clock"] - 15 - 48 * 60 - 2 * len(state["plates"]) < 15 + + +def test_apc_unshaken_and_reused_pipets_are_flagged(library): + model = library.get(APC) + seed = seed_where(model, lambda h: not h["diluent_cfu_ml"]) + view, _, state = finish_apc(model, seed, apc_actions(fresh=False, shake=False)) + met = checkpoint_map(view) + assert not met["separate-pipets"] and not met["shake-before-sampling"] + assert state["pipet_reuses"] > 0 + + +def test_apc_carryover_inflates_high_dilutions(library): + model = library.get(APC) + seed = seed_where(model, lambda h: not h["diluent_cfu_ml"] and h["log_cfu_per_g"] > 5.5) + _, _, dirty_state = finish_apc(model, seed, apc_actions(fresh=False)) + clean_state, _ = run_state(model, seed, "practice", apc_actions()) + last = lambda st: sum(p["target"] for p in st["plates"] if p["bottle"] == "B5") # noqa: E731 + # 0.1 mL carried from a tenfold stronger dilution adds about 10% per step, compounding to roughly 2x by B5. + assert last(dirty_state) > 1.6 * last(clean_state) + + +def test_apc_hot_agar_and_late_pour(library): + model = library.get(APC) + seed = seed_where(model, lambda h: not h["diluent_cfu_ml"]) + view, expected, _ = finish_apc(model, seed, apc_actions(agar_temp=52)) + good, good_expected, _ = finish_apc(model, seed, apc_actions()) + assert checkpoint_map(view)["agar-temperature"] is False + if expected["raw"] and good_expected["raw"]: + assert expected["raw"] < good_expected["raw"] + late, _, _ = finish_apc(model, seed, apc_actions(wait_before_pour=10)) + assert checkpoint_map(late)["agar-within-window"] is False + + +def test_apc_contaminated_diluent_needs_controls_to_detect(library): + model = library.get(APC) + seed = seed_where(model, lambda h: h["diluent_cfu_ml"] > 4) + view, expected, _ = finish_apc(model, seed, apc_actions()) + assert expected["accident"] is True + assert checkpoint_map(view)["report-flags"] + missed, _, _ = finish_apc(model, seed, apc_actions(), {"laboratory_accident": False}) + assert checkpoint_map(missed)["report-flags"] is False + blind, blind_expected, _ = finish_apc(model, seed, apc_actions(controls=False)) + assert blind_expected["accident"] is False + assert checkpoint_map(blind)["controls"] is False + + +def test_apc_wrong_rounding_is_a_near_miss(library): + model = library.get(APC) + seed = seed_where(model, lambda h: not h["diluent_cfu_ml"]) + _, expected, _ = finish_apc(model, seed, apc_actions()) + view, _, _ = finish_apc(model, seed, apc_actions(), {"apc_cfu_per_g": round(expected["raw"])}) + if round(expected["raw"]) != expected["apc"]: + assert checkpoint_map(view)["report-apc"] is False + + +def test_apc_upright_short_incubation(library): + model = library.get(APC) + view, _, _ = finish_apc(model, 4, apc_actions(inverted=False, hours=24)) + assert checkpoint_map(view)["incubation"] is False + + +def test_apc_cannot_count_before_incubation(library): + model = library.get(APC) + with pytest.raises(ActionRejected, match="Incubate"): + replay(model, 1, "practice", [act("count_plates")]) + + +# Biosafety cabinet -------------------------------------------------------------- + +def bsc_setup(): + actions = [act("inspect_cabinet"), act("don_ppe", coat_buttoned=True, gloves=True), act("blower", on=True), + act("set_sash", position="certified"), act("wait", minutes=5), act("close_drain_valve"), + act("disinfect_interior", agent="ethanol70"), act("lay_toweling", location="work_surface")] + for item, zone in [("pipettes", "rear"), ("pipette_aid", "center"), ("media", "rear"), ("plates", "rear"), + ("discard_tray", "side"), ("biohazard_bag", "side"), ("culture", "rear")]: + actions.append(act("load_item", item=item, zone=zone, wiped=True)) + return actions + [act("insert_arms", motion="slow_perpendicular"), act("settle", seconds=60)] + + +SMALL_SPILL = [act("cover_spill"), act("apply_disinfectant", direction="outside_in"), act("wait", minutes=25), + act("push_towels", direction="edge_to_center"), act("discard_towels", into="biohazard_bag"), + act("wipe_down_interior"), act("change_gloves")] +TEARDOWN_ORDER = ["culture", "media", "plates", "pipettes", "pipette_aid", "discard_tray", "biohazard_bag"] +CLOSE = [act("final_wipe", agent="ethanol70"), act("remove_ppe"), act("wash_hands"), act("end_session")] + + +def bsc_good_run(size, spill_step): + actions = bsc_setup() + work = act("work_step", distance_in=6, lids_over_plates=True) + for step in range(1, 4): + actions.append(work) + if step == spill_step: + actions += SMALL_SPILL + if size == "large": + actions += [act("remove_item", item=item, decontaminated=True) for item in TEARDOWN_ORDER] + actions += [act("flood_drain_pan"), act("drain_to_disinfectant")] + return actions + CLOSE + actions.append(act("withdraw_arms")) + actions += [act("remove_item", item=item, decontaminated=True) for item in TEARDOWN_ORDER] + return actions + CLOSE + + +@pytest.mark.parametrize("size", ["small", "large"]) +def test_bsc_correct_run(library, size): + model = library.get(BSC) + seed = seed_where(model, lambda h: not h["expired"] and h["spill_size"] == size) + view = replay(model, seed, "practice", bsc_good_run(size, hidden(model, seed)["spill_step"])).view() + met = checkpoint_map(view) + assert all(value for value in met.values() if value is not None), met + assert met["large-spill-drain"] is (True if size == "large" else None) + + +def test_bsc_expired_cabinet_must_be_taken_out_of_service(library): + model = library.get(BSC) + seed = seed_where(model, lambda h: h["expired"]) + view = replay(model, seed, "practice", [act("inspect_cabinet"), act("report_out_of_service")]).view() + met = checkpoint_map(view) + assert met["certification-current"] and met["certification-checked"] + assert all(value is None for key, value in met.items() if not key.startswith("certification")) + size, step = hidden(model, seed)["spill_size"], hidden(model, seed)["spill_step"] + used = replay(model, seed, "practice", bsc_good_run(size, step)).view() + assert checkpoint_map(used)["certification-current"] is False + + +def test_bsc_common_errors_are_flagged(library): + model = library.get(BSC) + seed = seed_where(model, lambda h: not h["expired"] and h["spill_size"] == "small" and h["drain_open"]) + actions = [act("blower", on=True), act("set_sash", position="raised"), + act("disinfect_interior", agent="water"), act("lay_toweling", location="front_grille"), + act("load_item", item="culture", zone="rear", wiped=False), + act("load_item", item="plates", zone="center", wiped=True), + act("load_item", item="pipettes", zone="center", wiped=True), + act("load_item", item="pipette_aid", zone="center", wiped=True), + act("load_item", item="extra_gloves", zone="side", wiped=True), + act("insert_arms", motion="sweeping"), act("light_burner"), + act("work_step", distance_in=2, lids_over_plates=False)] + spill_step = hidden(model, seed)["spill_step"] + if spill_step == 2: + actions.append(act("work_step", distance_in=2, lids_over_plates=False)) + actions += [act("apply_disinfectant", direction="center_out"), act("wait", minutes=5), + act("push_towels", direction="center_to_edge"), act("discard_towels", into="regular_trash"), + act("wipe_down_interior"), act("end_session")] + view = replay(model, seed, "practice", actions).view() + met = checkpoint_map(view) + for key in ("certification-checked", "ppe", "purge-before-loading", "interior-disinfected", "drain-valve-closed", + "toweling-placement", "items-wiped", "biological-last", "only-needed-items", + "sash-at-certified-height", "arms-slow", "arms-settle", "work-four-inches", "no-open-flame", + "spill-covered-outside-in", "spill-contact-time", "spill-towels-edge-to-center", + "spill-biohazard-disposal", "spill-gloves-changed", "final-wipe", "ppe-off-handwash"): + assert met[key] is False, key + assert not view["debrief"]["all_critical_met"] + assert any("mould" in e["text"] for e in view["log"][-1]["events"]) + + +def test_bsc_work_blocked_until_spill_dealt_with(library): + model = library.get(BSC) + seed = seed_where(model, lambda h: not h["expired"] and h["spill_step"] == 1) + actions = bsc_setup() + [act("work_step", distance_in=6, lids_over_plates=True)] + with pytest.raises(ActionRejected, match="spill"): + replay(model, seed, "practice", actions + [act("work_step", distance_in=6, lids_over_plates=True)]) + + +def test_bsc_large_spill_with_open_drain(library): + model = library.get(BSC) + seed = seed_where(model, lambda h: not h["expired"] and h["spill_size"] == "large") + actions = [a for a in bsc_good_run("large", hidden(model, seed)["spill_step"]) if a["name"] != "close_drain_valve"] + state, _ = run_state(model, seed, "practice", actions) + view = replay(model, seed, "practice", actions).view() + if state["hidden"]["drain_open"]: + assert checkpoint_map(view)["large-spill-drain"] is False + assert any("floor" in risk for risk in state["hidden"]["personnel_risk"]) + + +# API ------------------------------------------------------------------------------ + +@pytest.fixture +def api(tmp_path): + settings = Settings(database_url=f"sqlite:///{tmp_path / 'bench.db'}", content_root=CONTENT, + private_assessments_root=tmp_path / "private", allowed_origins=(ORIGIN,), + cookie_secure=False) + from courselab.db import Base + app = create_app(settings) + Base.metadata.create_all(app.state.engine) + with TestClient(app) as client: + yield client + + +def test_api_lists_protocols_without_checkpoints(api): + listed = api.get("/api/v1/bench/protocols").json() + assert {item["id"] for item in listed} == set(MODELS) + for item in listed: + detail = api.get(f"/api/v1/bench/protocols/{item['id']}").json() + assert "checkpoints" not in detail and "parameters" not in detail and "guided_steps" not in detail + assert detail["actions"] and detail["limitations"] + + +def test_api_replay_hides_debrief_until_finished(api): + url = f"/api/v1/bench/protocols/{BSC}/replay" + body = {"seed": 3, "mode": "practice", "actions": [act("inspect_cabinet")]} + running = api.post(url, json=body, headers={"Origin": ORIGIN}) + assert running.status_code == 200 + payload = running.json() + assert payload["debrief"] is None and "hidden" not in json.dumps(payload) + assert "description" not in json.dumps(payload["panels"]) + body["actions"].append(act("report_out_of_service")) + finished = api.post(url, json=body, headers={"Origin": ORIGIN}).json() + assert finished["finished"] and finished["debrief"]["checkpoints"] + assert "not a grade" in finished["debrief"]["note"] + + +def test_api_rejections(api): + url = f"/api/v1/bench/protocols/{PIPETTE}/replay" + rejected = api.post(url, json={"seed": 1, "mode": "practice", "actions": [act("dispense", touch_off=True)]}, + headers={"Origin": ORIGIN}) + assert rejected.status_code == 422 and rejected.json()["rejected_index"] == 0 + assert api.post(url, json={"seed": -1, "mode": "practice", "actions": []}, + headers={"Origin": ORIGIN}).status_code == 422 + assert api.post(url, json={"seed": 1, "mode": "exam", "actions": []}, headers={"Origin": ORIGIN}).status_code == 422 + assert api.post(url, json={"seed": 1, "mode": "practice", "actions": [], "extra": 1}, + headers={"Origin": ORIGIN}).status_code == 422 + assert api.post(url, json={"seed": 1, "mode": "practice", "actions": []}).status_code == 403 + assert api.post("/api/v1/bench/protocols/none/replay", json={"seed": 1, "mode": "practice", "actions": []}, + headers={"Origin": ORIGIN}).status_code == 404 + assert api.get("/api/v1/bench/protocols/none").status_code == 404 diff --git a/content/bench/protocols/class-ii-bsc-session-with-spill.json b/content/bench/protocols/class-ii-bsc-session-with-spill.json new file mode 100644 index 0000000..035a257 --- /dev/null +++ b/content/bench/protocols/class-ii-bsc-session-with-spill.json @@ -0,0 +1,931 @@ +{ + "schema_version": "1.0", + "id": "class-ii-bsc-session-with-spill", + "version": "0.1.0", + "title": "Working in a Class II biological safety cabinet, with a spill", + "layer": 1, + "summary": "Set up, use and shut down a Class II biological safety cabinet for a BSL-2 subculture, and respond to a spill that happens during the work, following the work practices in Appendix A of the CDC/NIH BMBL, 6th edition.", + "scenario": "You need to subculture a BSL-2 bacterial culture onto fresh plates in a Class II A2 cabinet that was switched off overnight. Everything you might need is on the bench beside it. Something will go wrong during the work.", + "guided_steps": [ + "Inspect the cabinet: read the certification label and check the drain valve. A cabinet more than 12 months past certification must be tagged out of service.", + "Put on a buttoned laboratory coat and gloves.", + "Switch on the blower, set the sash to its marked height and let it run at least 5 minutes.", + "Close the drain valve. Wipe the empty interior with 70% ethanol or 1:100 bleach, and follow bleach with sterile water.", + "Lay toweling on the work surface only, never over the grilles.", + "Wipe each item and place it: materials toward the rear, the discard tray and biohazard bag to one side, the culture last. Leave spare supplies outside.", + "Move your arms in slowly and perpendicular to the opening, then wait about a minute before working.", + "Work at least 4 inches behind the front grille, with no open flame, holding lids above open plates.", + "Spill on the surface: cover with towels, pour disinfectant from the outside in, wait 20–30 minutes, push towels from the edge to the centre, discard into the biohazard bag, wipe the interior and change gloves.", + "Spill through the grilles: also decontaminate and remove every item, confirm the drain valve is closed, flood the drain pan with disinfectant and empty it into a vessel of disinfectant.", + "To finish: with the blower running, decontaminate and remove items, the culture first; wipe the empty cabinet; remove PPE; wash your hands." + ], + "parameters": [ + { + "name": "purge_min", + "value": 5, + "unit": "min", + "label": "Purge time after start-up", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 384" + } + }, + { + "name": "arm_settle_s", + "value": 60, + "unit": "s", + "label": "Pause after putting arms in", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 383" + } + }, + { + "name": "min_distance_in", + "value": 4, + "unit": "in", + "label": "Minimum distance behind the front grille", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 383" + } + }, + { + "name": "spill_contact_min", + "value": [ + 20, + 30 + ], + "unit": "min", + "label": "Spill disinfectant contact time", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 387" + } + }, + { + "name": "certification_interval_months", + "value": 12, + "unit": "months", + "label": "Maximum time between certifications", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 7, pp. 391–392" + } + }, + { + "name": "expired_certification_probability", + "value": 0.2, + "unit": "probability", + "label": "Chance the certification has lapsed", + "source": { + "kind": "simulation-model" + } + }, + { + "name": "large_spill_probability", + "value": 0.3, + "unit": "probability", + "label": "Chance the spill reaches the grilles", + "source": { + "kind": "simulation-model" + } + } + ], + "actions": [ + { + "name": "inspect_cabinet", + "label": "Inspect the cabinet and its certification label", + "params": [] + }, + { + "name": "report_out_of_service", + "label": "Tag the cabinet out of service and stop", + "params": [] + }, + { + "name": "don_ppe", + "label": "Put on PPE", + "params": [ + { + "name": "coat_buttoned", + "label": "Laboratory coat buttoned", + "type": "boolean" + }, + { + "name": "gloves", + "label": "Gloves on", + "type": "boolean" + } + ] + }, + { + "name": "blower", + "label": "Switch the blower", + "params": [ + { + "name": "on", + "label": "On", + "type": "boolean" + } + ] + }, + { + "name": "set_sash", + "label": "Move the sash", + "params": [ + { + "name": "position", + "label": "Position", + "type": "choice", + "options": [ + { + "value": "certified", + "label": "Certified working height (marked)" + }, + { + "value": "raised", + "label": "Raised above the mark" + }, + { + "value": "closed", + "label": "Closed" + } + ] + } + ] + }, + { + "name": "wait", + "label": "Wait", + "params": [ + { + "name": "minutes", + "label": "Minutes", + "type": "number", + "min": 0.5, + "max": 60, + "unit": "min" + } + ] + }, + { + "name": "close_drain_valve", + "label": "Close the drain valve", + "params": [] + }, + { + "name": "disinfect_interior", + "label": "Disinfect the empty interior", + "params": [ + { + "name": "agent", + "label": "Agent", + "type": "choice", + "options": [ + { + "value": "ethanol70", + "label": "70% ethanol" + }, + { + "value": "bleach", + "label": "1:100 household bleach (0.05% hypochlorite)" + }, + { + "value": "water", + "label": "Tap water" + } + ] + } + ] + }, + { + "name": "rinse_sterile_water", + "label": "Wipe again with sterile water", + "params": [] + }, + { + "name": "lay_toweling", + "label": "Lay absorbent toweling", + "params": [ + { + "name": "location", + "label": "Where", + "type": "choice", + "options": [ + { + "value": "work_surface", + "label": "On the work surface" + }, + { + "value": "front_grille", + "label": "Over the front grille" + } + ] + } + ] + }, + { + "name": "load_item", + "label": "Place an item in the cabinet", + "params": [ + { + "name": "item", + "label": "Item", + "type": "choice", + "options": [ + { + "value": "culture", + "label": "BSL-2 culture" + }, + { + "value": "media", + "label": "Media bottle" + }, + { + "value": "plates", + "label": "Fresh culture plates" + }, + { + "value": "pipettes", + "label": "Sterile serological pipettes" + }, + { + "value": "pipette_aid", + "label": "Pipette aid" + }, + { + "value": "discard_tray", + "label": "Horizontal discard tray with disinfectant" + }, + { + "value": "biohazard_bag", + "label": "Biohazard bag" + }, + { + "value": "vortex", + "label": "Vortex mixer" + }, + { + "value": "extra_gloves", + "label": "Spare box of gloves" + }, + { + "value": "upright_canister", + "label": "Upright pipette canister" + } + ] + }, + { + "name": "zone", + "label": "Where", + "type": "choice", + "options": [ + { + "value": "rear", + "label": "Toward the rear of the work surface" + }, + { + "value": "center", + "label": "Centre of the work surface" + }, + { + "value": "side", + "label": "One side of the interior" + }, + { + "value": "front_edge", + "label": "Just inside the front grille" + }, + { + "value": "front_grille", + "label": "On the front grille" + }, + { + "value": "outside_taped", + "label": "Taped to the outside of the cabinet" + } + ] + }, + { + "name": "wiped", + "label": "Wiped with disinfectant first", + "type": "boolean" + } + ] + }, + { + "name": "insert_arms", + "label": "Put your arms into the cabinet", + "params": [ + { + "name": "motion", + "label": "How", + "type": "choice", + "options": [ + { + "value": "slow_perpendicular", + "label": "Slowly, perpendicular to the opening" + }, + { + "value": "sweeping", + "label": "Quickly, in a sweeping motion" + } + ] + } + ] + }, + { + "name": "settle", + "label": "Hold still before working", + "params": [ + { + "name": "seconds", + "label": "Seconds", + "type": "number", + "min": 0, + "max": 300, + "unit": "s" + } + ] + }, + { + "name": "withdraw_arms", + "label": "Withdraw your arms", + "params": [] + }, + { + "name": "light_burner", + "label": "Light a Bunsen burner in the cabinet", + "params": [] + }, + { + "name": "uv_lamp", + "label": "Run the UV lamp", + "params": [ + { + "name": "minutes", + "label": "Minutes", + "type": "number", + "min": 1, + "max": 60, + "unit": "min" + } + ] + }, + { + "name": "work_step", + "label": "Do the next subculture step", + "params": [ + { + "name": "distance_in", + "label": "Distance behind the front grille", + "type": "number", + "min": 0, + "max": 20, + "unit": "in" + }, + { + "name": "lids_over_plates", + "label": "Hold lids above open plates", + "type": "boolean" + } + ] + }, + { + "name": "cover_spill", + "label": "Cover the spill with paper towels", + "params": [] + }, + { + "name": "apply_disinfectant", + "label": "Pour disinfectant over the spill", + "params": [ + { + "name": "direction", + "label": "Working", + "type": "choice", + "options": [ + { + "value": "outside_in", + "label": "From the outside of the spill inward" + }, + { + "value": "center_out", + "label": "From the centre outward" + } + ] + } + ] + }, + { + "name": "push_towels", + "label": "Gather the spill towels", + "params": [ + { + "name": "direction", + "label": "Direction", + "type": "choice", + "options": [ + { + "value": "edge_to_center", + "label": "From the edge to the centre" + }, + { + "value": "center_to_edge", + "label": "From the centre to the edge" + } + ] + } + ] + }, + { + "name": "discard_towels", + "label": "Discard the spill towels", + "params": [ + { + "name": "into", + "label": "Into", + "type": "choice", + "options": [ + { + "value": "biohazard_bag", + "label": "Biohazard bag" + }, + { + "value": "regular_trash", + "label": "Ordinary waste" + } + ] + } + ] + }, + { + "name": "wipe_down_interior", + "label": "Wipe down the interior and items after the spill", + "params": [] + }, + { + "name": "flood_drain_pan", + "label": "Pour disinfectant through the grilles into the drain pan", + "params": [] + }, + { + "name": "drain_to_disinfectant", + "label": "Empty the drain pan into a vessel of disinfectant", + "params": [] + }, + { + "name": "change_gloves", + "label": "Change gloves", + "params": [] + }, + { + "name": "remove_item", + "label": "Remove an item from the cabinet", + "params": [ + { + "name": "item", + "label": "Item", + "type": "choice", + "options": [ + { + "value": "culture", + "label": "BSL-2 culture" + }, + { + "value": "media", + "label": "Media bottle" + }, + { + "value": "plates", + "label": "Fresh culture plates" + }, + { + "value": "pipettes", + "label": "Sterile serological pipettes" + }, + { + "value": "pipette_aid", + "label": "Pipette aid" + }, + { + "value": "discard_tray", + "label": "Horizontal discard tray with disinfectant" + }, + { + "value": "biohazard_bag", + "label": "Biohazard bag" + }, + { + "value": "vortex", + "label": "Vortex mixer" + }, + { + "value": "extra_gloves", + "label": "Spare box of gloves" + }, + { + "value": "upright_canister", + "label": "Upright pipette canister" + } + ] + }, + { + "name": "decontaminated", + "label": "Surface-decontaminated first", + "type": "boolean" + } + ] + }, + { + "name": "final_wipe", + "label": "Final wipe-down of the empty cabinet", + "params": [ + { + "name": "agent", + "label": "Agent", + "type": "choice", + "options": [ + { + "value": "ethanol70", + "label": "70% ethanol" + }, + { + "value": "bleach", + "label": "1:100 household bleach (0.05% hypochlorite)" + }, + { + "value": "water", + "label": "Tap water" + } + ] + } + ] + }, + { + "name": "remove_ppe", + "label": "Remove gloves and coat", + "params": [] + }, + { + "name": "wash_hands", + "label": "Wash hands", + "params": [] + }, + { + "name": "end_session", + "label": "End the session", + "params": [] + } + ], + "checkpoints": [ + { + "id": "certification-checked", + "severity": "major", + "description": "Check the certification label before use.", + "rationale": "A cabinet that is out of date may not contain aerosols.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 7, field certification, pp. 391–392: 'Each BSC should be tested and certified at least annually'" + } + }, + { + "id": "certification-current", + "severity": "critical", + "description": "Do not use a cabinet more than 12 months past certification; take it out of service.", + "rationale": "Containment is verified only by field certification.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, pp. 391–392" + } + }, + { + "id": "ppe", + "severity": "major", + "description": "Wear a buttoned laboratory coat and gloves.", + "rationale": "PPE protects skin and clothing from splashes.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 383: 'Laboratory coats ... should be worn buttoned over street clothing'" + } + }, + { + "id": "purge-before-loading", + "severity": "major", + "description": "Run the blower at least 5 minutes before starting work.", + "rationale": "The purge removes suspended particulates from the cabinet.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 384: 'the blowers should be operated at least five minutes before beginning work'" + } + }, + { + "id": "interior-disinfected", + "severity": "major", + "description": "Wipe the work surface, walls and inside of the window with 70% ethanol or 1:100 bleach.", + "rationale": "Disinfection reduces contaminants before work starts.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 384" + } + }, + { + "id": "bleach-rinsed", + "severity": "minor", + "description": "After bleach, wipe again with sterile water.", + "rationale": "Residual chlorine corrodes stainless steel; non-sterile water recontaminates.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 384" + } + }, + { + "id": "drain-valve-closed", + "severity": "minor", + "description": "Close the drain valve before beginning work.", + "rationale": "An open valve lets a spill escape the cabinet.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 383: 'If there is a drain valve under the work surface, it should be closed prior to beginning work'" + } + }, + { + "id": "toweling-placement", + "severity": "major", + "description": "Toweling may cover the work surface but not the grilles.", + "rationale": "Blocked grilles let room air into the work area and compromise containment.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 384" + } + }, + { + "id": "items-wiped", + "severity": "major", + "description": "Wipe every item with disinfectant before placing it inside.", + "rationale": "This reduces the mould spores brought into the cabinet.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 384" + } + }, + { + "id": "biological-last", + "severity": "major", + "description": "Place the biological material in the cabinet last.", + "rationale": "It limits the time hazardous material is exposed while the cabinet is being loaded.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 385: 'Biological material or other hazardous agents should be placed in the BSC last'" + } + }, + { + "id": "only-needed-items", + "severity": "minor", + "description": "Bring in only what the immediate work needs.", + "rationale": "Extra items disrupt the airflow; spare supplies stay outside.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 383" + } + }, + { + "id": "bulky-to-side", + "severity": "minor", + "description": "Put bulky items such as the discard tray and biohazard bag to one side.", + "rationale": "Keeps them from disrupting the work area airflow.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, pp. 384–385" + } + }, + { + "id": "aerosol-equipment-rear", + "severity": "minor", + "description": "Place aerosol-generating equipment toward the rear.", + "rationale": "The rear takes advantage of the cabinet's air split.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 384" + } + }, + { + "id": "no-outside-bag", + "severity": "minor", + "description": "Do not tape the biohazard bag to the outside of the cabinet.", + "rationale": "It encourages frequent movement in and out of the cabinet.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 385" + } + }, + { + "id": "sash-at-certified-height", + "severity": "critical", + "description": "Work only with the sash at its marked, certified height.", + "rationale": "Above that height the inflow no longer protects the worker.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 385" + } + }, + { + "id": "arms-slow", + "severity": "minor", + "description": "Move arms in and out slowly, perpendicular to the face opening.", + "rationale": "Sweeping movements disrupt the air curtain.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 382" + } + }, + { + "id": "arms-settle", + "severity": "minor", + "description": "Wait about one minute after putting arms in before manipulating materials.", + "rationale": "The cabinet stabilizes and air-sweeps the hands and arms.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 383: 'Manipulation of materials should be delayed for approximately one minute'" + } + }, + { + "id": "work-four-inches", + "severity": "major", + "description": "Work at least four inches in from the front grille.", + "rationale": "Work near the opening is not protected by the inflow.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 383" + } + }, + { + "id": "no-open-flame", + "severity": "major", + "description": "Do not use an open flame inside the cabinet.", + "rationale": "A flame creates turbulence that disrupts the HEPA-filtered downflow.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 386" + } + }, + { + "id": "uv-not-sole", + "severity": "major", + "description": "Never rely on a UV lamp as the only disinfection.", + "rationale": "UV does not replace chemical surface disinfection.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 6, p. 389" + } + }, + { + "id": "spill-covered-outside-in", + "severity": "major", + "description": "Cover a small spill with towels, then pour disinfectant starting from the outside.", + "rationale": "Working inward avoids spreading the spill.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 387" + } + }, + { + "id": "spill-contact-time", + "severity": "critical", + "description": "Leave the disinfectant for its contact time, usually 20 to 30 minutes.", + "rationale": "Shorter contact may not inactivate the agent.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 387" + } + }, + { + "id": "spill-towels-edge-to-center", + "severity": "minor", + "description": "Push the towels from the edge of the spill to the centre.", + "rationale": "Gathering inward keeps the contaminated area small.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 387" + } + }, + { + "id": "spill-biohazard-disposal", + "severity": "major", + "description": "Discard spill towels into a biohazard bag or receptacle.", + "rationale": "They are contaminated waste.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 387" + } + }, + { + "id": "spill-gloves-changed", + "severity": "major", + "description": "Change gloves after the work surface is decontaminated.", + "rationale": "Gloves used for clean-up are contaminated.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 387" + } + }, + { + "id": "large-spill-drain", + "severity": "critical", + "description": "For a spill through the grilles: remove decontaminated items, check the drain valve is closed, flood the drain pan with disinfectant and empty it into a vessel of disinfectant.", + "rationale": "Liquid under the work surface is out of reach of wiping.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 387" + } + }, + { + "id": "teardown-blower-running", + "severity": "major", + "description": "With the blower running, surface-decontaminate items and remove them.", + "rationale": "Running airflow keeps containment while items come out.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 387" + } + }, + { + "id": "teardown-biological-first", + "severity": "minor", + "description": "Remove biological materials first.", + "rationale": "Hazardous material should spend the least time in transit.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 387" + } + }, + { + "id": "final-wipe", + "severity": "major", + "description": "Finish with a wipe of the work surface, sides, back and the inside of the glass.", + "rationale": "This is the final surface decontamination of the cabinet.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 387" + } + }, + { + "id": "ppe-off-handwash", + "severity": "major", + "description": "Remove gloves and coat, then wash hands as the final step.", + "rationale": "Handwashing removes contamination that PPE removal can leave.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 387" + } + } + ], + "sources": [ + { + "source_id": "cdc-nih-bmbl-6", + "use": "Work practices, times and spill procedure, adapted from a US federal government work." + } + ], + "limitations": [ + "The culture is a generic BSL-2 bacterium; a real risk assessment for a named agent can change the PPE, disinfectant and contact time.", + "Contamination of the new plates is a simple model of how many airflow and hygiene errors occurred, not a physical simulation of the airflow.", + "Chemical and radioactive spills, gas decontamination and BSC classes other than II are not simulated.", + "Your institution's biosafety manual and biosafety officer, not this simulation, decide the procedures you must follow." + ] +} \ No newline at end of file diff --git a/content/bench/protocols/decimal-dilution-aerobic-plate-count.json b/content/bench/protocols/decimal-dilution-aerobic-plate-count.json new file mode 100644 index 0000000..2816328 --- /dev/null +++ b/content/bench/protocols/decimal-dilution-aerobic-plate-count.json @@ -0,0 +1,723 @@ +{ + "schema_version": "1.0", + "id": "decimal-dilution-aerobic-plate-count", + "version": "0.1.0", + "title": "Decimal dilutions and the aerobic plate count", + "layer": 0, + "summary": "Prepare tenfold dilutions of a food homogenate, pour duplicate plates with tempered agar, incubate, count the plates in the countable range and report the aerobic plate count, following the conventional method of FDA BAM Chapter 3.", + "scenario": "A 1:10 homogenate (H) of a chilled food sample has just been prepared, which starts the clock. Six 90 mL bottles of Butterfield's phosphate-buffered dilution water (B1–B6), sterile 10 mL and 1 mL pipets, Petri dishes, plate count agar tempered in a water bath and a 35 °C incubator are ready. You do not know how contaminated the sample is, so choose dilutions that will give countable plates.", + "guided_steps": [ + "Shake the homogenate 25 times through a 30 cm arc within 7 s.", + "With a separate sterile pipet for each step, move 10 mL of the previous dilution into 90 mL of diluent: H → B1 gives 10⁻², B1 → B2 gives 10⁻³, and so on as far as you need.", + "Shake every dilution 25 times in a 30 cm arc within 7 s before taking from it; reshake if it has stood more than 3 min.", + "Pipet 1 mL of each chosen dilution into two marked Petri dishes (duplicates).", + "Prepare a dilution-water control plate and an agar control plate.", + "Within 15 min of the original dilution, add 12–15 mL of plate count agar cooled to 45 ± 1 °C to every plate.", + "Mix each plate at once by alternate rotation and back-and-forth motion, then let the agar solidify without stacking.", + "Invert the plates and incubate promptly for 48 ± 2 h at 35 °C.", + "Count every plate. Use plates with 15–300 colonies: APC = total colonies on those plates ÷ total grams of sample they received.", + "Report two significant figures using the BAM rounding rule, mark the result as estimated if no plate was in range, and report a laboratory accident if a control shows contamination." + ], + "parameters": [ + { + "name": "transfer_ml", + "value": 10, + "unit": "mL", + "label": "Volume carried to the next dilution", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Conventional Plate Count Method, B: 'transferring 10 ml of previous dilution to 90 ml of diluent'" + } + }, + { + "name": "blank_ml", + "value": [ + 89, + 91 + ], + "unit": "mL", + "label": "Dilution blank volume", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section A, item 11: 'Dilution blanks, 90 ± 1 ml Butterfield's phosphate-buffered dilution water'" + } + }, + { + "name": "shake_times", + "value": 25, + "unit": "times", + "label": "Shakes per dilution", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B: 'Shake all dilutions 25 times in 30 cm (1 ft) arc within 7 s'" + } + }, + { + "name": "shake_arc_cm", + "value": 30, + "unit": "cm", + "label": "Shaking arc", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B, as above" + } + }, + { + "name": "shake_max_seconds", + "value": 7, + "unit": "s", + "label": "Time allowed for 25 shakes", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B, as above" + } + }, + { + "name": "reshake_after_min", + "value": 3, + "unit": "min", + "label": "Standing time that requires reshaking", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B: 'Reshake dilution bottle ... if it stands more than 3 min before it is pipetted'" + } + }, + { + "name": "plate_volume_ml", + "value": 1, + "unit": "mL", + "label": "Inoculum per plate", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B: 'Pipet 1 ml of each dilution into separate, duplicate, appropriately marked petri dishes'" + } + }, + { + "name": "agar_volume_ml", + "value": [ + 12, + 15 + ], + "unit": "mL", + "label": "Agar per plate", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B: 'Add 12-15 ml plate count agar (cooled to 45 ± 1° C)'" + } + }, + { + "name": "agar_temperature_c", + "value": [ + 44, + 46 + ], + "unit": "°C", + "label": "Agar temperature", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B, as above; water bath item 7, '45 ± 1°C'" + } + }, + { + "name": "agar_within_min", + "value": 15, + "unit": "min", + "label": "Time from original dilution to agar", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B: 'to each plate within 15 min of original dilution'" + } + }, + { + "name": "solidify_min", + "value": 10, + "unit": "min", + "label": "Time allowed for agar to set", + "source": { + "kind": "simulation-typical", + "note": "BAM says to let the agar solidify but gives no time; 10 min is the simulation's threshold." + } + }, + { + "name": "incubation_temperature_c", + "value": [ + 34, + 36 + ], + "unit": "°C", + "label": "Incubation temperature", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section A, item 8: 'Incubator, 35 ± 1°C'; Section B: 'incubate promptly for 48 ± 2 h at 35°C'" + } + }, + { + "name": "incubation_hours", + "value": [ + 46, + 50 + ], + "unit": "h", + "label": "Incubation time", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B, as above" + } + }, + { + "name": "countable_range", + "value": [ + 15, + 300 + ], + "unit": "colonies per plate", + "label": "Countable range", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Introduction: 'in this protocol, the suitable colony counting range is 15-300 per plate'" + } + }, + { + "name": "rounding", + "value": "two significant figures, third-digit rule", + "unit": "", + "label": "Rounding of the APC", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section 3, 'Rounding of APC result'" + } + }, + { + "name": "tntc_display_above", + "value": 1000, + "unit": "colonies", + "label": "Count shown as too numerous to count", + "source": { + "kind": "simulation-model" + } + }, + { + "name": "carryover_ml", + "value": 0.1, + "unit": "mL", + "label": "Liquid carried over by a reused pipet", + "source": { + "kind": "simulation-model" + } + }, + { + "name": "diluent_contamination_probability", + "value": 0.2, + "unit": "probability", + "label": "Chance the diluent is contaminated", + "source": { + "kind": "simulation-model" + } + } + ], + "actions": [ + { + "name": "shake", + "label": "Shake a bottle", + "params": [ + { + "name": "bottle", + "label": "Bottle", + "type": "choice", + "options": [ + { + "value": "H", + "label": "H, homogenate 10⁻¹" + }, + { + "value": "B1", + "label": "B1, dilution blank" + }, + { + "value": "B2", + "label": "B2, dilution blank" + }, + { + "value": "B3", + "label": "B3, dilution blank" + }, + { + "value": "B4", + "label": "B4, dilution blank" + }, + { + "value": "B5", + "label": "B5, dilution blank" + }, + { + "value": "B6", + "label": "B6, dilution blank" + } + ] + }, + { + "name": "times", + "label": "Number of shakes", + "type": "integer", + "min": 1, + "max": 60 + }, + { + "name": "arc_cm", + "label": "Arc", + "type": "number", + "min": 5, + "max": 60, + "unit": "cm" + }, + { + "name": "seconds", + "label": "Time taken", + "type": "number", + "min": 1, + "max": 60, + "unit": "s" + } + ] + }, + { + "name": "transfer", + "label": "Transfer between bottles", + "params": [ + { + "name": "source", + "label": "From", + "type": "choice", + "options": [ + { + "value": "H", + "label": "H, homogenate 10⁻¹" + }, + { + "value": "B1", + "label": "B1, dilution blank" + }, + { + "value": "B2", + "label": "B2, dilution blank" + }, + { + "value": "B3", + "label": "B3, dilution blank" + }, + { + "value": "B4", + "label": "B4, dilution blank" + }, + { + "value": "B5", + "label": "B5, dilution blank" + }, + { + "value": "B6", + "label": "B6, dilution blank" + } + ] + }, + { + "name": "destination", + "label": "Into", + "type": "choice", + "options": [ + { + "value": "B1", + "label": "B1, dilution blank" + }, + { + "value": "B2", + "label": "B2, dilution blank" + }, + { + "value": "B3", + "label": "B3, dilution blank" + }, + { + "value": "B4", + "label": "B4, dilution blank" + }, + { + "value": "B5", + "label": "B5, dilution blank" + }, + { + "value": "B6", + "label": "B6, dilution blank" + } + ] + }, + { + "name": "volume_ml", + "label": "Volume", + "type": "number", + "min": 0.1, + "max": 25, + "unit": "mL" + }, + { + "name": "fresh_pipet", + "label": "Use a new sterile pipet", + "type": "boolean" + } + ] + }, + { + "name": "plate", + "label": "Pipet into a Petri dish", + "params": [ + { + "name": "bottle", + "label": "From", + "type": "choice", + "options": [ + { + "value": "H", + "label": "H, homogenate 10⁻¹" + }, + { + "value": "B1", + "label": "B1, dilution blank" + }, + { + "value": "B2", + "label": "B2, dilution blank" + }, + { + "value": "B3", + "label": "B3, dilution blank" + }, + { + "value": "B4", + "label": "B4, dilution blank" + }, + { + "value": "B5", + "label": "B5, dilution blank" + }, + { + "value": "B6", + "label": "B6, dilution blank" + } + ] + }, + { + "name": "volume_ml", + "label": "Volume", + "type": "number", + "min": 0.1, + "max": 5, + "unit": "mL" + }, + { + "name": "fresh_pipet", + "label": "Use a new sterile pipet", + "type": "boolean" + } + ] + }, + { + "name": "add_control", + "label": "Prepare a control plate", + "params": [ + { + "name": "kind", + "label": "Control", + "type": "choice", + "options": [ + { + "value": "diluent", + "label": "Dilution-water control (1 mL sterile diluent)" + }, + { + "value": "agar", + "label": "Agar control (no inoculum)" + } + ] + } + ] + }, + { + "name": "pour_agar", + "label": "Pour agar into waiting plates", + "params": [ + { + "name": "volume_ml", + "label": "Agar per plate", + "type": "number", + "min": 5, + "max": 30, + "unit": "mL" + }, + { + "name": "temperature_c", + "label": "Agar temperature", + "type": "number", + "min": 35, + "max": 60, + "unit": "°C" + } + ] + }, + { + "name": "mix_plates", + "label": "Mix the freshly poured plates", + "params": [] + }, + { + "name": "wait", + "label": "Wait", + "params": [ + { + "name": "minutes", + "label": "Minutes", + "type": "number", + "min": 0.5, + "max": 120, + "unit": "min" + } + ] + }, + { + "name": "incubate", + "label": "Incubate the plates", + "params": [ + { + "name": "temperature_c", + "label": "Temperature", + "type": "number", + "min": 20, + "max": 45, + "unit": "°C" + }, + { + "name": "hours", + "label": "Time", + "type": "number", + "min": 1, + "max": 96, + "unit": "h" + }, + { + "name": "inverted", + "label": "Plates inverted", + "type": "boolean" + } + ] + }, + { + "name": "count_plates", + "label": "Count colonies", + "params": [] + }, + { + "name": "submit_result", + "label": "Report the aerobic plate count", + "params": [ + { + "name": "apc_cfu_per_g", + "label": "APC", + "type": "number", + "min": 0, + "max": 1000000000000.0, + "unit": "CFU/g" + }, + { + "name": "estimated", + "label": "Report as estimated (no plate in range)", + "type": "boolean" + }, + { + "name": "laboratory_accident", + "label": "Report a laboratory accident (contaminated control)", + "type": "boolean" + } + ] + } + ], + "checkpoints": [ + { + "id": "decimal-dilutions", + "severity": "critical", + "description": "Make each dilution by carrying 10 mL into a 90 mL blank.", + "rationale": "Every factor in the final calculation assumes exact tenfold steps.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B" + } + }, + { + "id": "separate-pipets", + "severity": "major", + "description": "Use a separate sterile pipet for every transfer and every plate.", + "rationale": "Liquid left in a pipet carries cells from a stronger dilution into a weaker one and inflates the counts at high dilutions.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B: 'Using separate sterile pipets, prepare decimal dilutions'" + } + }, + { + "id": "shake-before-sampling", + "severity": "major", + "description": "Shake each dilution 25 times in a 30 cm arc within 7 s before taking from it.", + "rationale": "Cells settle and clump; an unshaken bottle gives an unrepresentative sample.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B" + } + }, + { + "id": "reshake-after-standing", + "severity": "minor", + "description": "Reshake a dilution that has stood more than 3 min before pipetting.", + "rationale": "Settling resumes as soon as shaking stops.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B" + } + }, + { + "id": "plate-volume", + "severity": "major", + "description": "Pipet 1 mL of each dilution per plate.", + "rationale": "The grams of sample on each plate are dilution × inoculum; a different inoculum changes the factor.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B" + } + }, + { + "id": "duplicate-plates", + "severity": "major", + "description": "Plate every chosen dilution in duplicate.", + "rationale": "Duplicates show plating variation and give a second plate if one is lost.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B" + } + }, + { + "id": "agar-temperature", + "severity": "critical", + "description": "Pour agar tempered to 45 ± 1 °C.", + "rationale": "Hotter agar injures cells and lowers the count; cooler agar sets before it mixes.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section A item 7; Section B" + } + }, + { + "id": "agar-volume", + "severity": "minor", + "description": "Add 12–15 mL of agar per plate.", + "rationale": "A consistent agar depth gives comparable colony growth.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B" + } + }, + { + "id": "agar-within-window", + "severity": "major", + "description": "Add agar to every plate within 15 min of the original dilution.", + "rationale": "Cells in diluent can begin to multiply or die, so the count no longer reflects the sample.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B" + } + }, + { + "id": "controls", + "severity": "major", + "description": "Pour an agar control and a dilution-water control for the series.", + "rationale": "Only controls show whether colonies came from the medium or the diluent rather than the sample.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B: 'Pour agar and dilution water control plates for each series of samples'" + } + }, + { + "id": "mix-and-solidify", + "severity": "minor", + "description": "Mix the plates at once, then let the agar solidify before inverting.", + "rationale": "Mixing spreads cells evenly; inverting soft agar can slide or tear it.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section B" + } + }, + { + "id": "incubation", + "severity": "critical", + "description": "Invert the plates and incubate for 48 ± 2 h at 35 °C.", + "rationale": "Short or off-temperature incubation leaves colonies too small to count; upright plates collect condensation that causes spreading growth.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section A item 8; Section B" + } + }, + { + "id": "report-apc", + "severity": "critical", + "description": "Compute the APC from plates with 15–300 colonies and round it to two significant figures by the BAM rule.", + "rationale": "With no too-numerous plates, the maximum likelihood estimate in BAM equals total colonies on countable plates divided by total sample they received.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section C, 'Maximum likelihood method background and examples'; Section 3, rounding" + } + }, + { + "id": "report-flags", + "severity": "major", + "description": "Flag an estimated count when no plate is countable, and report a laboratory accident when a control is contaminated.", + "rationale": "BAM requires counts outside the range to be reported as estimated and contaminated plates as laboratory accidents.", + "source": { + "kind": "cited", + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "location": "Section 3, items 2 and 3" + } + } + ], + "sources": [ + { + "source_id": "fda-bam-chapter-3-aerobic-plate-count", + "use": "Procedure, volumes, temperatures, times, countable range, calculation and rounding rule, adapted from a US federal government work." + } + ], + "limitations": [ + "The microbiology is a model: survival and growth factors for hot agar, late pouring and short incubation are invented to show the direction and rough size of each error.", + "Spreader rules, too-numerous-to-count plates in the maximum likelihood estimate and the spiral plate method are not simulated.", + "Sample preparation (BAM Chapter 1) is assumed done; the homogenate is given.", + "The simulation cannot train aseptic handling or pouring technique, which are learned only at a bench." + ] +} \ No newline at end of file diff --git a/content/bench/protocols/micropipette-gravimetric-verification.json b/content/bench/protocols/micropipette-gravimetric-verification.json new file mode 100644 index 0000000..3951fca --- /dev/null +++ b/content/bench/protocols/micropipette-gravimetric-verification.json @@ -0,0 +1,75 @@ +{ + "schema_version": "1.0", + "id": "micropipette-gravimetric-verification", + "version": "0.1.0", + "title": "Gravimetric verification of a 1000 µL micropipette", + "layer": 0, + "summary": "Check whether an adjustable air-displacement pipette delivers its nominal volume by weighing replicate deliveries of water, converting mass to volume with a temperature-dependent factor, and comparing the systematic and random errors with acceptance limits.", + "scenario": "A 100–1000 µL pipette is due for its routine check. You have a reservoir of distilled water equilibrated to the room, an analytical balance with a small weighing vessel and a thermometer. Run the check at the nominal volume and complete the verification record.", + "guided_steps": [ + "Measure the temperature of the test water; the mass-to-volume factor depends on it.", + "Set the pipette to its nominal volume, 1000 µL.", + "Fit a new tip and pre-wet it by filling and emptying it several times, discarding the water to waste.", + "Tare the balance with the weighing vessel in place.", + "Hold the pipette vertical and immerse the tip 2–4 mm below the surface while aspirating.", + "Dispense against the inside wall of the vessel and finish with a touch-off.", + "Record the balance reading, tare again, and repeat until you have 10 readings.", + "Convert each reading to volume with V = m × Z, where Z = (1/(ρ_water − ρ_air)) × (1 − ρ_air/ρ_weights) in µL/mg.", + "Compute the mean volume, the systematic error (mean − nominal)/nominal × 100 and the random error as a coefficient of variation, s/mean × 100.", + "Compare both with the acceptance limits and record pass or fail." + ], + "parameters": [ + {"name": "nominal_volume_ul", "value": 1000, "unit": "µL", "label": "Nominal volume", "source": {"kind": "simulation-typical"}}, + {"name": "balance_readability_mg", "value": 0.1, "unit": "mg", "label": "Balance readability", "source": {"kind": "simulation-typical", "note": "A readability of 0.1 mg is common for checking 1000 µL deliveries; smaller volumes need a finer balance."}}, + {"name": "replicates_required", "value": 10, "unit": "readings", "label": "Replicates at the test volume", "source": {"kind": "simulation-typical", "note": "Ten replicates per test volume is common practice; the governing method is ISO 8655-6, which was not read for this protocol."}}, + {"name": "immersion_mm_range", "value": [2, 4], "unit": "mm", "label": "Tip immersion depth", "source": {"kind": "simulation-typical"}}, + {"name": "systematic_limit_pct", "value": 0.8, "unit": "%", "label": "Systematic error limit", "source": {"kind": "simulation-typical", "note": "Practice limit for this simulation. A real laboratory takes its limits from its SOP, the manufacturer or ISO 8655-2, which was not read for this protocol."}}, + {"name": "random_limit_pct", "value": 0.3, "unit": "%", "label": "Random error limit (CV)", "source": {"kind": "simulation-typical", "note": "Practice limit for this simulation; see the systematic limit."}}, + {"name": "fault_probability", "value": 0.35, "unit": "probability", "label": "Chance the pipette has a fault", "source": {"kind": "simulation-model"}}, + {"name": "mean_tolerance_ul", "value": 0.5, "unit": "µL", "label": "Tolerance on the reported mean", "source": {"kind": "simulation-model"}}, + {"name": "percent_tolerance", "value": 0.05, "unit": "percentage points", "label": "Tolerance on reported percentages", "source": {"kind": "simulation-model"}}, + {"name": "water_density_formula", "value": "Tanaka et al. (2001)", "unit": "g/mL", "label": "Water density", "source": {"kind": "cited", "source_id": "tanaka-2001-water-density", "location": "Recommended formula for air-free water, 0–40 °C; title checked, text not read in this session"}} + ], + "actions": [ + {"name": "measure_water_temperature", "label": "Measure water temperature", "params": []}, + {"name": "set_volume", "label": "Set pipette volume", "params": [{"name": "volume_ul", "label": "Volume", "type": "number", "min": 100, "max": 1000, "unit": "µL"}]}, + {"name": "fit_tip", "label": "Fit a new tip", "params": []}, + {"name": "prewet", "label": "Pre-wet the tip", "params": [{"name": "cycles", "label": "Fill-and-empty cycles", "type": "integer", "min": 0, "max": 5}]}, + {"name": "tare", "label": "Tare the balance", "params": []}, + {"name": "aspirate", "label": "Aspirate water", "params": [ + {"name": "immersion_mm", "label": "Tip immersion depth", "type": "number", "min": 0, "max": 10, "unit": "mm"}, + {"name": "vertical", "label": "Pipette held vertical", "type": "boolean"} + ]}, + {"name": "dispense", "label": "Dispense into the weighing vessel", "params": [{"name": "touch_off", "label": "Finish with a touch-off against the wall", "type": "boolean"}]}, + {"name": "read_balance", "label": "Record the balance reading", "params": []}, + {"name": "eject_tip", "label": "Eject the tip", "params": []}, + {"name": "submit_report", "label": "Submit the verification record", "params": [ + {"name": "mean_volume_ul", "label": "Mean delivered volume", "type": "number", "min": 0, "max": 2000, "unit": "µL"}, + {"name": "systematic_error_pct", "label": "Systematic error", "type": "number", "min": -100, "max": 100, "unit": "%"}, + {"name": "random_error_cv_pct", "label": "Random error (CV)", "type": "number", "min": 0, "max": 100, "unit": "%"}, + {"name": "verdict", "label": "Verdict", "type": "choice", "options": [{"value": "pass", "label": "Pass"}, {"value": "fail", "label": "Fail"}]} + ]} + ], + "checkpoints": [ + {"id": "temperature-recorded", "severity": "major", "description": "Measure the water temperature before converting masses to volumes.", "rationale": "Water density, and so the factor Z, changes with temperature; near 21 °C it changes by about 0.02% per °C.", "source": {"kind": "cited", "source_id": "tanaka-2001-water-density", "location": "title checked"}}, + {"id": "nominal-volume", "severity": "critical", "description": "Test at the nominal volume of the pipette.", "rationale": "Acceptance limits are stated for a specified test volume; readings at another setting cannot be compared with them.", "source": {"kind": "simulation-typical"}}, + {"id": "tip-prewetted", "severity": "major", "description": "Pre-wet each new tip before its first measured delivery.", "rationale": "A dry tip and its air cushion take up vapour on the first fill, so the first delivery is low.", "source": {"kind": "simulation-typical"}}, + {"id": "immersion-depth", "severity": "major", "description": "Immerse the tip 2–4 mm while aspirating.", "rationale": "Too shallow draws air; too deep leaves liquid on the outside of the tip that is then delivered.", "source": {"kind": "simulation-typical"}}, + {"id": "vertical-aspiration", "severity": "minor", "description": "Hold the pipette vertical while aspirating.", "rationale": "Tilting changes the hydrostatic head on the air cushion and the volume drawn.", "source": {"kind": "simulation-typical"}}, + {"id": "touch-off", "severity": "minor", "description": "Finish each dispense with a touch-off against the vessel wall.", "rationale": "Without it the last drop can stay on the tip.", "source": {"kind": "simulation-typical"}}, + {"id": "tare-before-each", "severity": "minor", "description": "Tare before each dispense, or difference successive readings.", "rationale": "Cumulative readings give the total, not each delivery.", "source": {"kind": "simulation-typical"}}, + {"id": "replicate-count", "severity": "critical", "description": "Record at least 10 readings at the test volume.", "rationale": "The random error is a standard deviation; too few replicates estimate it poorly.", "source": {"kind": "simulation-typical"}}, + {"id": "report-mean", "severity": "critical", "description": "Report the mean volume computed from your readings with the correct factor Z.", "rationale": "Mass becomes volume only through the density of water at the measured temperature and the buoyancy of air.", "source": {"kind": "cited", "source_id": "tanaka-2001-water-density", "location": "title checked"}}, + {"id": "report-systematic", "severity": "critical", "description": "Report the systematic error as (mean − nominal)/nominal × 100.", "rationale": "This is the inaccuracy the acceptance limit is written against.", "source": {"kind": "simulation-typical"}}, + {"id": "report-random", "severity": "major", "description": "Report the random error as the coefficient of variation, s/mean × 100, using the sample standard deviation.", "rationale": "This is the imprecision the second limit is written against.", "source": {"kind": "simulation-typical"}}, + {"id": "report-verdict", "severity": "critical", "description": "Record pass only if both errors are within their limits.", "rationale": "A pipette that fails either limit must be taken out of use, adjusted or serviced, then re-checked.", "source": {"kind": "simulation-typical"}} + ], + "sources": [ + {"source_id": "tanaka-2001-water-density", "use": "Water density formula used to compute Z."} + ], + "limitations": [ + "Acceptance limits, immersion depth and replicate count are practice values chosen for this simulation, not limits taken from ISO 8655, which was not read.", + "Evaporation from the weighing vessel, balance drift and the operator's hand warming the pipette are not modelled.", + "The simulation cannot train the feel of the plunger stops, which is learned only at a bench." + ] +} diff --git a/content/schemas/bench-protocol.schema.json b/content/schemas/bench-protocol.schema.json new file mode 100644 index 0000000..77d4276 --- /dev/null +++ b/content/schemas/bench-protocol.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:uni-stem-course-simulators:schema:bench-protocol:1.0", + "title": "uniStemCourseSimulators bench-procedure protocol", + "description": "A practice protocol for the bench simulator. Checkpoints and simulation-model parameters stay on the server; the browser receives the scenario, actions and limitations, and the checkpoint debrief only after a run ends. Every parameter states whether it is cited, a typical value chosen for the simulation, or part of the simulation's error model.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "id", "version", "title", "layer", "summary", "scenario", "guided_steps", "parameters", "actions", "checkpoints", "sources", "limitations"], + "$defs": { + "source": { + "type": "object", "additionalProperties": false, "required": ["kind"], + "properties": { + "kind": {"enum": ["cited", "simulation-typical", "simulation-model"]}, + "source_id": {"type": "string", "minLength": 1}, + "location": {"type": "string", "minLength": 3}, + "note": {"type": "string", "minLength": 3} + }, + "if": {"properties": {"kind": {"const": "cited"}}}, + "then": {"required": ["source_id", "location"]} + }, + "param": { + "type": "object", "additionalProperties": false, "required": ["name", "label", "type"], + "properties": { + "name": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, + "label": {"type": "string", "minLength": 2}, + "type": {"enum": ["number", "integer", "boolean", "choice"]}, + "min": {"type": "number"}, "max": {"type": "number"}, "unit": {"type": "string"}, + "options": {"type": "array", "minItems": 1, "items": { + "type": "object", "additionalProperties": false, "required": ["value", "label"], + "properties": {"value": {"type": "string", "minLength": 1}, "label": {"type": "string", "minLength": 1}}}} + } + } + }, + "properties": { + "schema_version": {"const": "1.0"}, + "id": {"type": "string", "pattern": "^[a-z0-9-]+$"}, + "version": {"type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$"}, + "title": {"type": "string", "minLength": 5}, + "layer": {"type": "integer", "minimum": 0, "maximum": 5}, + "summary": {"type": "string", "minLength": 20}, + "scenario": {"type": "string", "minLength": 20}, + "guided_steps": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 5}}, + "parameters": {"type": "array", "items": { + "type": "object", "additionalProperties": false, "required": ["name", "value", "unit", "label", "source"], + "properties": { + "name": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, + "value": {"type": ["number", "string", "array"]}, + "unit": {"type": "string"}, "label": {"type": "string", "minLength": 2}, + "source": {"$ref": "#/$defs/source"} + }}}, + "actions": {"type": "array", "minItems": 1, "items": { + "type": "object", "additionalProperties": false, "required": ["name", "label", "params"], + "properties": { + "name": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, + "label": {"type": "string", "minLength": 3}, + "params": {"type": "array", "items": {"$ref": "#/$defs/param"}} + }}}, + "checkpoints": {"type": "array", "minItems": 1, "items": { + "type": "object", "additionalProperties": false, "required": ["id", "severity", "description", "rationale", "source"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9-]+$"}, + "severity": {"enum": ["critical", "major", "minor"]}, + "description": {"type": "string", "minLength": 10}, + "rationale": {"type": "string", "minLength": 10}, + "source": {"$ref": "#/$defs/source"} + }}}, + "sources": {"type": "array", "items": { + "type": "object", "additionalProperties": false, "required": ["source_id", "use"], + "properties": {"source_id": {"type": "string", "minLength": 1}, "use": {"type": "string", "minLength": 10}}}}, + "limitations": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 10}} + } +} diff --git a/content/sources/registry.json b/content/sources/registry.json index 8e3ad7e..cc78e33 100644 --- a/content/sources/registry.json +++ b/content/sources/registry.json @@ -12118,6 +12118,81 @@ "checksum": null, "imported_assets": [], "rights_notes": "This record documents link-only use and does not clear linked content for reuse." + }, + { + "id": "cdc-nih-bmbl-6", + "title": "Biosafety in Microbiological and Biomedical Laboratories (BMBL), 6th Edition", + "institution": "US Centers for Disease Control and Prevention and National Institutes of Health", + "url": "https://www.cdc.gov/labs/bmbl/index.html", + "accessed_at": "2026-10-30", + "license": "US federal government work, not subject to copyright in the United States (17 U.S.C. 105). Figures or material credited to other parties inside the document may carry their own rights and are not used.", + "reuse_mode": "adapted", + "permissions": { + "linking": true, + "quotation": true, + "adaptation": true, + "redistribution": false + }, + "attribution_requirements": [ + "Identify Biosafety in Microbiological and Biomedical Laboratories, 6th Edition (CDC/NIH, 2020), the section and page, and the URL. Do not imply CDC or NIH endorsement." + ], + "third_party_notes": "Work practices, times and the spill procedure in Appendix A (Primary Containment for Biohazards), Part 5 and the field-certification section, are adapted with page references into the bench protocol class-ii-bsc-session-with-spill. Read from the full PDF linked on the CDC page; no figures are reproduced.", + "mapped_courses": [], + "mapped_modules": [], + "version": "6th edition, June 2020 (PDF linked from the CDC page, page dated 18 March 2026); Appendix A pp. 382–392 read on 2026-10-30", + "checksum": null, + "imported_assets": [], + "rights_notes": "BMBL is advisory guidance. Using it in practice protocols does not establish compliance, endorsement or review by CDC or NIH." + }, + { + "id": "fda-bam-chapter-3-aerobic-plate-count", + "title": "Bacteriological Analytical Manual (BAM) Chapter 3: Aerobic Plate Count", + "institution": "US Food and Drug Administration", + "url": "https://www.fda.gov/food/laboratory-methods-food/bam-chapter-3-aerobic-plate-count", + "accessed_at": "2026-10-30", + "license": "US federal government work, not subject to copyright in the United States (17 U.S.C. 105). Figures or material credited to other parties inside the document may carry their own rights and are not used.", + "reuse_mode": "adapted", + "permissions": { + "linking": true, + "quotation": true, + "adaptation": true, + "redistribution": false + }, + "attribution_requirements": [ + "Identify BAM Chapter 3: Aerobic Plate Count (FDA, January 2026 edition; authors G. Zhang, M. Ferguson and R. J. Blodgett), the section, and the URL. Do not imply FDA endorsement." + ], + "third_party_notes": "The conventional plate count procedure (sections A and B), the maximum likelihood calculation (section C) and the recording and rounding rules (section 3) are adapted, with section references, into the bench protocol decimal-dilution-aerobic-plate-count. Read from the January 2026 PDF; the spiral plate method is not used.", + "mapped_courses": [], + "mapped_modules": [], + "version": "January 2026 edition (content current as of 26 February 2026); full PDF read on 2026-10-30", + "checksum": null, + "imported_assets": [], + "rights_notes": "Using the method in a practice protocol does not establish compliance, endorsement or review by FDA." + }, + { + "id": "tanaka-2001-water-density", + "title": "Recommended table for the density of water between 0 °C and 40 °C based on recent experimental reports", + "institution": "Metrologia (IOP Publishing); Tanaka M, Girard G, Davis R, Peuto A, Bignell N", + "url": "https://doi.org/10.1088/0026-1394/38/4/3", + "accessed_at": "2026-10-30", + "license": "Third-party reuse rights not cleared for this package; link-only scientific reference selected", + "reuse_mode": "link-only-reference", + "permissions": { + "linking": true, + "quotation": false, + "adaptation": false, + "redistribution": false + }, + "attribution_requirements": [ + "Identify the article title, Metrologia 38(4) 2001, and the DOI link." + ], + "third_party_notes": "Cited as the origin of the water-density formula used to convert balance readings to volume in the bench protocol micropipette-gravimetric-verification. The title was checked; the article text was not read in this session. The implemented formula reproduces the widely tabulated 998.2067 kg/m³ at 20 °C.", + "mapped_courses": [], + "mapped_modules": [], + "version": "Metrologia 2001; DOI 10.1088/0026-1394/38/4/3; title checked; identifier resolved with the citation-check script on 2026-10-30", + "checksum": null, + "imported_assets": [], + "rights_notes": "This record documents link-only use and does not clear linked content for reuse." } ] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4fa19cb..594f100 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,6 +28,8 @@ Weighted-grade math uses exact `Decimal` arithmetic and is isolated in `coursela `content/curriculum-map.json` stores eight pathway sequences and catalog-only nodes independently from course packages. The API merges package manifests with planned nodes in `/api/v1/curriculum`; it labels each maturity and includes explicit prerequisite edges. The React catalog uses native expandable sections to show the sequence, dependencies, related partial packages, and source links. Catalog-only nodes cannot be enrolled. The validator checks the combined graph and the required engineering/JHU coverage; this planning map is not a university-reviewed curriculum. +Bench practice (`courselab.bench`, `/api/v1/bench/...`) replays procedure simulations without storing runs. Each protocol in `content/bench/protocols/` is a JSON definition, validated against `bench-protocol.schema.json`, paired with a Python model that holds the physics, error models and checkpoint rules. The browser sends a seed, a mode and the whole action list, and the server replays it deterministically with a generator seeded from the protocol ID, version and seed. Learners see the scenario, the action specifications, the visible bench state and the event log. Hidden variables such as instrument faults or contaminated diluent, the checkpoint rules and the simulation-model parameters stay on the server until the run ends, when a formative debrief is returned. Runs are not graded and are not recorded against an account. + No submitted Python runs in the API; no Docker socket is mounted. Optional feedback providers are future extensions and cannot mutate deterministic scores. Credentials come from server secrets. ## Compatibility diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 26cc160..e5fa6ac 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,6 +30,8 @@ Delivered: guest/account access; version-pinned enrollment; server-backed notes, The case studio supports private drafts, version/prompt snapshots, conflict protection, earlier response history, account export/deletion and read-only archived cases. Historical feature-batch verification is recorded in the linked review reports. Since 2026-10-13 each batch runs the full root, backend and frontend suites; earlier batches recorded "no test suite was run", and the drift they left was repaired in one pass. Platform verification is not course review or protected-exam release approval. +Bench practice (owner request, 2026-10-30) follows [the wet-lab practice plan](WET_LAB_PRACTICE_PLAN.md). The replay engine, three protocols and a React bench view are delivered: micropipette gravimetric verification, decimal dilutions with the BAM aerobic plate count, and a Class II biosafety cabinet session with a spill. Next: the layer-0 laboratory-measurement course package, then the layer-1 biosafety package and more protocols, layer by layer. + The searchable notebook reuses the existing private note store, exposes case-insensitive literal search with course filtering and pagination, and links matches back to enrolled course lessons. Remaining shared work should address a demonstrated learner workflow or blocker and fit alongside the instructional rotation. Avoid adding isolated grader types while course foundations remain thin. ## Course development and review diff --git a/docs/WET_LAB_PRACTICE_PLAN.md b/docs/WET_LAB_PRACTICE_PLAN.md index 804335d..f2b0393 100644 --- a/docs/WET_LAB_PRACTICE_PLAN.md +++ b/docs/WET_LAB_PRACTICE_PLAN.md @@ -1,6 +1,8 @@ # Wet-lab practice track: plan -Status: plan only, drafted with AI assistance on 2026-10-30. Nothing here is built yet, and nothing here has been reviewed by a laboratory professional, a biosafety officer or a certifying body. +Status: drafted with AI assistance on 2026-10-30. Nothing here has been reviewed by a laboratory professional, a biosafety officer or a certifying body. + +Built so far (2026-10-30, agent-run, unreviewed): the replay engine and API, the protocol schema, three protocols and a React bench view. These are items 1–3 of the first milestone below. The protocols are `micropipette-gravimetric-verification` (layer 0), `decimal-dilution-aerobic-plate-count` (layer 0, adapted from FDA BAM Chapter 3, January 2026 edition) and `class-ii-bsc-session-with-spill` (layer 1, adapted from BMBL 6th edition, Appendix A). Item 4, the layer-0 course package, is next. ## Goal diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aa37d4f..61c8855 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import type { Source, } from "./types"; import { AccountPanel } from "./components/AccountPanel"; +import { BenchPractice } from "./components/BenchPractice"; import { CourseCatalog } from "./components/CourseCatalog"; import { CourseWorkspace } from "./components/CourseWorkspace"; import { InstructorReview } from "./components/InstructorReview"; @@ -136,15 +137,17 @@ export default function App() { ? "course" : segments[0] === "tools" ? "tools" - : segments[0] === "account" - ? "account" - : segments[0] === "notebook" - ? "notebook" - : segments[0] === "instructor" - ? "instructor" - : segments[0] === "learning" - ? "learning" - : "courses"; + : segments[0] === "bench" + ? "bench" + : segments[0] === "account" + ? "account" + : segments[0] === "notebook" + ? "notebook" + : segments[0] === "instructor" + ? "instructor" + : segments[0] === "learning" + ? "learning" + : "courses"; const currentEnrollments = enrollments.filter((enrollment) => courses.some( (course) => @@ -219,6 +222,12 @@ export default function App() { > Study tools + + Bench practice + ) : page === "tools" ? ( + ) : page === "bench" ? ( + ) : page === "notebook" ? ( request("/courses"), + benchProtocols: () => request("/bench/protocols"), + benchProtocol: (id: string) => + request(`/bench/protocols/${encode(id)}`), + benchReplay: ( + id: string, + seed: number, + mode: "guided" | "practice", + actions: BenchAction[], + ) => + request(`/bench/protocols/${encode(id)}/replay`, "POST", { + seed, + mode, + actions, + }), curriculum: () => request("/curriculum"), course: (id: string) => request(`/courses/${encode(id)}`), archivedCourseVersions: (id: string) => diff --git a/frontend/src/components/BenchPractice.test.tsx b/frontend/src/components/BenchPractice.test.tsx new file mode 100644 index 0000000..6b6dd59 --- /dev/null +++ b/frontend/src/components/BenchPractice.test.tsx @@ -0,0 +1,162 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { api, ApiError } from "../api"; +import type { BenchProtocol, BenchRun } from "../types"; +import { BenchPractice } from "./BenchPractice"; + +const protocol: BenchProtocol = { + id: "micropipette-gravimetric-verification", + version: "0.1.0", + title: "Gravimetric verification of a 1000 µL micropipette", + layer: 0, + summary: "Check a pipette by weighing water.", + scenario: "A pipette is due for its routine check.", + actions: [ + { name: "fit_tip", label: "Fit a new tip", params: [] }, + { + name: "set_volume", + label: "Set pipette volume", + params: [ + { + name: "volume_ul", + label: "Volume", + type: "number", + min: 100, + max: 1000, + unit: "µL", + }, + ], + }, + ], + limitations: ["Limits are practice values."], + sources: [{ source_id: "tanaka-2001-water-density", use: "Water density." }], +}; + +function run(overrides: Partial = {}): BenchRun { + return { + protocol_id: protocol.id, + protocol_version: "0.1.0", + seed: 1, + mode: "guided", + finished: false, + clock_minutes: 0, + panels: [{ title: "Pipette", rows: [["Tip", "none"]] }], + log: [], + guidance: ["Measure the water temperature."], + debrief: null, + ...overrides, + }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("bench practice", () => { + it("starts a run, sends each action with the full history and shows the debrief", async () => { + const user = userEvent.setup(); + vi.spyOn(api, "benchProtocols").mockResolvedValue([protocol]); + vi.spyOn(api, "benchProtocol").mockResolvedValue(protocol); + const replay = vi + .spyOn(api, "benchReplay") + .mockResolvedValueOnce(run()) + .mockResolvedValueOnce( + run({ + log: [ + { + index: 0, + name: "set_volume", + label: "Set pipette volume", + params: { volume_ul: 1000 }, + clock_minutes: 0.2, + events: [{ kind: "warning", text: "Pre-wet the tip.", data: {} }], + }, + ], + }), + ) + .mockResolvedValueOnce( + run({ + finished: true, + debrief: { + checkpoints: [ + { + id: "replicate-count", + severity: "critical", + description: "Record at least 10 readings.", + rationale: "Few replicates estimate spread poorly.", + source: { kind: "simulation-typical" }, + met: false, + detail: "2 readings recorded.", + }, + ], + missed: { critical: 1, major: 0, minor: 0 }, + met_count: 0, + applicable_count: 1, + all_critical_met: false, + revealed: ["This pipette had no fault."], + note: "Formative practice only.", + }, + }), + ); + render(); + await user.click(await screen.findByRole("button", { name: "Start run" })); + expect( + await screen.findByText("Measure the water temperature."), + ).toBeInTheDocument(); + await user.selectOptions( + screen.getByRole("combobox", { name: "Action" }), + "set_volume", + ); + await user.type( + screen.getByRole("spinbutton", { name: "Volume (µL)" }), + "1000", + ); + await user.click(screen.getByRole("button", { name: "Do it" })); + expect(await screen.findByText(/Pre-wet the tip/)).toBeInTheDocument(); + const [, , , actions] = replay.mock.calls[1]; + expect(actions).toEqual([ + { name: "set_volume", params: { volume_ul: 1000 } }, + ]); + await user.click(screen.getByRole("button", { name: "Do it" })); + expect( + await screen.findByRole("heading", { name: "Debrief" }), + ).toBeInTheDocument(); + expect(screen.getByText("This pipette had no fault.")).toBeInTheDocument(); + expect(screen.getByText("Missed")).toBeInTheDocument(); + expect(replay.mock.calls[2][3]).toHaveLength(2); + }); + + it("refuses an empty number locally and keeps history when the server rejects an action", async () => { + const user = userEvent.setup(); + vi.spyOn(api, "benchProtocols").mockResolvedValue([protocol]); + vi.spyOn(api, "benchProtocol").mockResolvedValue(protocol); + const replay = vi + .spyOn(api, "benchReplay") + .mockResolvedValueOnce(run()) + .mockRejectedValueOnce( + new ApiError("Action 1 (fit_tip): A tip is already fitted", 422), + ); + render(); + await user.click(await screen.findByRole("button", { name: "Start run" })); + await screen.findByText("Measure the water temperature."); + await user.selectOptions( + screen.getByRole("combobox", { name: "Action" }), + "set_volume", + ); + await user.click(screen.getByRole("button", { name: "Do it" })); + expect(screen.getByRole("alert")).toHaveTextContent( + "Enter a number for volume", + ); + expect(replay).toHaveBeenCalledTimes(1); + await user.selectOptions( + screen.getByRole("combobox", { name: "Action" }), + "fit_tip", + ); + await user.click(screen.getByRole("button", { name: "Do it" })); + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent( + "A tip is already fitted", + ), + ); + expect(screen.queryByRole("heading", { name: "Debrief" })).toBeNull(); + }); +}); diff --git a/frontend/src/components/BenchPractice.tsx b/frontend/src/components/BenchPractice.tsx new file mode 100644 index 0000000..c5a85a3 --- /dev/null +++ b/frontend/src/components/BenchPractice.tsx @@ -0,0 +1,454 @@ +import { useEffect, useState } from "react"; +import { api, errorMessage } from "../api"; +import type { + BenchAction, + BenchActionSpec, + BenchParam, + BenchParamValue, + BenchProtocol, + BenchProtocolSummary, + BenchRun, +} from "../types"; + +type Mode = "guided" | "practice"; + +function newSeed() { + return Math.floor(Math.random() * 2_000_000_000); +} + +function initialValue(param: BenchParam): BenchParamValue { + if (param.type === "boolean") return false; + if (param.type === "choice") return param.options?.[0]?.value ?? ""; + return ""; +} + +export function BenchPractice({ protocolId }: { protocolId?: string }) { + const [protocols, setProtocols] = useState([]); + const [error, setError] = useState(""); + useEffect(() => { + api + .benchProtocols() + .then(setProtocols) + .catch((reason) => setError(errorMessage(reason))); + }, []); + const selected = + protocols.find((item) => item.id === protocolId) || protocols[0]; + return ( + <> + Rehearse · decide · debrief +

Bench practice

+

+ Rehearse real laboratory procedures before doing them at a bench. Choose + each action yourself; the simulation keeps track of what a real bench + would, including what you cannot see. +

+

+ Formative practice only. A run is not a grade, a competency record or a + certification, and it cannot train hand skills. Follow your own + laboratory's procedures and supervisors at a real bench. +

+ {error && ( +

+ {error} +

+ )} + {protocols.length > 0 && ( +
+ )} + {selected && } + + ); +} + +function ProtocolRunner({ id }: { id: string }) { + const [protocol, setProtocol] = useState(null); + const [mode, setMode] = useState("guided"); + const [seed, setSeed] = useState(null); + const [actions, setActions] = useState([]); + const [run, setRun] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + api + .benchProtocol(id) + .then(setProtocol) + .catch((reason) => setError(errorMessage(reason))); + }, [id]); + + async function submit(next: BenchAction[], runSeed = seed, runMode = mode) { + if (runSeed === null) return; + setBusy(true); + setError(""); + try { + const result = await api.benchReplay(id, runSeed, runMode, next); + setActions(next); + setRun(result); + } catch (reason) { + setError(errorMessage(reason)); + } finally { + setBusy(false); + } + } + + function start() { + const fresh = newSeed(); + setSeed(fresh); + void submit([], fresh, mode); + } + + if (!protocol) { + return error ? ( +

+ {error} +

+ ) : ( +

Loading protocol…

+ ); + } + + return ( +
+
+

{protocol.title}

+

{protocol.summary}

+

+ Scenario. {protocol.scenario} +

+
+ + +
+
+ + {error && ( +

+ {error} +

+ )} + + {run && ( +
+
+ {run.guidance && ( +
+

Procedure

+
    + {run.guidance.map((step) => ( +
  1. {step}
  2. + ))} +
+
+ )} +
+

+ Bench state{" "} + + (clock {run.clock_minutes.toFixed(1)} min) + +

+ {run.panels.map((panel) => ( + + + + {panel.rows.map(([label, value]) => ( + + + + + ))} + +
{panel.title}
{label}{value}
+ ))} +
+
+
+ {!run.finished ? ( + void submit([...actions, action])} + /> + ) : ( + + )} + {mode === "guided" && actions.length > 0 && !run.finished && ( + + )} + +
+
+ )} + +
+

Limits of this simulation

+
    + {protocol.limitations.map((item) => ( +
  • {item}
  • + ))} +
+

+ Sources:{" "} + {protocol.sources + .map((source) => `${source.source_id} (${source.use})`) + .join("; ")} + . Listed in the source registry. +

+
+
+ ); +} + +function ActionForm({ + specs, + busy, + onAct, +}: { + specs: BenchActionSpec[]; + busy: boolean; + onAct: (action: BenchAction) => void; +}) { + const [name, setName] = useState(specs[0].name); + const spec = specs.find((item) => item.name === name) || specs[0]; + const [values, setValues] = useState>(() => + Object.fromEntries(spec.params.map((p) => [p.name, initialValue(p)])), + ); + const [problem, setProblem] = useState(""); + + function choose(next: string) { + const nextSpec = specs.find((item) => item.name === next) || specs[0]; + setName(next); + setProblem(""); + setValues( + Object.fromEntries(nextSpec.params.map((p) => [p.name, initialValue(p)])), + ); + } + + return ( +
{ + event.preventDefault(); + const params: Record = {}; + for (const param of spec.params) { + const value = values[param.name]; + if (param.type === "number" || param.type === "integer") { + const number = Number(value); + if (value === "" || !Number.isFinite(number)) { + setProblem(`Enter a number for ${param.label.toLowerCase()}.`); + return; + } + params[param.name] = number; + } else { + params[param.name] = value; + } + } + setProblem(""); + onAct({ name: spec.name, params }); + }} + > +

Next action

+ + {spec.params.map((param) => ( + setValues({ ...values, [param.name]: value })} + /> + ))} + {problem && ( +

+ {problem} +

+ )} + + + ); +} + +function ParamInput({ + param, + value, + onChange, +}: { + param: BenchParam; + value: BenchParamValue; + onChange: (value: BenchParamValue) => void; +}) { + if (param.type === "boolean") { + return ( + + ); + } + if (param.type === "choice") { + return ( + + ); + } + return ( + + ); +} + +function RunLog({ run }: { run: BenchRun }) { + if (!run.log.length) return null; + return ( +
+

What happened

+
    + {[...run.log].reverse().map((entry) => ( +
  1. + {entry.label}{" "} + + at {entry.clock_minutes.toFixed(1)} min + +
      + {entry.events.map((event, index) => ( +
    • + {event.kind === "warning" ? "Check: " : ""} + {event.kind === "consequence" ? "Result: " : ""} + {event.text} +
    • + ))} +
    +
  2. + ))} +
+
+ ); +} + +function Debrief({ run }: { run: BenchRun }) { + const debrief = run.debrief; + if (!debrief) return null; + const label = (met: boolean | null) => + met === null ? "Not applicable" : met ? "Met" : "Missed"; + return ( +
+

Debrief

+

+ {debrief.met_count} of {debrief.applicable_count} applicable checkpoints + met. Missed: {debrief.missed.critical} critical, {debrief.missed.major}{" "} + major, {debrief.missed.minor} minor.{" "} + {debrief.all_critical_met + ? "Every critical checkpoint was met." + : "At least one critical checkpoint was missed."} +

+ {debrief.revealed.length > 0 && ( + <> +

What you could not see

+
    + {debrief.revealed.map((item) => ( +
  • {item}
  • + ))} +
+ + )} + + + + + + + + + + {debrief.checkpoints.map((row) => ( + + + + + + ))} + +
CheckpointSeverityResult
+ {row.description} +
+ {row.detail} +
+ + Why: {row.rationale} + {row.source.kind === "cited" + ? ` Source: ${row.source.source_id}, ${row.source.location}.` + : " Source: a typical value or model chosen for this simulation."} + +
{row.severity}{label(row.met)}
+

{debrief.note}

+
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 1d5d7f3..4dff86d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1208,3 +1208,64 @@ footer { transition: none !important; } } + +.bench-start { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: end; +} + +.bench-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + gap: 1rem; + align-items: start; +} + +.bench-panel { + width: 100%; + margin-bottom: 1rem; + border-collapse: collapse; +} + +.bench-panel caption { + text-align: left; + font-weight: 600; +} + +.bench-panel th, +.bench-panel td, +.bench-checkpoints th, +.bench-checkpoints td { + padding: 0.3rem 0.5rem; + border-bottom: 1px solid var(--border, #d0d7de); + text-align: left; + vertical-align: top; +} + +.bench-action label { + display: block; + margin-bottom: 0.6rem; +} + +.bench-log > li { + margin-bottom: 0.6rem; +} + +.bench-event-warning { + font-weight: 600; +} + +.bench-event-consequence { + font-style: italic; +} + +.bench-checkpoints { + width: 100%; + border-collapse: collapse; +} + +.bench-met-false td:last-child { + font-weight: 700; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 648aa0a..7468280 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -476,3 +476,90 @@ export interface CardReviewResult { rating: CardRating; schedule: CardSchedule; } + +export interface BenchProtocolSummary { + id: string; + version: string; + title: string; + layer: number; + summary: string; +} + +export interface BenchParam { + name: string; + label: string; + type: "number" | "integer" | "boolean" | "choice"; + min?: number; + max?: number; + unit?: string; + options?: { value: string; label: string }[]; +} + +export interface BenchActionSpec { + name: string; + label: string; + params: BenchParam[]; +} + +export interface BenchProtocol extends BenchProtocolSummary { + scenario: string; + actions: BenchActionSpec[]; + limitations: string[]; + sources: { source_id: string; use: string }[]; +} + +export type BenchParamValue = string | number | boolean; + +export interface BenchAction { + name: string; + params: Record; +} + +export interface BenchEvent { + kind: "info" | "readout" | "warning" | "consequence"; + text: string; + data: Record; +} + +export interface BenchCheckpoint { + id: string; + severity: "critical" | "major" | "minor"; + description: string; + rationale: string; + source: { + kind: string; + source_id?: string; + location?: string; + note?: string; + }; + met: boolean | null; + detail: string; +} + +export interface BenchRun { + protocol_id: string; + protocol_version: string; + seed: number; + mode: "guided" | "practice"; + finished: boolean; + clock_minutes: number; + panels: { title: string; rows: [string, string][] }[]; + log: { + index: number; + name: string; + label: string; + params: Record; + clock_minutes: number; + events: BenchEvent[]; + }[]; + guidance: string[] | null; + debrief: { + checkpoints: BenchCheckpoint[]; + missed: Record<"critical" | "major" | "minor", number>; + met_count: number; + applicable_count: number; + all_critical_met: boolean; + revealed: string[]; + note: string; + } | null; +} diff --git a/tools/validate_content.py b/tools/validate_content.py index 5d2005f..9877245 100644 --- a/tools/validate_content.py +++ b/tools/validate_content.py @@ -321,6 +321,35 @@ def check_refs( report.error("reference", path, f"Unknown {kind} reference: {reference}") +def validate_bench_protocols( + root: Path, + source_ids: set[str], + validators: dict[str, Draft202012Validator], + report: Report, +) -> None: + """Bench-simulator protocols: schema, file naming and registered citations.""" + for path in sorted((root / "content/bench/protocols").glob("*.json")): + protocol = load_json(path, report) + if protocol is None or not apply_schema( + protocol, "bench-protocol.schema.json", path, validators, report + ): + continue + if protocol["id"] != path.stem: + report.error("bench-protocol", path, "File name must match the protocol ID.") + cited = {item["source_id"] for item in protocol["sources"]} + check_refs(sorted(cited), source_ids, "source", path, report) + for group in ("parameters", "checkpoints"): + for item in protocol[group]: + source = item["source"] + if source["kind"] == "cited" and source["source_id"] not in cited: + report.error( + "bench-protocol", + path, + f"{group[:-1].capitalize()} {item.get('name') or item.get('id')} cites " + f"{source['source_id']}, which the protocol's sources list does not name.", + ) + + def validate_graph( courses: dict[str, dict[str, Any]], report: Report, @@ -1700,6 +1729,7 @@ def validate_repository( ) catalog_ids = validate_curriculum_map(root, courses, sources, validators, report) validate_graph(courses, report, catalog_ids) + validate_bench_protocols(root, sources, validators, report) if source_document and isinstance(source_document, dict): for source in source_document.get("sources", []): if isinstance(source, dict): From 8af3b187f0957f6b29a7ab8af3489b7256f7c042 Mon Sep 17 00:00:00 2001 From: Dylan Stechmann Date: Sat, 31 Oct 2026 00:59:12 +0000 Subject: [PATCH 3/7] Add Laboratory Measurement and Solution Preparation (0.1.0), layer 0 of the wet-lab track A new six-week foundation package, the catalog's 26th: concentration and solution preparation, dilution series and error propagation, volume measurement and gravimetric pipette checks, balances and minimum weight, pH-meter calibration and buffers, and spectrophotometry with standard curves. 64 practice items, 26 cards, a self-check case and an optional reagent and assay qualification project (guide, four rehearsal items, five Case studio milestones). Lessons 2 and 3 link the plate-count and pipette bench simulations. Scope compared by topic with MIT OCW 5.310 Laboratory Chemistry, Fall 2019 (link-only comparator record). The package sits in the tissue-regenerative-medicine pathway before the cell-culture node. A new package test recomputes every numeric key and caught two items whose trailing-zero keys (750, 100) the three-significant-figure grader would have rejected; both are now exact-value items. Count pins, the lesson-link test (now accepting #/bench/ routes that name a protocol), README, roadmap, audit, plan index and generated reports are updated. Partial, unreviewed and formative-only. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +- backend/tests/test_case_studio.py | 2 +- ...t-and-assay-qualification-project-guide.md | 60 + content/courses/lab-measurement/course.json | 850 +++++++ ...-concentration-and-solution-preparation.md | 87 + .../02-dilutions-and-dilution-series.md | 63 + ...-measuring-volume-and-checking-pipettes.md | 84 + .../modules/04-balances-and-weighing.md | 69 + ...5-ph-measurement-and-buffer-preparation.md | 72 + ...6-spectrophotometry-and-standard-curves.md | 74 + .../question-banks/practice.json | 2126 +++++++++++++++++ .../question-banks/retrieval-cards.json | 266 +++ .../lab-measurement/rubrics/case-studio.json | 208 ++ .../lab-measurement/scope-crosswalk.md | 28 + .../courses/lab-measurement/source-map.json | 108 + content/courses/lab-measurement/syllabus.md | 51 + content/curriculum-map.json | 6 +- content/sources/registry.json | 51 +- docs/ARCHITECTURE.md | 2 +- docs/ASSESSMENT_CANDIDATE_REPORT.md | 1 + docs/COURSE_GAP_REPORT.md | 3 +- docs/COURSE_PLAN_COMPLETION.md | 5 +- docs/COURSE_SCOPE_AUDIT.md | 1 + docs/ROADMAP.md | 6 +- tests/content/test_assessment_candidates.py | 2 +- tests/content/test_content_validation.py | 2 +- tests/content/test_course_plan_completion.py | 2 +- tests/content/test_lab_measurement_package.py | 276 +++ tests/content/test_lesson_links_resolve.py | 17 +- tests/content/test_robotics_recalculations.py | 2 +- 30 files changed, 4512 insertions(+), 23 deletions(-) create mode 100644 content/courses/lab-measurement/assignments/reagent-and-assay-qualification-project-guide.md create mode 100644 content/courses/lab-measurement/course.json create mode 100644 content/courses/lab-measurement/modules/01-concentration-and-solution-preparation.md create mode 100644 content/courses/lab-measurement/modules/02-dilutions-and-dilution-series.md create mode 100644 content/courses/lab-measurement/modules/03-measuring-volume-and-checking-pipettes.md create mode 100644 content/courses/lab-measurement/modules/04-balances-and-weighing.md create mode 100644 content/courses/lab-measurement/modules/05-ph-measurement-and-buffer-preparation.md create mode 100644 content/courses/lab-measurement/modules/06-spectrophotometry-and-standard-curves.md create mode 100644 content/courses/lab-measurement/question-banks/practice.json create mode 100644 content/courses/lab-measurement/question-banks/retrieval-cards.json create mode 100644 content/courses/lab-measurement/rubrics/case-studio.json create mode 100644 content/courses/lab-measurement/scope-crosswalk.md create mode 100644 content/courses/lab-measurement/source-map.json create mode 100644 content/courses/lab-measurement/syllabus.md create mode 100644 tests/content/test_lab_measurement_package.py diff --git a/README.md b/README.md index aa1d9d6..abc7df5 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,18 @@ An independent open-source learning platform under development. It does **not co | --- | ---: | --- | | catalog-only | 0 | Catalog metadata only | | outlined | 0 | Structured plan without developed teaching | -| partial | 25 | Authored readings, connected coursework and formative checks; depth and review remain limited | +| partial | 26 | Authored readings, connected coursework and formative checks; depth and review remain limited | | beta | 0 | Developed course awaiting required review/gates | | complete | 0 | All full-course gates satisfied | | externally reviewed | 0 | Qualified named reviewer examined a specified version | -**No current package meets the complete-course standard.** The repository contains 25 partial packages with 634 lessons, 3,537 formative questions, 2,131 retrieval cards and 151 self-assessed case prompts. These counts measure authored material; they do not establish semester depth, measured workload, scientific review or university equivalence. +**No current package meets the complete-course standard.** The repository contains 26 partial packages with 667 lessons, 3,852 formative questions, 2,258 retrieval cards and 157 self-assessed case prompts (content validator inventory, 2026-10-31). These counts measure authored material; they do not establish semester depth, measured workload, scientific review or university equivalence. The packages span life sciences, chemistry, mathematics, physics, statistics, computing and engineering. Original readings, worked examples, synthetic datasets and practice are available in each subject, with uneven coverage and depth. See the [package inventory](#package-inventory), [measured content gaps](docs/COURSE_GAP_REPORT.md), and [MIT OpenCourseWare scope audit](docs/COURSE_SCOPE_AUDIT.md) for the current boundaries and priorities. Detailed development history belongs in the [progress review](docs/PROGRESS_REVIEW.md). Development now proceeds across the repository in bounded instructional batches. Follow-up priorities address demonstrated instructional gaps, broader course boundaries and learner evidence identified against official MIT OpenCourseWare syllabi and calendars. New material is original and attributed; references compare scope and expectations without claiming affiliation, copied course delivery, or equivalent credit. Metadata and review-report refreshes are bundled with substantive changes. -The curriculum map adds **37 catalog-only subject nodes** and **8 pathway maps** beside the 25 partial course packages. Catalog-only entries describe planned study areas and dependencies; they contain no authored lessons and cannot be enrolled in. Mapped prerequisite order is planning guidance and does not establish university equivalency. +The curriculum map adds **37 catalog-only subject nodes** and **8 pathway maps** beside the 26 partial course packages. Catalog-only entries describe planned study areas and dependencies; they contain no authored lessons and cannot be enrolled in. Mapped prerequisite order is planning guidance and does not establish university equivalency. See [the audit](docs/CURRENT_STATE_AUDIT.md), [quality standard](docs/CONTENT_QUALITY_STANDARD.md), and [remaining work](docs/ROADMAP.md). @@ -47,6 +47,7 @@ See [the audit](docs/CURRENT_STATE_AUDIT.md), [quality standard](docs/CONTENT_QU | [General Chemistry II](content/courses/general-chemistry-2/course.json) | Chemistry | partial | | [Genetics & Genomics](content/courses/genetics/course.json) | Life sciences | partial | | [Geroscience & Regenerative Biology](content/courses/geroscience/course.json) | Life sciences | partial | +| [Laboratory Measurement and Solution Preparation](content/courses/lab-measurement/course.json) | Life sciences laboratory | partial | | [Linear Algebra for Modeling & Robotics](content/courses/linear-algebra/course.json) | Mathematics | partial | | [Organic Chemistry I for Life Sciences](content/courses/organic-chemistry/course.json) | Chemistry | partial | | [Physics: Electricity & Magnetism](content/courses/physics-em/course.json) | Physics | partial | @@ -87,7 +88,9 @@ Single-choice, multiple-select, numeric, bounded symbolic-expression, narrow dat The React reader now includes six scientific calculators under **Study tools**, available without enrollment: dilution, buffer pH, enzyme kinetics, RC charging, diffusion timescale, and Ohm's law. Each shows units and model assumptions and links to related courses. Calculator scratch work is ungraded. -The React **Case studio** also exposes the authored prompts and self-check criteria across all 25 partial courses. Enrolled learners can save a private written response and checklist marks; drafts stay bound to the course version and prompt, remain available in account exports, and do not affect grades. Archived cases are read-only. +**Bench practice** replays simulations of real bench procedures (micropipette gravimetric verification, decimal dilutions with the FDA BAM aerobic plate count, and a Class II biosafety cabinet session with a spill) from the learner's own choices of action, with a checkpoint debrief at the end. Runs are formative, unrecorded and cannot train hand skill; see the [wet-lab practice plan](docs/WET_LAB_PRACTICE_PLAN.md). + +The React **Case studio** also exposes the authored prompts and self-check criteria across all 26 partial courses. Enrolled learners can save a private written response and checklist marks; drafts stay bound to the course version and prompt, remain available in account exports, and do not affect grades. Archived cases are read-only. The previous calculators, cards, case self-assessment and browser notebook remain preserved in `legacy/`; the React reader now has its own searchable notebook over server-saved lesson notes. To use the old reader separately: diff --git a/backend/tests/test_case_studio.py b/backend/tests/test_case_studio.py index 8ddbac9..1bd553a 100644 --- a/backend/tests/test_case_studio.py +++ b/backend/tests/test_case_studio.py @@ -188,7 +188,7 @@ def test_all_authored_courses_expose_their_existing_case_studies(): "cellular-biomechanics", "transport", "calculus-2", "calculus-3", "differential-equations", "linear-algebra", "circuits", "physiology", "signals-control", "general-chemistry-1", - "organic-chemistry", "statistics", "robotics", "geroscience", + "organic-chemistry", "statistics", "robotics", "geroscience", "lab-measurement", ) } # Physics E&M authored six milestones for its six project tasks. diff --git a/content/courses/lab-measurement/assignments/reagent-and-assay-qualification-project-guide.md b/content/courses/lab-measurement/assignments/reagent-and-assay-qualification-project-guide.md new file mode 100644 index 0000000..499616c --- /dev/null +++ b/content/courses/lab-measurement/assignments/reagent-and-assay-qualification-project-guide.md @@ -0,0 +1,60 @@ +# Project guide: qualifying a buffer and a protein assay (optional, ungraded) + +This optional project joins the six lessons into one piece of bench planning: you prepare a buffer, check the instruments you will use, calibrate a pH meter, build a protein standard curve and report two unknown samples, with the evidence a careful laboratory would keep. All data are synthetic. The project is self-checked in the Case studio in five milestones and earns no points; it is not a grade or a competency record. + +## The task + +Your group needs 500 mL of 50.0 mM HEPES buffer, pH 7.40 at 37 °C, for cell work, and a protein assay to measure two samples of a purified protein. You have HEPES free acid (M = 238.30 g/mol, assay 99.5%), NaOH for titration, an analytical balance, a 200 µL pipette, a pH meter and a spectrophotometer. + +## Milestone 1: plan the solution + +Calculate the mass of HEPES to weigh, state how you will dissolve it, titrate it and make up to volume, and check the mass against the balance's minimum weight. The balance's repeatability is s = 0.08 mg, and you need a weighing tolerance of 0.10%. + +Things to settle: the purity correction; why the pH is adjusted before making up to volume; at what temperature the pH is set, and why that matters for a buffer used at 37 °C. + +## Milestone 2: check the pipette and the balance + +The 200 µL pipette used for the standards was checked at 21.0 °C. Ten deliveries weighed, in mg: + +| Reading | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Mass (mg) | 199.0 | 199.5 | 198.8 | 199.3 | 199.6 | 198.9 | 199.2 | 199.4 | 199.1 | 198.7 | + +At 21.0 °C, Z = 1.0031 µL/mg. Practice limits for this project are ±1.0% systematic and 0.5% random error. The balance's 100 g check weight read 100.0002 g against a limit of ±0.5 mg. + +Compute the mean volume, both errors and the verdict, and record the balance check. + +## Milestone 3: calibrate the pH meter + +At 25 °C the pH 7.00 buffer reads +1.5 mV and the pH 4.01 buffer reads +170.2 mV. Your laboratory accepts slopes of 95–105% of the theoretical 59.16 mV per pH unit. Decide whether the calibration is acceptable, then explain how you will bring the HEPES solution to pH 7.40 at 37 °C. The pH-meter calibration buffers here bracket the acidic side; state whether you also need a pH 10.01 buffer for a target of 7.40. + +## Milestone 4: build the standard curve + +Blank-subtracted absorbances of the protein standards, made by a parallel series from a 2.00 mg/mL stock: + +| Standard (mg/mL) | 0 | 0.100 | 0.200 | 0.400 | 0.600 | 0.800 | +| --- | --- | --- | --- | --- | --- | --- | +| Absorbance | 0.000 | 0.061 | 0.124 | 0.247 | 0.352 | 0.441 | + +Fit a straight line to all six points and inspect the residuals; then fit only the standards up to 0.400 mg/mL. Decide which range is usable as a straight line and justify it from the residual pattern. + +## Milestone 5: measure the unknowns and report + +Sample X reads 0.188. Sample Y reads 0.512; diluted 1:5 (one part in five parts total) in the same buffer, it reads 0.139. Report both concentrations with the range, the fit and the dilution used, and list the limitations of the result. + +## Worked example with different numbers + +A different group made 250 mL of 20.0 mM HEPES and checked a 100 µL pipette at 23.0 °C (Z = 1.0035 µL/mg), where ten deliveries averaged 99.12 mg. + +- **Mass.** 0.0200 × 0.250 × 238.30/0.995 = 1.20 g, far above a 160 mg minimum weight. +- **Pipette.** V̄ = 99.12 × 1.0035 = 99.47 µL, a systematic error of −0.53%, within ±1.0%. +- **pH meter.** Buffers read +0.8 mV at pH 7.00 and +166.9 mV at pH 4.01, so the slope is (166.9 − 0.8)/2.99 = 55.55 mV per pH unit, 93.9% of theoretical. The calibration is rejected; clean the electrode, use fresh buffers and recalibrate before adjusting any buffer. +- **Unknown above range.** With the 0–0.400 mg/mL fit (slope 0.6183, intercept −0.0002), a 1:4 dilution (one part in four parts total) reading 0.215 gives (0.215 + 0.0002)/0.6183 × 4 = 1.39 mg/mL. + +## What to hand in to yourself + +A one-page record: the solution recipe with its calculation, the pipette and balance check results with verdicts, the pH calibration decision, the chosen standard-curve range with its equation and residuals, the two results with their dilution factors, and three limitations. Compare it with the milestone checklists in the Case studio. + +## Limits of this project + +All data are synthetic, and the acceptance limits are practice values for this project. HEPES temperature behaviour is described qualitatively; check the value for your buffer before relying on it. The project plans work; it does not train the hand skills of pipetting, titrating or reading a meter, which need supervised bench practice. diff --git a/content/courses/lab-measurement/course.json b/content/courses/lab-measurement/course.json new file mode 100644 index 0000000..efc4b91 --- /dev/null +++ b/content/courses/lab-measurement/course.json @@ -0,0 +1,850 @@ +{ + "schema_version": "1.0", + "id": "lab-measurement", + "title": "Laboratory Measurement and Solution Preparation", + "description": "Prepare solutions and dilution series, measure volume and mass with checked instruments, calibrate and use pH meters and spectrophotometers, and rehearse real bench procedures before doing them in a laboratory.", + "domain": "Life sciences laboratory", + "level": "Year 1", + "maturity": "partial", + "version": "0.1.0", + "license": "CC-BY-4.0", + "content_origin": { + "kind": "original", + "source_path": null, + "source_sha256": null, + "migration_version": null, + "modifications": [ + "Original readings, worked examples, practice and cards developed with substantial AI assistance on 2026-10-30 as layer 0 of the wet-lab practice track (docs/WET_LAB_PRACTICE_PLAN.md)." + ] + }, + "prerequisites": { + "course_ids": [], + "recommended_course_ids": [ + "general-chemistry-1" + ], + "concurrent_course_ids": [], + "knowledge": [ + "High-school algebra", + "Moles and molar mass" + ], + "statement": "General Chemistry I recommended; algebra and the mole concept are assumed. This package establishes no university prerequisite equivalency." + }, + "outcomes": [ + { + "id": "lab-measurement-outcome-1", + "description": "Prepare solutions and dilution series at stated concentrations and back-calculate concentrations from diluted measurements.", + "bloom": "apply" + }, + { + "id": "lab-measurement-outcome-2", + "description": "Measure volume and mass with checked instruments, and quantify accuracy and precision from replicate measurements.", + "bloom": "evaluate" + }, + { + "id": "lab-measurement-outcome-3", + "description": "Calibrate and use a pH meter and a spectrophotometer, prepare buffers, and build and use standard curves.", + "bloom": "apply" + }, + { + "id": "lab-measurement-outcome-4", + "description": "Connect bench technique to measured results by rehearsing procedures and debriefing their errors.", + "bloom": "analyze" + } + ], + "lesson_objectives": [ + { + "id": "lab-measurement-1-objective-1", + "description": "Convert between molar, mass, percentage and parts-per-million concentrations, and state which quantity each fixes.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-measurement-outcome-1" + ] + }, + { + "id": "lab-measurement-1-objective-2", + "description": "Calculate the mass of a solid to weigh for a target concentration and volume, correcting for hydration and purity.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-measurement-outcome-1" + ] + }, + { + "id": "lab-measurement-1-objective-3", + "description": "Calculate the molarity of a concentrated liquid reagent from its mass percentage and density.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-measurement-outcome-1" + ] + }, + { + "id": "lab-measurement-1-objective-4", + "description": "Describe how to make a solution up to volume, and decide when to make a concentrated stock.", + "bloom": "evaluate", + "course_outcome_ids": [ + "lab-measurement-outcome-1" + ] + }, + { + "id": "lab-measurement-2-objective-1", + "description": "Use C₁V₁ = C₂V₂ to plan a single dilution and state the dilution factor unambiguously.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-measurement-outcome-1" + ] + }, + { + "id": "lab-measurement-2-objective-2", + "description": "Design a serial dilution series to reach a target dilution and compute the dilution of every tube.", + "bloom": "create", + "course_outcome_ids": [ + "lab-measurement-outcome-1", + "lab-measurement-outcome-4" + ] + }, + { + "id": "lab-measurement-2-objective-3", + "description": "Back-calculate an original concentration from a count or reading in a diluted tube.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-measurement-outcome-1", + "lab-measurement-outcome-4" + ] + }, + { + "id": "lab-measurement-2-objective-4", + "description": "Compare serial and parallel series and estimate how pipetting errors propagate along a series.", + "bloom": "analyze", + "course_outcome_ids": [ + "lab-measurement-outcome-1", + "lab-measurement-outcome-2" + ] + }, + { + "id": "lab-measurement-3-objective-1", + "description": "Choose a volumetric device for a task, distinguishing devices calibrated to contain from those calibrated to deliver.", + "bloom": "evaluate", + "course_outcome_ids": [ + "lab-measurement-outcome-2" + ] + }, + { + "id": "lab-measurement-3-objective-2", + "description": "Distinguish systematic from random error and compute both from replicate measurements.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-measurement-outcome-2" + ] + }, + { + "id": "lab-measurement-3-objective-3", + "description": "Explain how air-displacement pipetting technique affects delivered volume, and when to reverse pipette.", + "bloom": "understand", + "course_outcome_ids": [ + "lab-measurement-outcome-2", + "lab-measurement-outcome-4" + ] + }, + { + "id": "lab-measurement-3-objective-4", + "description": "Convert gravimetric readings of water to volume with the factor Z and judge a pipette against acceptance limits.", + "bloom": "evaluate", + "course_outcome_ids": [ + "lab-measurement-outcome-2", + "lab-measurement-outcome-4" + ] + }, + { + "id": "lab-measurement-4-objective-1", + "description": "Distinguish readability from repeatability and compute a minimum weight.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-measurement-outcome-2" + ] + }, + { + "id": "lab-measurement-4-objective-2", + "description": "Describe the set-up and routine checks of an analytical balance and judge a check-weight reading.", + "bloom": "evaluate", + "course_outcome_ids": [ + "lab-measurement-outcome-2" + ] + }, + { + "id": "lab-measurement-4-objective-3", + "description": "Weigh by difference and explain when it is preferable to taring a boat.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-measurement-outcome-2" + ] + }, + { + "id": "lab-measurement-4-objective-4", + "description": "Identify the effects of temperature, draughts, static and buoyancy on weighing, and correct a mass for buoyancy.", + "bloom": "analyze", + "course_outcome_ids": [ + "lab-measurement-outcome-2" + ] + }, + { + "id": "lab-measurement-5-objective-1", + "description": "Calibrate a pH meter, compute the electrode slope as a percentage of theoretical, and accept or reject the calibration.", + "bloom": "evaluate", + "course_outcome_ids": [ + "lab-measurement-outcome-3" + ] + }, + { + "id": "lab-measurement-5-objective-2", + "description": "Convert an electrode reading in millivolts to pH from a calibration.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-measurement-outcome-3" + ] + }, + { + "id": "lab-measurement-5-objective-3", + "description": "Plan a buffer with the Henderson–Hasselbalch equation and explain why it must still be measured and adjusted.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-measurement-outcome-3" + ] + }, + { + "id": "lab-measurement-5-objective-4", + "description": "Account for temperature in pH measurement: electrode slope, automatic compensation and buffer pKa.", + "bloom": "analyze", + "course_outcome_ids": [ + "lab-measurement-outcome-3" + ] + }, + { + "id": "lab-measurement-6-objective-1", + "description": "Relate absorbance, transmittance, path length and concentration through the Beer–Lambert law.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-measurement-outcome-3" + ] + }, + { + "id": "lab-measurement-6-objective-2", + "description": "Choose the right blank and recognise readings outside the linear range.", + "bloom": "evaluate", + "course_outcome_ids": [ + "lab-measurement-outcome-3" + ] + }, + { + "id": "lab-measurement-6-objective-3", + "description": "Fit a straight-line standard curve, check its residuals and interpolate an unknown.", + "bloom": "analyze", + "course_outcome_ids": [ + "lab-measurement-outcome-3" + ] + }, + { + "id": "lab-measurement-6-objective-4", + "description": "Handle unknowns above the top standard by dilution and back-calculate the original concentration.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-measurement-outcome-3", + "lab-measurement-outcome-1" + ] + } + ], + "duration": { + "instructional_weeks": 6, + "equivalent_structure": "Proposed six-week short course of one reading, one practice set and, in weeks 2 and 3, one bench-practice simulation per week. It is a foundation module for later laboratory packages, not a full semester course, and the schedule has not been measured or reviewed.", + "weeks": [ + { + "week": 1, + "title": "Concentration units and making solutions", + "lesson_ids": [ + "lab-measurement-1" + ], + "assessment_ids": [ + "lab-measurement-1-practice" + ] + }, + { + "week": 2, + "title": "Dilutions and dilution series; bench practice: decimal dilutions and the aerobic plate count", + "lesson_ids": [ + "lab-measurement-2" + ], + "assessment_ids": [ + "lab-measurement-2-practice" + ] + }, + { + "week": 3, + "title": "Measuring volume and checking pipettes; bench practice: gravimetric pipette verification", + "lesson_ids": [ + "lab-measurement-3" + ], + "assessment_ids": [ + "lab-measurement-3-practice" + ] + }, + { + "week": 4, + "title": "Balances, minimum weight and weighing technique", + "lesson_ids": [ + "lab-measurement-4" + ], + "assessment_ids": [ + "lab-measurement-4-practice" + ] + }, + { + "week": 5, + "title": "pH meter calibration and buffer preparation", + "lesson_ids": [ + "lab-measurement-5" + ], + "assessment_ids": [ + "lab-measurement-5-practice" + ] + }, + { + "week": 6, + "title": "Spectrophotometry, blanks and standard curves; optional reagent and assay qualification project", + "lesson_ids": [ + "lab-measurement-6", + "lab-measurement-project-guide" + ], + "assessment_ids": [ + "lab-measurement-6-practice", + "lab-measurement-project-rehearsal-practice", + "lab-measurement-qualification-project" + ] + } + ] + }, + "workload": { + "estimated_total_hours": null, + "estimated_hours_per_week": null, + "basis": "Not measured." + }, + "modules": [ + { + "id": "lab-measurement-module-1", + "title": "Concentration and solution preparation", + "source_ids": [ + "lab-chemistry-mit-5310" + ], + "lessons": [ + { + "id": "lab-measurement-1", + "title": "Concentration and solution preparation", + "reading": "modules/01-concentration-and-solution-preparation.md", + "objectives": [ + "lab-measurement-1-objective-1", + "lab-measurement-1-objective-2", + "lab-measurement-1-objective-3", + "lab-measurement-1-objective-4" + ], + "worked_example": "For 200 mL of 25.0 mM MgSO₄ from MgSO₄·7H₂O (M = 246.47 g/mol, assay 99.5%), weigh 0.0250 × 0.200 × 246.47/0.995 = 1.24 g, dissolve in about 150 mL and make up to 200 mL.", + "question_ids": [ + "lab-measurement-1:nacl-mass", + "lab-measurement-1:hydrate-mass", + "lab-measurement-1:hydrate-error", + "lab-measurement-1:purity", + "lab-measurement-1:conc-acid", + "lab-measurement-1:percent-to-molar", + "lab-measurement-1:small-mass", + "lab-measurement-1:make-to-volume", + "lab-measurement-1:ph-before-volume", + "lab-measurement-1:stock-reason", + "lab-measurement-1:unit-statements" + ], + "card_ids": [ + "lab-measurement-1-card-1", + "lab-measurement-1-card-2", + "lab-measurement-1-card-3", + "lab-measurement-1-card-4" + ] + } + ] + }, + { + "id": "lab-measurement-module-2", + "title": "Dilutions and dilution series", + "source_ids": [ + "lab-chemistry-mit-5310", + "fda-bam-chapter-3-aerobic-plate-count" + ], + "lessons": [ + { + "id": "lab-measurement-2", + "title": "Dilutions and dilution series", + "reading": "modules/02-dilutions-and-dilution-series.md", + "objectives": [ + "lab-measurement-2-objective-1", + "lab-measurement-2-objective-2", + "lab-measurement-2-objective-3", + "lab-measurement-2-objective-4" + ], + "worked_example": "For a culture near 10⁸ CFU/mL and 0.1 mL spread plates, plate 10⁻⁴ to 10⁻⁶; 152 colonies from the 10⁻⁶ tube give 152/(0.1 × 10⁻⁶) = 1.52 × 10⁹ CFU/mL.", + "question_ids": [ + "lab-measurement-2:c1v1", + "lab-measurement-2:serial-total", + "lab-measurement-2:fivefold", + "lab-measurement-2:back-calc", + "lab-measurement-2:systematic-compound", + "lab-measurement-2:random-compound", + "lab-measurement-2:parallel-volume", + "lab-measurement-2:ratio-ambiguity", + "lab-measurement-2:plate-choice", + "lab-measurement-2:series-statements" + ], + "card_ids": [ + "lab-measurement-2-card-1", + "lab-measurement-2-card-2", + "lab-measurement-2-card-3", + "lab-measurement-2-card-4" + ] + } + ] + }, + { + "id": "lab-measurement-module-3", + "title": "Measuring volume and checking pipettes", + "source_ids": [ + "lab-chemistry-mit-5310", + "tanaka-2001-water-density" + ], + "lessons": [ + { + "id": "lab-measurement-3", + "title": "Measuring volume and checking pipettes", + "reading": "modules/03-measuring-volume-and-checking-pipettes.md", + "objectives": [ + "lab-measurement-3-objective-1", + "lab-measurement-3-objective-2", + "lab-measurement-3-objective-3", + "lab-measurement-3-objective-4" + ], + "worked_example": "A 1000 µL check at 25 °C with mean mass 992.8 mg and s = 2.10 mg gives V̄ = 996.8 µL, −0.32% systematic and 0.21% random error, passing limits of ±0.8% and 0.3%.", + "question_ids": [ + "lab-measurement-3:z-20", + "lab-measurement-3:mean-volume", + "lab-measurement-3:systematic", + "lab-measurement-3:cv", + "lab-measurement-3:relative-low-end", + "lab-measurement-3:tc-td", + "lab-measurement-3:precise-inaccurate", + "lab-measurement-3:reverse", + "lab-measurement-3:technique-errors", + "lab-measurement-3:verdict" + ], + "card_ids": [ + "lab-measurement-3-card-1", + "lab-measurement-3-card-2", + "lab-measurement-3-card-3", + "lab-measurement-3-card-4" + ] + } + ] + }, + { + "id": "lab-measurement-module-4", + "title": "Balances and weighing", + "source_ids": [ + "lab-chemistry-mit-5310", + "tanaka-2001-water-density" + ], + "lessons": [ + { + "id": "lab-measurement-4", + "title": "Balances and weighing", + "reading": "modules/04-balances-and-weighing.md", + "objectives": [ + "lab-measurement-4-objective-1", + "lab-measurement-4-objective-2", + "lab-measurement-4-objective-3", + "lab-measurement-4-objective-4" + ], + "worked_example": "With s = 0.12 mg and a 0.5% tolerance, the minimum weight is 2 × 0.12/0.005 = 48 mg, so 25 mg cannot be weighed to tolerance; weigh 50 mg into twice the volume.", + "question_ids": [ + "lab-measurement-4:min-weight", + "lab-measurement-4:relative-uncertainty", + "lab-measurement-4:by-difference", + "lab-measurement-4:buoyancy", + "lab-measurement-4:readability", + "lab-measurement-4:check-weight", + "lab-measurement-4:warm-object", + "lab-measurement-4:difference-when", + "lab-measurement-4:setup-statements" + ], + "card_ids": [ + "lab-measurement-4-card-1", + "lab-measurement-4-card-2", + "lab-measurement-4-card-3", + "lab-measurement-4-card-4" + ] + } + ] + }, + { + "id": "lab-measurement-module-5", + "title": "pH measurement and buffer preparation", + "source_ids": [ + "lab-chemistry-mit-5310" + ], + "lessons": [ + { + "id": "lab-measurement-5", + "title": "pH measurement and buffer preparation", + "reading": "modules/05-ph-measurement-and-buffer-preparation.md", + "objectives": [ + "lab-measurement-5-objective-1", + "lab-measurement-5-objective-2", + "lab-measurement-5-objective-3", + "lab-measurement-5-objective-4" + ], + "worked_example": "A calibration of −2.0 mV at pH 7.00 and −170.6 mV at pH 10.01 gives a slope of 56.01 mV per pH, 94.7% of theoretical, which fails a 95–105% limit.", + "question_ids": [ + "lab-measurement-5:nernst-37", + "lab-measurement-5:slope-percent", + "lab-measurement-5:sample-ph", + "lab-measurement-5:hh-ratio", + "lab-measurement-5:hh-mass", + "lab-measurement-5:tris-temp", + "lab-measurement-5:calibration-verdict", + "lab-measurement-5:atc", + "lab-measurement-5:storage", + "lab-measurement-5:buffer-statements" + ], + "card_ids": [ + "lab-measurement-5-card-1", + "lab-measurement-5-card-2", + "lab-measurement-5-card-3", + "lab-measurement-5-card-4" + ] + } + ] + }, + { + "id": "lab-measurement-module-6", + "title": "Spectrophotometry and standard curves", + "source_ids": [ + "lab-chemistry-mit-5310" + ], + "lessons": [ + { + "id": "lab-measurement-6", + "title": "Spectrophotometry and standard curves", + "reading": "modules/06-spectrophotometry-and-standard-curves.md", + "objectives": [ + "lab-measurement-6-objective-1", + "lab-measurement-6-objective-2", + "lab-measurement-6-objective-3", + "lab-measurement-6-objective-4" + ], + "worked_example": "Triplicate readings averaging 0.287 on a standard curve with slope 0.6404 per mg/mL and intercept 0.00005 interpolate to 0.448 mg/mL, inside the range of the standards; undoing the 1:10 dilution gives 4.48 mg/mL in the undiluted sample.", + "question_ids": [ + "lab-measurement-6:transmittance", + "lab-measurement-6:beer-lambert", + "lab-measurement-6:path-length", + "lab-measurement-6:interpolate", + "lab-measurement-6:dilution-back", + "lab-measurement-6:blank", + "lab-measurement-6:above-range", + "lab-measurement-6:residuals", + "lab-measurement-6:new-curve", + "lab-measurement-6:spectro-statements" + ], + "card_ids": [ + "lab-measurement-6-card-1", + "lab-measurement-6-card-2", + "lab-measurement-6-card-3", + "lab-measurement-6-card-4" + ] + } + ] + }, + { + "id": "lab-measurement-module-project", + "title": "Reagent and assay qualification project guide (optional, ungraded)", + "source_ids": [ + "lab-chemistry-mit-5310" + ], + "lessons": [ + { + "id": "lab-measurement-project-guide", + "title": "Project guide: qualifying a buffer and a protein assay (optional, ungraded)", + "reading": "assignments/reagent-and-assay-qualification-project-guide.md", + "objectives": [ + "lab-measurement-1-objective-2", + "lab-measurement-3-objective-4", + "lab-measurement-5-objective-1", + "lab-measurement-6-objective-3", + "lab-measurement-6-objective-4" + ], + "worked_example": "For 250 mL of 20.0 mM HEPES weigh 0.0200 × 0.250 × 238.30/0.995 = 1.20 g; a 100 µL pipette averaging 99.12 mg at 23.0 °C delivers 99.47 µL (−0.53%); a 93.9% electrode slope fails calibration; and a 1:4 dilution reading 0.215 gives 1.39 mg/mL.", + "question_ids": [ + "lab-measurement-project:rehearsal-mass", + "lab-measurement-project:rehearsal-pipette", + "lab-measurement-project:rehearsal-slope", + "lab-measurement-project:rehearsal-dilution" + ], + "card_ids": [ + "lab-measurement-project-1", + "lab-measurement-project-2" + ] + } + ] + } + ], + "assessments": [ + { + "id": "lab-measurement-1-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: concentration and solution preparation", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-measurement-1-objective-1", + "lab-measurement-1-objective-2", + "lab-measurement-1-objective-3", + "lab-measurement-1-objective-4", + "lab-measurement-outcome-1" + ], + "question_ids": [ + "lab-measurement-1:nacl-mass", + "lab-measurement-1:hydrate-mass", + "lab-measurement-1:hydrate-error", + "lab-measurement-1:purity", + "lab-measurement-1:conc-acid", + "lab-measurement-1:percent-to-molar", + "lab-measurement-1:small-mass", + "lab-measurement-1:make-to-volume", + "lab-measurement-1:ph-before-volume", + "lab-measurement-1:stock-reason", + "lab-measurement-1:unit-statements" + ], + "points": 12 + }, + { + "id": "lab-measurement-2-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: dilutions and dilution series", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-measurement-2-objective-1", + "lab-measurement-2-objective-2", + "lab-measurement-2-objective-3", + "lab-measurement-2-objective-4", + "lab-measurement-outcome-1", + "lab-measurement-outcome-2", + "lab-measurement-outcome-4" + ], + "question_ids": [ + "lab-measurement-2:c1v1", + "lab-measurement-2:serial-total", + "lab-measurement-2:fivefold", + "lab-measurement-2:back-calc", + "lab-measurement-2:systematic-compound", + "lab-measurement-2:random-compound", + "lab-measurement-2:parallel-volume", + "lab-measurement-2:ratio-ambiguity", + "lab-measurement-2:plate-choice", + "lab-measurement-2:series-statements" + ], + "points": 11 + }, + { + "id": "lab-measurement-3-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: measuring volume and checking pipettes", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-measurement-3-objective-1", + "lab-measurement-3-objective-2", + "lab-measurement-3-objective-3", + "lab-measurement-3-objective-4", + "lab-measurement-outcome-2", + "lab-measurement-outcome-4" + ], + "question_ids": [ + "lab-measurement-3:z-20", + "lab-measurement-3:mean-volume", + "lab-measurement-3:systematic", + "lab-measurement-3:cv", + "lab-measurement-3:relative-low-end", + "lab-measurement-3:tc-td", + "lab-measurement-3:precise-inaccurate", + "lab-measurement-3:reverse", + "lab-measurement-3:technique-errors", + "lab-measurement-3:verdict" + ], + "points": 11 + }, + { + "id": "lab-measurement-4-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: balances and weighing", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-measurement-4-objective-1", + "lab-measurement-4-objective-2", + "lab-measurement-4-objective-3", + "lab-measurement-4-objective-4", + "lab-measurement-outcome-2" + ], + "question_ids": [ + "lab-measurement-4:min-weight", + "lab-measurement-4:relative-uncertainty", + "lab-measurement-4:by-difference", + "lab-measurement-4:buoyancy", + "lab-measurement-4:readability", + "lab-measurement-4:check-weight", + "lab-measurement-4:warm-object", + "lab-measurement-4:difference-when", + "lab-measurement-4:setup-statements" + ], + "points": 11 + }, + { + "id": "lab-measurement-5-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: ph measurement and buffer preparation", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-measurement-5-objective-1", + "lab-measurement-5-objective-2", + "lab-measurement-5-objective-3", + "lab-measurement-5-objective-4", + "lab-measurement-outcome-3" + ], + "question_ids": [ + "lab-measurement-5:nernst-37", + "lab-measurement-5:slope-percent", + "lab-measurement-5:sample-ph", + "lab-measurement-5:hh-ratio", + "lab-measurement-5:hh-mass", + "lab-measurement-5:tris-temp", + "lab-measurement-5:calibration-verdict", + "lab-measurement-5:atc", + "lab-measurement-5:storage", + "lab-measurement-5:buffer-statements" + ], + "points": 11 + }, + { + "id": "lab-measurement-6-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: spectrophotometry and standard curves", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-measurement-6-objective-1", + "lab-measurement-6-objective-2", + "lab-measurement-6-objective-3", + "lab-measurement-6-objective-4", + "lab-measurement-outcome-1", + "lab-measurement-outcome-3" + ], + "question_ids": [ + "lab-measurement-6:transmittance", + "lab-measurement-6:beer-lambert", + "lab-measurement-6:path-length", + "lab-measurement-6:interpolate", + "lab-measurement-6:dilution-back", + "lab-measurement-6:blank", + "lab-measurement-6:above-range", + "lab-measurement-6:residuals", + "lab-measurement-6:new-curve", + "lab-measurement-6:spectro-statements" + ], + "points": 11 + }, + { + "id": "lab-measurement-project-rehearsal-practice", + "type": "practice", + "mode": "practice", + "title": "Qualification project rehearsal: mass, pipette error, electrode slope and dilution (open, ungraded practice)", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-measurement-1-objective-2", + "lab-measurement-3-objective-4", + "lab-measurement-5-objective-1", + "lab-measurement-6-objective-3", + "lab-measurement-6-objective-4", + "lab-measurement-outcome-1", + "lab-measurement-outcome-2", + "lab-measurement-outcome-3", + "lab-measurement-outcome-4" + ], + "question_ids": [ + "lab-measurement-project:rehearsal-mass", + "lab-measurement-project:rehearsal-pipette", + "lab-measurement-project:rehearsal-slope", + "lab-measurement-project:rehearsal-dilution" + ], + "points": 4 + }, + { + "id": "lab-measurement-qualification-project", + "type": "project", + "mode": "self-assessment", + "title": "Qualification project milestones M1–M5 in the Case studio (optional self-check; ungraded)", + "path": "rubrics/case-studio.json", + "objective_ids": [], + "question_ids": [], + "points": 0 + } + ], + "grading_policy": { + "mode": "formative-only", + "categories": [], + "attempt_policy": "Unlimited formative retries; no summative grade is awarded.", + "solution_release": "Public practice solutions may be shown after an attempt. They are not production exam keys.", + "late_policy": "No deadlines or late penalties exist in this partial package.", + "appeals": "No instructor review workflow is implemented; report ambiguous practice items as content issues." + }, + "retrieval_cards": "question-banks/retrieval-cards.json", + "case_rubrics": "rubrics/case-studio.json", + "source_map": "source-map.json", + "syllabus": "syllabus.md", + "review": { + "status": "unreviewed", + "reviewed_version": null, + "reviewers": [] + }, + "accessibility": { + "language": "en", + "reading_format": "markdown", + "math_format": "unicode-text", + "diagram_policy": "The package contains no diagrams; any added diagram requires a text description and license tracking.", + "known_issues": [ + "Unicode equations require structured mathematical alternatives and assistive-technology review." + ], + "review_status": "unreviewed" + }, + "history": [ + { + "version": "0.1.0", + "date": "2026-10-30", + "summary": "New package, layer 0 of the wet-lab practice track: six original readings (concentration and solution preparation, dilution series, volume measurement and pipette checks, balances, pH measurement and buffers, spectrophotometry and standard curves) with 60 practice items, 24 retrieval cards one self-check case, and an optional reagent and assay qualification project (guide, four rehearsal items, two cards and five Case studio milestones), linked to the gravimetric pipette and plate-count bench simulations. Compared by topic with the MIT OCW 5.310 Fall 2019 syllabus and calendar. Partial, unreviewed and formative-only." + } + ], + "limitations": [ + "A six-week foundation module, not a semester course; the schedule is proposed, not measured, and has not been reviewed by a laboratory instructor.", + "Practice items are short formative checks of about ten per lesson. They test calculations and decisions, not hand skill, which only bench work under supervision can train.", + "Of the 38 numerical answers, 0 require units, 35 check significant figures and 0 check dimensions.", + "Acceptance limits for pipettes, balances and pH electrodes are examples or practice values; real limits come from each laboratory's SOPs, manufacturers and standards such as ISO 8655, which were not read.", + "All data are synthetic. Buffer pKa values, the Tris temperature coefficient and the NADH extinction coefficient are rounded standard values used for illustration." + ], + "intentionally_omitted": [ + "Graded assessments, measured workload and review by a qualified person; the qualification project is an optional, ungraded self-check.", + "Biosafety, aseptic technique and cell culture, which belong to later layers of the wet-lab track (the biological safety cabinet simulation is already available on the bench practice page).", + "Titration, chromatography and instrumental spectroscopy beyond UV–visible absorbance, which the MIT 5.310 comparator teaches; see the scope crosswalk.", + "Formal measurement-uncertainty budgets, weighted and non-linear calibration fits, and ISO 8655 and pharmacopoeial balance requirements." + ], + "learner_resources": [] +} diff --git a/content/courses/lab-measurement/modules/01-concentration-and-solution-preparation.md b/content/courses/lab-measurement/modules/01-concentration-and-solution-preparation.md new file mode 100644 index 0000000..eac9343 --- /dev/null +++ b/content/courses/lab-measurement/modules/01-concentration-and-solution-preparation.md @@ -0,0 +1,87 @@ +# Concentration and solution preparation + +Almost every bench procedure begins with a solution someone had to make: a buffer, a medium supplement, a stain, a standard. A concentration error at this stage passes silently into every later result. This lesson covers the concentration units used at the bench, how to calculate what to weigh or measure, and how to make a solution so that it actually has the concentration on the label. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Convert between molar, mass, percentage and parts-per-million concentrations, and state which quantity each one fixes. +2. Calculate the mass of a solid to weigh for a target concentration and volume, correcting for water of hydration and reagent purity. +3. Calculate the molarity of a concentrated liquid reagent from its mass percentage and density. +4. Describe how to bring a solution to final volume, and decide when to make a concentrated stock instead of weighing the working amount directly. + +## Units that look alike but fix different things + +| Unit | Definition | Example | +| --- | --- | --- | +| Molarity, M | moles of solute per litre of final solution | 0.150 M NaCl | +| Mass concentration | mass of solute per volume of final solution | 9.0 g/L, or 1 mg/mL | +| % w/v | grams of solute per 100 mL of final solution | 2% w/v agarose = 2 g in 100 mL | +| % v/v | millilitres of solute per 100 mL of final solution | 70% v/v ethanol | +| % w/w | grams of solute per 100 g of solution | concentrated HCl, 37% w/w | +| ppm (dilute water) | milligrams per litre, approximately | 5 ppm ≈ 5 mg/L | + +Every unit except % w/w is defined by the **final volume of solution**, not the volume of solvent added. That single fact explains most preparation errors. The prefixes follow the usual steps of a thousand: 1 M = 1000 mM = 10⁶ µM. + +Converting needs the molar mass. Physiological saline, 0.9% w/v NaCl, holds 9 g per litre; with M(NaCl) = 58.44 g/mol that is 9/58.44 = 0.154 mol/L, or 154 mM. + +## Weighing a solid: mass = C × V × M + +To make a volume V of a solution of molarity C from a solid of molar mass M, weigh + + m = C × V × M. + +For 500 mL of 0.150 M NaCl: m = 0.150 mol/L × 0.500 L × 58.44 g/mol = 4.38 g. + +Two corrections are common. + +- **Water of hydration.** The molar mass is that of the material in the bottle. Calcium chloride is often sold as the dihydrate, CaCl₂·2H₂O, with M = 147.01 g/mol; the anhydrous salt has M = 110.98 g/mol. For 250 mL of 0.100 M CaCl₂ from the dihydrate, weigh 0.100 × 0.250 × 147.01 = 3.68 g. Weighing the anhydrous figure, 2.77 g, of the dihydrate gives only 0.100 × 110.98/147.01 = 0.0755 M, a quarter low. +- **Purity.** If the label states an assay below 100%, divide by it. For 1.00 L of 0.200 M Tris (M = 121.14 g/mol) from material that is 98.0% pure, weigh 0.200 × 1.00 × 121.14/0.980 = 24.7 g. + +## Concentrated liquid reagents + +Concentrated acids and some solvents are labelled by % w/w and density. One litre of the reagent has a mass of 1000 mL × density, of which the stated fraction is solute. For concentrated hydrochloric acid at 37.0% w/w and 1.18 g/mL: + + C = 1000 × 1.18 × 0.370 / 36.46 = 12.0 mol/L. + +To make 250 mL of 1.00 M HCl, measure 1.00 × 250/11.97 = 20.9 mL of the concentrated acid and add it to roughly 200 mL of water before making up to volume. Concentrated acid is added to water, never water to acid, because the heat released when a little water meets a lot of acid can boil and spatter it. This is chemical-hygiene practice; follow the safety data sheet and your laboratory's chemical hygiene plan for any real reagent. + +## Making up to volume + +Because concentration is defined by the final volume, the solid is dissolved in less solvent than the final volume, and only then is the solution brought to the mark: + +1. Weigh the solid into a beaker and dissolve it in about 70–80% of the final volume. +2. Adjust pH now if the recipe calls for it, because acid or base adds volume. +3. Transfer quantitatively to a volumetric flask (rinsing the beaker into the flask), or into a graduated cylinder for less exacting work. +4. Add solvent to the mark, cap and invert to mix. + +Adding 500 mL of water to 4.38 g of NaCl does not give 500 mL of 0.150 M solution: dissolved solute changes the volume, and for many solutes the volumes of solute and solvent are not additive. The error is small for dilute salts and large for concentrated sugars, glycerol or ethanol. + +## When to make a stock instead + +Small amounts are hard to weigh accurately. Ten millilitres of a 50 µM solution of a compound with M = 400 g/mol needs 50 × 10⁻⁶ × 0.010 × 400 = 2.0 × 10⁻⁴ g, which is 0.200 mg, below the minimum weight a typical analytical balance can deliver to 1% (the [weighing lesson](04-balances-and-weighing.md) explains minimum weight). Instead, make 1 mL of a 10 mM stock, which needs 4.00 mg, and dilute it 200-fold as the [dilution lesson](02-dilutions-and-dilution-series.md) describes. Concentrated stocks (10× buffers, 1000× antibiotics, 10 mM drug solutions) also save time and reduce the number of separate weighings that can go wrong. + +## Common mistakes + +- Using the molar mass of the anhydrous compound for a hydrate. +- Adding the full volume of solvent to the solute instead of making up to the final volume. +- Adjusting pH after bringing to volume, which dilutes the solution. +- Treating % w/w like % w/v when calculating from a concentrated reagent. +- Weighing a sub-milligram amount directly instead of making a stock. + +## Worked example + +**Problem.** You need 200 mL of 25.0 mM MgSO₄ and the bottle holds MgSO₄·7H₂O (M = 246.47 g/mol, assay 99.5%). How much do you weigh, and how do you make the solution? + +**Step 1: moles needed.** 0.0250 mol/L × 0.200 L = 5.00 × 10⁻³ mol. + +**Step 2: mass of the hydrate.** 5.00 × 10⁻³ × 246.47 = 1.232 g. + +**Step 3: purity.** 1.232/0.995 = 1.24 g. + +**Step 4: preparation.** Dissolve 1.24 g in about 150 mL of water, transfer to a 200 mL volumetric flask with rinses, make up to the mark and mix by inversion. + +## Limits of this lesson + +Molar masses are rounded to two decimals. Non-ideal volumes, temperature effects on volumetric glassware and the activity of ions are ignored. Reagent hazards are mentioned only where they change the procedure; the safety data sheet and your laboratory's chemical hygiene plan govern real handling. diff --git a/content/courses/lab-measurement/modules/02-dilutions-and-dilution-series.md b/content/courses/lab-measurement/modules/02-dilutions-and-dilution-series.md new file mode 100644 index 0000000..c28fd21 --- /dev/null +++ b/content/courses/lab-measurement/modules/02-dilutions-and-dilution-series.md @@ -0,0 +1,63 @@ +# Dilutions and dilution series + +Diluting is the most frequent calculation at a bench: a 10× buffer to 1×, a stock drug to its working concentration, a culture to countable numbers of colonies, a protein standard into a series. The arithmetic is simple, but the conventions for naming dilutions are inconsistent, and errors compound along a series. This lesson fixes the conventions, sets out serial and parallel series, and shows how pipetting error accumulates. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Use C₁V₁ = C₂V₂ to plan a single dilution, and state the dilution factor unambiguously. +2. Design a serial dilution series to reach a target dilution, and compute the dilution of every tube. +3. Back-calculate a concentration in the original sample from a count or a reading in a diluted tube. +4. Compare serial and parallel dilution series, and estimate how pipetting errors propagate along a series. + +## One dilution: C₁V₁ = C₂V₂ + +The amount of solute does not change when solvent is added, so the concentration times the volume is the same before and after: C₁V₁ = C₂V₂. To make 500 mL of 1× buffer from a 10× stock, V₁ = 1 × 500/10 = 50 mL of stock, made up to 500 mL, which means adding 450 mL of water. + +A **dilution factor** is the ratio of the final volume to the volume of the sample: 50 mL made up to 500 mL is a tenfold dilution, written 1:10, 1/10 or 10⁻¹. Here 1:10 means one part in ten parts total. Some fields write "1:9" for one part sample plus nine parts diluent, which is the same tenfold dilution. Because the two readings differ, write the volumes in a protocol (for example "100 µL into 900 µL") rather than a ratio alone. + +## Serial dilution series + +In a serial dilution each tube is made from the previous one. With a constant step of factor f, after n steps the dilution is f⁻ⁿ. A tenfold series of 100 µL into 900 µL, or 10 mL into 90 mL as in the [plate-count protocol](#/bench/decimal-dilution-aerobic-plate-count), gives 10⁻¹, 10⁻², 10⁻³ and so on. A fivefold series of 200 µL into 800 µL reaches 5⁻⁴ = 1/625 after four steps. + +Serial series are the only practical way to reach very large dilutions, which is why microbiology uses them to bring a sample containing millions of cells per millilitre down to tens or hundreds of colonies on a plate. The cost is that each tube inherits every earlier error. + +**Back-calculation.** The concentration in the original sample is the measured quantity divided by the volume plated and by the dilution of the tube it came from. If 47 colonies grow from 0.1 mL of the 10⁻⁵ tube, the original concentration is 47/(0.1 mL × 10⁻⁵) = 4.7 × 10⁷ CFU/mL. The [plate-count protocol](#/bench/decimal-dilution-aerobic-plate-count) uses the same reasoning to combine plates from several dilutions, as FDA BAM Chapter 3 describes: total colonies on the countable plates divided by the total amount of sample they received. + +## Parallel dilution series + +In a parallel series every tube is made directly from the same stock. A protein standard curve from a 2.00 mg/mL stock might use 5, 10, 20 and 40 µL each made up to 200 µL, giving 0.0500, 0.100, 0.200 and 0.400 mg/mL. Errors do not accumulate, because no tube depends on another, but very large dilutions would need impractically small volumes of stock. Standard curves usually use parallel series or short serial series for this reason. + +## How errors propagate + +Suppose each transfer is accurate to within a relative error e, independent from step to step. After n serial steps: + +- **Random errors** add in quadrature, so the relative error of the final tube is about e√n. With e = 1% and six steps, about 2.4%. +- **Systematic errors** multiply. A pipette that always delivers 1% too much makes each step slightly stronger than intended, and after six steps the final tube is 1.01⁶ − 1 = 6.2% too concentrated. + +A transfer that loses liquid systematically is just as bad. Delivering 98 µL instead of 100 µL into 900 µL gives 98/998 = 0.0982 of the intended 0.1, about 2% low per step. A reused tip or pipet is worse in the other direction: liquid left in it from a stronger tube is carried into a weaker one. In the plate-count simulation a reused pipet carrying 0.1 mL back from a dilution ten times stronger makes each step about 10% too strong, which roughly doubles the count by the fifth dilution. + +Three habits keep serial series honest: mix each tube thoroughly before taking from it, use a fresh tip for every transfer, and check pipettes regularly, as in the [gravimetric verification protocol](#/bench/micropipette-gravimetric-verification). + +## Common mistakes + +- Reading "1:10" as one part plus ten parts when the protocol meant one part in ten. +- Forgetting the dilution of the plated volume itself (0.1 mL plated is another factor of 10 relative to per-millilitre counts). +- Taking from a tube before mixing it, so the sample is not representative. +- Reusing a tip along a series, which carries stronger solution forward. +- Extrapolating a single high-dilution result back to the sample without checking the neighbouring dilutions. + +## Worked example + +**Problem.** A culture is thought to contain about 10⁸ CFU/mL. You want plates with 30–300 colonies from 0.1 mL spread plates. Which tubes of a tenfold series should you plate, and how do you back-calculate if 152 colonies grow from the 10⁻⁶ tube? + +**Step 1: target concentration.** 30–300 colonies from 0.1 mL means 300–3000 CFU/mL in the plated tube. + +**Step 2: dilution needed.** 10⁸/3000 to 10⁸/300 is about 10⁻⁴·⁵ to 10⁻⁵·⁵. Plate 10⁻⁵ and the neighbours 10⁻⁴ and 10⁻⁶, because the estimate of 10⁸ could be off by tenfold. + +**Step 3: back-calculation.** 152/(0.1 mL × 10⁻⁶) = 1.52 × 10⁹ CFU/mL, so the culture was denser than thought, and the 10⁻⁶ tube was the right one to rely on. + +## Limits of this lesson + +Error estimates assume independent, normally distributed transfer errors and perfect mixing. Counting statistics (the Poisson spread of colony counts), the ranges and rounding rules of specific counting methods, and plate-to-plate variation are only touched on; the plate-count protocol follows FDA BAM Chapter 3 for those. diff --git a/content/courses/lab-measurement/modules/03-measuring-volume-and-checking-pipettes.md b/content/courses/lab-measurement/modules/03-measuring-volume-and-checking-pipettes.md new file mode 100644 index 0000000..a32ebb2 --- /dev/null +++ b/content/courses/lab-measurement/modules/03-measuring-volume-and-checking-pipettes.md @@ -0,0 +1,84 @@ +# Measuring volume and checking pipettes + +Every concentration in the laboratory depends on a volume. This lesson sorts volumetric devices by what they are designed to do, separates accuracy from precision, sets out micropipette technique and the errors each lapse causes, and shows how to check a pipette by weighing water. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Choose a volumetric device for a task, distinguishing devices calibrated to contain from those calibrated to deliver. +2. Distinguish systematic from random error and compute both, as percentages, from replicate measurements. +3. Explain how pre-wetting, immersion depth, angle, speed and touch-off affect the volume an air-displacement pipette delivers, and when reverse pipetting is preferable. +4. Convert gravimetric readings of water to volume with a temperature-dependent factor Z, and decide whether a pipette meets acceptance limits. + +## Devices and what they are calibrated for + +| Device | Calibrated | Typical use | +| --- | --- | --- | +| Volumetric flask | to contain (TC) one volume | making a solution up to an exact volume | +| Volumetric (transfer) pipette | to deliver (TD) one volume | transferring an exact volume of a standard | +| Burette | to deliver variable volumes | titration | +| Graduated cylinder | to contain, graduated | approximate volumes of reagents and media | +| Serological pipette | to deliver, graduated | millilitre transfers in culture work | +| Air-displacement micropipette | to deliver, adjustable | microlitre transfers of aqueous liquids | +| Positive-displacement pipette | to deliver, adjustable | viscous, volatile or dense liquids | + +A flask marked TC holds the stated volume when filled to the mark; pouring it out delivers slightly less, because a film stays on the glass. A pipette marked TD delivers the stated volume when drained as specified, and the liquid that remains in the tip is accounted for in its calibration. Beakers and flask graduations are not measuring devices. Class A glassware is made and marked to tighter tolerances than Class B; use it, with its marked tolerance, when a volume enters a calculation. + +## Accuracy and precision + +Repeated deliveries scatter around a mean. **Systematic error** (inaccuracy) is how far the mean lies from the intended value; **random error** (imprecision) is the scatter around the mean. Both are commonly reported as percentages of the nominal volume V₀: + + systematic error = (V̄ − V₀)/V₀ × 100, random error (CV) = s/V̄ × 100, + +where V̄ is the mean delivered volume and s the sample standard deviation. A pipette can be precise and inaccurate (tightly grouped around the wrong value, often a calibration offset) or accurate on average and imprecise (often a worn seal or poor technique). Averaging more replicates shrinks the effect of random error on the mean; it does nothing to systematic error. + +Relative error grows at the low end of a pipette's range. An absolute error of 1 µL is 1% of 100 µL but 10% of 10 µL, so choose the smallest pipette whose range includes the volume. + +## Technique with an air-displacement pipette + +An air-displacement pipette moves a piston that draws an air cushion, and the air cushion draws the liquid. Anything that changes the cushion or leaves liquid behind changes the volume. + +- **Pre-wet the tip.** Fill and empty a new tip two or three times. The first fill of a dry tip and its air cushion takes up vapour and delivers low. +- **Immerse a few millimetres, vertically.** Too shallow draws air; too deep leaves a film on the outside of the tip that is then delivered. Tilting the pipette changes the hydrostatic pressure on the cushion. +- **Move the plunger smoothly and consistently.** Releasing it abruptly can splash liquid into the shaft and draws an inconsistent volume. +- **Dispense against the wall and touch off.** Press to the first stop, wait, press to the second stop to blow out, and draw the tip along the wall to collect the last drop. +- **Equilibrate.** Liquid, pipette and tips at room temperature give the volume the pipette was set for; a pipette warmed by the hand drifts. + +**Reverse pipetting** draws to the second stop and dispenses only to the first, leaving the excess in the tip. It is preferable for viscous, foaming or volatile liquids and for very small volumes of protein solutions, where forward pipetting delivers inconsistently. + +## Checking a pipette by weighing water + +Water has a well-known density, so a balance can measure delivered volume. Deliver water into a tared vessel, record each mass m, and convert with + + V = m × Z, Z = (1/(ρ_water − ρ_air)) × (1 − ρ_air/ρ_weights), + +where ρ_water depends on temperature, ρ_air ≈ 0.0012 g/mL, and ρ_weights = 8.0 g/mL is the conventional density of the balance's reference weights. Z corrects both for water being slightly less dense than 1 g/mL and for air buoyancy. With masses in milligrams, Z is in µL/mg: 1.0029 at 20 °C, 1.0032 at 21.5 °C and 1.0040 at 25 °C. Water density in this course follows the formula of Tanaka and colleagues (2001), which gives 998.207 kg/m³ at 20 °C. + +**Example.** Ten deliveries from a pipette set to 200 µL weigh, in mg: 198.6, 199.1, 198.9, 199.4, 198.7, 199.0, 198.8, 199.2, 198.5 and 199.0, at 21.5 °C. The mean mass is 198.92 mg, so V̄ = 198.92 × 1.0032 = 199.55 µL. The systematic error is (199.55 − 200)/200 × 100 = −0.224% and the random error is s/V̄ = 0.279/199.55 × 100 = 0.140%. Treating milligrams as microlitres would have reported −0.540%, more than double the real offset. + +Whether the pipette passes depends on acceptance limits, which a laboratory takes from its SOP, the manufacturer or ISO 8655 for the pipette's size and volume. The [bench practice](#/bench/micropipette-gravimetric-verification) simulation uses practice limits of ±0.8% systematic and 0.3% random at 1000 µL. A pipette that fails either limit is taken out of use, then adjusted or serviced and checked again. + +## Common mistakes + +- Pouring a solution out of a TC flask and treating the poured volume as exact. +- Averaging more replicates to fix an inaccurate pipette. +- Working at the bottom of a pipette's range when a smaller pipette is available. +- Skipping pre-wetting, or immersing too deep, then blaming the pipette. +- Converting milligrams to microlitres without the factor Z. + +## Worked example + +**Problem.** A check of a 1000 µL pipette at 25 °C gives a mean mass of 992.8 mg and a sample standard deviation of 2.10 mg over ten readings. Practice limits are ±0.8% systematic and 0.3% random. Does it pass? + +**Step 1: volume.** V̄ = 992.8 × 1.0040 = 996.8 µL, and s_V = 2.10 × 1.0040 = 2.11 µL. + +**Step 2: systematic error.** (996.8 − 1000)/1000 × 100 = −0.32%, within ±0.8%. + +**Step 3: random error.** 2.11/996.8 × 100 = 0.21%, within 0.3%. + +**Step 4: verdict.** The pipette passes at this volume. A full check repeats the test at other volumes in its range. + +## Limits of this lesson + +Evaporation during weighing, balance drift, and the exact requirements of ISO 8655 (which was not read for this course) are not covered. Acceptance limits shown are practice values for this course's simulation, not limits for any specific instrument. Technique effects are described qualitatively; their size depends on the pipette, tip and liquid. diff --git a/content/courses/lab-measurement/modules/04-balances-and-weighing.md b/content/courses/lab-measurement/modules/04-balances-and-weighing.md new file mode 100644 index 0000000..2871c79 --- /dev/null +++ b/content/courses/lab-measurement/modules/04-balances-and-weighing.md @@ -0,0 +1,69 @@ +# Balances and weighing + +A balance display shows more digits than it can be trusted to. This lesson explains readability, repeatability and minimum weight, the routine checks that tell you a balance is working, the habits that keep a weighing honest, and why air buoyancy matters at the level of precision analytical work needs. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Distinguish readability from repeatability, and compute a minimum weight from a balance's repeatability and a required tolerance. +2. Describe the set-up and routine checks of an analytical balance, and judge a check-weight reading against an acceptance limit. +3. Weigh by difference, and explain when it is better than taring a weighing boat. +4. Identify the effects of temperature, draughts, static and air buoyancy on a weighing, and correct a mass for buoyancy. + +## Readability, repeatability and minimum weight + +**Readability** (d) is the smallest step the display shows: 0.1 mg on a typical analytical balance, 0.01 g on a top-loading balance. **Repeatability** (s) is the standard deviation of repeated weighings of the same object, measured on that balance where it stands. It is usually larger than the readability and depends on the room as much as the instrument. + +The relative uncertainty of a weighing falls as the mass rises, because s stays roughly constant. The **minimum weight** is the smallest mass for which that uncertainty meets the required tolerance. Using a coverage factor of 2: + + m_min = 2s / tolerance. + +With s = 0.08 mg and a required tolerance of 0.10%, m_min = 2 × 0.08/0.0010 = 160 mg. For a looser tolerance of 1%, m_min = 16 mg. Weighing 0.2 mg on that balance, as the [solution-preparation lesson](01-concentration-and-solution-preparation.md) warns against, would carry a relative uncertainty of about 2 × 0.08/0.2 = 80%. Below the minimum weight, make a more concentrated stock or use a more sensitive balance. + +## Setting up and checking a balance + +- **Level.** Centre the bubble with the adjustable feet. An unlevel balance reads differently depending on where the load sits. +- **Warm up.** Leave the balance powered; electronic balances drift for some time after being switched on. +- **Close the draught shield** and keep the balance away from doors, vents and foot traffic. +- **Adjust or calibrate** with the internal or an external reference weight as the laboratory's procedure requires. +- **Check with a check weight** before use. A laboratory sets the acceptance limit. If a 100 g check weight must read within ±0.5 mg and reads 100.0003 g, the deviation of 0.3 mg is acceptable; a reading of 99.9992 g (−0.8 mg) is not, and the balance needs adjustment before use. +- **Record** the check in the balance log, with the weight's identity and its calibration status. + +## Weighing habits + +- **Tare** the empty container, add the sample, and read once the stability indicator settles. +- **Weigh by difference** for hygroscopic, volatile or static-prone solids, or whenever material may stick to a boat. Weigh the capped vial with the solid, transfer some solid to the receiving vessel, and weigh the vial again: 12.3456 g − 12.1123 g = 0.2333 g transferred. The mass is what left the vial, so anything left in a boat does not matter. +- **Equilibrate temperature.** A warm object sets up a convection current that pushes up on the pan, so it reads light; a cold one reads heavy and gathers condensation. Let objects from an oven or refrigerator come to room temperature in a desiccator or closed container. +- **Control static.** Dry plastic and powders can carry charge that pulls on the pan, giving drifting readings. Antistatic devices, metal or glass containers and grounding help. +- **Handle with care.** Use tongs or gloves for check weights and small vessels, since fingerprints add mass, and clean spills at once. + +## Air buoyancy + +A balance compares the downward force of the sample with that of its reference weights, both reduced by the buoyancy of the air they displace. If the sample is much less dense than the reference weights (8.0 g/cm³), it displaces more air and reads slightly light. The true mass is + + m_true ≈ m_reading × (1 − ρ_air/ρ_weights)/(1 − ρ_air/ρ_sample), + +with ρ_air ≈ 0.0012 g/cm³. For water (ρ ≈ 1.00 g/cm³) the correction is about +0.105%: a reading of 1.0000 g corresponds to a true mass of about 1.0011 g. The correction is negligible for most solution preparation but not for calibrating volumes, which is why the factor Z in the [pipette lesson](03-measuring-volume-and-checking-pipettes.md) includes it. + +## Common mistakes + +- Treating the readability as the uncertainty. +- Weighing below the minimum weight and trusting the display. +- Weighing a warm beaker straight from the oven. +- Skipping the daily check because the balance "looked fine yesterday". +- Weighing a hygroscopic solid slowly on an open boat, so it gains water while you watch. + +## Worked example + +**Problem.** A balance's repeatability, measured with ten weighings, is s = 0.12 mg. You must weigh about 25 mg of a reference standard with a tolerance of 0.5%. Is the balance suitable, and what would you do if not? + +**Step 1: minimum weight.** m_min = 2 × 0.12/0.005 = 48 mg. + +**Step 2: compare.** 25 mg is below 48 mg, so the uncertainty of a single weighing would be about 2 × 0.12/25 = 0.96%, nearly twice the tolerance. + +**Step 3: options.** Weigh at least 48 mg (for example 50 mg) and dissolve it in twice the volume, so the concentration is unchanged, or use a balance with better repeatability. + +## Limits of this lesson + +The minimum-weight formula is a simplified form of the approach used in pharmaceutical and metrology practice, with a coverage factor of 2 and repeatability as the only uncertainty; formal standards also consider other contributions. Acceptance limits here are examples, not requirements for any balance. Balance calibration by an accredited provider and the traceability of reference weights are described, not taught. diff --git a/content/courses/lab-measurement/modules/05-ph-measurement-and-buffer-preparation.md b/content/courses/lab-measurement/modules/05-ph-measurement-and-buffer-preparation.md new file mode 100644 index 0000000..a27407d --- /dev/null +++ b/content/courses/lab-measurement/modules/05-ph-measurement-and-buffer-preparation.md @@ -0,0 +1,72 @@ +# pH measurement and buffer preparation + +Cells, enzymes and many assays are sensitive to pH, so buffers are made and checked constantly. A pH meter is a voltmeter attached to an electrode whose response must be calibrated every day it is used. This lesson covers calibration and its acceptance checks, electrode care, and making a buffer that has the right pH at the temperature where it will be used. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Calibrate a pH meter with two or three buffers, compute the electrode slope as a percentage of the theoretical value, and decide whether the calibration is acceptable. +2. Convert an electrode reading in millivolts to pH from a calibration. +3. Plan a buffer from the Henderson–Hasselbalch equation, then explain why the pH must still be measured and adjusted. +4. Account for temperature: the electrode's slope, automatic temperature compensation, and the temperature dependence of the buffer itself. + +## How the electrode responds + +A glass pH electrode develops a potential that changes linearly with pH. The theoretical change, the Nernst slope, is (RT ln 10)/F: 59.16 mV per pH unit at 25 °C, 58.17 at 20 °C and 61.54 at 37 °C. At pH 7 a typical combination electrode reads close to 0 mV, and the reading rises as pH falls. Real electrodes have a slope a little below theoretical and an offset from 0 mV, both of which drift as the electrode ages. Calibration measures them. + +## Calibration + +Calibrate with fresh standard buffers that bracket the expected sample pH, usually pH 7.00 first and then 4.01, 10.01 or both. Rinse the electrode with water between buffers and blot it dry; do not wipe the glass bulb, which can build up static and damage the hydrated layer. + +**Example.** At 25 °C the pH 7.00 buffer reads +3.5 mV and the pH 4.01 buffer reads +172.0 mV. The slope is (172.0 − 3.5)/(7.00 − 4.01) = 56.35 mV per pH unit, which is 56.35/59.16 = 95.3% of theoretical. The offset at pH 7 is +3.5 mV. Laboratories commonly accept slopes of about 95–105% and offsets within about ±30 mV; your laboratory's SOP sets the actual limits. Here the slope is at the low edge: the calibration may be used, but the electrode should be cleaned or replaced soon. + +A sample reading +60.0 mV then has pH = 7.00 − (60.0 − 3.5)/56.35 = 6.00. Readings outside the calibrated range are extrapolations; recalibrate with a bracketing buffer before trusting them. + +## Electrode care + +- Store the electrode in its storage solution (usually concentrated KCl), never dry and never in pure water, which leaches ions from the glass and the reference junction. +- Keep the fill hole open during measurements on refillable electrodes so the electrolyte flows through the junction. +- Stir samples and buffers the same way, and wait for a stable reading. +- Protein and Tris solutions can clog the reference junction; clean as the manufacturer directs if response becomes slow. + +## Planning a buffer + +The Henderson–Hasselbalch equation gives the ratio of base to acid forms needed for a target pH: + + pH = pKa + log₁₀([base]/[acid]). + +**Example.** For 1.00 L of 0.100 M sodium phosphate at pH 7.40, using pKa₂ = 7.20: [base]/[acid] = 10^0.20 = 1.585, so the acid fraction is 1/2.585 = 0.387. That is 0.0387 mol of NaH₂PO₄ (5.34 g of the monohydrate, M = 137.99 g/mol) and 0.0613 mol of Na₂HPO₄ (8.70 g anhydrous, M = 141.96 g/mol). + +The calculation gets you close; it does not finish the job. The tabulated pKa applies at a particular temperature and at low ionic strength, and a 0.1 M phosphate buffer is not dilute. So dissolve the components in about 80% of the final volume, measure the pH with a calibrated meter, adjust with small amounts of acid or base, and only then bring to volume, as the [solution-preparation lesson](01-concentration-and-solution-preparation.md) describes. Single-component buffers such as Tris are made the same way: dissolve Tris base and titrate down with HCl to the target pH. + +## Temperature + +Temperature enters in two separate ways, and they are easy to confuse. + +- **The electrode.** The slope changes with temperature, following the Nernst equation. Automatic temperature compensation (ATC) corrects for this using a temperature probe. It does not change the sample's actual pH. +- **The buffer.** The pKa of the buffer itself changes with temperature. Tris is the classic example: its pKa falls by about 0.028 per °C. A Tris buffer adjusted to pH 8.00 at 25 °C and used at 4 °C is near pH 8.00 − 0.028 × (4 − 25) = 8.59. Phosphate changes much less. + +So adjust a buffer's pH at the temperature where it will be used, or correct for the known shift, and record the temperature with the pH. + +## Common mistakes + +- Calibrating once a week, or with old or contaminated buffers. +- Measuring outside the range bracketed by the calibration buffers. +- Assuming ATC corrects the buffer's pH for temperature. +- Bringing the buffer to volume before adjusting its pH. +- Storing the electrode in pure water. + +## Worked example + +**Problem.** A meter is calibrated at 25 °C with pH 7.00 (−2.0 mV) and pH 10.01 (−170.6 mV) buffers. Is the calibration acceptable against a 95–105% slope limit, and what is the pH of a sample at −120.0 mV? + +**Step 1: slope.** (−170.6 − (−2.0))/(10.01 − 7.00) = −168.6/3.01 = −56.01 mV per pH unit; the magnitude is 56.01/59.16 = 94.7% of theoretical. + +**Step 2: verdict.** 94.7% is below 95%, so the calibration fails. Clean the electrode, use fresh buffers and recalibrate; replace the electrode if the slope stays low. + +**Step 3: what the reading would have meant.** Using the failed calibration anyway, pH = 7.00 + (−120.0 − (−2.0))/(−56.01) = 9.11. The number can be computed, but it should not be reported. + +## Limits of this lesson + +Buffer pKa values and the Tris temperature coefficient are rounded literature-style values used for illustration; check the value for your buffer and conditions. Activity effects are described qualitatively. Acceptance limits for slope and offset are examples; your SOP sets the real ones. Reference electrodes, ion-selective electrodes and pH measurement in non-aqueous or very viscous samples are not covered. diff --git a/content/courses/lab-measurement/modules/06-spectrophotometry-and-standard-curves.md b/content/courses/lab-measurement/modules/06-spectrophotometry-and-standard-curves.md new file mode 100644 index 0000000..7c89d48 --- /dev/null +++ b/content/courses/lab-measurement/modules/06-spectrophotometry-and-standard-curves.md @@ -0,0 +1,74 @@ +# Spectrophotometry and standard curves + +Many bench measurements end with a number read from a spectrophotometer or plate reader: a protein assay, an enzyme rate, a cell-density reading, a colorimetric test. This lesson covers the Beer–Lambert law and its limits, blanks, the linear range, and how to build and use a standard curve without fooling yourself. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Relate absorbance, transmittance, path length and concentration through the Beer–Lambert law, and compute a concentration from an absorbance and an extinction coefficient. +2. Choose the right blank, and recognise readings outside the linear range of an assay or instrument. +3. Fit a straight-line standard curve by least squares, check its residuals, and interpolate an unknown. +4. Handle unknowns above the top standard by dilution rather than extrapolation, and back-calculate the original concentration. + +## Absorbance and the Beer–Lambert law + +A spectrophotometer measures the fraction of light transmitted through a sample, T = I/I₀. Absorbance is A = −log₁₀ T, so A = 1 means 10% of the light gets through and A = 2 means 1%. For a dilute solution of one absorbing species, + + A = ε l c, + +where ε is the molar extinction coefficient at that wavelength, l the path length and c the concentration. NADH at 340 nm has ε = 6220 M⁻¹ cm⁻¹, so a reading of A₃₄₀ = 0.311 in a 1 cm cuvette means c = 0.311/(6220 × 1) = 5.00 × 10⁻⁵ M, or 50.0 µM. Plate readers have a path length set by the liquid depth, not 1 cm, so either correct for it or calibrate with standards in the same plate. + +The law holds only over a limited range. At high absorbance so little light reaches the detector that stray light dominates, and many instruments become unreliable above an absorbance of roughly 1.5–2. At high concentration molecules interact and ε changes. Colorimetric assays such as protein dye-binding assays are often curved even at moderate absorbance. Treat the linear range as something to check, not assume. + +## Blanks + +The blank sets the instrument's zero and should contain everything the sample contains except the analyte: the same buffer, reagent and cuvette or plate type. A reagent blank in a protein assay is buffer plus dye, not water. For an assay with a standard curve, the zero standard serves as the blank, and blank-subtracted absorbances are used in the fit. + +## Building a standard curve + +Prepare standards spanning the range you expect, ideally by a [parallel dilution series](02-dilutions-and-dilution-series.md) from one stock, and treat them exactly like the samples. + +**Example.** Blank-subtracted absorbances for protein standards are: + +| Standard (mg/mL) | 0 | 0.125 | 0.250 | 0.500 | 1.000 | +| --- | --- | --- | --- | --- | --- | +| A₅₉₅ | 0.000 | 0.081 | 0.158 | 0.322 | 0.640 | + +A least-squares straight line gives slope b = 0.6404 per (mg/mL) and intercept a = 0.00005, effectively zero. The residuals (observed minus fitted) are all within ±0.0022 and show no pattern, so a straight line describes these standards. A run of residuals that are positive in the middle and negative at the ends would signal curvature, and a non-linear fit or a narrower range would be needed. + +An unknown reading A = 0.254 interpolates to c = (0.254 − a)/b = 0.397 mg/mL. + +## Unknowns outside the range + +An unknown reading 0.812 lies above the top standard (0.640). The fitted line would give 1.27 mg/mL, but that is an extrapolation: nothing shows the response is still linear there. Dilute the sample into the range and measure again. If a fourfold dilution (one part sample plus three parts buffer) reads 0.406, the diluted concentration is 0.634 mg/mL and the original is 4 × 0.634 = 2.54 mg/mL. That differs from the extrapolated value, as it often will for a curving assay. Dilute in the same buffer as the standards. + +Below the lowest non-zero standard, results are also unreliable; report them as below the range of the curve rather than as a number. + +## Replicates and records + +Run standards and unknowns in replicate (duplicates at least, triplicates where the assay is noisy), and report the mean with the spread. Record the wavelength, path length or plate type, blank, standards with their source and preparation, the fitted equation and the range. Make a new curve for each run; standards and reagents age, and a curve from last week does not describe today's reagents. + +## Common mistakes + +- Blanking with water when the reagent itself absorbs. +- Extrapolating beyond the highest standard instead of diluting. +- Forgetting the dilution factor when back-calculating. +- Fitting a straight line to a curved assay because the correlation coefficient looks high; inspect the residuals. +- Reusing an old standard curve with new reagents. + +## Worked example + +**Problem.** Using the curve above, triplicate wells of a sample diluted 1:10 (one part in ten parts total) read 0.287, 0.291 and 0.283. What is the protein concentration of the undiluted sample? + +**Step 1: mean absorbance.** (0.287 + 0.291 + 0.283)/3 = 0.287. + +**Step 2: interpolate.** (0.287 − 0.00005)/0.6404 = 0.448 mg/mL, inside the range of the standards. + +**Step 3: undo the dilution.** 10 × 0.448 = 4.48 mg/mL. + +**Step 4: report.** 4.48 mg/mL from triplicate readings of a 1:10 dilution, interpolated within a 0–1.000 mg/mL standard curve. + +## Limits of this lesson + +Absorbance values are synthetic. The extinction coefficient of NADH is a standard value used for illustration. Weighted and non-linear fits, the uncertainty of an interpolated value, scattering samples such as cell suspensions, and fluorescence are not covered. diff --git a/content/courses/lab-measurement/question-banks/practice.json b/content/courses/lab-measurement/question-banks/practice.json new file mode 100644 index 0000000..6034e6e --- /dev/null +++ b/content/courses/lab-measurement/question-banks/practice.json @@ -0,0 +1,2126 @@ +{ + "schema_version": "1.0", + "course_id": "lab-measurement", + "license": "CC-BY-4.0", + "questions": [ + { + "id": "lab-measurement-1:nacl-mass", + "type": "numeric", + "prompt": "How many grams of NaCl (M = 58.44 g/mol) are needed for 250 mL of 0.200 M solution? Give grams. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-1-objective-2" + ], + "feedback": { + "hint": "m = C × V × M, with V in litres.", + "solution": "m = 0.200 × 0.250 × 58.44 = 2.92 g.", + "lesson_ids": [ + "lab-measurement-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 2.92, + "unit": "", + "tolerance": 0.0007334708380007972, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-1:hydrate-mass", + "type": "numeric", + "prompt": "You need 500 mL of 0.0500 M CuSO₄ and the bottle holds CuSO₄·5H₂O (M = 249.69 g/mol). How many grams do you weigh? Give grams. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-1-objective-2" + ], + "feedback": { + "hint": "Use the molar mass of the material in the bottle, including its water of hydration.", + "solution": "m = 0.0500 × 0.500 × 249.69 = 6.24 g.", + "lesson_ids": [ + "lab-measurement-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 6.24, + "unit": "", + "tolerance": 0.0015674171332619776, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-1:hydrate-error", + "type": "numeric", + "prompt": "Someone makes 500 mL of what they believe is 0.0500 M CuSO₄ by weighing the anhydrous mass, 0.0500 × 0.500 × 159.61 = 3.99 g, of CuSO₄·5H₂O (M = 249.69 g/mol). What is the actual concentration? Give mol/L. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-1-objective-2" + ], + "feedback": { + "hint": "Only 159.61/249.69 of each gram of the pentahydrate is CuSO₄.", + "solution": "Actual moles = 3.99 g/249.69 g/mol in 0.500 L, which is 0.0500 × 159.61/249.69 = 0.0320 M, about 36% low.", + "lesson_ids": [ + "lab-measurement-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 0.032, + "unit": "", + "tolerance": 8.038036580830655e-06, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-1:purity", + "type": "numeric", + "prompt": "How many grams of HEPES (M = 238.30 g/mol) with an assay of 99.0% do you weigh for 500 mL of 0.0250 M? Give grams. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-1-objective-2" + ], + "feedback": { + "hint": "Divide the pure mass by the assay fraction.", + "solution": "0.0250 × 0.500 × 238.30 = 2.979 g of pure HEPES; 2.979/0.990 = 3.01 g of the reagent.", + "lesson_ids": [ + "lab-measurement-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 3.01, + "unit": "", + "tolerance": 0.0007560778158843833, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-1:conc-acid", + "type": "numeric", + "prompt": "Concentrated sulfuric acid is 96.0% w/w H₂SO₄ (M = 98.08 g/mol) with a density of 1.84 g/mL. What is its molarity? Give mol/L. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-1-objective-3" + ], + "feedback": { + "hint": "One litre of the reagent has a mass of 1000 mL × density; the stated fraction of that mass is H₂SO₄.", + "solution": "C = 1000 × 1.84 × 0.960/98.08 = 18.0 mol/L.", + "lesson_ids": [ + "lab-measurement-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 18.0, + "unit": "", + "tolerance": 0.0045213955767172435, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-1:percent-to-molar", + "type": "numeric", + "prompt": "A glucose solution is 5.00% w/v (glucose M = 180.16 g/mol). What is its molarity in mM? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-1-objective-1" + ], + "feedback": { + "hint": "5.00% w/v is 5.00 g per 100 mL, so 50.0 g per litre.", + "solution": "50.0 g/L ÷ 180.16 g/mol = 0.2775 mol/L = 278 mM.", + "lesson_ids": [ + "lab-measurement-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 278.0, + "unit": "", + "tolerance": 0.06983044279596631, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-1:small-mass", + "type": "numeric", + "prompt": "How many milligrams of a compound with M = 350 g/mol are in 20.0 mL of a 25.0 µM solution? Give mg. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-1-objective-4" + ], + "feedback": { + "hint": "Convert µM to mol/L and mL to L, then multiply by M and by 1000 mg/g.", + "solution": "25.0 × 10⁻⁶ × 0.0200 × 350 = 1.75 × 10⁻⁴ g = 0.175 mg, which is too little to weigh accurately; make a stock instead.", + "lesson_ids": [ + "lab-measurement-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 0.175, + "unit": "", + "tolerance": 4.395801255141764e-05, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-1:make-to-volume", + "type": "single_choice", + "prompt": "Which procedure makes 250 mL of 0.200 M NaCl correctly?", + "points": 1, + "objective_ids": [ + "lab-measurement-1-objective-4" + ], + "feedback": { + "hint": "Concentration is defined by the final volume of solution.", + "solution": "Dissolve in less than the final volume, transfer quantitatively and then make up to the mark. Adding the solid to 250 mL of solvent gives a final volume that is not exactly 250 mL.", + "lesson_ids": [ + "lab-measurement-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Dissolve 2.92 g in about 200 mL of water, transfer to a 250 mL volumetric flask with rinses, make up to the mark and invert to mix.", + "Add 2.92 g to 250 mL of water measured in a graduated cylinder and stir.", + "Dissolve 2.92 g in 250 mL of water in a volumetric flask filled to the mark first.", + "Dissolve 2.92 g in 225 mL of water, then add 25 mL more without checking the volume." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-1:ph-before-volume", + "type": "single_choice", + "prompt": "A recipe says to adjust a buffer to pH 7.4. When should the pH be adjusted?", + "points": 1, + "objective_ids": [ + "lab-measurement-1-objective-4" + ], + "feedback": { + "hint": "Acid or base added later changes both the volume and the concentration.", + "solution": "Adjust pH at about 80% of the final volume, then bring to volume; adjusting afterwards dilutes the solution by the volume of titrant added.", + "lesson_ids": [ + "lab-measurement-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Before making up to the final volume, so the acid or base added becomes part of that volume.", + "After making up to the final volume, then the solution is ready.", + "Before weighing any components.", + "Only after the buffer has been sterilized." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-1:stock-reason", + "type": "single_choice", + "prompt": "Why make a 10 mM stock of a drug and dilute it, instead of weighing the amount needed for 10 mL of a 50 µM working solution?", + "points": 1, + "objective_ids": [ + "lab-measurement-1-objective-4" + ], + "feedback": { + "hint": "Compare the mass needed with the balance's minimum weight.", + "solution": "For M = 400 g/mol the working amount is 0.200 mg, far below a typical analytical balance's minimum weight; weighing 4.00 mg for a 10 mM stock is practical.", + "lesson_ids": [ + "lab-measurement-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "The working amount is a fraction of a milligram, below the minimum weight a typical analytical balance can deliver accurately.", + "Stocks are always more stable than any working solution.", + "Weighing small masses is forbidden by safety rules.", + "A stock avoids having to know the molar mass." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-1:unit-statements", + "type": "multiple_select", + "prompt": "Which statements about concentration units are correct? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-measurement-1-objective-1" + ], + "feedback": { + "hint": "Each percentage unit is defined relative to a particular total: final volume or total mass.", + "solution": "% w/v and % v/v are per 100 mL of final solution (70 mL ethanol made up to 100 mL, not added to 100 mL); % w/w is per 100 g of solution; 1 ppm ≈ 1 mg/L in dilute water; 0.9% w/v NaCl is 154 mM, not 0.9 M.", + "lesson_ids": [ + "lab-measurement-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "2% w/v agarose contains 2 g of agarose per 100 mL of final solution.", + "70% v/v ethanol is made by adding 70 mL of ethanol to 100 mL of water.", + "Concentrated HCl labelled 37% w/w contains 37 g of HCl per 100 g of solution.", + "In dilute aqueous solution, 1 ppm is approximately 1 mg/L.", + "0.9% w/v NaCl is about 0.9 M." + ], + "solution_spec": { + "answer": [ + 0, + 2, + 3 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-measurement-2:c1v1", + "type": "numeric", + "prompt": "How many microlitres of a 1.00 M stock are needed to make 50.0 mL of 15.0 mM working solution? Give µL. Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-measurement-2-objective-1" + ], + "feedback": { + "hint": "C₁V₁ = C₂V₂; keep the concentration units the same on both sides.", + "solution": "V₁ = 0.0150 M × 50.0 mL/1.00 M = 0.750 mL = 750 µL.", + "lesson_ids": [ + "lab-measurement-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 750, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-measurement-2:serial-total", + "type": "numeric", + "prompt": "A series is made by moving 50 µL into 450 µL, four times in succession. By what overall factor has the original been diluted in the last tube? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-measurement-2-objective-2" + ], + "feedback": { + "hint": "Each step is 50 µL in a total of 500 µL.", + "solution": "Each step is a tenfold dilution (50 µL in 500 µL total), so four steps dilute 10 × 10 × 10 × 10 = 10,000-fold.", + "lesson_ids": [ + "lab-measurement-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 10000, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-measurement-2:fivefold", + "type": "numeric", + "prompt": "A fivefold serial series uses 200 µL into 800 µL. After three steps, what is the concentration if the stock is 2.00 mg/mL? Give mg/mL. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-2-objective-2" + ], + "feedback": { + "hint": "Each step divides the concentration by 1000/200 = 5.", + "solution": "2.00/5³ = 2.00/125 = 0.0160 mg/mL.", + "lesson_ids": [ + "lab-measurement-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 0.016, + "unit": "", + "tolerance": 4.019018290415327e-06, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-2:back-calc", + "type": "numeric", + "prompt": "Spread plates of 0.100 mL from the 10⁻⁶ tube of a tenfold series show 83 colonies. What is the concentration in the original culture, in units of 10⁸ CFU/mL? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-2-objective-3" + ], + "feedback": { + "hint": "Divide the count by the volume plated and by the dilution of the tube.", + "solution": "83/(0.100 mL × 10⁻⁶) = 8.30 × 10⁸ CFU/mL, so the answer is 8.30.", + "lesson_ids": [ + "lab-measurement-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 8.3, + "unit": "", + "tolerance": 0.002084865738152951, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-2:systematic-compound", + "type": "numeric", + "prompt": "Each step of a six-step serial series is 1.5% too concentrated because the pipette over-delivers. By what percentage is the final tube too concentrated? Give %. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-2-objective-4" + ], + "feedback": { + "hint": "Systematic errors multiply from step to step.", + "solution": "(1.015⁶ − 1) × 100 = 9.34%.", + "lesson_ids": [ + "lab-measurement-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 9.34, + "unit": "", + "tolerance": 0.0023461019270299473, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-2:random-compound", + "type": "numeric", + "prompt": "Each step of a nine-step serial series has an independent random relative error of 0.80%. Approximately what is the relative random error of the final tube? Give %. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-2-objective-4" + ], + "feedback": { + "hint": "Independent random errors add in quadrature: e√n.", + "solution": "0.80 × √9 = 2.40%.", + "lesson_ids": [ + "lab-measurement-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 2.4, + "unit": "", + "tolerance": 0.000602852743562299, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-2:parallel-volume", + "type": "numeric", + "prompt": "For a parallel series of standards each made up to 250 µL from a 2.00 mg/mL stock, how much stock is needed for the 0.160 mg/mL standard? Give µL. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-2-objective-4" + ], + "feedback": { + "hint": "Each standard comes directly from the stock: V_stock = C × V/C_stock.", + "solution": "0.160 × 250/2.00 = 20.0 µL of stock, made up to 250 µL.", + "lesson_ids": [ + "lab-measurement-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 20.0, + "unit": "", + "tolerance": 0.005023772863019159, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-2:ratio-ambiguity", + "type": "single_choice", + "prompt": "A protocol says only 'dilute the antibody 1:4'. What is the best response?", + "points": 1, + "objective_ids": [ + "lab-measurement-2-objective-1" + ], + "feedback": { + "hint": "The ratio notation is used both ways in different laboratories.", + "solution": "1:4 can mean a fourfold or a fivefold dilution depending on convention. Clarify it and write volumes (for example 100 µL into 300 µL) in your own records.", + "lesson_ids": [ + "lab-measurement-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Ask, or check the source, whether it means one part in four total or one part plus four parts diluent, and record the volumes used.", + "Use one part plus four parts diluent, because that is universal.", + "Use one part in four total, because that is universal.", + "Assume the difference does not matter." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-2:plate-choice", + "type": "single_choice", + "prompt": "A culture is expected to hold about 10⁷ CFU/mL and you want 30–300 colonies on 0.1 mL spread plates. Which tenfold dilutions should you plate?", + "points": 1, + "objective_ids": [ + "lab-measurement-2-objective-2", + "lab-measurement-2-objective-3" + ], + "feedback": { + "hint": "Countable plates need 300–3000 CFU/mL in the plated tube; bracket the estimate.", + "solution": "10⁷/3000 to 10⁷/300 is about 10⁻³·⁵ to 10⁻⁴·⁵, so plate 10⁻⁴ with its neighbours 10⁻³ and 10⁻⁵ in case the estimate is off by tenfold.", + "lesson_ids": [ + "lab-measurement-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "10⁻³, 10⁻⁴ and 10⁻⁵, centred on the dilution expected to give countable plates.", + "Only 10⁻⁷, so that the count is low.", + "Only the undiluted culture.", + "10⁻¹ and 10⁻² only." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-2:series-statements", + "type": "multiple_select", + "prompt": "Which statements about dilution series are correct? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-measurement-2-objective-4" + ], + "feedback": { + "hint": "Separate systematic from random errors, and think about what carries over.", + "solution": "Serial errors accumulate; parallel series need impractically small volumes for large dilutions; carry-over inflates weaker tubes. Standing does not mix (cells settle), and averaging does not remove a systematic error.", + "lesson_ids": [ + "lab-measurement-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Errors accumulate along a serial series because each tube is made from the previous one.", + "A parallel series is impractical for very large dilutions because the stock volumes become tiny.", + "Reusing a tip from a stronger tube makes weaker tubes too concentrated.", + "Mixing tubes before sampling is unnecessary if they stand for a few minutes.", + "Averaging duplicate plates removes the effect of an over-delivering pipette." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-measurement-3:z-20", + "type": "numeric", + "prompt": "What is the factor Z at 20.0 °C, in µL/mg, using ρ_water = 0.998207 g/mL, ρ_air = 0.0012 g/mL and ρ_weights = 8.0 g/mL? Keep full precision in intermediate calculations. Enter the number only, to five significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-3-objective-4" + ], + "feedback": { + "hint": "Z = (1/(ρ_water − ρ_air)) × (1 − ρ_air/ρ_weights).", + "solution": "Z = (1/(0.998207 − 0.0012)) × (1 − 0.0012/8.0) = 1.0029 µL/mg; converting milligrams with Z instead of treating them as microlitres changes the result by about 0.29%.", + "lesson_ids": [ + "lab-measurement-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 1.0029, + "unit": "", + "tolerance": 2.5191709021609596e-06, + "unit_required": false, + "significant_figures": 5, + "dimensions": null + } + }, + { + "id": "lab-measurement-3:mean-volume", + "type": "numeric", + "prompt": "Ten deliveries from a 500 µL setting have a mean mass of 497.6 mg at 22.0 °C, where Z = 1.0033 µL/mg. What is the mean delivered volume? Give µL. Keep full precision in intermediate calculations. Enter the number only, to four significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-3-objective-4" + ], + "feedback": { + "hint": "V = m × Z.", + "solution": "V̄ = 497.6 × 1.0033 = 499.2 µL.", + "lesson_ids": [ + "lab-measurement-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 499.2, + "unit": "", + "tolerance": 0.012539337066095835, + "unit_required": false, + "significant_figures": 4, + "dimensions": null + } + }, + { + "id": "lab-measurement-3:systematic", + "type": "numeric", + "prompt": "A 500 µL pipette delivers a mean of 499.2 µL. What is the systematic error? Give % (negative if low). Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-3-objective-2" + ], + "feedback": { + "hint": "(V̄ − V₀)/V₀ × 100.", + "solution": "(499.2 − 500)/500 × 100 = −0.160%.", + "lesson_ids": [ + "lab-measurement-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": -0.16, + "unit": "", + "tolerance": 4.0190182904153276e-05, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-3:cv", + "type": "numeric", + "prompt": "The same check has a sample standard deviation of 0.95 µL around the mean of 499.2 µL. What is the random error as a coefficient of variation? Give %. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-3-objective-2" + ], + "feedback": { + "hint": "CV = s/V̄ × 100.", + "solution": "0.95/499.2 × 100 = 0.190%.", + "lesson_ids": [ + "lab-measurement-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 0.19, + "unit": "", + "tolerance": 4.772584219868201e-05, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-3:relative-low-end", + "type": "numeric", + "prompt": "An absolute delivery error of 0.30 µL is what percentage of a 5.0 µL delivery? Give %. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-3-objective-3" + ], + "feedback": { + "hint": "Relative error = absolute error/volume × 100.", + "solution": "0.30/5.0 × 100 = 6.00%, against 0.30% of a 100 µL delivery; choose the smallest pipette whose range covers the volume.", + "lesson_ids": [ + "lab-measurement-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 6.0, + "unit": "", + "tolerance": 0.0015071318589057478, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-3:tc-td", + "type": "single_choice", + "prompt": "A 100 mL volumetric flask is marked TC. What does that mean for its use?", + "points": 1, + "objective_ids": [ + "lab-measurement-3-objective-1" + ], + "feedback": { + "hint": "TC means 'to contain'.", + "solution": "A TC flask holds the stated volume; pouring leaves a film on the glass, so the delivered volume is slightly less.", + "lesson_ids": [ + "lab-measurement-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "It contains 100 mL when filled to the mark, so it is for making solutions up to volume, not for delivering 100 mL.", + "It delivers exactly 100 mL when poured out.", + "It is calibrated at 100 °C.", + "It can be used at any temperature without error." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-3:precise-inaccurate", + "type": "single_choice", + "prompt": "Ten readings from a pipette set to 200 µL cluster tightly around 196 µL. What does this pattern indicate?", + "points": 1, + "objective_ids": [ + "lab-measurement-3-objective-2" + ], + "feedback": { + "hint": "Separate the scatter from the offset of the mean.", + "solution": "Tight clustering means small random error; the 2% offset of the mean is systematic. The pipette needs adjustment or service and a re-check.", + "lesson_ids": [ + "lab-measurement-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "High precision with a systematic error, typical of a calibration offset; more replicates will not fix it.", + "Low precision; more replicates will fix it.", + "The pipette is accurate because the readings agree with each other.", + "The balance must be faulty because the readings are consistent." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-3:reverse", + "type": "single_choice", + "prompt": "When is reverse pipetting preferable to forward pipetting?", + "points": 1, + "objective_ids": [ + "lab-measurement-3-objective-3" + ], + "feedback": { + "hint": "Reverse pipetting leaves the excess in the tip and avoids blowing out.", + "solution": "Drawing to the second stop and dispensing to the first gives more consistent delivery of liquids that cling, foam or evaporate.", + "lesson_ids": [ + "lab-measurement-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "For viscous, foaming or volatile liquids, such as glycerol or detergent solutions.", + "For every aqueous buffer, because it is always more accurate.", + "Only when the pipette has failed its check.", + "Never; it always over-delivers." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-3:technique-errors", + "type": "multiple_select", + "prompt": "Which lapses make an air-displacement pipette deliver a different volume from its setting? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-measurement-3-objective-3" + ], + "feedback": { + "hint": "Think about the air cushion and liquid left on or in the tip.", + "solution": "A dry tip delivers low on its first fill, deep immersion delivers liquid clinging to the outside, and tilting changes the volume drawn. Equilibrated liquid and touch-off are correct practice.", + "lesson_ids": [ + "lab-measurement-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Not pre-wetting a new tip before the first delivery.", + "Immersing the tip much deeper than a few millimetres.", + "Aspirating with the pipette held at a steep angle.", + "Using liquid at the same temperature as the room and the pipette.", + "Ending each dispense with a touch-off against the wall." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-measurement-3:verdict", + "type": "single_choice", + "prompt": "A 1000 µL pipette check gives −0.45% systematic error and 0.36% random error against limits of ±0.8% and 0.3%. What is the verdict?", + "points": 1, + "objective_ids": [ + "lab-measurement-3-objective-4" + ], + "feedback": { + "hint": "Both limits must be met.", + "solution": "A pipette must meet both limits; 0.36% random error exceeds 0.3%, often a sign of a worn seal or poor technique.", + "lesson_ids": [ + "lab-measurement-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Fail: the random error exceeds its limit, so the pipette is taken out of use for service and re-checked.", + "Pass: the systematic error is within its limit.", + "Pass: the two errors average to within the limits.", + "Fail only if the systematic error also exceeds its limit." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-4:min-weight", + "type": "numeric", + "prompt": "A balance has a repeatability of s = 0.050 mg. With a coverage factor of 2, what is the minimum weight for a tolerance of 0.10%? Give mg. Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-measurement-4-objective-1" + ], + "feedback": { + "hint": "m_min = 2s/tolerance, with the tolerance as a fraction.", + "solution": "m_min = 2 × 0.050/0.0010 = 100 mg.", + "lesson_ids": [ + "lab-measurement-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 100, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-measurement-4:relative-uncertainty", + "type": "numeric", + "prompt": "On a balance with s = 0.10 mg, what is the relative uncertainty (2s/m) of weighing 5.0 mg? Give %. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-4-objective-1" + ], + "feedback": { + "hint": "2s/m × 100.", + "solution": "2 × 0.10/5.0 × 100 = 4.00%.", + "lesson_ids": [ + "lab-measurement-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 4.0, + "unit": "", + "tolerance": 0.0010047545726038318, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-4:by-difference", + "type": "numeric", + "prompt": "A capped vial weighs 15.2318 g with sample and 14.9874 g after some solid is tipped out. How many milligrams were transferred? Give mg. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-4-objective-2" + ], + "feedback": { + "hint": "The mass transferred is what left the vial.", + "solution": "15.2318 − 14.9874 = 0.2444 g = 244 mg.", + "lesson_ids": [ + "lab-measurement-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 244.0, + "unit": "", + "tolerance": 0.06129002892883374, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-4:buoyancy", + "type": "numeric", + "prompt": "A water sample reads 2.0000 g on a balance calibrated with 8.0 g/cm³ weights, in air of 0.0012 g/cm³. Using ρ_water = 0.998 g/cm³, what is the true mass? Give g. Keep full precision in intermediate calculations. Enter the number only, to five significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-4-objective-4" + ], + "feedback": { + "hint": "m_true = m_reading × (1 − ρ_air/ρ_weights)/(1 − ρ_air/ρ_sample).", + "solution": "2.0000 × (1 − 0.00015)/(1 − 0.001202) = 2.0021 g, about 0.105% more than the reading.", + "lesson_ids": [ + "lab-measurement-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 2.0021, + "unit": "", + "tolerance": 5.029047824525334e-06, + "unit_required": false, + "significant_figures": 5, + "dimensions": null + } + }, + { + "id": "lab-measurement-4:readability", + "type": "single_choice", + "prompt": "A balance displays 0.1 mg steps. What can you conclude about the uncertainty of a 10 mg weighing?", + "points": 1, + "objective_ids": [ + "lab-measurement-4-objective-1" + ], + "feedback": { + "hint": "Readability is a property of the display, not of the measurement.", + "solution": "Repeatability, not readability, sets the uncertainty, and it depends on the room, draughts and the load.", + "lesson_ids": [ + "lab-measurement-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Nothing definite from the display; the uncertainty depends on the repeatability measured where the balance stands, which is usually larger than 0.1 mg.", + "It is exactly ±0.1 mg.", + "It is ±0.05 mg, half a display step.", + "It is zero if the balance was calibrated this year." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-4:check-weight", + "type": "single_choice", + "prompt": "A 50 g check weight must read within ±0.3 mg. It reads 49.9994 g. What should you do?", + "points": 1, + "objective_ids": [ + "lab-measurement-4-objective-2" + ], + "feedback": { + "hint": "Compare the deviation with the acceptance limit.", + "solution": "49.9994 g is 0.6 mg below 50 g, outside ±0.3 mg; the balance must be adjusted and re-checked, and the failure recorded.", + "lesson_ids": [ + "lab-measurement-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Stop: the reading is 0.6 mg low, outside the limit, so adjust or calibrate the balance and check again before using it.", + "Use the balance; 0.6 mg is negligible at 50 g.", + "Record the reading and correct every later weighing by 0.6 mg.", + "Replace the check weight." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-4:warm-object", + "type": "single_choice", + "prompt": "A crucible taken straight from a drying oven is weighed. How is the reading likely to be affected?", + "points": 1, + "objective_ids": [ + "lab-measurement-4-objective-4" + ], + "feedback": { + "hint": "Think about air currents around a warm object.", + "solution": "Convection from a warm object produces an upward force, so it reads light; cool it in a desiccator first.", + "lesson_ids": [ + "lab-measurement-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "It reads light, because warm air rising around it pushes up on the pan.", + "It reads heavy, because heat adds mass.", + "It reads correctly; temperature does not affect electronic balances.", + "It reads heavy, because the oven adds moisture." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-4:difference-when", + "type": "multiple_select", + "prompt": "When is weighing by difference preferable to taring a weighing boat? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-measurement-4-objective-3" + ], + "feedback": { + "hint": "Weighing by difference records what left a closed container.", + "solution": "It limits exposure of hygroscopic material and is insensitive to material left behind; it does not make an unlevel balance acceptable, and gravimetric liquid checks tare the receiving vessel.", + "lesson_ids": [ + "lab-measurement-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "When the solid is hygroscopic and gains water while exposed.", + "When the solid sticks to the boat or carries static.", + "When the exact mass delivered into the receiving vessel matters more than the mass in a boat.", + "When the balance has not been levelled.", + "When the sample is a liquid being pipetted gravimetrically." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-measurement-4:setup-statements", + "type": "multiple_select", + "prompt": "Which practices belong to setting up and checking an analytical balance? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-measurement-4-objective-2" + ], + "feedback": { + "hint": "Consider levelling, environment, verification and handling.", + "solution": "Level, shield and check before use. Fingerprints add mass to weights, and a freshly switched-on balance drifts while it warms up.", + "lesson_ids": [ + "lab-measurement-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Centre the level bubble.", + "Close the draught shield and keep the balance away from vents and doors.", + "Check it with a check weight before use and record the result.", + "Handle check weights with bare fingers to feel their temperature.", + "Switch it on just before each weighing so it does not drift overnight." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-measurement-5:nernst-37", + "type": "numeric", + "prompt": "What is the theoretical Nernst slope of a pH electrode at 37.0 °C? Give mV per pH unit. Use R = 8.3145 J/(mol·K), F = 96485 C/mol and T = 310.15 K. Keep full precision in intermediate calculations. Enter the number only, to four significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-5-objective-1" + ], + "feedback": { + "hint": "Slope = RT ln10/F, converted to mV.", + "solution": "8.3145 × 310.15 × 2.3026/96485 = 61.54 mV per pH unit.", + "lesson_ids": [ + "lab-measurement-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 61.54, + "unit": "", + "tolerance": 0.0015458149099509969, + "unit_required": false, + "significant_figures": 4, + "dimensions": null + } + }, + { + "id": "lab-measurement-5:slope-percent", + "type": "numeric", + "prompt": "At 25 °C a pH 7.00 buffer reads +2.5 mV and a pH 4.01 buffer reads +168.0 mV. What is the slope as a percentage of the theoretical 59.16 mV per pH unit? Give %. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-5-objective-1" + ], + "feedback": { + "hint": "Slope = ΔmV/ΔpH; divide by 59.16 and multiply by 100.", + "solution": "(168.0 − 2.5)/(7.00 − 4.01) = 55.35 mV per pH; 55.35/59.16 × 100 = 93.6%.", + "lesson_ids": [ + "lab-measurement-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 93.6, + "unit": "", + "tolerance": 0.023511256998929662, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-5:sample-ph", + "type": "numeric", + "prompt": "With that calibration (+2.5 mV at pH 7.00, slope from the previous item), a sample reads +110.0 mV. What is its pH? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-5-objective-2" + ], + "feedback": { + "hint": "pH = 7.00 − (E − E₇)/slope.", + "solution": "7.00 − (110.0 − 2.5)/55.35 = 5.06.", + "lesson_ids": [ + "lab-measurement-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 5.06, + "unit": "", + "tolerance": 0.0012710145343438472, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-5:hh-ratio", + "type": "numeric", + "prompt": "For an acetate buffer at pH 5.00 with pKa = 4.76, what is the ratio [acetate]/[acetic acid]? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-5-objective-3" + ], + "feedback": { + "hint": "pH = pKa + log₁₀([base]/[acid]).", + "solution": "10^(5.00 − 4.76) = 10^0.24 = 1.74.", + "lesson_ids": [ + "lab-measurement-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 1.74, + "unit": "", + "tolerance": 0.00043706823908266684, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-5:hh-mass", + "type": "numeric", + "prompt": "For 1.00 L of 0.0500 M phosphate at pH 7.00 with pKa₂ = 7.20, how many grams of Na₂HPO₄ (M = 141.96 g/mol) are needed? Give grams. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-5-objective-3" + ], + "feedback": { + "hint": "Find the base fraction r/(1 + r) with r = 10^(pH − pKa), then multiply by the total moles and M.", + "solution": "r = 10^−0.20 = 0.631; base fraction = 0.631/1.631 = 0.387; 0.0500 × 0.387 × 141.96 = 2.75 g.", + "lesson_ids": [ + "lab-measurement-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 2.75, + "unit": "", + "tolerance": 0.0006907687686651344, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-5:tris-temp", + "type": "numeric", + "prompt": "A Tris buffer is adjusted to pH 7.60 at 25 °C. Using a temperature coefficient of −0.028 pH units per °C, what is its approximate pH at 37 °C? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-5-objective-4" + ], + "feedback": { + "hint": "ΔpH = −0.028 × (T − 25).", + "solution": "7.60 − 0.028 × 12 = 7.26.", + "lesson_ids": [ + "lab-measurement-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 7.26, + "unit": "", + "tolerance": 0.0018236295492759548, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-5:calibration-verdict", + "type": "single_choice", + "prompt": "A calibration at 25 °C gives a slope of 92.4% of theoretical. Against a laboratory limit of 95–105%, what do you do?", + "points": 1, + "objective_ids": [ + "lab-measurement-5-objective-1" + ], + "feedback": { + "hint": "Compare the slope with the acceptance range.", + "solution": "92.4% is outside 95–105%, so the calibration fails; ATC corrects for temperature, not for a worn or dirty electrode.", + "lesson_ids": [ + "lab-measurement-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Reject the calibration: clean the electrode, use fresh buffers and recalibrate; replace the electrode if the slope stays low.", + "Accept it, because the meter displays a pH.", + "Accept it, but add 0.1 to every reading.", + "Switch on automatic temperature compensation to fix the slope." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-5:atc", + "type": "single_choice", + "prompt": "What does automatic temperature compensation on a pH meter correct?", + "points": 1, + "objective_ids": [ + "lab-measurement-5-objective-4" + ], + "feedback": { + "hint": "Separate the electrode's response from the chemistry of the buffer.", + "solution": "ATC adjusts the Nernst slope for the measured temperature. The buffer's pKa still changes with temperature, so adjust the pH at the temperature of use.", + "lesson_ids": [ + "lab-measurement-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "The change in the electrode's slope with temperature; it does not correct the sample's own pH change with temperature.", + "The change of a Tris buffer's pH between 25 °C and 4 °C.", + "Both the electrode and the sample's pH, to report the pH at 25 °C.", + "Drift in the reference junction." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-5:storage", + "type": "single_choice", + "prompt": "How should a combination pH electrode be stored between uses?", + "points": 1, + "objective_ids": [ + "lab-measurement-5-objective-1" + ], + "feedback": { + "hint": "Pure water leaches ions from the glass and the junction.", + "solution": "Storage solution keeps the glass hydrated and the junction filled; deionized water and drying both degrade the response.", + "lesson_ids": [ + "lab-measurement-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Wet, in the manufacturer's storage solution, usually concentrated KCl.", + "In deionized water.", + "Dry, with the bulb uncovered.", + "In the pH 4.01 calibration buffer from that day." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-5:buffer-statements", + "type": "multiple_select", + "prompt": "Which statements about preparing a buffer are correct? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-measurement-5-objective-3" + ], + "feedback": { + "hint": "Think about ionic strength, temperature and the order of steps.", + "solution": "Ionic strength and temperature shift the pKa, so measure and adjust before making up to volume, with a bracketing calibration. Blot, do not wipe, the bulb.", + "lesson_ids": [ + "lab-measurement-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "The Henderson–Hasselbalch ratio is a starting point; the pH must still be measured and adjusted.", + "The pH should be adjusted before bringing the solution to its final volume.", + "Calibration buffers should bracket the target pH.", + "Once calculated, the mass ratio guarantees the pH at any temperature.", + "Wiping the glass bulb dry between buffers improves accuracy." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-measurement-6:transmittance", + "type": "numeric", + "prompt": "A sample transmits 25.0% of the incident light. What is its absorbance? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-6-objective-1" + ], + "feedback": { + "hint": "A = −log₁₀ T.", + "solution": "−log₁₀ 0.250 = 0.602.", + "lesson_ids": [ + "lab-measurement-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 0.602, + "unit": "", + "tolerance": 0.0001512155631768767, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-6:beer-lambert", + "type": "numeric", + "prompt": "NADH has ε₃₄₀ = 6220 M⁻¹ cm⁻¹. A reading of A₃₄₀ = 0.467 in a 1.00 cm cuvette corresponds to what concentration? Give µM. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-6-objective-1" + ], + "feedback": { + "hint": "c = A/(ε l).", + "solution": "0.467/6220 = 7.51 × 10⁻⁵ M = 75.1 µM.", + "lesson_ids": [ + "lab-measurement-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 75.1, + "unit": "", + "tolerance": 0.01886426710063694, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-6:path-length", + "type": "numeric", + "prompt": "The same NADH solution is read in a plate well with a 0.300 cm liquid path. What absorbance do you expect if it reads 0.467 in a 1.00 cm cuvette? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-6-objective-1" + ], + "feedback": { + "hint": "Absorbance is proportional to path length.", + "solution": "0.467 × 0.300 = 0.140.", + "lesson_ids": [ + "lab-measurement-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 0.14, + "unit": "", + "tolerance": 3.516641004113412e-05, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-6:interpolate", + "type": "numeric", + "prompt": "A standard curve has slope 0.0124 per (µg/mL) and intercept 0.0030. What concentration gives a blank-subtracted absorbance of 0.415? Give µg/mL. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-6-objective-3" + ], + "feedback": { + "hint": "c = (A − a)/b.", + "solution": "(0.415 − 0.0030)/0.0124 = 33.2 µg/mL.", + "lesson_ids": [ + "lab-measurement-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 33.2, + "unit": "", + "tolerance": 0.008339462952611804, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-6:dilution-back", + "type": "numeric", + "prompt": "A sample diluted 1:20 (one part in twenty parts total) interpolates to 0.315 mg/mL on a standard curve. What is the concentration of the undiluted sample? Give mg/mL. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-6-objective-4" + ], + "feedback": { + "hint": "Multiply by the dilution factor.", + "solution": "0.315 × 20 = 6.30 mg/mL.", + "lesson_ids": [ + "lab-measurement-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 6.3, + "unit": "", + "tolerance": 0.001582488451851035, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-6:blank", + "type": "single_choice", + "prompt": "Which blank is right for a dye-binding protein assay in which samples are in Tris buffer?", + "points": 1, + "objective_ids": [ + "lab-measurement-6-objective-2" + ], + "feedback": { + "hint": "The blank should contain everything except the analyte.", + "solution": "Buffer plus reagent removes the absorbance of the reagent and any buffer effect, leaving only the protein's contribution.", + "lesson_ids": [ + "lab-measurement-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Tris buffer plus the dye reagent, treated exactly like the samples.", + "Deionized water.", + "The dye reagent alone, without buffer.", + "An empty cuvette." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-6:above-range", + "type": "single_choice", + "prompt": "An unknown reads 0.95 on a protein assay whose highest standard reads 0.64. What should you do?", + "points": 1, + "objective_ids": [ + "lab-measurement-6-objective-4" + ], + "feedback": { + "hint": "The curve says nothing about the response above the top standard.", + "solution": "Extrapolation assumes linearity where none was shown; dilution brings the reading into the calibrated range.", + "lesson_ids": [ + "lab-measurement-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Dilute the unknown into the range of the standards, measure it again and multiply by the dilution factor.", + "Extrapolate the fitted line, because the correlation coefficient is high.", + "Report it as 'above 0.64'.", + "Add a higher standard after the fact using the extrapolated value." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-6:residuals", + "type": "single_choice", + "prompt": "Residuals from a straight-line fit are positive for the middle standards and negative for the lowest and highest. What does this suggest?", + "points": 1, + "objective_ids": [ + "lab-measurement-6-objective-3" + ], + "feedback": { + "hint": "Look for patterns, not only size.", + "solution": "A systematic pattern means the model is wrong for the data; a straight line through a curve misestimates unknowns.", + "lesson_ids": [ + "lab-measurement-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "The response is curved over this range; use a narrower range or a non-linear fit.", + "The fit is excellent because the residuals average to zero.", + "One standard was pipetted wrongly and should be deleted.", + "The blank was wrong but the curve can be used." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-6:new-curve", + "type": "single_choice", + "prompt": "Why make a new standard curve for each run of an assay?", + "points": 1, + "objective_ids": [ + "lab-measurement-6-objective-3" + ], + "feedback": { + "hint": "What could differ between today and last week?", + "solution": "Standards and samples must be measured under the same conditions; reagents age and instruments drift.", + "lesson_ids": [ + "lab-measurement-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "Reagent activity, standards and instrument response change between runs, so an old curve may not describe today's measurements.", + "Old curves are deleted by the software.", + "Standard curves are only valid for one unknown.", + "It is required only for fluorescence assays." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-measurement-6:spectro-statements", + "type": "multiple_select", + "prompt": "Which statements about absorbance measurements are correct? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-measurement-6-objective-2" + ], + "feedback": { + "hint": "Consider the definition of absorbance, stray light, and the limits of the calibrated range.", + "solution": "A = 2 is T = 0.01; stray light limits high readings; values below the curve are not quantified. The law fails at high concentration, and plate path length depends on the liquid depth.", + "lesson_ids": [ + "lab-measurement-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "options": [ + "An absorbance of 2 means only 1% of the light is transmitted.", + "Many instruments become unreliable at high absorbance because stray light dominates.", + "Readings below the lowest non-zero standard should be reported as below the range rather than as a number.", + "The Beer–Lambert law holds at any concentration.", + "A microplate well always has a 1 cm path length." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-measurement-project:rehearsal-mass", + "type": "numeric", + "prompt": "How many grams of HEPES free acid (M = 238.30 g/mol, assay 99.5%) are needed for 250 mL of 20.0 mM buffer? Give grams. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-1-objective-2" + ], + "feedback": { + "hint": "m = C × V × M, divided by the assay fraction.", + "solution": "0.0200 × 0.250 × 238.30/0.995 = 1.20 g.", + "lesson_ids": [ + "lab-measurement-project-guide" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 1.2, + "unit": "", + "tolerance": 0.0003014263717811495, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-project:rehearsal-pipette", + "type": "numeric", + "prompt": "Ten deliveries from a 100 µL setting average 99.12 mg at 23.0 °C, where Z = 1.0035 µL/mg. What is the systematic error? Give % (negative if low). Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-3-objective-4", + "lab-measurement-3-objective-2" + ], + "feedback": { + "hint": "Convert the mean mass to volume with Z, then compute (V̄ − V₀)/V₀ × 100.", + "solution": "V̄ = 99.12 × 1.0035 = 99.47 µL; (99.47 − 100)/100 × 100 = -0.533%.", + "lesson_ids": [ + "lab-measurement-project-guide" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": -0.533, + "unit": "", + "tolerance": 0.0001338835467994606, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-project:rehearsal-slope", + "type": "numeric", + "prompt": "pH buffers read +0.8 mV at pH 7.00 and +166.9 mV at pH 4.01 at 25 °C. What is the slope as a percentage of the theoretical 59.16 mV per pH unit? Give %. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-5-objective-1" + ], + "feedback": { + "hint": "Slope = ΔmV/ΔpH; divide by 59.16.", + "solution": "(166.9 − 0.8)/2.99 = 55.55 mV per pH; 93.9% of theoretical, so the calibration fails a 95–105% limit.", + "lesson_ids": [ + "lab-measurement-project-guide" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 93.9, + "unit": "", + "tolerance": 0.023586613591874955, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-measurement-project:rehearsal-dilution", + "type": "numeric", + "prompt": "Using a standard curve with slope 0.6183 per (mg/mL) and intercept −0.0002, a sample diluted 1:4 (one part in four parts total) reads 0.215. What is the concentration of the undiluted sample? Give mg/mL. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-measurement-6-objective-4", + "lab-measurement-6-objective-3" + ], + "feedback": { + "hint": "Interpolate, then multiply by the dilution factor.", + "solution": "(0.215 + 0.0002)/0.6183 = 0.3481 mg/mL; × 4 = 1.39 mg/mL.", + "lesson_ids": [ + "lab-measurement-project-guide" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original bench-measurement practice developed with substantial AI assistance; no external problem, dataset or figure imported." + ] + }, + "solution_spec": { + "answer": 1.39, + "unit": "", + "tolerance": 0.00034915221397983156, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + } + ] +} diff --git a/content/courses/lab-measurement/question-banks/retrieval-cards.json b/content/courses/lab-measurement/question-banks/retrieval-cards.json new file mode 100644 index 0000000..d361fca --- /dev/null +++ b/content/courses/lab-measurement/question-banks/retrieval-cards.json @@ -0,0 +1,266 @@ +{ + "schema_version": "1.0", + "course_id": "lab-measurement", + "cards": [ + { + "id": "lab-measurement-1-card-1", + "lesson_id": "lab-measurement-1", + "front": "Molarity", + "back": "Moles of solute per litre of final solution, not per litre of solvent added.", + "objective_ids": [ + "lab-measurement-1-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-1-card-2", + "lesson_id": "lab-measurement-1", + "front": "Mass to weigh for a solution", + "back": "m = C × V × M, using the molar mass of the material in the bottle (including water of hydration), divided by the assay fraction.", + "objective_ids": [ + "lab-measurement-1-objective-2" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-1-card-3", + "lesson_id": "lab-measurement-1", + "front": "Molarity of a concentrated reagent", + "back": "C = 1000 × density × mass fraction/M; for 37% HCl at 1.18 g/mL, about 12.0 M.", + "objective_ids": [ + "lab-measurement-1-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-1-card-4", + "lesson_id": "lab-measurement-1", + "front": "Making up to volume", + "back": "Dissolve in about 70–80% of the final volume, adjust pH, transfer with rinses, then bring to the mark and mix.", + "objective_ids": [ + "lab-measurement-1-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-2-card-1", + "lesson_id": "lab-measurement-2", + "front": "Dilution factor", + "back": "Final volume divided by sample volume; write volumes, because '1:4' is read both as one in four and one plus four.", + "objective_ids": [ + "lab-measurement-2-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-2-card-2", + "lesson_id": "lab-measurement-2", + "front": "Serial dilution", + "back": "Each tube made from the previous; constant factor f gives f⁻ⁿ after n steps.", + "objective_ids": [ + "lab-measurement-2-objective-2" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-2-card-3", + "lesson_id": "lab-measurement-2", + "front": "Back-calculation", + "back": "Original concentration = count or reading ÷ (volume used × dilution of the tube).", + "objective_ids": [ + "lab-measurement-2-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-2-card-4", + "lesson_id": "lab-measurement-2", + "front": "Error along a series", + "back": "Random errors grow as e√n; systematic errors multiply, (1 + e)ⁿ − 1.", + "objective_ids": [ + "lab-measurement-2-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-3-card-1", + "lesson_id": "lab-measurement-3", + "front": "TC versus TD", + "back": "TC glassware contains the stated volume; TD glassware delivers it when drained as specified.", + "objective_ids": [ + "lab-measurement-3-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-3-card-2", + "lesson_id": "lab-measurement-3", + "front": "Systematic and random error", + "back": "Systematic = (V̄ − V₀)/V₀ × 100; random (CV) = s/V̄ × 100. Averaging reduces only random error.", + "objective_ids": [ + "lab-measurement-3-objective-2" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-3-card-3", + "lesson_id": "lab-measurement-3", + "front": "Pre-wetting", + "back": "Fill and empty a new tip a few times; the first fill of a dry tip delivers low.", + "objective_ids": [ + "lab-measurement-3-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-3-card-4", + "lesson_id": "lab-measurement-3", + "front": "Factor Z", + "back": "V = m × Z, Z = (1/(ρ_water − ρ_air))(1 − ρ_air/ρ_weights); about 1.003 µL/mg near room temperature.", + "objective_ids": [ + "lab-measurement-3-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-4-card-1", + "lesson_id": "lab-measurement-4", + "front": "Readability versus repeatability", + "back": "Readability is the display step; repeatability is the measured scatter of repeated weighings and sets the uncertainty.", + "objective_ids": [ + "lab-measurement-4-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-4-card-2", + "lesson_id": "lab-measurement-4", + "front": "Minimum weight", + "back": "m_min = 2s/tolerance; below it the relative uncertainty of a weighing exceeds the tolerance.", + "objective_ids": [ + "lab-measurement-4-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-4-card-3", + "lesson_id": "lab-measurement-4", + "front": "Weighing by difference", + "back": "Weigh the closed container before and after removing sample; the difference is what left it.", + "objective_ids": [ + "lab-measurement-4-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-4-card-4", + "lesson_id": "lab-measurement-4", + "front": "Warm objects on a balance", + "back": "Read light because of convection; equilibrate to room temperature first.", + "objective_ids": [ + "lab-measurement-4-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-5-card-1", + "lesson_id": "lab-measurement-5", + "front": "Nernst slope", + "back": "RT ln10/F: 59.16 mV per pH at 25 °C, 61.54 at 37 °C.", + "objective_ids": [ + "lab-measurement-5-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-5-card-2", + "lesson_id": "lab-measurement-5", + "front": "Calibration slope", + "back": "ΔmV/ΔpH between buffers, as a percentage of theoretical; commonly accepted within about 95–105%.", + "objective_ids": [ + "lab-measurement-5-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-5-card-3", + "lesson_id": "lab-measurement-5", + "front": "Henderson–Hasselbalch", + "back": "pH = pKa + log₁₀([base]/[acid]); a starting point that must be checked with a meter.", + "objective_ids": [ + "lab-measurement-5-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-5-card-4", + "lesson_id": "lab-measurement-5", + "front": "ATC versus buffer temperature", + "back": "ATC corrects the electrode slope; the buffer's pKa still shifts (Tris about −0.028 per °C).", + "objective_ids": [ + "lab-measurement-5-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-6-card-1", + "lesson_id": "lab-measurement-6", + "front": "Absorbance", + "back": "A = −log₁₀ T; A = 1 transmits 10%, A = 2 transmits 1%.", + "objective_ids": [ + "lab-measurement-6-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-6-card-2", + "lesson_id": "lab-measurement-6", + "front": "Beer–Lambert law", + "back": "A = ε l c for a dilute solution of one absorber; it fails at high concentration and absorbance.", + "objective_ids": [ + "lab-measurement-6-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-6-card-3", + "lesson_id": "lab-measurement-6", + "front": "Residual pattern", + "back": "Systematic residuals mean the model is wrong for the range, even if the fit statistic looks high.", + "objective_ids": [ + "lab-measurement-6-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-6-card-4", + "lesson_id": "lab-measurement-6", + "front": "Above the top standard", + "back": "Dilute into range, re-measure and multiply by the dilution factor; do not extrapolate.", + "objective_ids": [ + "lab-measurement-6-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-project-1", + "lesson_id": "lab-measurement-project-guide", + "front": "Qualifying a reagent and an assay", + "back": "Record the recipe and calculation, the instrument checks with verdicts, the pH calibration decision, the usable curve range with residuals, and each result with its dilution factor.", + "objective_ids": [ + "lab-measurement-6-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-measurement-project-2", + "lesson_id": "lab-measurement-project-guide", + "front": "Calibration buffers for a target pH", + "back": "Bracket the target: for pH 7.40, calibrate with pH 7.00 and 10.01, not only 4.01 and 7.00.", + "objective_ids": [ + "lab-measurement-5-objective-1" + ], + "license": "CC-BY-4.0" + } + ] +} diff --git a/content/courses/lab-measurement/rubrics/case-studio.json b/content/courses/lab-measurement/rubrics/case-studio.json new file mode 100644 index 0000000..ea07e1a --- /dev/null +++ b/content/courses/lab-measurement/rubrics/case-studio.json @@ -0,0 +1,208 @@ +{ + "schema_version": "1.0", + "course_id": "lab-measurement", + "cases": [ + { + "id": "lab-measurement:case", + "prompt": "You must prepare 500 mL of a 50 mM HEPES buffer at pH 7.40 for cell work at 37 °C, then use it to make a six-point standard curve for a protein assay. Write the preparation and verification plan: what you weigh and why, how you make up to volume, how you set and check the pH, which instruments you check before use and against what, and how you design and verify the standard curve.", + "mode": "self-assessment", + "objective_ids": [ + "lab-measurement-1-objective-2", + "lab-measurement-1-objective-4", + "lab-measurement-5-objective-3", + "lab-measurement-5-objective-4", + "lab-measurement-6-objective-3" + ], + "rubric": [ + { + "id": "lab-measurement-case-criterion-1", + "criterion": "Computes the mass from C × V × M with the correct molar mass and assay, and checks it against the balance's minimum weight", + "points": 0, + "levels": [] + }, + { + "id": "lab-measurement-case-criterion-2", + "criterion": "Dissolves in less than the final volume, adjusts pH before making up to volume, and records the temperature of adjustment", + "points": 0, + "levels": [] + }, + { + "id": "lab-measurement-case-criterion-3", + "criterion": "Calibrates the pH meter with bracketing buffers and accepts or rejects it against stated slope and offset limits", + "points": 0, + "levels": [] + }, + { + "id": "lab-measurement-case-criterion-4", + "criterion": "Checks the balance with a check weight and the pipettes by a recent gravimetric record before use", + "points": 0, + "levels": [] + }, + { + "id": "lab-measurement-case-criterion-5", + "criterion": "Designs standards by a parallel series spanning the expected range, uses a matched blank, inspects residuals and plans dilution of samples above the top standard", + "points": 0, + "levels": [] + } + ], + "limitations": [ + "A zero-point self-check; it is not a grade or a competency record, and your laboratory's SOPs govern real preparation." + ] + }, + { + "id": "lab-measurement:project-m1-solution", + "prompt": "Qualification project milestone 1 (suggested week 6). Calculate the mass of HEPES for 500 mL of 50.0 mM buffer, check it against the balance's minimum weight, and plan how you will dissolve, titrate at the temperature of use and make up to volume (project guide, milestone 1).", + "mode": "self-assessment", + "objective_ids": [ + "lab-measurement-1-objective-2", + "lab-measurement-1-objective-4", + "lab-measurement-4-objective-1" + ], + "rubric": [ + { + "id": "project-m1-solution-criterion-1", + "criterion": "I applied m = C × V × M with the purity correction.", + "points": 0, + "levels": [] + }, + { + "id": "project-m1-solution-criterion-2", + "criterion": "I compared the mass with a minimum weight computed from s and the tolerance.", + "points": 0, + "levels": [] + }, + { + "id": "project-m1-solution-criterion-3", + "criterion": "I planned to adjust pH at 37 °C before making up to volume.", + "points": 0, + "levels": [] + } + ], + "limitations": [ + "A zero-point self-check of one project milestone; it is not a grade or a competency record." + ] + }, + { + "id": "lab-measurement:project-m2-instruments", + "prompt": "Qualification project milestone 2. From the ten gravimetric readings and Z, compute the pipette's mean volume, systematic and random errors and verdict, and judge the balance check weight (project guide, milestone 2).", + "mode": "self-assessment", + "objective_ids": [ + "lab-measurement-3-objective-2", + "lab-measurement-3-objective-4", + "lab-measurement-4-objective-2" + ], + "rubric": [ + { + "id": "project-m2-instruments-criterion-1", + "criterion": "I converted every mass with Z before averaging.", + "points": 0, + "levels": [] + }, + { + "id": "project-m2-instruments-criterion-2", + "criterion": "I used the sample standard deviation for the coefficient of variation.", + "points": 0, + "levels": [] + }, + { + "id": "project-m2-instruments-criterion-3", + "criterion": "I gave a verdict against both limits and recorded the balance check.", + "points": 0, + "levels": [] + } + ], + "limitations": [ + "A zero-point self-check of one project milestone; it is not a grade or a competency record." + ] + }, + { + "id": "lab-measurement:project-m3-ph", + "prompt": "Qualification project milestone 3. Decide whether the pH-meter calibration is acceptable, and plan the calibration and adjustment needed to reach pH 7.40 at 37 °C (project guide, milestone 3).", + "mode": "self-assessment", + "objective_ids": [ + "lab-measurement-5-objective-1", + "lab-measurement-5-objective-4" + ], + "rubric": [ + { + "id": "project-m3-ph-criterion-1", + "criterion": "I computed the slope as a percentage of 59.16 mV per pH unit and compared it with 95–105%.", + "points": 0, + "levels": [] + }, + { + "id": "project-m3-ph-criterion-2", + "criterion": "I noticed that 4.01 and 7.00 do not bracket 7.40 and added a 10.01 buffer.", + "points": 0, + "levels": [] + }, + { + "id": "project-m3-ph-criterion-3", + "criterion": "I separated the electrode's temperature compensation from the buffer's own temperature dependence.", + "points": 0, + "levels": [] + } + ], + "limitations": [ + "A zero-point self-check of one project milestone; it is not a grade or a competency record." + ] + }, + { + "id": "lab-measurement:project-m4-curve", + "prompt": "Qualification project milestone 4. Fit the six standards, inspect the residuals, fit the standards up to 0.400 mg/mL, and justify the usable straight-line range (project guide, milestone 4).", + "mode": "self-assessment", + "objective_ids": [ + "lab-measurement-6-objective-3" + ], + "rubric": [ + { + "id": "project-m4-curve-criterion-1", + "criterion": "I reported the residual pattern of the six-point fit, not only its correlation.", + "points": 0, + "levels": [] + }, + { + "id": "project-m4-curve-criterion-2", + "criterion": "I chose a range in which residuals are small and patternless and gave its equation.", + "points": 0, + "levels": [] + } + ], + "limitations": [ + "A zero-point self-check of one project milestone; it is not a grade or a competency record." + ] + }, + { + "id": "lab-measurement:project-m5-report", + "prompt": "Qualification project milestone 5. Report samples X and Y with the range, fit and dilution used, and state the limitations of the results (project guide, milestone 5).", + "mode": "self-assessment", + "objective_ids": [ + "lab-measurement-6-objective-4", + "lab-measurement-2-objective-3" + ], + "rubric": [ + { + "id": "project-m5-report-criterion-1", + "criterion": "I interpolated X within the range and did not extrapolate Y.", + "points": 0, + "levels": [] + }, + { + "id": "project-m5-report-criterion-2", + "criterion": "I multiplied the diluted reading of Y by the correct dilution factor.", + "points": 0, + "levels": [] + }, + { + "id": "project-m5-report-criterion-3", + "criterion": "I listed at least three limitations, including the synthetic data and practice limits.", + "points": 0, + "levels": [] + } + ], + "limitations": [ + "A zero-point self-check of one project milestone; it is not a grade or a competency record." + ] + } + ] +} diff --git a/content/courses/lab-measurement/scope-crosswalk.md b/content/courses/lab-measurement/scope-crosswalk.md new file mode 100644 index 0000000..2e34e48 --- /dev/null +++ b/content/courses/lab-measurement/scope-crosswalk.md @@ -0,0 +1,28 @@ +# Laboratory Measurement scope crosswalk + +This crosswalk compares the local package, version 0.1.0, with MIT OpenCourseWare 5.310 Laboratory Chemistry, Fall 2019 ([syllabus](https://ocw.mit.edu/courses/5-310-laboratory-chemistry-fall-2019/pages/syllabus/), [calendar](https://ocw.mit.edu/courses/5-310-laboratory-chemistry-fall-2019/pages/calendar/)). It was compiled with AI assistance on 2026-10-30 from the syllabus's course description and the calendar's lecture and experiment titles. It is an agent-run comparison, not a review by the owner or by a laboratory instructor. Topic alignment establishes no equivalence, credit or endorsement. + +5.310 is a full-semester laboratory course built around five experiments (ferrocene, the Ellen Swallow Richards lab, essential oils, esterification and catalase) with lectures on the chemistry and instruments behind them. This package is a six-week foundation module on measurement, so most of 5.310's instrumental and synthetic scope is deliberately outside it. Status labels: **covered** means a local reading develops the topic with a worked example and practice; **shallow** means part of it is taught; **absent** means no local instruction; **local extension** means 5.310's published titles and description do not name the topic. + +| Topic | MIT 5.310 Fall 2019 | Local lessons | Status | +| --- | --- | --- | --- | +| Laboratory safety | Week 1 mandatory safety lecture | — | Absent here by design: biosafety is layer 1 of the track; the cabinet simulation exists on the bench page | +| Measurement of pH | Named in the course description | [5](modules/05-ph-measurement-and-buffer-preparation.md) | Covered: calibration, slope acceptance, mV-to-pH conversion, buffer planning and temperature | +| Visible–ultraviolet spectrophotometry | Named in the course description | [6](modules/06-spectrophotometry-and-standard-curves.md) | Covered for absorbance, Beer–Lambert, blanks and standard curves; instrument optics are not taught | +| Error analysis and data analysis | L4, "What's Significant in Laboratory Measurement? Error Analysis"; description names data analysis | [2](modules/02-dilutions-and-dilution-series.md), [3](modules/03-measuring-volume-and-checking-pipettes.md), [4](modules/04-balances-and-weighing.md), [6](modules/06-spectrophotometry-and-standard-curves.md) | Shallow: systematic and random error, propagation along a series, minimum weight and residuals; formal uncertainty budgets are not taught | +| Preparation of chemical materials | Named in the course description | [1](modules/01-concentration-and-solution-preparation.md) | Covered for solutions; synthesis and purification are absent | +| Lab notebooks and reports | L3, "Writing Up the Lab Report"; notebooks referenced in the syllabus | — | Absent: record-keeping appears only as advice inside lessons | +| Kinetics | Named in the description; catalase experiment | — | Absent | +| Gas and liquid chromatography, IR, NMR, mass spectrometry, polarimetry, X-ray diffraction | Named in the description; lectures L13–L15 | — | Absent by design | +| Organic synthesis | Ferrocene, essential oils and esterification experiments | — | Absent by design | +| Volumetric glassware and micropipettes | Not named in the published titles or description | [3](modules/03-measuring-volume-and-checking-pipettes.md) | Local extension, with the gravimetric pipette simulation | +| Balances and weighing | Not named | [4](modules/04-balances-and-weighing.md) | Local extension | +| Dilution series and plate counts | Not named | [2](modules/02-dilutions-and-dilution-series.md) | Local extension, with the BAM plate-count simulation | +| Graded laboratory reports, quizzes and oral reports | Five reports, quizzes and an oral report | Open practice only | Absent by design: all practice is formative | + +## Remaining gaps + +- Laboratory notebooks, data integrity and report writing. +- Titration and volumetric analysis; kinetics measurements. +- A formal treatment of measurement uncertainty (uncertainty budgets, coverage factors beyond the minimum-weight example). +- Review by a qualified laboratory instructor, measured workload and any graded assessment. diff --git a/content/courses/lab-measurement/source-map.json b/content/courses/lab-measurement/source-map.json new file mode 100644 index 0000000..6506956 --- /dev/null +++ b/content/courses/lab-measurement/source-map.json @@ -0,0 +1,108 @@ +{ + "schema_version": "1.0", + "course_id": "lab-measurement", + "license": "CC-BY-4.0", + "modules": [ + { + "module_id": "lab-measurement-module-1", + "lesson_id": "lab-measurement-1", + "reading": "modules/01-concentration-and-solution-preparation.md", + "source_ids": [ + "lab-chemistry-mit-5310" + ], + "relationship": "original-instruction-with-link-only-scope-comparators", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original instruction, synthetic examples and practice developed with substantial AI assistance. No source text, figure, problem or data imported." + ] + }, + { + "module_id": "lab-measurement-module-2", + "lesson_id": "lab-measurement-2", + "reading": "modules/02-dilutions-and-dilution-series.md", + "source_ids": [ + "lab-chemistry-mit-5310", + "fda-bam-chapter-3-aerobic-plate-count" + ], + "relationship": "original-instruction-with-link-only-scope-comparators", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original instruction, synthetic examples and practice developed with substantial AI assistance. No source text, figure, problem or data imported." + ] + }, + { + "module_id": "lab-measurement-module-3", + "lesson_id": "lab-measurement-3", + "reading": "modules/03-measuring-volume-and-checking-pipettes.md", + "source_ids": [ + "lab-chemistry-mit-5310", + "tanaka-2001-water-density" + ], + "relationship": "original-instruction-with-link-only-scope-comparators", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original instruction, synthetic examples and practice developed with substantial AI assistance. No source text, figure, problem or data imported." + ] + }, + { + "module_id": "lab-measurement-module-4", + "lesson_id": "lab-measurement-4", + "reading": "modules/04-balances-and-weighing.md", + "source_ids": [ + "lab-chemistry-mit-5310", + "tanaka-2001-water-density" + ], + "relationship": "original-instruction-with-link-only-scope-comparators", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original instruction, synthetic examples and practice developed with substantial AI assistance. No source text, figure, problem or data imported." + ] + }, + { + "module_id": "lab-measurement-module-5", + "lesson_id": "lab-measurement-5", + "reading": "modules/05-ph-measurement-and-buffer-preparation.md", + "source_ids": [ + "lab-chemistry-mit-5310" + ], + "relationship": "original-instruction-with-link-only-scope-comparators", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original instruction, synthetic examples and practice developed with substantial AI assistance. No source text, figure, problem or data imported." + ] + }, + { + "module_id": "lab-measurement-module-6", + "lesson_id": "lab-measurement-6", + "reading": "modules/06-spectrophotometry-and-standard-curves.md", + "source_ids": [ + "lab-chemistry-mit-5310" + ], + "relationship": "original-instruction-with-link-only-scope-comparators", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original instruction, synthetic examples and practice developed with substantial AI assistance. No source text, figure, problem or data imported." + ] + }, + { + "module_id": "lab-measurement-module-project", + "lesson_id": "lab-measurement-project-guide", + "reading": "assignments/reagent-and-assay-qualification-project-guide.md", + "source_ids": [ + "lab-chemistry-mit-5310" + ], + "relationship": "original-instruction-with-link-only-scope-comparators", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original synthetic project, data and rehearsal practice developed with substantial AI assistance. No source text, figure, problem or data imported." + ] + } + ] +} diff --git a/content/courses/lab-measurement/syllabus.md b/content/courses/lab-measurement/syllabus.md new file mode 100644 index 0000000..c8ef3cd --- /dev/null +++ b/content/courses/lab-measurement/syllabus.md @@ -0,0 +1,51 @@ +# Laboratory Measurement and Solution Preparation + +**Maturity: partial. Version: 0.1.0.** + +Prepare solutions and dilution series, measure volume and mass with checked instruments, calibrate and use pH meters and spectrophotometers, and rehearse real bench procedures before doing them in a laboratory. This is layer 0 of the wet-lab practice track described in the repository's wet-lab practice plan: the measurement skills every later laboratory package assumes. + +This package was written with substantial AI assistance. It has not been reviewed by a laboratory instructor or any certifying body, and it confers no credit, competency record or certification. + +## Prerequisites + +General Chemistry I recommended; algebra and the mole concept are assumed. This package establishes no university prerequisite equivalency. + +## Authored outcomes + +- Prepare solutions and dilution series at stated concentrations and back-calculate concentrations from diluted measurements. +- Measure volume and mass with checked instruments, and quantify accuracy and precision from replicate measurements. +- Calibrate and use a pH meter and a spectrophotometer, prepare buffers, and build and use standard curves. +- Connect bench technique to measured results by rehearsing procedures and debriefing their errors. + +## Proposed six-week schedule + +This is a proposed scope, not a measured or reviewed schedule. Each week has one reading with a worked example, a formative practice set and retrieval cards. Weeks 2 and 3 add a bench-practice simulation. + +| Week | Readings and activities | +| --- | --- | +| 1 | [Concentration and solution preparation](modules/01-concentration-and-solution-preparation.md) | +| 2 | [Dilutions and dilution series](modules/02-dilutions-and-dilution-series.md); [bench practice: decimal dilutions and the aerobic plate count](#/bench/decimal-dilution-aerobic-plate-count) | +| 3 | [Measuring volume and checking pipettes](modules/03-measuring-volume-and-checking-pipettes.md); [bench practice: gravimetric verification of a micropipette](#/bench/micropipette-gravimetric-verification) | +| 4 | [Balances and weighing](modules/04-balances-and-weighing.md) | +| 5 | [pH measurement and buffer preparation](modules/05-ph-measurement-and-buffer-preparation.md) | +| 6 | [Spectrophotometry and standard curves](modules/06-spectrophotometry-and-standard-curves.md); [optional qualification project guide](assignments/reagent-and-assay-qualification-project-guide.md) | + +## Bench practice + +The bench simulations replay a procedure from your own choices of action, with real volumes, temperatures and times, hidden faults that change from run to run, and a debrief of every checkpoint once the run ends. The plate-count procedure is adapted from FDA BAM Chapter 3 (January 2026 edition). The pipette check uses a water-density formula from Tanaka and colleagues (2001), with acceptance limits that are practice values. The simulations are formative and cannot train hand skill. + +## Optional qualification project (ungraded) + +The [project guide](assignments/reagent-and-assay-qualification-project-guide.md) joins the six lessons: plan a HEPES buffer, check a pipette and a balance, calibrate a pH meter, choose the usable range of a curving protein standard curve and report two samples. Four rehearsal items with different numbers sit in week 6, and five Case studio milestones give zero-point self-checks. The data are synthetic. + +## Assessment and study policy + +Every lesson has public formative items (numeric, single-choice and multiple-select) mapped to its objectives, plus retrieval cards for the review queue. One zero-point case-studio prompt asks for a preparation and verification plan. Nothing is graded. + +## Current limitations + +The schedule is proposed and unmeasured. Acceptance limits for instruments are examples, and all data are synthetic. Hand skill, aseptic technique and biosafety are not taught here; biosafety and cell culture are later layers of the track. + +## Sources and licensing + +Readings, examples, items and cards are original and licensed CC BY 4.0. The scope was compared by topic with [MIT OCW 5.310 Laboratory Chemistry, Fall 2019](https://ocw.mit.edu/courses/5-310-laboratory-chemistry-fall-2019/pages/syllabus/), whose syllabus names pH measurement, visible–ultraviolet spectrophotometry and data and error analysis among its techniques; see the [scope crosswalk](scope-crosswalk.md). No OCW material is copied, and the comparison establishes no equivalence, credit or endorsement. The plate-count simulation adapts a US federal government work (FDA BAM Chapter 3). diff --git a/content/curriculum-map.json b/content/curriculum-map.json index 532e74d..9452888 100644 --- a/content/curriculum-map.json +++ b/content/curriculum-map.json @@ -733,11 +733,12 @@ "stem-cell-biology" ], "recommended_course_ids": [ - "bioreactors" + "bioreactors", + "lab-measurement" ], "concurrent_course_ids": [], "knowledge": [], - "statement": "Recommended sequence: Foundations of Cell and Molecular Biology; Stem Cell Biology; Bioreactors & Tissue Culture Engineering (recommended). These project-planned dependencies do not establish any university's admission or course-equivalency rules." + "statement": "Recommended sequence: Foundations of Cell and Molecular Biology; Stem Cell Biology; Bioreactors & Tissue Culture Engineering (recommended); Laboratory Measurement and Solution Preparation (recommended). These project-planned dependencies do not establish any university's admission or course-equivalency rules." }, "related_package_ids": [], "relation_note": null @@ -956,6 +957,7 @@ "stem-cell-biology", "biomaterials", "bioreactors", + "lab-measurement", "cell-culture-stem-cell-lab", "tissue-engineered-drug-discovery", "gene-therapy", diff --git a/content/sources/registry.json b/content/sources/registry.json index cc78e33..a403816 100644 --- a/content/sources/registry.json +++ b/content/sources/registry.json @@ -12162,8 +12162,12 @@ "Identify BAM Chapter 3: Aerobic Plate Count (FDA, January 2026 edition; authors G. Zhang, M. Ferguson and R. J. Blodgett), the section, and the URL. Do not imply FDA endorsement." ], "third_party_notes": "The conventional plate count procedure (sections A and B), the maximum likelihood calculation (section C) and the recording and rounding rules (section 3) are adapted, with section references, into the bench protocol decimal-dilution-aerobic-plate-count. Read from the January 2026 PDF; the spiral plate method is not used.", - "mapped_courses": [], - "mapped_modules": [], + "mapped_courses": [ + "lab-measurement" + ], + "mapped_modules": [ + "lab-measurement-module-2" + ], "version": "January 2026 edition (content current as of 26 February 2026); full PDF read on 2026-10-30", "checksum": null, "imported_assets": [], @@ -12187,12 +12191,51 @@ "Identify the article title, Metrologia 38(4) 2001, and the DOI link." ], "third_party_notes": "Cited as the origin of the water-density formula used to convert balance readings to volume in the bench protocol micropipette-gravimetric-verification. The title was checked; the article text was not read in this session. The implemented formula reproduces the widely tabulated 998.2067 kg/m³ at 20 °C.", - "mapped_courses": [], - "mapped_modules": [], + "mapped_courses": [ + "lab-measurement" + ], + "mapped_modules": [ + "lab-measurement-module-3", + "lab-measurement-module-4" + ], "version": "Metrologia 2001; DOI 10.1088/0026-1394/38/4/3; title checked; identifier resolved with the citation-check script on 2026-10-30", "checksum": null, "imported_assets": [], "rights_notes": "This record documents link-only use and does not clear linked content for reuse." + }, + { + "id": "lab-chemistry-mit-5310", + "title": "MIT OCW 5.310 Laboratory Chemistry, Fall 2019", + "institution": "Massachusetts Institute of Technology / MIT OpenCourseWare", + "url": "https://ocw.mit.edu/courses/5-310-laboratory-chemistry-fall-2019/pages/syllabus/", + "accessed_at": "2026-10-30", + "license": "CC-BY-NC-SA-4.0", + "reuse_mode": "link-only-comparator", + "permissions": { + "linking": true, + "quotation": false, + "adaptation": false, + "redistribution": false + }, + "attribution_requirements": [ + "For a comparator link, display the course title, institution, term and source URL without implying endorsement." + ], + "third_party_notes": "Syllabus course description and calendar titles read on 2026-10-30 to set the scope of the lab-measurement package. No OCW text, problem, figure or asset is imported.", + "mapped_courses": [ + "lab-measurement" + ], + "mapped_modules": [ + "lab-measurement-module-1", + "lab-measurement-module-2", + "lab-measurement-module-3", + "lab-measurement-module-4", + "lab-measurement-module-5", + "lab-measurement-module-6" + ], + "version": "Fall 2019 (instructors J. J. Dolhun and S. Hewett); syllabus and calendar pages read 2026-10-30", + "checksum": null, + "imported_assets": [], + "rights_notes": "Comparator mapping does not establish equivalent scope, credit, endorsement or review." } ] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 594f100..496cf06 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## Milestone 1 foundation -The static reader is preserved. Versioned JSON manifests, Markdown and validators separate content from application code. All 25 packages are partial. Browser migration retains the previous key and backup; browser checks are unverified practice evidence. +The static reader is preserved. Versioned JSON manifests, Markdown and validators separate content from application code. All 26 packages are partial. Browser migration retains the previous key and backup; browser checks are unverified practice evidence. ## Milestone 2: implemented full stack diff --git a/docs/ASSESSMENT_CANDIDATE_REPORT.md b/docs/ASSESSMENT_CANDIDATE_REPORT.md index cd558f8..226b189 100644 --- a/docs/ASSESSMENT_CANDIDATE_REPORT.md +++ b/docs/ASSESSMENT_CANDIDATE_REPORT.md @@ -22,6 +22,7 @@ No course or candidate is activated by this command. `--check` detects report dr | general-chemistry-2 | 0.6.0 | partial | unreviewed | formative-only | 0 | | genetics | 0.9.0 | partial | unreviewed | formative-only | 0 | | geroscience | 0.7.0 | partial | unreviewed | formative-only | 0 | +| lab-measurement | 0.1.0 | partial | unreviewed | formative-only | 0 | | linear-algebra | 0.5.0 | partial | unreviewed | formative-only | 0 | | organic-chemistry | 0.7.0 | partial | unreviewed | formative-only | 0 | | physics-em | 0.7.0 | partial | unreviewed | formative-only | 0 | diff --git a/docs/COURSE_GAP_REPORT.md b/docs/COURSE_GAP_REPORT.md index 0c54713..4e2afca 100644 --- a/docs/COURSE_GAP_REPORT.md +++ b/docs/COURSE_GAP_REPORT.md @@ -20,6 +20,7 @@ comprehensive coverage. Every package is still `partial` and unreviewed. | general-chemistry-2 | partial | 23 | 62 | 132 | 77 | 0 | 0 | 0 | 4 | 0 | 0 | | genetics | partial | 31 | 91 | 165 | 110 | 0 | 0 | 0 | 8 | 0 | 0 | | geroscience | partial | 23 | 66 | 105 | 83 | 0 | 0 | 0 | 4 | 0 | 0 | +| lab-measurement | partial | 7 | 24 | 64 | 26 | 0 | 0 | 0 | 4 | 0 | 0 | | linear-algebra | partial | 22 | 62 | 133 | 79 | 0 | 0 | 0 | 5 | 0 | 0 | | organic-chemistry | partial | 23 | 68 | 155 | 84 | 0 | 0 | 0 | 4 | 0 | 0 | | physics-em | partial | 27 | 72 | 165 | 91 | 0 | 0 | 0 | 6 | 0 | 0 | @@ -32,7 +33,7 @@ comprehensive coverage. Every package is still `partial` and unreviewed. | statistics | partial | 27 | 78 | 135 | 92 | 0 | 0 | 0 | 12 | 0 | 0 | | transport | partial | 25 | 68 | 150 | 85 | 0 | 0 | 0 | 7 | 0 | 0 | -Totals: 25 packages, 660 lessons, 1755 objectives, 3788 practice items, 0 lesson objectives with no assessment mapping, 0 with no practice item, 0 linked to no course outcome; 0 of 130 course outcomes that no lesson objective links to, 0 that no assessment lists. +Totals: 26 packages, 667 lessons, 1779 objectives, 3852 practice items, 0 lesson objectives with no assessment mapping, 0 with no practice item, 0 linked to no course outcome; 0 of 134 course outcomes that no lesson objective links to, 0 that no assessment lists. Each assessment with items lists the course outcomes its own items assess (item, then its tagged lesson objective, then that objective's outcome links), and `tools/validate_content.py` rejects an outcome that none of the diff --git a/docs/COURSE_PLAN_COMPLETION.md b/docs/COURSE_PLAN_COMPLETION.md index 69afa83..f1ff1cc 100644 --- a/docs/COURSE_PLAN_COMPLETION.md +++ b/docs/COURSE_PLAN_COMPLETION.md @@ -1,12 +1,12 @@ # Existing course plans: learner routes -As of 2026-10-22, all **25 existing course plans** have authored routes for their scheduled readings, practice and proposed projects. This completes the existing-plan route stage. The owner subsequently expanded the request to full semester depth across the repository; the delivered authoring pass and remaining depth/review limits are recorded in [SEMESTER_DEPTH_EXPANSION.md](SEMESTER_DEPTH_EXPANSION.md). It does **not** make these complete university courses: all remain **partial, unreviewed and formative-only**. Workload, scientific review, deeper curricular coverage and graded release remain open gates in the [scope audit](COURSE_SCOPE_AUDIT.md) and [roadmap](ROADMAP.md). +As of 2026-10-22, all **25 existing course plans** have authored routes for their scheduled readings, practice and proposed projects. A 26th package, the six-week Laboratory Measurement foundation module of the wet-lab practice track, was added on 2026-10-31 with its own optional project route. This completes the existing-plan route stage. The owner subsequently expanded the request to full semester depth across the repository; the delivered authoring pass and remaining depth/review limits are recorded in [SEMESTER_DEPTH_EXPANSION.md](SEMESTER_DEPTH_EXPANSION.md). It does **not** make these complete university courses: all remain **partial, unreviewed and formative-only**. Workload, scientific review, deeper curricular coverage and graded release remain open gates in the [scope audit](COURSE_SCOPE_AUDIT.md) and [roadmap](ROADMAP.md). This is a personal hobby and learning project developed with substantial AI assistance. The new guides, synthetic datasets and practice were authored and checked by Codex, an AI coding agent. Automated checks are recorded in [VALIDATION_REPORT.md](VALIDATION_REPORT.md); the owner has not been recorded as having performed or approved this walkthrough. Original exercises use link-only research/software references recorded in each source map and the [source registry](../content/sources/registry.json). ## Current plans -“Blocks” means grouped calendar entries, not measured study weeks. General Chemistry II has 11 and Organic Chemistry I has 12; the other plans, including the expanded General Chemistry I sequence, have 14. Every project has a rehearsal and private Case studio milestones. Cell Biology and Biochemistry expose their eight original homework packets and cumulative reviews; Biochemistry also has two new bridge reports. The semester authoring pass adds 66 foundations and 86 connected assignments across 24 packages; later Circuits batches add two digital foundations and the AC-power/final-audit completion (0.7.0–0.8.0), including a fifth and sixth connected report. Course-specific content, sources and remaining limits are in the [semester record](SEMESTER_DEPTH_EXPANSION.md). No project or practice sequence is a protected exam or a reviewed university course. +“Blocks” means grouped calendar entries, not measured study weeks. General Chemistry II has 11, Organic Chemistry I has 12 and the Laboratory Measurement foundation module has 6; the other plans, including the expanded General Chemistry I sequence, have 14. Every project has a rehearsal and private Case studio milestones. Cell Biology and Biochemistry expose their eight original homework packets and cumulative reviews; Biochemistry also has two new bridge reports. The semester authoring pass adds 66 foundations and 86 connected assignments across 24 packages; later Circuits batches add two digital foundations and the AC-power/final-audit completion (0.7.0–0.8.0), including a fifth and sixth connected report. Course-specific content, sources and remaining limits are in the [semester record](SEMESTER_DEPTH_EXPANSION.md). No project or practice sequence is a protected exam or a reviewed university course. | Course and syllabus | Version | Calendar blocks | Project guide block | Guide | | --- | --- | ---: | ---: | --- | @@ -24,6 +24,7 @@ This is a personal hobby and learning project developed with substantial AI assi | [General Chemistry II](../content/courses/general-chemistry-2/syllabus.md) | 0.6.0 | 11 | 8 | [Project guide](../content/courses/general-chemistry-2/assignments/redox-cell-project-guide.md) | | [Genetics & Genomics](../content/courses/genetics/syllabus.md) | 0.9.0 | 14 | 6 | [Project guide](../content/courses/genetics/assignments/three-point-mapping-project-guide.md) | | [Geroscience & Regenerative Biology](../content/courses/geroscience/syllabus.md) | 0.7.0 | 14 | 14 | [Project guide](../content/courses/geroscience/assignments/surrogate-evidence-project-guide.md) | +| [Laboratory Measurement and Solution Preparation](../content/courses/lab-measurement/syllabus.md) | 0.1.0 | 6 | 6 | [Project guide](../content/courses/lab-measurement/assignments/reagent-and-assay-qualification-project-guide.md) | | [Linear Algebra for Modeling & Robotics](../content/courses/linear-algebra/syllabus.md) | 0.5.0 | 14 | 12 | [Project guide](../content/courses/linear-algebra/assignments/hidden-state-reconstruction-project-guide.md) | | [Organic Chemistry I for Life Sciences](../content/courses/organic-chemistry/syllabus.md) | 0.7.0 | 12 | 11 | [Project guide](../content/courses/organic-chemistry/assignments/aldol-identity-project-guide.md) | | [Physics: Electricity & Magnetism](../content/courses/physics-em/syllabus.md) | 0.7.0 | 14 | 13 | [Project guide](../content/courses/physics-em/assignments/series-resonance-project-guide.md) | diff --git a/docs/COURSE_SCOPE_AUDIT.md b/docs/COURSE_SCOPE_AUDIT.md index 0e2fc50..3234c0c 100644 --- a/docs/COURSE_SCOPE_AUDIT.md +++ b/docs/COURSE_SCOPE_AUDIT.md @@ -31,6 +31,7 @@ Local anchors: [Calculus I shape analysis](../content/courses/calculus-1/modules | [General Chemistry I](../content/courses/general-chemistry-1/course.json), 0.7.0 | [5.111SC, Fall 2014 syllabus](https://ocw.mit.edu/courses/5-111sc-principles-of-chemical-science-fall-2014/pages/syllabus/) and [Units I–III](GENERAL_CHEMISTRY_SEQUENCE_CROSSWALK.md); second comparator [UC Irvine Chem 1A](https://ocw.uci.edu/courses/chem_1a_general_chemistry.html), [1B](https://ocw.uci.edu/courses/chem_1b_general_chemistry.html) and [1C](https://ocw.uci.edu/courses/chem_1c_general_chemistry.html) lecture lists and Chem 1A topic index (2026-10-28) | Introductory electronic configurations and periodic trends, Lewis/VSEPR, stoichiometry, gases, calorimetry and general equilibrium are present; selected N₂/O₂ molecular-orbital bond-order instruction has been added. Full wavefunction-based atomic structure and valence-bond/hybridization coverage remain limited. Week-9 buffers and its calibration lab are an introductory bridge to Chemistry II. | Delivered: a ten-week regrouping preserves stable IDs and groups the compact readings with their expanded treatment. The new [paired-course crosswalk](GENERAL_CHEMISTRY_SEQUENCE_CROSSWALK.md) maps this package with Chemistry II to one MIT sequence and identifies the handoff without claiming two course equivalents. **Delivered bounded batch (0.4.10):** valence-bond theory and hybridization (5.111SC Lecture 14) were recorded as missing in the crosswalk. An original reading on s/p hybrid sets, σ and π bonds, restricted rotation and the angle–s-character relation is now grouped with the week-2 bonding lesson (eleven items, four cards). Rotate to another course before revisiting General Chemistry I. **Delivered (0.5.0, 2026-10-22):** optional activity/extent gas-equilibrium brief guide, separate worked example, five rehearsal items, five retrieval cards and five private milestone self-checks; scoped evidence and remaining depth limits are in the [completion index](COURSE_PLAN_COMPLETION.md#final-eight-routes-instructional-scope-comparison). **Delivered electronic/coupled-ledger depth (0.6.0, 2026-10-22):** two foundations, four reports and sixteen checkpoints/cards. [Actual instruction and limits](SEMESTER_DEPTH_EXPANSION.md#general-chemistry-i-060). **Delivered second-comparator batch (0.7.0, 2026-10-28):** a [scope crosswalk](../content/courses/general-chemistry-1/scope-crosswalk.md) against 5.111SC and Chem 1A–1C found the photoelectric effect, de Broglie wavelength, uncertainty principle and particle in a box (5.111SC Lectures 3–4; Chem 1A Lectures 3–4), and the octet-rule exceptions with steric numbers 5 and 6 (5.111SC Lecture 11; Chem 1A Lectures 8–10), absent; two original readings close them, with 26 items and eight cards. Liquids and solids, effusion, black-body radiation, nuclear chemistry and nomenclature remain one-comparator gaps, and entropy with the second and third laws is recorded for General Chemistry II. Rotate to another course before revisiting General Chemistry I. | | [General Chemistry II](../content/courses/general-chemistry-2/course.json), 0.6.0 | Same [5.111SC syllabus](https://ocw.mit.edu/courses/5-111sc-principles-of-chemical-science-fall-2014/pages/syllabus/) and [Units III–V](GENERAL_CHEMISTRY_SEQUENCE_CROSSWALK.md) | Acid/base speciation, free-energy reaction direction, kinetics, electrochemistry, coordination, ligand models and solubility are present. The 11-week local schedule groups compact buffer, kinetics, redox and coordination primers with expanded treatment, retaining all IDs and the synthetic lab. | **Delivered paired scope crosswalk:** maps five MIT units to both local packages, distinguishes the introductory buffer/equilibrium bridge from later application, and records remaining scope limits. No lesson, question or card was duplicated or added. **Delivered bounded batch (0.4.8):** a titration-curve reading, following 5.111SC Unit III Lectures 23–24, is grouped with week-3 coupled speciation: equivalence and endpoint, weak- and strong-acid curve regions checked against an exact charge balance, indicators and diprotic equivalence volumes (eleven items, four cards). Rotate to another course before revisiting General Chemistry II. **Delivered (0.5.0):** an optional week-8 guide, rehearsal practice and Case studio milestones make the fictional redox-cell project usable as ungraded practice, and the relaxation lab's dataset link is package-pinned. The scope boundary above is unchanged. **Delivered coupled/evidence depth (0.6.0, 2026-10-22):** three foundations, three connected reports and eighteen checkpoints/cards. [Actual instruction and remaining limits](SEMESTER_DEPTH_EXPANSION.md#general-chemistry-ii-060). | | [Organic Chemistry I](../content/courses/organic-chemistry/course.json), 0.7.0 | [5.12, Spring 2005 calendar](https://ocw.mit.edu/courses/5-12-organic-chemistry-i-spring-2005/pages/calendar/); second comparator [UCI OpenCourseWare Chem 51A, Fall 2009](https://open.uci.edu/courses/chem_51a_organic_chemistry_nowick.html) lecture titles (2026-10-26) | Resonance, stereochemistry, substitution/elimination, selected alkene/alkyne additions, carbonyl/enolate reactions and spectroscopy are present in twelve grouped blocks. New 0.6.0 readings now develop introductory benzyne elimination–addition/blocked-ring atom maps and multistep forward-compatibility/protection/stereochemical/yield reasoning, with two paper assignments. Reagent breadth, broad synthesis/protecting-group scope and experimentally established selectivity remain limited. No full 5.12 equivalence, measured workload or qualified approval is implied. | **Delivered bounded units:** alkene/alkyne mechanism, regioselectivity, stereochemistry, hydration and selective reduction; then an SNAr strand integrated into the existing aromaticity lesson, with seven mapped items and three cards. Rotate to another course before revisiting Organic Chemistry. **Delivered (0.5.0, 2026-10-22):** optional crossed-aldol identity brief guide, separate worked example, five rehearsal items, five retrieval cards and five private milestone self-checks; scoped evidence and remaining depth limits are in the [completion index](COURSE_PLAN_COMPLETION.md#final-eight-routes-instructional-scope-comparison). **Delivered deeper mechanisms/synthesis (0.6.0, 2026-10-22):** two original readings, two connected paper assignments and twelve checkpoints/cards. [Actual instructional comparison and remaining limits](SEMESTER_DEPTH_EXPANSION.md#organic-chemistry-060). **Delivered second-comparator batch (0.7.0, 2026-10-27):** a [scope crosswalk](../content/courses/organic-chemistry/scope-crosswalk.md) against 5.12 and UCI Chem 51A found reaction energetics (5.12 sessions 8–11 and 14; Chem 51A lectures 18–20) absent; an original week-2 reading closes it with 13 items and four cards. Alkane nomenclature and alcohol/phenol reactions, each named by one comparator, remain recorded gaps. Rotate to another course before revisiting Organic Chemistry. | +| [Laboratory Measurement and Solution Preparation](../content/courses/lab-measurement/course.json), 0.1.0 | [5.310 Laboratory Chemistry, Fall 2019 syllabus](https://ocw.mit.edu/courses/5-310-laboratory-chemistry-fall-2019/pages/syllabus/) and [calendar](https://ocw.mit.edu/courses/5-310-laboratory-chemistry-fall-2019/pages/calendar/) | Added 2026-10-31 as layer 0 of the wet-lab practice track: a six-week module on solutions, dilutions, volume and mass measurement, pH and spectrophotometry, with two bench simulations. 5.310 overlaps on pH measurement, UV–visible spectrophotometry and error analysis; its synthesis, chromatography and instrumental spectroscopy are deliberately outside this module (see the package's scope crosswalk). | Laboratory notebooks and data integrity, titration, and a formal uncertainty treatment; then the layer-1 biosafety package. | Local anchors: [Cell Biology syllabus](../content/courses/cell-biology/syllabus.md), [Genetics regulation](../content/courses/genetics/modules/03-gene-regulation-and-causal-perturbation.md), [Physiology hemodynamics](../content/courses/physiology/modules/10-hemodynamics-pressure-flow-resistance-compliance.md), [Biochemistry binding/cooperativity](../content/courses/biochemistry/modules/07-binding-equilibria-and-cooperativity.md), [Geroscience intervention evidence comparison](../content/courses/geroscience/modules/09-reading-an-aging-intervention-paper.md), [Geroscience syllabus](../content/courses/geroscience/syllabus.md), [Chemistry I atoms/photons](../content/courses/general-chemistry-1/modules/07-atoms-amount-and-photon-accounting.md), [Chemistry II coordination](../content/courses/general-chemistry-2/modules/13-ligand-balance-and-coordination-models.md), and [Organic Chemistry syllabus](../content/courses/organic-chemistry/syllabus.md). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e5fa6ac..fe8c76d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Repository-wide roadmap -The active scope is all 25 partial course packages and their shared learning platform. No package is complete or independently reviewed. Historical authoring and validation records remain in [PROGRESS_REVIEW.md](PROGRESS_REVIEW.md), [VALIDATION_REPORT.md](VALIDATION_REPORT.md), and the course review packets; they are not the current work queue. +The active scope is all 26 partial course packages and their shared learning platform. No package is complete or independently reviewed. Historical authoring and validation records remain in [PROGRESS_REVIEW.md](PROGRESS_REVIEW.md), [VALIDATION_REPORT.md](VALIDATION_REPORT.md), and the course review packets; they are not the current work queue. ## Owner priority (2026-10-22): expand to semester depth @@ -30,7 +30,7 @@ Delivered: guest/account access; version-pinned enrollment; server-backed notes, The case studio supports private drafts, version/prompt snapshots, conflict protection, earlier response history, account export/deletion and read-only archived cases. Historical feature-batch verification is recorded in the linked review reports. Since 2026-10-13 each batch runs the full root, backend and frontend suites; earlier batches recorded "no test suite was run", and the drift they left was repaired in one pass. Platform verification is not course review or protected-exam release approval. -Bench practice (owner request, 2026-10-30) follows [the wet-lab practice plan](WET_LAB_PRACTICE_PLAN.md). The replay engine, three protocols and a React bench view are delivered: micropipette gravimetric verification, decimal dilutions with the BAM aerobic plate count, and a Class II biosafety cabinet session with a spill. Next: the layer-0 laboratory-measurement course package, then the layer-1 biosafety package and more protocols, layer by layer. +Bench practice (owner request, 2026-10-30) follows [the wet-lab practice plan](WET_LAB_PRACTICE_PLAN.md). The replay engine, three protocols and a React bench view are delivered: micropipette gravimetric verification, decimal dilutions with the BAM aerobic plate count, and a Class II biosafety cabinet session with a spill. The layer-0 Laboratory Measurement package (0.1.0, six weeks, with an optional qualification project) is delivered. Next: the layer-1 biosafety package and more protocols, layer by layer. The searchable notebook reuses the existing private note store, exposes case-insensitive literal search with course filtering and pagination, and links matches back to enrolled course lessons. Remaining shared work should address a demonstrated learner workflow or blocker and fit alongside the instructional rotation. Avoid adding isolated grader types while course foundations remain thin. @@ -42,6 +42,6 @@ Develop coherent instruction, appropriately challenging assessment, source suppo ## Catalog and operation -The catalog contains 25 authored partial packages, 37 planning nodes and eight pathway maps. Validate prerequisite edges and subject boundaries against appropriate references before promoting planning nodes to instruction. Preserve historical packages and assessment evidence during consolidation. +The catalog contains 26 authored partial packages, 37 planning nodes and eight pathway maps. Validate prerequisite edges and subject boundaries against appropriate references before promoting planning nodes to instruction. Preserve historical packages and assessment evidence during consolidation. Operational work remains bounded by verified needs: deployment/backup and archive recovery, privacy and learner isolation, distributed throttling, accessibility and protected-delivery validation. The existing [historical assessment archive recovery runbook](HISTORICAL_ASSESSMENT_ARCHIVE_RECOVERY.md) documents conservative handling of unverifiable records. Local test passes do not establish public deployment readiness or independently reviewed courses. diff --git a/tests/content/test_assessment_candidates.py b/tests/content/test_assessment_candidates.py index 5a23066..88843fd 100644 --- a/tests/content/test_assessment_candidates.py +++ b/tests/content/test_assessment_candidates.py @@ -39,7 +39,7 @@ def packet(tmp_path): def test_real_report_is_current_and_does_not_open_private_stores(): report = MODULE.audit(ROOT) - assert len(report["packages"]) == 25 + assert len(report["packages"]) == 26 assert sum(len(p["candidates"]) for p in report["packages"]) == 26 assert MODULE.render(report) == (ROOT / "docs/ASSESSMENT_CANDIDATE_REPORT.md").read_text() assert "private_source_ref" not in json.dumps(report) diff --git a/tests/content/test_content_validation.py b/tests/content/test_content_validation.py index f8501de..7c6a5a3 100644 --- a/tests/content/test_content_validation.py +++ b/tests/content/test_content_validation.py @@ -175,7 +175,7 @@ def test_preserved_inventory_is_honest_partial(repository): # General Chemistry II, Programming, Biomaterials, Bioreactors, Cellular Biomechanics, Transport, Calculus II and # Calculus III, Differential Equations, Linear Algebra and the final eight project routes, # plus six E&M milestones. - assert result.inventory["courses"] == 25 and result.inventory["cases"] == 151 + assert result.inventory["courses"] == 26 and result.inventory["cases"] == 157 assert sum(warning["code"] == "legacy-depth" for warning in result.warnings) == 0 # Assessments list the course outcomes their items assess, so no package has an unmapped outcome. assert not [warning for warning in result.warnings if warning["code"] == "objective-coverage"] diff --git a/tests/content/test_course_plan_completion.py b/tests/content/test_course_plan_completion.py index 8559254..0a65d65 100644 --- a/tests/content/test_course_plan_completion.py +++ b/tests/content/test_course_plan_completion.py @@ -12,7 +12,7 @@ @pytest.mark.parametrize("path", MANIFESTS, ids=lambda path: path.parent.name) def test_existing_plan_has_usable_project_and_current_index(path): manifest = json.loads(path.read_text(encoding="utf-8")) - assert len(MANIFESTS) == 25 + assert len(MANIFESTS) == 26 assert manifest["maturity"] == "partial" assert manifest["review"]["status"] == "unreviewed" assert manifest["grading_policy"]["mode"] == "formative-only" diff --git a/tests/content/test_lab_measurement_package.py b/tests/content/test_lab_measurement_package.py new file mode 100644 index 0000000..00acac0 --- /dev/null +++ b/tests/content/test_lab_measurement_package.py @@ -0,0 +1,276 @@ +"""Laboratory Measurement 0.1.0: recomputed keys, stated numbers, schedule, project route, comparator and grading. + +Each numeric key is recomputed here from its formula (C × V × M, C₁V₁ = C₂V₂, the Tanaka water-density formula and +the buoyancy factor Z, the Nernst slope, Henderson–Hasselbalch, Beer–Lambert, least squares), not read from the item. +Same-AI checks, not human review. +""" + +from __future__ import annotations + +import json +import math +import re +import statistics +from pathlib import Path + +import pytest + +from courselab.grading import grade +from courselab.schemas import AttemptRequest + +ROOT = Path(__file__).resolve().parents[2] +COURSE = ROOT / "content/courses/lab-measurement" +MANIFEST = json.loads((COURSE / "course.json").read_text(encoding="utf-8")) +BANK = {q["id"]: q for q in json.loads((COURSE / "question-banks/practice.json").read_text(encoding="utf-8"))["questions"]} +CARDS = json.loads((COURSE / "question-banks/retrieval-cards.json").read_text(encoding="utf-8"))["cards"] +CASES = json.loads((COURSE / "rubrics/case-studio.json").read_text(encoding="utf-8"))["cases"] +READINGS = {lesson["id"]: (COURSE / lesson["reading"]).read_text(encoding="utf-8") + for module in MANIFEST["modules"] for lesson in module["lessons"]} +SYLLABUS = (COURSE / "syllabus.md").read_text(encoding="utf-8") +CROSSWALK = (COURSE / "scope-crosswalk.md").read_text(encoding="utf-8") + + +def stated(text, *phrases): + missing = [phrase for phrase in phrases if phrase not in text] + assert not missing, missing + + +def rho_water(t): + a1, a2, a3, a4, a5 = -3.983035, 301.797, 522528.9, 69.34881, 999.974950 + return a5 * (1 - (t + a1) ** 2 * (t + a2) / (a3 * (t + a4))) / 1000 + + +def z_factor(t, air=0.0012, weights=8.0): + return (1 / (rho_water(t) - air)) * (1 - air / weights) + + +def fit(xs, ys): + mx, my = statistics.fmean(xs), statistics.fmean(ys) + slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sum((x - mx) ** 2 for x in xs) + return slope, my - slope * mx + + +NERNST_25 = 8.314462618 * 298.15 * math.log(10) / 96485.33212 * 1000 +CURVE = fit([0, 0.125, 0.25, 0.5, 1.0], [0.000, 0.081, 0.158, 0.322, 0.640]) +PROJECT = fit([0, 0.100, 0.200, 0.400], [0.000, 0.061, 0.124, 0.247]) + +# Recomputed key and one named misconception for every numeric item. +KEYS = { + "1:nacl-mass": (0.200 * 0.250 * 58.44, 0.200 * 250 * 58.44), # volume left in mL + "1:hydrate-mass": (0.0500 * 0.500 * 249.69, 0.0500 * 0.500 * 159.61), # anhydrous molar mass + "1:hydrate-error": (3.99 / 249.69 / 0.500, 0.0500), # the intended concentration + "1:purity": (0.0250 * 0.500 * 238.30 / 0.990, 0.0250 * 0.500 * 238.30 * 0.990), # multiplying by the assay + "1:conc-acid": (1000 * 1.84 * 0.960 / 98.08, 1000 * 0.960 / 98.08), # ignoring density + "1:percent-to-molar": (50.0 / 180.16 * 1000, 5.00 / 180.16 * 1000), # % w/v read as g/L + "1:small-mass": (25.0e-6 * 0.0200 * 350 * 1000, 25.0e-6 * 20.0 * 350 * 1000), # volume left in mL + "2:c1v1": (15.0e-3 * 50.0 / 1.00 * 1000, 15.0e-3 * 50.0 / 1.00), # answer left in mL + "2:serial-total": (10 ** 4, 9 ** 4), # 50 into 450 read as ninefold + "2:fivefold": (2.00 / 5 ** 3, 2.00 / 4 ** 3), # 200 into 800 read as fourfold + "2:back-calc": (83 / (0.100 * 1e-6) / 1e8, 83 / 1e-6 / 1e8), # forgetting the plated volume + "2:systematic-compound": ((1.015 ** 6 - 1) * 100, 1.5), # one step only + "2:random-compound": (0.80 * math.sqrt(9), 0.80 * 9), # adding instead of quadrature + "2:parallel-volume": (0.160 * 250 / 2.00, 0.160 * 250 / 0.200), # wrong stock + "3:z-20": (z_factor(20.0), 1 / 0.998207), # density only, no buoyancy + "3:mean-volume": (497.6 * 1.0033, 497.6), # milligrams taken as microlitres + "3:systematic": ((499.2 - 500) / 500 * 100, 0.16), # sign dropped + "3:cv": (0.95 / 499.2 * 100, 0.95), # absolute instead of relative + "3:relative-low-end": (0.30 / 5.0 * 100, 0.30 / 100 * 100), # wrong reference volume + "4:min-weight": (2 * 0.050 / 0.0010, 0.050 / 0.0010), # without the factor of 2 + "4:relative-uncertainty": (2 * 0.10 / 5.0 * 100, 0.10 / 5.0 * 100), # without the factor of 2 + "4:by-difference": ((15.2318 - 14.9874) * 1000, 14.9874 * 1000 - 14000), # unrelated subtraction + "4:buoyancy": (2.0000 * (1 - 0.0012 / 8.0) / (1 - 0.0012 / 0.998), 2.0000 * (1 - 0.0012 / 0.998)), # inverted + "5:nernst-37": (8.3145 * 310.15 * math.log(10) / 96485 * 1000, NERNST_25), # 25 °C value + "5:slope-percent": ((168.0 - 2.5) / (7.00 - 4.01) / 59.16 * 100, (168.0 - 2.5) / 3.00 / 59.16 * 100), + "5:sample-ph": (7.00 - (110.0 - 2.5) / ((168.0 - 2.5) / 2.99), 7.00 + (110.0 - 2.5) / ((168.0 - 2.5) / 2.99)), + "5:hh-ratio": (10 ** (5.00 - 4.76), 10 ** (4.76 - 5.00)), # inverted ratio + "5:hh-mass": (0.0500 * (10 ** -0.20 / (1 + 10 ** -0.20)) * 141.96, 0.0500 * (1 / (1 + 10 ** -0.20)) * 141.96), + "5:tris-temp": (7.60 - 0.028 * 12, 7.60 + 0.028 * 12), # sign of the coefficient + "6:transmittance": (-math.log10(0.250), -math.log(0.250)), # natural logarithm + "6:beer-lambert": (0.467 / 6220 * 1e6, 0.467 * 6220), # multiplying + "6:path-length": (0.467 * 0.300, 0.467 / 0.300), # dividing + "6:interpolate": ((0.415 - 0.0030) / 0.0124, (0.415 + 0.0030) / 0.0124), # intercept sign + "6:dilution-back": (0.315 * 20, 0.315 * 19), # one-plus-nineteen reading + "project:rehearsal-mass": (0.0200 * 0.250 * 238.30 / 0.995, 0.0200 * 0.250 * 238.30), # no purity correction + "project:rehearsal-pipette": ((99.12 * 1.0035 - 100) / 100 * 100, (99.12 - 100) / 100 * 100), # no Z + "project:rehearsal-slope": ((166.9 - 0.8) / 2.99 / 59.16 * 100, (166.9 - 0.8) / 3.00 / 59.16 * 100), + "project:rehearsal-dilution": ((0.215 + 0.0002) / 0.6183 * 4, (0.215 + 0.0002) / 0.6183 * 5), +} + + +def item_id(key): + lesson, slug = key.split(":") + return f"lab-measurement-{lesson}:{slug}" + + +def shown(value, sig): + if not sig: + return f"{value:g}" + decimals = max(0, sig - 1 - math.floor(math.log10(abs(value)))) + return f"{value:.{decimals}f}" + + +def test_every_numeric_item_has_a_recomputed_key(): + numeric = {qid for qid, q in BANK.items() if q["type"] == "numeric"} + assert numeric == {item_id(key) for key in KEYS} + + +@pytest.mark.parametrize("key", sorted(KEYS)) +def test_numeric_keys_match_recomputation_and_reject_the_misconception(key): + item = BANK[item_id(key)] + spec = item["solution_spec"] + sig = spec["significant_figures"] + value, wrong = KEYS[key] + if sig: + assert float(shown(value, sig)) == pytest.approx(spec["answer"], rel=1e-9), (value, spec["answer"]) + words = {3: "three", 4: "four", 5: "five"}[sig] + assert item["prompt"].endswith(f"to {words} significant figures."), item["prompt"] + else: + assert value == pytest.approx(spec["answer"]) and item["prompt"].endswith("Enter the number only.") + assert grade(item, AttemptRequest(response=shown(spec["answer"], sig))).correct + assert not grade(item, AttemptRequest(response=shown(wrong, sig))).correct, shown(wrong, sig) + if sig: + # One digit too many is not accepted where significant figures are checked. + assert not grade(item, AttemptRequest(response=shown(spec["answer"], sig) + "0")).correct + + +def test_choice_items_accept_only_the_key_and_multiple_select_uses_partial_credit(): + for qid, item in BANK.items(): + if item["type"] == "single_choice": + accepted = [i for i in range(len(item["options"])) if grade(item, AttemptRequest(response=str(i))).correct] + assert accepted == [item["solution_spec"]["answer"]], qid + elif item["type"] == "multiple_select": + key = item["solution_spec"]["answer"] + assert item["points"] == 2 and item["solution_spec"]["partial_credit"] == "correct-minus-incorrect-clamped-v1" + wrong = next(i for i in range(len(item["options"])) if i not in key) + full = grade(item, AttemptRequest(response=key)).score + assert full == pytest.approx(2.0) + assert grade(item, AttemptRequest(response=[*key, wrong])).score < full, qid + assert grade(item, AttemptRequest(response=[wrong])).score == 0, qid + + +def test_lesson_one_numbers(): + text = READINGS["lab-measurement-1"] + assert round(0.150 * 0.500 * 58.44, 2) == 4.38 and round(9 / 58.44 * 1000) == 154 + assert round(0.100 * 0.250 * 147.01, 2) == 3.68 and round(0.100 * 110.98 / 147.01, 4) == 0.0755 + assert round(0.200 * 121.14 / 0.980, 1) == 24.7 and round(1000 * 1.18 * 0.370 / 36.46, 1) == 12.0 + assert round(250 / (1000 * 1.18 * 0.370 / 36.46), 1) == 20.9 + assert round(0.0250 * 0.200 * 246.47 / 0.995, 2) == 1.24 + stated(text, "4.38 g", "154 mM", "3.68 g", "0.0755 M", "24.7 g", "12.0 mol/L", "20.9 mL", "0.200 mg", "4.00 mg", "1.24 g") + + +def test_lesson_two_numbers(): + text = READINGS["lab-measurement-2"] + assert 5 ** 4 == 625 and round(47 / (0.1 * 1e-5)) == 47_000_000 + assert round(math.sqrt(6) * 1.0, 1) == 2.4 and round((1.01 ** 6 - 1) * 100, 1) == 6.2 + assert round(98 / 998, 4) == 0.0982 and round(152 / (0.1 * 1e-6)) == 1_520_000_000 + assert [round(v * 2.00 / 200, 4) for v in (5, 10, 20, 40)] == [0.05, 0.1, 0.2, 0.4] + stated(text, "1/625", "4.7 × 10⁷ CFU/mL", "about 2.4%", "6.2% too concentrated", "98/998 = 0.0982", + "1.52 × 10⁹ CFU/mL", "0.0500, 0.100, 0.200 and 0.400 mg/mL") + + +def test_lesson_three_gravimetric_example_and_worked_example(): + text = READINGS["lab-measurement-3"] + masses = [198.6, 199.1, 198.9, 199.4, 198.7, 199.0, 198.8, 199.2, 198.5, 199.0] + volumes = [m * z_factor(21.5) for m in masses] + mean = statistics.fmean(volumes) + assert round(statistics.fmean(masses), 2) == 198.92 and round(mean, 2) == 199.55 + assert round((mean - 200) / 2, 3) == -0.224 and round(statistics.stdev(volumes) / mean * 100, 3) == 0.140 + assert round((statistics.fmean(masses) - 200) / 2, 2) == -0.54 + v = 992.8 * z_factor(25.0) + assert round(v, 1) == 996.8 and round((v - 1000) / 10, 2) == -0.32 + assert round(2.10 * z_factor(25.0) / v * 100, 2) == 0.21 + assert round(z_factor(20.0), 4) == 1.0029 and round(z_factor(21.5), 4) == 1.0032 and round(z_factor(25.0), 4) == 1.0040 + assert round(rho_water(20.0) * 1000, 3) == 998.207 + stated(text, "198.92 mg", "199.55 µL", "−0.224%", "0.140%", "−0.540%", "996.8 µL", "−0.32%", "0.21%", + "1.0029 at 20 °C, 1.0032 at 21.5 °C and 1.0040 at 25 °C", "998.207 kg/m³") + + +def test_lesson_four_numbers(): + text = READINGS["lab-measurement-4"] + assert round(2 * 0.08 / 0.0010) == 160 and round(2 * 0.08 / 0.01) == 16 and round(2 * 0.08 / 0.2 * 100) == 80 + assert round((1 - 0.0012 / 8.0) / (1 - 0.0012 / 1.00) * 100 - 100, 3) == 0.105 + assert round(2 * 0.12 / 0.005) == 48 and round(2 * 0.12 / 25 * 100, 2) == 0.96 + assert round((12.3456 - 12.1123) * 1000, 1) == 233.3 + stated(text, "m_min = 2 × 0.08/0.0010 = 160 mg", "m_min = 16 mg", "80%", "+0.105%", "48 mg", "0.96%", "0.2333 g") + + +def test_lesson_five_numbers(): + text = READINGS["lab-measurement-5"] + assert round(NERNST_25, 2) == 59.16 + slope = (172.0 - 3.5) / 2.99 + assert round(slope, 2) == 56.35 and round(slope / 59.16 * 100, 1) == 95.3 + assert round(7.00 - (60.0 - 3.5) / slope, 2) == 6.00 + ratio = 10 ** 0.20 + assert round(ratio, 3) == 1.585 and round(0.1 / (1 + ratio) * 137.99, 2) == 5.34 + assert round(0.1 * ratio / (1 + ratio) * 141.96, 2) == 8.70 + assert round(8.00 - 0.028 * (4 - 25), 2) == 8.59 + bad = (-170.6 + 2.0) / 3.01 + assert round(abs(bad) / 59.16 * 100, 1) == 94.7 and round(7.00 + (-120.0 + 2.0) / bad, 2) == 9.11 + stated(text, "59.16 mV per pH unit at 25 °C, 58.17 at 20 °C and 61.54 at 37 °C", "56.35 mV per pH unit", "95.3%", + "= 6.00", "5.34 g", "8.70 g", "8.59", "94.7%", "9.11") + + +def test_lesson_six_curve_and_examples(): + text = READINGS["lab-measurement-6"] + slope, intercept = CURVE + assert round(slope, 4) == 0.6404 and abs(intercept) < 1e-4 + residuals = [y - (intercept + slope * x) for x, y in zip([0, 0.125, 0.25, 0.5, 1.0], [0.000, 0.081, 0.158, 0.322, 0.640])] + assert max(abs(r) for r in residuals) <= 0.0022 + assert round((0.254 - intercept) / slope, 3) == 0.397 and round((0.812 - intercept) / slope, 2) == 1.27 + assert round((0.406 - intercept) / slope * 4, 2) == 2.54 + mean = (0.287 + 0.291 + 0.283) / 3 + assert round((mean - intercept) / slope, 3) == 0.448 and round(10 * (mean - intercept) / slope, 2) == 4.48 + assert round(0.311 / 6220 * 1e6, 1) == 50.0 + stated(text, "b = 0.6404", "±0.0022", "0.397 mg/mL", "1.27 mg/mL", "2.54 mg/mL", "0.448 mg/mL", "4.48 mg/mL", "50.0 µM") + + +def test_project_dataset_curvature_and_results(): + guide = READINGS["lab-measurement-project-guide"] + xs, ys = [0, 0.100, 0.200, 0.400, 0.600, 0.800], [0.000, 0.061, 0.124, 0.247, 0.352, 0.441] + slope, intercept = fit(xs, ys) + residuals = [y - (intercept + slope * x) for x, y in zip(xs, ys)] + # Negative at both ends and positive in the middle: curvature over the full range. + assert residuals[0] < 0 and residuals[-1] < 0 and all(r > 0 for r in residuals[2:5]) + low_slope, low_intercept = PROJECT + assert round(low_slope, 4) == 0.6183 and round(low_intercept, 4) == -0.0002 + assert max(abs(y - (low_intercept + low_slope * x)) for x, y in zip(xs[:4], ys[:4])) < 0.001 + assert round((0.188 - low_intercept) / low_slope, 3) == 0.304 + assert round(5 * (0.139 - low_intercept) / low_slope, 2) == 1.13 + masses = [199.0, 199.5, 198.8, 199.3, 199.6, 198.9, 199.2, 199.4, 199.1, 198.7] + volumes = [m * z_factor(21.0) for m in masses] + assert round(z_factor(21.0), 4) == 1.0031 + assert abs((statistics.fmean(volumes) - 200) / 2) < 1.0 and statistics.stdev(volumes) / statistics.fmean(volumes) * 100 < 0.5 + assert round((170.2 - 1.5) / 2.99 / 59.16 * 100, 1) == 95.4 + assert round(0.0500 * 0.500 * 238.30 / 0.995, 2) == 5.99 + stated(guide, "Z = 1.0031 µL/mg", "+1.5 mV", "+170.2 mV", "0.512", "0.139", "1.20 g", "99.47 µL", "−0.53%", "93.9%", "1.39 mg/mL") + + +def test_schedule_project_route_and_formative_policy(): + weeks = MANIFEST["duration"]["weeks"] + assert MANIFEST["duration"]["instructional_weeks"] == 6 == len(weeks) + scheduled = [lesson for week in weeks for lesson in week["lesson_ids"]] + lessons = [lesson["id"] for module in MANIFEST["modules"] for lesson in module["lessons"]] + assert sorted(scheduled) == sorted(lessons) and len(set(scheduled)) == 7 + assert MANIFEST["maturity"] == "partial" and MANIFEST["review"]["status"] == "unreviewed" + assert MANIFEST["grading_policy"]["mode"] == "formative-only" + milestones = [case for case in CASES if ":project-m" in case["id"]] + assert len(milestones) == 5 and all(c["points"] == 0 for case in CASES for c in case["rubric"]) + for card in CARDS: + assert card["lesson_id"] in lessons + assert f"Version: {MANIFEST['version']}." in SYLLABUS + numeric = [q for q in BANK.values() if q["type"] == "numeric"] + sig = sum(1 for q in numeric if q["solution_spec"]["significant_figures"]) + assert f"Of the {len(numeric)} numerical answers, 0 require units, {sig} check significant figures" in " ".join( + MANIFEST["limitations"]) + + +def test_bench_links_and_comparator(): + assert "#/bench/decimal-dilution-aerobic-plate-count" in READINGS["lab-measurement-2"] + assert "#/bench/micropipette-gravimetric-verification" in READINGS["lab-measurement-3"] + stated(CROSSWALK, "MIT OpenCourseWare 5.310 Laboratory Chemistry, Fall 2019", "agent-run comparison", + "Local extension", "Absent by design") + registry = {s["id"]: s for s in json.loads((ROOT / "content/sources/registry.json").read_text(encoding="utf-8"))["sources"]} + comparator = registry["lab-chemistry-mit-5310"] + assert comparator["reuse_mode"] == "link-only-comparator" and not comparator["permissions"]["adaptation"] + assert registry["fda-bam-chapter-3-aerobic-plate-count"]["mapped_courses"] == ["lab-measurement"] + assert re.search(r"no OCW (text|material)", (SYLLABUS + comparator["third_party_notes"]), re.IGNORECASE) diff --git a/tests/content/test_lesson_links_resolve.py b/tests/content/test_lesson_links_resolve.py index 2ee7c1f..f359df0 100644 --- a/tests/content/test_lesson_links_resolve.py +++ b/tests/content/test_lesson_links_resolve.py @@ -11,7 +11,8 @@ ROOT = Path(__file__).resolve().parents[2] COURSES = ROOT / "content/courses" -RESOLVED = ("#/course/", "/api/v1/courses/", "https://", "http://", "mailto:") +RESOLVED = ("#/course/", "#/bench/", "/api/v1/courses/", "https://", "http://", "mailto:") +BENCH_PROTOCOLS = ROOT / "content/bench/protocols" SOURCE_LINK = re.compile(r"(? Date: Sat, 31 Oct 2026 01:48:47 +0000 Subject: [PATCH 4/7] Add Laboratory Safety and Biosafety (0.1.0), layer 1 of the wet-lab track A new six-week package, the catalog's 27th, adapting BMBL 6th edition (Sections II-IV, Appendices A, B, C, H) and OSHA 29 CFR 1910.1030, 1910.1450 and 1910.1200 (US federal works) with paragraph and page references: biological risk assessment, BSL-1/2 practices, biosafety cabinets, decontamination and sterilization with D-values and autoclave verification, human materials and bloodborne pathogens with exposure response, and chemical hygiene, hazard communication and transport. 64 practice items, 26 cards, a self-check case and an optional iPSC biosafety plan project with five Case studio milestones. Scope compared task by task with the ABSA CBSP examination content outline (2026, link-only); program-management and facility-review tasks are outside the package by design. New registry records for the three OSHA sections and the CBSP outline; count pins, docs and reports updated. Partial, unreviewed and formative-only; does not replace institutional safety training. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- backend/tests/test_case_studio.py | 1 + .../ipsc-project-biosafety-plan-guide.md | 44 + content/courses/lab-biosafety/course.json | 845 +++++++ .../modules/01-biological-risk-assessment.md | 84 + .../modules/02-biosafety-levels-1-and-2.md | 82 + .../modules/03-biological-safety-cabinets.md | 87 + ...mination-disinfection-and-sterilization.md | 98 + ...ials-bloodborne-pathogens-and-exposures.md | 76 + ...iene-hazard-communication-and-transport.md | 95 + .../question-banks/practice.json | 2177 +++++++++++++++++ .../question-banks/retrieval-cards.json | 266 ++ .../lab-biosafety/rubrics/case-studio.json | 209 ++ .../courses/lab-biosafety/scope-crosswalk.md | 29 + content/courses/lab-biosafety/source-map.json | 115 + content/courses/lab-biosafety/syllabus.md | 47 + content/curriculum-map.json | 6 +- content/sources/registry.json | 137 +- docs/ARCHITECTURE.md | 2 +- docs/ASSESSMENT_CANDIDATE_REPORT.md | 1 + docs/COURSE_GAP_REPORT.md | 3 +- docs/COURSE_PLAN_COMPLETION.md | 5 +- docs/COURSE_SCOPE_AUDIT.md | 1 + docs/ROADMAP.md | 6 +- tests/content/test_assessment_candidates.py | 2 +- tests/content/test_content_validation.py | 2 +- tests/content/test_course_plan_completion.py | 2 +- tests/content/test_lab_biosafety_package.py | 143 ++ tests/content/test_robotics_recalculations.py | 2 +- 29 files changed, 4556 insertions(+), 20 deletions(-) create mode 100644 content/courses/lab-biosafety/assignments/ipsc-project-biosafety-plan-guide.md create mode 100644 content/courses/lab-biosafety/course.json create mode 100644 content/courses/lab-biosafety/modules/01-biological-risk-assessment.md create mode 100644 content/courses/lab-biosafety/modules/02-biosafety-levels-1-and-2.md create mode 100644 content/courses/lab-biosafety/modules/03-biological-safety-cabinets.md create mode 100644 content/courses/lab-biosafety/modules/04-decontamination-disinfection-and-sterilization.md create mode 100644 content/courses/lab-biosafety/modules/05-human-materials-bloodborne-pathogens-and-exposures.md create mode 100644 content/courses/lab-biosafety/modules/06-chemical-hygiene-hazard-communication-and-transport.md create mode 100644 content/courses/lab-biosafety/question-banks/practice.json create mode 100644 content/courses/lab-biosafety/question-banks/retrieval-cards.json create mode 100644 content/courses/lab-biosafety/rubrics/case-studio.json create mode 100644 content/courses/lab-biosafety/scope-crosswalk.md create mode 100644 content/courses/lab-biosafety/source-map.json create mode 100644 content/courses/lab-biosafety/syllabus.md create mode 100644 tests/content/test_lab_biosafety_package.py diff --git a/README.md b/README.md index abc7df5..9e5efae 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,18 @@ An independent open-source learning platform under development. It does **not co | --- | ---: | --- | | catalog-only | 0 | Catalog metadata only | | outlined | 0 | Structured plan without developed teaching | -| partial | 26 | Authored readings, connected coursework and formative checks; depth and review remain limited | +| partial | 27 | Authored readings, connected coursework and formative checks; depth and review remain limited | | beta | 0 | Developed course awaiting required review/gates | | complete | 0 | All full-course gates satisfied | | externally reviewed | 0 | Qualified named reviewer examined a specified version | -**No current package meets the complete-course standard.** The repository contains 26 partial packages with 667 lessons, 3,852 formative questions, 2,258 retrieval cards and 157 self-assessed case prompts (content validator inventory, 2026-10-31). These counts measure authored material; they do not establish semester depth, measured workload, scientific review or university equivalence. +**No current package meets the complete-course standard.** The repository contains 27 partial packages with 674 lessons, 3,916 formative questions, 2,284 retrieval cards and 163 self-assessed case prompts (content validator inventory, 2026-10-31). These counts measure authored material; they do not establish semester depth, measured workload, scientific review or university equivalence. The packages span life sciences, chemistry, mathematics, physics, statistics, computing and engineering. Original readings, worked examples, synthetic datasets and practice are available in each subject, with uneven coverage and depth. See the [package inventory](#package-inventory), [measured content gaps](docs/COURSE_GAP_REPORT.md), and [MIT OpenCourseWare scope audit](docs/COURSE_SCOPE_AUDIT.md) for the current boundaries and priorities. Detailed development history belongs in the [progress review](docs/PROGRESS_REVIEW.md). Development now proceeds across the repository in bounded instructional batches. Follow-up priorities address demonstrated instructional gaps, broader course boundaries and learner evidence identified against official MIT OpenCourseWare syllabi and calendars. New material is original and attributed; references compare scope and expectations without claiming affiliation, copied course delivery, or equivalent credit. Metadata and review-report refreshes are bundled with substantive changes. -The curriculum map adds **37 catalog-only subject nodes** and **8 pathway maps** beside the 26 partial course packages. Catalog-only entries describe planned study areas and dependencies; they contain no authored lessons and cannot be enrolled in. Mapped prerequisite order is planning guidance and does not establish university equivalency. +The curriculum map adds **37 catalog-only subject nodes** and **8 pathway maps** beside the 27 partial course packages. Catalog-only entries describe planned study areas and dependencies; they contain no authored lessons and cannot be enrolled in. Mapped prerequisite order is planning guidance and does not establish university equivalency. See [the audit](docs/CURRENT_STATE_AUDIT.md), [quality standard](docs/CONTENT_QUALITY_STANDARD.md), and [remaining work](docs/ROADMAP.md). @@ -47,6 +47,7 @@ See [the audit](docs/CURRENT_STATE_AUDIT.md), [quality standard](docs/CONTENT_QU | [General Chemistry II](content/courses/general-chemistry-2/course.json) | Chemistry | partial | | [Genetics & Genomics](content/courses/genetics/course.json) | Life sciences | partial | | [Geroscience & Regenerative Biology](content/courses/geroscience/course.json) | Life sciences | partial | +| [Laboratory Safety and Biosafety](content/courses/lab-biosafety/course.json) | Life sciences laboratory | partial | | [Laboratory Measurement and Solution Preparation](content/courses/lab-measurement/course.json) | Life sciences laboratory | partial | | [Linear Algebra for Modeling & Robotics](content/courses/linear-algebra/course.json) | Mathematics | partial | | [Organic Chemistry I for Life Sciences](content/courses/organic-chemistry/course.json) | Chemistry | partial | @@ -90,7 +91,7 @@ The React reader now includes six scientific calculators under **Study tools**, **Bench practice** replays simulations of real bench procedures (micropipette gravimetric verification, decimal dilutions with the FDA BAM aerobic plate count, and a Class II biosafety cabinet session with a spill) from the learner's own choices of action, with a checkpoint debrief at the end. Runs are formative, unrecorded and cannot train hand skill; see the [wet-lab practice plan](docs/WET_LAB_PRACTICE_PLAN.md). -The React **Case studio** also exposes the authored prompts and self-check criteria across all 26 partial courses. Enrolled learners can save a private written response and checklist marks; drafts stay bound to the course version and prompt, remain available in account exports, and do not affect grades. Archived cases are read-only. +The React **Case studio** also exposes the authored prompts and self-check criteria across all 27 partial courses. Enrolled learners can save a private written response and checklist marks; drafts stay bound to the course version and prompt, remain available in account exports, and do not affect grades. Archived cases are read-only. The previous calculators, cards, case self-assessment and browser notebook remain preserved in `legacy/`; the React reader now has its own searchable notebook over server-saved lesson notes. To use the old reader separately: diff --git a/backend/tests/test_case_studio.py b/backend/tests/test_case_studio.py index 1bd553a..c1e158e 100644 --- a/backend/tests/test_case_studio.py +++ b/backend/tests/test_case_studio.py @@ -189,6 +189,7 @@ def test_all_authored_courses_expose_their_existing_case_studies(): "differential-equations", "linear-algebra", "circuits", "physiology", "signals-control", "general-chemistry-1", "organic-chemistry", "statistics", "robotics", "geroscience", "lab-measurement", + "lab-biosafety", ) } # Physics E&M authored six milestones for its six project tasks. diff --git a/content/courses/lab-biosafety/assignments/ipsc-project-biosafety-plan-guide.md b/content/courses/lab-biosafety/assignments/ipsc-project-biosafety-plan-guide.md new file mode 100644 index 0000000..396abb1 --- /dev/null +++ b/content/courses/lab-biosafety/assignments/ipsc-project-biosafety-plan-guide.md @@ -0,0 +1,44 @@ +# Project guide: a biosafety plan for an iPSC derivation project (optional, ungraded) + +This optional project joins the six lessons into the document a laboratory writes before new work starts: a biosafety plan. The scenario is invented and all numbers are synthetic. The project is self-checked in the Case studio in five milestones and earns no points. It is practice in reasoning, not an approved plan: a real one is written with your biosafety officer and reviewed by your Institutional Biosafety Committee. + +## The scenario + +A laboratory will derive induced pluripotent stem cells (iPSCs) from peripheral blood of adult donors whose infection status is unknown. Blood (10 mL per donor) is processed to isolate mononuclear cells by density-gradient centrifugation. The cells are reprogrammed with a non-integrating viral vector supplied as a concentrated stock, expanded in culture, frozen, and some lines are sent to a collaborator in another state. The laboratory has a Class II Type A2 cabinet, a centrifuge with sealed safety cups, an autoclave and household bleach labelled 6.0%. + +## Milestone 1: risk assessment + +List the agent hazards (the donor material, the vector) and the procedure hazards (centrifugation, pipetting, opening tubes, sharps if any). Score each task's probability and consequence on 1–5 scales and rank them; then apply controls in hierarchy order and rescore. Say who must review the assessment before work starts and what would make you revisit it. + +## Milestone 2: containment level and practices + +State the minimum Biosafety Level and justify it from BMBL Appendix H. List the BSL-2 practices that matter most for this work, which procedures must be in the cabinet, and how centrifugation will be done. Name the OSHA Bloodborne Pathogens Standard obligations that apply to staff on this project, with their timing. + +## Milestone 3: the cabinet + +The cabinet's front opening is 4.0 ft wide with an 8.0 in sash. Compute the inflow volume needed for the Type A2 minimum of 100 lfm, and the face velocity if a user raises the sash to 10.0 in without changing the airflow. The certification label says 13 months. What do you do before the project starts? + +## Milestone 4: decontamination and waste + +Plan surface disinfection and spill response using hypochlorite. Compute the bleach volume for 1.00 L of 5,000 ppm from the 6.0% stock. Waste culture is autoclaved; in a validation run the centre of a typical load reached 121 °C 15 min after the chamber. Using biological indicators of 10⁶ spores with D₁₂₁ = 2.0 min, compute the minimum chamber time for a twelve-log reduction at the centre, and say how routine cycles will be verified. + +## Milestone 5: exposures and shipping + +Write the first-minutes procedure for a needlestick and for an eye splash, and who is called. Then describe how the frozen iPSC lines will be shipped to the collaborator: who classifies and packs them, and what triple packaging consists of. + +## Worked example with different numbers + +A different laboratory has a 5.0 ft cabinet opening with a 10.0 in sash, a 7.5% bleach stock and a waste load that lags 20 min with D₁₂₁ = 1.5 min. + +- **Inflow.** A = 5.0 × 10.0/12 = 4.17 ft², so Q = 100 × 4.17 = 417 cubic feet per minute. +- **Bleach.** 7.5% ≈ 75,000 ppm; for 500 mL of 5,000 ppm, V₁ = 500 × 5,000/75,000 = 33.3 mL. +- **Autoclave.** 20 + 12 × 1.5 = 38 min at 121 °C as the minimum chamber time for the centre of that load. +- **Ranking.** Opening centrifuge tubes of donor cells on the bench scores 4 × 3 = 12; opening sealed cups in the cabinet scores 2 × 3 = 6. + +## What to hand in to yourself + +A two-page plan: the risk table before and after controls, the containment level and key practices, the cabinet checks, the decontamination and waste plan with its calculations, and the exposure and shipping procedures. Compare it with the milestone checklists in the Case studio. + +## Limits of this project + +The scenario, scores and numbers are synthetic. Real projects with human donor material and viral vectors need institutional review, donor consent and material agreements, occupational health enrolment and trained shippers, none of which this exercise provides. diff --git a/content/courses/lab-biosafety/course.json b/content/courses/lab-biosafety/course.json new file mode 100644 index 0000000..57b82c0 --- /dev/null +++ b/content/courses/lab-biosafety/course.json @@ -0,0 +1,845 @@ +{ + "schema_version": "1.0", + "id": "lab-biosafety", + "title": "Laboratory Safety and Biosafety", + "description": "Assess biological risk, apply Biosafety Level 1 and 2 practices, select and use biological safety cabinets, decontaminate and sterilize with verification, work safely with human materials and hazardous chemicals, and respond to exposures.", + "domain": "Life sciences laboratory", + "level": "Year 1", + "maturity": "partial", + "version": "0.1.0", + "license": "CC-BY-4.0", + "content_origin": { + "kind": "original", + "source_path": null, + "source_sha256": null, + "migration_version": null, + "modifications": [ + "Original readings, worked examples, practice and cards developed with substantial AI assistance on 2026-10-30 and 2026-10-31 as layer 1 of the wet-lab practice track, adapting guidance from BMBL 6th edition and OSHA standards (US federal works) with attribution." + ] + }, + "prerequisites": { + "course_ids": [], + "recommended_course_ids": [ + "lab-measurement", + "cell-biology" + ], + "concurrent_course_ids": [], + "knowledge": [ + "Basic microbiology vocabulary" + ], + "statement": "Laboratory Measurement and Foundations of Cell and Molecular Biology recommended. This package establishes no university prerequisite equivalency and replaces no institutional safety training." + }, + "outcomes": [ + { + "id": "lab-biosafety-outcome-1", + "description": "Assess biological risk from agent and procedure hazards and choose controls in hierarchy order.", + "bloom": "evaluate" + }, + { + "id": "lab-biosafety-outcome-2", + "description": "Apply BSL-1 and BSL-2 practices and select, use and verify biological safety cabinets.", + "bloom": "apply" + }, + { + "id": "lab-biosafety-outcome-3", + "description": "Decontaminate, disinfect and sterilize correctly, and verify that the process worked.", + "bloom": "apply" + }, + { + "id": "lab-biosafety-outcome-4", + "description": "Work safely with human materials and hazardous chemicals, respond to exposures, and recognise when shipping requires trained personnel.", + "bloom": "apply" + } + ], + "lesson_objectives": [ + { + "id": "lab-biosafety-1-objective-1", + "description": "Distinguish a hazard from a risk and rank tasks with a probability × consequence score.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-biosafety-outcome-1" + ] + }, + { + "id": "lab-biosafety-1-objective-2", + "description": "Identify agent and procedure hazards, including the routes of laboratory-associated infection.", + "bloom": "analyze", + "course_outcome_ids": [ + "lab-biosafety-outcome-1" + ] + }, + { + "id": "lab-biosafety-1-objective-3", + "description": "Apply the hierarchy of controls and distinguish inherent from residual risk.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-biosafety-outcome-1" + ] + }, + { + "id": "lab-biosafety-1-objective-4", + "description": "Carry out the six steps of the BMBL risk management process, including review and revisiting.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-biosafety-outcome-1" + ] + }, + { + "id": "lab-biosafety-2-objective-1", + "description": "Distinguish primary from secondary barriers and explain how practice, equipment and facility combine.", + "bloom": "understand", + "course_outcome_ids": [ + "lab-biosafety-outcome-2" + ] + }, + { + "id": "lab-biosafety-2-objective-2", + "description": "State how BSL-2 differs from BSL-1 and what is posted at a BSL-2 entrance.", + "bloom": "remember", + "course_outcome_ids": [ + "lab-biosafety-outcome-2" + ] + }, + { + "id": "lab-biosafety-2-objective-3", + "description": "Apply BSL-2 standard microbiological practices.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-biosafety-outcome-2" + ] + }, + { + "id": "lab-biosafety-2-objective-4", + "description": "Identify BSL-2 special practices, safety equipment and facilities, and decide when work moves into a cabinet.", + "bloom": "evaluate", + "course_outcome_ids": [ + "lab-biosafety-outcome-2" + ] + }, + { + "id": "lab-biosafety-3-objective-1", + "description": "Distinguish personnel, product and environmental protection across cabinet classes, clean benches and fume hoods.", + "bloom": "understand", + "course_outcome_ids": [ + "lab-biosafety-outcome-2" + ] + }, + { + "id": "lab-biosafety-3-objective-2", + "description": "Describe Class II Types A1, A2, B1 and B2 airflow, inflow velocity and exhaust.", + "bloom": "understand", + "course_outcome_ids": [ + "lab-biosafety-outcome-2" + ] + }, + { + "id": "lab-biosafety-3-objective-3", + "description": "Calculate inflow volume and face velocity, and select a cabinet type for the work.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-biosafety-outcome-2" + ] + }, + { + "id": "lab-biosafety-3-objective-4", + "description": "State certification triggers, the limits of UV lamps and placement effects on containment.", + "bloom": "remember", + "course_outcome_ids": [ + "lab-biosafety-outcome-2" + ] + }, + { + "id": "lab-biosafety-4-objective-1", + "description": "Define cleaning, disinfection, sterilization and decontamination and what each guarantees.", + "bloom": "understand", + "course_outcome_ids": [ + "lab-biosafety-outcome-3" + ] + }, + { + "id": "lab-biosafety-4-objective-2", + "description": "Rank microorganisms by resistance and list the factors that determine disinfection.", + "bloom": "analyze", + "course_outcome_ids": [ + "lab-biosafety-outcome-3" + ] + }, + { + "id": "lab-biosafety-4-objective-3", + "description": "Choose a disinfectant by activity level and prepare a hypochlorite solution of a target concentration.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-biosafety-outcome-3" + ] + }, + { + "id": "lab-biosafety-4-objective-4", + "description": "Relate exposure time to log reduction and sterility assurance with D-values, and describe autoclave verification.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-biosafety-outcome-3" + ] + }, + { + "id": "lab-biosafety-5-objective-1", + "description": "Assess the risk of human and primate cell material and state the minimum containment.", + "bloom": "evaluate", + "course_outcome_ids": [ + "lab-biosafety-outcome-4" + ] + }, + { + "id": "lab-biosafety-5-objective-2", + "description": "Explain universal precautions and the materials the Bloodborne Pathogens Standard covers.", + "bloom": "understand", + "course_outcome_ids": [ + "lab-biosafety-outcome-4" + ] + }, + { + "id": "lab-biosafety-5-objective-3", + "description": "List the employer's obligations under 29 CFR 1910.1030 with their timing.", + "bloom": "remember", + "course_outcome_ids": [ + "lab-biosafety-outcome-4" + ] + }, + { + "id": "lab-biosafety-5-objective-4", + "description": "Carry out the immediate response to a sharps injury or splash and report it at once.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-biosafety-outcome-4" + ] + }, + { + "id": "lab-biosafety-6-objective-1", + "description": "State what a Chemical Hygiene Plan contains, who is responsible and how often it is reviewed.", + "bloom": "remember", + "course_outcome_ids": [ + "lab-biosafety-outcome-4" + ] + }, + { + "id": "lab-biosafety-6-objective-2", + "description": "Read a hazard-communication label, including its six elements and signal words.", + "bloom": "understand", + "course_outcome_ids": [ + "lab-biosafety-outcome-4" + ] + }, + { + "id": "lab-biosafety-6-objective-3", + "description": "Find information on a 16-section safety data sheet.", + "bloom": "apply", + "course_outcome_ids": [ + "lab-biosafety-outcome-4" + ] + }, + { + "id": "lab-biosafety-6-objective-4", + "description": "Distinguish Category A and B infectious substances and describe triple packaging and shipper training.", + "bloom": "understand", + "course_outcome_ids": [ + "lab-biosafety-outcome-4" + ] + } + ], + "duration": { + "instructional_weeks": 6, + "equivalent_structure": "Proposed six-week module of one reading and practice set per week, with the cabinet simulation in week 3 and an optional project in week 6. It prepares for, and does not replace, institutional biosafety, bloodborne-pathogen and chemical-hygiene training. The schedule has not been measured or reviewed.", + "weeks": [ + { + "week": 1, + "title": "Biological risk assessment and the hierarchy of controls", + "lesson_ids": [ + "lab-biosafety-1" + ], + "assessment_ids": [ + "lab-biosafety-1-practice" + ] + }, + { + "week": 2, + "title": "Biosafety Levels 1 and 2: practices, equipment and facilities", + "lesson_ids": [ + "lab-biosafety-2" + ], + "assessment_ids": [ + "lab-biosafety-2-practice" + ] + }, + { + "week": 3, + "title": "Biological safety cabinets; bench practice: a Class II cabinet session with a spill", + "lesson_ids": [ + "lab-biosafety-3" + ], + "assessment_ids": [ + "lab-biosafety-3-practice" + ] + }, + { + "week": 4, + "title": "Decontamination, disinfection and sterilization", + "lesson_ids": [ + "lab-biosafety-4" + ], + "assessment_ids": [ + "lab-biosafety-4-practice" + ] + }, + { + "week": 5, + "title": "Human cells and tissues, bloodborne pathogens and exposure response", + "lesson_ids": [ + "lab-biosafety-5" + ], + "assessment_ids": [ + "lab-biosafety-5-practice" + ] + }, + { + "week": 6, + "title": "Chemical hygiene, hazard communication and transport of infectious substances; optional iPSC biosafety plan project", + "lesson_ids": [ + "lab-biosafety-6", + "lab-biosafety-project-guide" + ], + "assessment_ids": [ + "lab-biosafety-6-practice", + "lab-biosafety-project-rehearsal-practice", + "lab-biosafety-biosafety-plan-project" + ] + } + ] + }, + "workload": { + "estimated_total_hours": null, + "estimated_hours_per_week": null, + "basis": "Not measured." + }, + "modules": [ + { + "id": "lab-biosafety-module-1", + "title": "Biological risk assessment", + "source_ids": [ + "cdc-nih-bmbl-6", + "absa-cbsp-examination-content" + ], + "lessons": [ + { + "id": "lab-biosafety-1", + "title": "Biological risk assessment", + "reading": "modules/01-biological-risk-assessment.md", + "objectives": [ + "lab-biosafety-1-objective-1", + "lab-biosafety-1-objective-2", + "lab-biosafety-1-objective-3", + "lab-biosafety-1-objective-4" + ], + "worked_example": "For dense BSL-2 culture centrifuged and sonicated, the main hazards are concentration, volume and aerosols; controls in order are a smaller volume, sealed cups opened in a Class II cabinet, a written SOP with training, then coat, gloves and eye protection, reviewed before starting.", + "question_ids": [ + "lab-biosafety-1:risk-score", + "lab-biosafety-1:residual-score", + "lab-biosafety-1:aerosol-release", + "lab-biosafety-1:hazard-risk", + "lab-biosafety-1:risk-group-bsl", + "lab-biosafety-1:lai-routes", + "lab-biosafety-1:hierarchy", + "lab-biosafety-1:inherent-residual", + "lab-biosafety-1:review-step", + "lab-biosafety-1:revisit" + ], + "card_ids": [ + "lab-biosafety-1-card-1", + "lab-biosafety-1-card-2", + "lab-biosafety-1-card-3", + "lab-biosafety-1-card-4" + ] + } + ] + }, + { + "id": "lab-biosafety-module-2", + "title": "Biosafety Levels 1 and 2", + "source_ids": [ + "cdc-nih-bmbl-6", + "absa-cbsp-examination-content" + ], + "lessons": [ + { + "id": "lab-biosafety-2", + "title": "Biosafety Levels 1 and 2", + "reading": "modules/02-biosafety-levels-1-and-2.md", + "objectives": [ + "lab-biosafety-2-objective-1", + "lab-biosafety-2-objective-2", + "lab-biosafety-2-objective-3", + "lab-biosafety-2-objective-4" + ], + "worked_example": "For a new BSL-2 researcher, vortexing a dense suspension moves into the cabinet with the tube capped, a 50 mL high-concentration spin uses sealed safety cups opened in the cabinet, and desk work means gloves off, no food and hands washed before leaving.", + "question_ids": [ + "lab-biosafety-2:bsl2-differences", + "lab-biosafety-2:barriers", + "lab-biosafety-2:sign", + "lab-biosafety-2:gloves", + "lab-biosafety-2:needles", + "lab-biosafety-2:broken-glass", + "lab-biosafety-2:bsc-needed", + "lab-biosafety-2:centrifuge", + "lab-biosafety-2:lab-coats", + "lab-biosafety-2:incident" + ], + "card_ids": [ + "lab-biosafety-2-card-1", + "lab-biosafety-2-card-2", + "lab-biosafety-2-card-3", + "lab-biosafety-2-card-4" + ] + } + ] + }, + { + "id": "lab-biosafety-module-3", + "title": "Biological safety cabinets: classes, selection and certification", + "source_ids": [ + "cdc-nih-bmbl-6", + "absa-cbsp-examination-content" + ], + "lessons": [ + { + "id": "lab-biosafety-3", + "title": "Biological safety cabinets: classes, selection and certification", + "reading": "modules/03-biological-safety-cabinets.md", + "objectives": [ + "lab-biosafety-3-objective-1", + "lab-biosafety-3-objective-2", + "lab-biosafety-3-objective-3", + "lab-biosafety-3-objective-4" + ], + "worked_example": "A 5.0 ft opening with a 10.0 in sash needs 100 × 4.17 = 417 cubic feet per minute for the Type A2 minimum; culture alone suits a recirculating A2, while volatile chemicals need exhaust outside or, better, a separate fume hood.", + "question_ids": [ + "lab-biosafety-3:inflow-volume", + "lab-biosafety-3:raised-sash", + "lab-biosafety-3:a1-exhaust", + "lab-biosafety-3:uv-threshold", + "lab-biosafety-3:clean-bench", + "lab-biosafety-3:class-i", + "lab-biosafety-3:volatile", + "lab-biosafety-3:hard-duct", + "lab-biosafety-3:certification", + "lab-biosafety-3:b2-failure" + ], + "card_ids": [ + "lab-biosafety-3-card-1", + "lab-biosafety-3-card-2", + "lab-biosafety-3-card-3", + "lab-biosafety-3-card-4" + ] + } + ] + }, + { + "id": "lab-biosafety-module-4", + "title": "Decontamination, disinfection and sterilization", + "source_ids": [ + "cdc-nih-bmbl-6", + "absa-cbsp-examination-content" + ], + "lessons": [ + { + "id": "lab-biosafety-4", + "title": "Decontamination, disinfection and sterilization", + "reading": "modules/04-decontamination-disinfection-and-sterilization.md", + "objectives": [ + "lab-biosafety-4-objective-1", + "lab-biosafety-4-objective-2", + "lab-biosafety-4-objective-3", + "lab-biosafety-4-objective-4" + ], + "worked_example": "To make 500 mL of 5,000 ppm hypochlorite from 8.25% bleach use 30.3 mL; a waste load whose centre lags 18 min behind the chamber needs at least 18 + 12 × 2.0 = 42 min at 121 °C for a twelve-log reduction there.", + "question_ids": [ + "lab-biosafety-4:bleach-volume", + "lab-biosafety-4:bleach-ppm", + "lab-biosafety-4:sal-time", + "lab-biosafety-4:survivors", + "lab-biosafety-4:lag", + "lab-biosafety-4:definitions", + "lab-biosafety-4:resistance", + "lab-biosafety-4:factors", + "lab-biosafety-4:tape", + "lab-biosafety-4:ethanol" + ], + "card_ids": [ + "lab-biosafety-4-card-1", + "lab-biosafety-4-card-2", + "lab-biosafety-4-card-3", + "lab-biosafety-4-card-4" + ] + } + ] + }, + { + "id": "lab-biosafety-module-5", + "title": "Human cells and tissues, bloodborne pathogens and exposure response", + "source_ids": [ + "cdc-nih-bmbl-6", + "osha-29cfr1910-1030-bloodborne-pathogens", + "absa-cbsp-examination-content" + ], + "lessons": [ + { + "id": "lab-biosafety-5", + "title": "Human cells and tissues, bloodborne pathogens and exposure response", + "reading": "modules/05-human-materials-bloodborne-pathogens-and-exposures.md", + "objectives": [ + "lab-biosafety-5-objective-1", + "lab-biosafety-5-objective-2", + "lab-biosafety-5-objective-3", + "lab-biosafety-5-objective-4" + ], + "worked_example": "Deriving iPSCs from donor blood means human primary hematopoietic cells of unknown infection status, so BSL-2 practices with all open work in a Class II cabinet, the exposure control plan, training, hepatitis B vaccination within 10 working days and immediate reporting of any exposure.", + "question_ids": [ + "lab-biosafety-5:hbv-days", + "lab-biosafety-5:medical-records", + "lab-biosafety-5:training-records", + "lab-biosafety-5:universal", + "lab-biosafety-5:culture-risk", + "lab-biosafety-5:minimum", + "lab-biosafety-5:ecp", + "lab-biosafety-5:immediate", + "lab-biosafety-5:hbv-cost", + "lab-biosafety-5:sharps-container" + ], + "card_ids": [ + "lab-biosafety-5-card-1", + "lab-biosafety-5-card-2", + "lab-biosafety-5-card-3", + "lab-biosafety-5-card-4" + ] + } + ] + }, + { + "id": "lab-biosafety-module-6", + "title": "Chemical hygiene, hazard communication and transport of infectious substances", + "source_ids": [ + "osha-29cfr1910-1450-laboratory-standard", + "osha-29cfr1910-1200-hazard-communication", + "cdc-nih-bmbl-6", + "absa-cbsp-examination-content" + ], + "lessons": [ + { + "id": "lab-biosafety-6", + "title": "Chemical hygiene, hazard communication and transport of infectious substances", + "reading": "modules/06-chemical-hygiene-hazard-communication-and-transport.md", + "objectives": [ + "lab-biosafety-6-objective-1", + "lab-biosafety-6-objective-2", + "lab-biosafety-6-objective-3", + "lab-biosafety-6-objective-4" + ], + "worked_example": "Before making a formaldehyde fixative read SDS sections 2, 4, 7 and 8, work in a functioning fume hood under the plan's provisions for particularly hazardous substances, label the bottle, and hand any infectious-substance shipment to trained shipping personnel.", + "question_ids": [ + "lab-biosafety-6:sds-ppe", + "lab-biosafety-6:sds-spill", + "lab-biosafety-6:un-category-b", + "lab-biosafety-6:pressure", + "lab-biosafety-6:signal-word", + "lab-biosafety-6:label-elements", + "lab-biosafety-6:chp-review", + "lab-biosafety-6:chp-elements", + "lab-biosafety-6:triple", + "lab-biosafety-6:who-ships" + ], + "card_ids": [ + "lab-biosafety-6-card-1", + "lab-biosafety-6-card-2", + "lab-biosafety-6-card-3", + "lab-biosafety-6-card-4" + ] + } + ] + }, + { + "id": "lab-biosafety-module-project", + "title": "iPSC project biosafety plan guide (optional, ungraded)", + "source_ids": [ + "cdc-nih-bmbl-6", + "absa-cbsp-examination-content" + ], + "lessons": [ + { + "id": "lab-biosafety-project-guide", + "title": "Project guide: a biosafety plan for an iPSC derivation project (optional, ungraded)", + "reading": "assignments/ipsc-project-biosafety-plan-guide.md", + "objectives": [ + "lab-biosafety-1-objective-1", + "lab-biosafety-1-objective-3", + "lab-biosafety-3-objective-3", + "lab-biosafety-4-objective-3", + "lab-biosafety-4-objective-4", + "lab-biosafety-5-objective-1" + ], + "worked_example": "For a 5.0 ft opening with a 10.0 in sash the Type A2 inflow is 417 cubic feet per minute; 33.3 mL of 7.5% bleach makes 500 mL of 5,000 ppm; and a load lagging 20 min with D₁₂₁ = 1.5 min needs at least 38 min at 121 °C.", + "question_ids": [ + "lab-biosafety-project:rehearsal-ranking", + "lab-biosafety-project:rehearsal-inflow", + "lab-biosafety-project:rehearsal-bleach", + "lab-biosafety-project:rehearsal-autoclave" + ], + "card_ids": [ + "lab-biosafety-project-1", + "lab-biosafety-project-2" + ] + } + ] + } + ], + "assessments": [ + { + "id": "lab-biosafety-1-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: biological risk assessment", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-biosafety-1-objective-1", + "lab-biosafety-1-objective-2", + "lab-biosafety-1-objective-3", + "lab-biosafety-1-objective-4", + "lab-biosafety-outcome-1" + ], + "question_ids": [ + "lab-biosafety-1:risk-score", + "lab-biosafety-1:residual-score", + "lab-biosafety-1:aerosol-release", + "lab-biosafety-1:hazard-risk", + "lab-biosafety-1:risk-group-bsl", + "lab-biosafety-1:lai-routes", + "lab-biosafety-1:hierarchy", + "lab-biosafety-1:inherent-residual", + "lab-biosafety-1:review-step", + "lab-biosafety-1:revisit" + ], + "points": 12 + }, + { + "id": "lab-biosafety-2-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: biosafety Levels 1 and 2", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-biosafety-2-objective-1", + "lab-biosafety-2-objective-2", + "lab-biosafety-2-objective-3", + "lab-biosafety-2-objective-4", + "lab-biosafety-outcome-2" + ], + "question_ids": [ + "lab-biosafety-2:bsl2-differences", + "lab-biosafety-2:barriers", + "lab-biosafety-2:sign", + "lab-biosafety-2:gloves", + "lab-biosafety-2:needles", + "lab-biosafety-2:broken-glass", + "lab-biosafety-2:bsc-needed", + "lab-biosafety-2:centrifuge", + "lab-biosafety-2:lab-coats", + "lab-biosafety-2:incident" + ], + "points": 13 + }, + { + "id": "lab-biosafety-3-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: biological safety cabinets: classes, selection and certification", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-biosafety-3-objective-1", + "lab-biosafety-3-objective-2", + "lab-biosafety-3-objective-3", + "lab-biosafety-3-objective-4", + "lab-biosafety-outcome-2" + ], + "question_ids": [ + "lab-biosafety-3:inflow-volume", + "lab-biosafety-3:raised-sash", + "lab-biosafety-3:a1-exhaust", + "lab-biosafety-3:uv-threshold", + "lab-biosafety-3:clean-bench", + "lab-biosafety-3:class-i", + "lab-biosafety-3:volatile", + "lab-biosafety-3:hard-duct", + "lab-biosafety-3:certification", + "lab-biosafety-3:b2-failure" + ], + "points": 11 + }, + { + "id": "lab-biosafety-4-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: decontamination, disinfection and sterilization", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-biosafety-4-objective-1", + "lab-biosafety-4-objective-2", + "lab-biosafety-4-objective-3", + "lab-biosafety-4-objective-4", + "lab-biosafety-outcome-3" + ], + "question_ids": [ + "lab-biosafety-4:bleach-volume", + "lab-biosafety-4:bleach-ppm", + "lab-biosafety-4:sal-time", + "lab-biosafety-4:survivors", + "lab-biosafety-4:lag", + "lab-biosafety-4:definitions", + "lab-biosafety-4:resistance", + "lab-biosafety-4:factors", + "lab-biosafety-4:tape", + "lab-biosafety-4:ethanol" + ], + "points": 12 + }, + { + "id": "lab-biosafety-5-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: human cells and tissues, bloodborne pathogens and exposure response", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-biosafety-5-objective-1", + "lab-biosafety-5-objective-2", + "lab-biosafety-5-objective-3", + "lab-biosafety-5-objective-4", + "lab-biosafety-outcome-4" + ], + "question_ids": [ + "lab-biosafety-5:hbv-days", + "lab-biosafety-5:medical-records", + "lab-biosafety-5:training-records", + "lab-biosafety-5:universal", + "lab-biosafety-5:culture-risk", + "lab-biosafety-5:minimum", + "lab-biosafety-5:ecp", + "lab-biosafety-5:immediate", + "lab-biosafety-5:hbv-cost", + "lab-biosafety-5:sharps-container" + ], + "points": 12 + }, + { + "id": "lab-biosafety-6-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: chemical hygiene, hazard communication and transport of infectious substances", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-biosafety-6-objective-1", + "lab-biosafety-6-objective-2", + "lab-biosafety-6-objective-3", + "lab-biosafety-6-objective-4", + "lab-biosafety-outcome-4" + ], + "question_ids": [ + "lab-biosafety-6:sds-ppe", + "lab-biosafety-6:sds-spill", + "lab-biosafety-6:un-category-b", + "lab-biosafety-6:pressure", + "lab-biosafety-6:signal-word", + "lab-biosafety-6:label-elements", + "lab-biosafety-6:chp-review", + "lab-biosafety-6:chp-elements", + "lab-biosafety-6:triple", + "lab-biosafety-6:who-ships" + ], + "points": 12 + }, + { + "id": "lab-biosafety-project-rehearsal-practice", + "type": "practice", + "mode": "practice", + "title": "Biosafety plan rehearsal: ranking, inflow, bleach and autoclave time (open, ungraded practice)", + "path": "question-banks/practice.json", + "objective_ids": [ + "lab-biosafety-1-objective-1", + "lab-biosafety-3-objective-3", + "lab-biosafety-4-objective-3", + "lab-biosafety-4-objective-4", + "lab-biosafety-outcome-1", + "lab-biosafety-outcome-2", + "lab-biosafety-outcome-3" + ], + "question_ids": [ + "lab-biosafety-project:rehearsal-ranking", + "lab-biosafety-project:rehearsal-inflow", + "lab-biosafety-project:rehearsal-bleach", + "lab-biosafety-project:rehearsal-autoclave" + ], + "points": 4 + }, + { + "id": "lab-biosafety-biosafety-plan-project", + "type": "project", + "mode": "self-assessment", + "title": "Biosafety plan milestones M1–M5 in the Case studio (optional self-check; ungraded)", + "path": "rubrics/case-studio.json", + "objective_ids": [], + "question_ids": [], + "points": 0 + } + ], + "grading_policy": { + "mode": "formative-only", + "categories": [], + "attempt_policy": "Unlimited formative retries; no summative grade is awarded.", + "solution_release": "Public practice solutions may be shown after an attempt. They are not production exam keys.", + "late_policy": "No deadlines or late penalties exist in this partial package.", + "appeals": "No instructor review workflow is implemented; report ambiguous practice items as content issues." + }, + "retrieval_cards": "question-banks/retrieval-cards.json", + "case_rubrics": "rubrics/case-studio.json", + "source_map": "source-map.json", + "syllabus": "syllabus.md", + "review": { + "status": "unreviewed", + "reviewed_version": null, + "reviewers": [] + }, + "accessibility": { + "language": "en", + "reading_format": "markdown", + "math_format": "unicode-text", + "diagram_policy": "The package contains no diagrams; any added diagram requires a text description and license tracking.", + "known_issues": [ + "Unicode equations require structured mathematical alternatives and assistive-technology review." + ], + "review_status": "unreviewed" + }, + "history": [ + { + "version": "0.1.0", + "date": "2026-10-31", + "summary": "New package, layer 1 of the wet-lab practice track: six original readings adapting BMBL 6th edition (Sections II–IV, Appendices A, B, C and H) and OSHA 29 CFR 1910.1030, 1910.1450 and 1910.1200, with 64 practice items, 26 retrieval cards, a self-check case and an optional iPSC biosafety plan project (guide, four rehearsal items, five Case studio milestones), linked to the Class II cabinet simulation. Compared task by task with the ABSA CBSP examination content outline (2026). Partial, unreviewed and formative-only." + } + ], + "limitations": [ + "A six-week module that prepares for, and does not replace, the biosafety, bloodborne-pathogen and chemical-hygiene training an employer must provide; the schedule is proposed, not measured, and unreviewed by a biosafety professional.", + "Practice items test knowledge and decisions, not technique; donning and doffing PPE, cabinet work and spill response need supervised practice.", + "Of the 23 numerical answers, 0 require units, 5 check significant figures and 0 check dimensions.", + "Regulatory requirements are paraphrased for teaching from the US federal texts read on 2026-10-30; the regulations, your institution's plans and your biosafety officer govern real work, and requirements outside the United States differ.", + "Risk scores, aerosol fractions, D-values and load lags are synthetic illustrations." + ], + "intentionally_omitted": [ + "Graded assessments, measured workload and review by a qualified person; the biosafety plan project is an optional, ungraded self-check.", + "BSL-3, BSL-4 and the animal biosafety levels; select agents; the NIH Guidelines for recombinant work; respiratory protection programs; import and export permits. See the scope crosswalk.", + "Program-management and facility-review tasks of the ABSA CBSP outline, which are for biosafety professionals rather than bench workers." + ], + "learner_resources": [] +} diff --git a/content/courses/lab-biosafety/modules/01-biological-risk-assessment.md b/content/courses/lab-biosafety/modules/01-biological-risk-assessment.md new file mode 100644 index 0000000..9259309 --- /dev/null +++ b/content/courses/lab-biosafety/modules/01-biological-risk-assessment.md @@ -0,0 +1,84 @@ +# Biological risk assessment + +Every biosafety decision, from the containment level of a room to the gloves on a bench, should come out of a risk assessment. Biosafety manuals describe practices, but only an assessment of a particular agent, in a particular procedure, done by particular people, says which practices are needed. This lesson follows the six-step process set out in Section II of *Biosafety in Microbiological and Biomedical Laboratories* (BMBL, 6th edition, CDC and NIH, 2020), a US federal work whose guidance is adapted here with attribution. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Distinguish a hazard from a risk, and rank tasks with a qualitative probability × consequence score. +2. Identify agent hazards and procedure hazards, including the routes by which laboratory-associated infections occur. +3. Apply the hierarchy of controls, distinguishing inherent risk from residual risk. +4. Carry out the six steps of the BMBL risk management process, including who reviews the assessment and when it is revisited. + +## Hazard and risk + +BMBL defines **hazards** as substances or situations capable of causing adverse effects to health or safety, and **risk** as what arises when people interact with hazards: a function of both the probability of an adverse event and its consequences. A culture of a pathogen in a closed, labelled tube in a freezer is a hazard with little risk; the same culture being vortexed on an open bench is a risk. + +Biological risk assessment is usually qualitative. A common working method scores probability and consequence each from 1 (lowest) to 5 (highest) and multiplies them to rank tasks. BMBL notes that the product of the probability and consequence estimates gives a relative value for prioritizing risks. The numbers rank tasks against each other; they are not measurements. + +| Task (synthetic example) | Probability | Consequence | Score | +| --- | --- | --- | --- | +| Pipetting a BSL-2 culture in a biosafety cabinet | 2 | 3 | 6 | +| Centrifuging the culture in unsealed buckets | 4 | 3 | 12 | +| Passaging a well-characterized non-human cell line | 1 | 1 | 1 | + +The centrifugation scores highest and is addressed first. Sealed rotors or safety cups loaded and unloaded in the cabinet lower its probability score. + +## Step 1: agent hazards and inherent risk + +Start with the agent and estimate the **inherent risk**, the risk with no mitigation in place. BMBL lists the agent characteristics to consider: its capability to infect and cause disease in a susceptible host, the severity of disease, the availability of preventive measures and effective treatments, the routes of transmission, the infectious dose, stability in the environment, host range, whether it is indigenous or exotic, and its genetic characteristics. For a genetically modified agent, ask the same questions of the wild type, then ask whether the modification could change pathogenicity or treatment susceptibility. + +Sources include the agent summary statements in BMBL Section VIII and the Public Health Agency of Canada's Pathogen Safety Data Sheets. The NIH Guidelines assign agents to four **Risk Groups**, based on the severity of the disease they cause and the availability of interventions. BMBL stresses that Risk Groups correlate with, but do not equate to, Biosafety Levels: the Risk Group is a starting estimate, and the procedure can raise or lower what is needed. + +## Step 2: procedure hazards + +BMBL names the principal procedure hazards: agent concentration, suspension volume, equipment and procedures that generate small-particle aerosols and droplets, and the use of sharps. Investigations of laboratory-associated infections have found five routes of transmission: + +1. parenteral inoculation with syringe needles or other contaminated sharps; +2. spills and splashes onto skin and mucous membranes; +3. ingestion through mouth pipetting; +4. animal bites and scratches; +5. inhalation of infectious aerosols. + +The first four are easy to detect, yet in the retrospective review BMBL cites they accounted for less than 20% of reported infections; many have no known source. Aerosols are the probable cause of many of the rest, because any procedure that puts energy into a suspension makes them: pipetting, blending, centrifuging, sonicating, vortexing and cell sorting among others. + +Concentration and volume multiply the hazard. In a synthetic illustration, if a procedure releases a fixed fraction of 10⁻⁶ of a 1 mL suspension as respirable aerosol, a culture at 10⁹ organisms per mL releases about 1000 organisms, while a sample at 10³ per mL releases about 0.001, that is, almost certainly none. The same procedure can be trivial or serious depending on what is in the tube. + +## Step 3: choose the Biosafety Level and additional precautions + +With agent and procedure hazards assessed, choose the Biosafety Level and any extra precautions. BMBL asks risk management to follow a **hierarchy of controls**: first eliminate or reduce the hazard (an attenuated strain, a smaller volume, inactivated material), then apply engineering controls (a biosafety cabinet, sealed rotors, safety-engineered sharps) and administrative controls (training, procedures, restricted access), and only then add personal protective equipment for what remains. Controls are layered so that one failure does not lead to exposure. + +What is left after the controls is the **residual risk**, which must be brought to a level the institution accepts. In the table above, sealed centrifuge safety cups opened only in a cabinet might lower the probability score from 4 to 2, giving a residual score of 6. BMBL also warns against over-control: safeguards far beyond the need add cost and burden with little gain. Where information is insufficient, it recommends considering additional safeguards until more data are available. + +## Steps 4–6: review, proficiency and revisiting + +4. **Review before implementing.** Review the assessment and the chosen safeguards with a biosafety professional, a subject matter expert and the Institutional Biosafety Committee or its equivalent. +5. **Evaluate proficiency and equipment.** As an ongoing process, check that staff are proficient in the safe practices and that safety equipment works: cabinets certified, centrifuge cups intact, training current. Close any training or competency gaps. +6. **Revisit and verify.** Review the strategies regularly and whenever procedures, equipment, agents or people change, and update the biosafety manual and SOPs to match. + +Throughout, BMBL emphasizes **risk communication**: the people doing the work should help make the assessment and understand its conclusions, which is part of a positive culture of safety. + +## Common mistakes + +- Equating an agent's Risk Group with the required Biosafety Level, without assessing the procedure. +- Treating the score as a measurement rather than a ranking. +- Starting with PPE instead of eliminating or engineering out the hazard. +- Writing the assessment once and never revisiting it after the protocol changes. +- Assuming that an infection without an obvious incident could not have come from the laboratory; most have no identified source. + +## Worked example + +**Problem.** A laboratory will grow a BSL-2 bacterium to high density (10¹⁰ CFU/mL, 50 mL), concentrate it by centrifugation and sonicate it to make lysates. Identify the main hazards and propose controls in hierarchy order. + +**Step 1: agent.** A moderate-hazard pathogen, treatable, with possible aerosol transmission (consult its agent summary statement). + +**Step 2: procedure.** High concentration and a large volume; centrifugation and sonication both generate aerosols. + +**Step 3: controls.** Reduce the hazard first: grow only the volume needed. Engineering: sealed rotors or safety cups loaded and opened in a Class II cabinet, and sonication inside the cabinet or in a sealed vessel. Administrative: a written SOP, training, and restricted access during the work. PPE: coat, gloves and eye protection, and respiratory protection if the assessment calls for it. + +**Steps 4–6.** Review with the biosafety officer and the Institutional Biosafety Committee before starting, confirm cabinet certification and centrifuge cup integrity, and revisit the assessment if the strain or scale changes. + +## Limits of this lesson + +The probability and consequence scores and the aerosol-release fraction are synthetic illustrations, not measured values. Risk assessment for a real agent needs its agent summary statement, institutional policy and review by qualified biosafety professionals; this lesson does not replace them. diff --git a/content/courses/lab-biosafety/modules/02-biosafety-levels-1-and-2.md b/content/courses/lab-biosafety/modules/02-biosafety-levels-1-and-2.md new file mode 100644 index 0000000..488b372 --- /dev/null +++ b/content/courses/lab-biosafety/modules/02-biosafety-levels-1-and-2.md @@ -0,0 +1,82 @@ +# Biosafety Levels 1 and 2 + +A Biosafety Level (BSL) is a combination of practices, safety equipment and facility features matched to the risks of the work. Most teaching, research and stem-cell laboratories work at BSL-1 or BSL-2, so these two levels are worth knowing item by item. This lesson follows Sections III and IV of BMBL, 6th edition (CDC and NIH, 2020), a US federal work adapted here with attribution, and explains what each element is for. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Distinguish primary barriers (safety equipment and personal protective equipment) from secondary barriers (facility design), and explain how practice, equipment and facility combine. +2. State the three main ways BSL-2 differs from BSL-1, and list the information posted at a BSL-2 entrance. +3. Apply the BSL-2 standard microbiological practices for gloves, hand hygiene, food, pipetting, sharps, aerosols, spills and waste. +4. Identify the BSL-2 special practices, safety equipment and facility features, and decide when a procedure must move into a biosafety cabinet. + +## Three elements of containment + +BMBL describes containment as three overlapping elements. + +- **Laboratory practice and technique**: the most important element. Trained people following procedures prevent most exposures. +- **Safety equipment (primary barriers)**: physical containment placed directly at the level of the hazard, such as biological safety cabinets, enclosed containers and sealed rotors. Personal protective equipment (PPE) is also a primary barrier and becomes the main one where a cabinet cannot be used. +- **Facility design and construction (secondary barriers)**: features that protect people outside the laboratory and the environment, such as doors, a handwashing sink, cleanable surfaces and, at higher levels, directional airflow. + +## BSL-1 + +BSL-1 is the basic level, for well-characterized agents not known to consistently cause disease in immunocompetent adult humans, which present minimal potential hazard to personnel and the environment. Work can be done on open benches with standard microbiological practices. Its facility needs are simple: doors for access control, a handwashing sink, an eyewash station, cleanable surfaces with no carpet, benchtops impervious to water and resistant to chemicals, and screens on windows that open. + +## What BSL-2 adds + +BMBL states that BSL-2 builds on BSL-1 and suits agents associated with human disease that pose moderate hazards. It differs from BSL-1 mainly in three ways: + +1. personnel receive specific training in handling pathogenic agents and are supervised by scientists competent with those agents; +2. access to the laboratory is restricted while work is being done; +3. all procedures that can create infectious aerosols or splashes are done in biological safety cabinets or other physical containment equipment. + +A sign with the universal biohazard symbol is posted at the entrance when infectious materials are present. It shows the laboratory's Biosafety Level, the name and telephone number of the supervisor or another responsible person, the PPE required, general occupational health requirements such as immunizations or respiratory protection, and the procedures for entering and leaving. + +## Standard microbiological practices at BSL-2 + +The standard practices are the everyday habits. The main ones in BMBL's list are: + +- **Training and supervision.** The supervisor ensures training on the hazards and precautions, keeps records, and provides annual updates and extra training when equipment, procedures or policies change. +- **Safety manual.** A facility-specific manual describes the containment and decontamination procedures in use, and the emergency procedures for exposures, medical emergencies and facility malfunctions. +- **Gloves.** Chosen by risk assessment, not worn outside the laboratory, changed when contaminated or damaged, and never washed or reused. +- **PPE removal and hand hygiene.** PPE is removed in a way that limits self-contamination, and hands are washed after handling hazardous materials and before leaving. +- **No eating, drinking, smoking, handling contact lenses or applying cosmetics**, and no food stored in the laboratory. +- **No mouth pipetting**: mechanical pipetting devices only. +- **Sharps.** Plasticware replaces glass where possible; needles are used only where there is no alternative, safety-engineered where possible, and never bent, sheared, recapped or removed by hand. Used needles go straight into a puncture-resistant container placed close to the point of use. Broken glass is picked up with a brush and dustpan, tongs or forceps, never by hand. +- **Minimize splashes and aerosols** in every procedure. +- **Decontaminate work surfaces** after work and after any spill or splash, and follow the posted spill procedure. +- **Decontaminate cultures and stocks** before disposal. Material leaving the laboratory for decontamination travels in a durable, leak-proof, closed container with a disinfected outer surface and a biohazard label. +- **Pest control**, and no animals or plants unrelated to the work. + +## Special practices, equipment and facilities at BSL-2 + +**Special practices.** Access is controlled during work, and the supervisor confirms that staff are proficient. Medical surveillance and available immunizations are offered as appropriate. Properly maintained cabinets or other containment devices are used whenever procedures can create infectious aerosols or splashes, or when high concentrations or large volumes are handled. BMBL lists pipetting, centrifuging, grinding, blending, shaking, mixing, sonicating and opening containers of infectious material. High-concentration material may be centrifuged on the open bench only in sealed rotors or safety cups that are loaded and unloaded in the cabinet. Equipment is decontaminated routinely, after spills and before repair or removal. A validated method for decontaminating all laboratory waste is available. Incidents that may cause exposure are evaluated immediately, reported to the supervisor and recorded. + +**Safety equipment.** Laboratory coats or gowns are worn for work and removed before going to offices, libraries or cafeterias, and they are not taken home. Eye and face protection is worn wherever splashes or sprays are possible. The risk assessment decides whether respiratory protection is needed, and anyone who needs it is enrolled in a respiratory protection program. + +**Facilities.** BSL-2 adds self-closing, lockable doors, a handwashing sink near the exit, and vacuum lines protected by liquid disinfectant traps and in-line HEPA filters. Windows that open are not recommended. + +## Common mistakes + +- Wearing gloves to answer the door or the phone, or wearing a lab coat to the cafeteria. +- Recapping a needle "just this once" by hand. +- Vortexing or opening tubes of a BSL-2 culture on the open bench. +- Centrifuging in sealed cups but opening them on the bench. +- Treating the biohazard sign as decoration rather than information for anyone entering. + +## Worked example + +**Problem.** A new researcher in a BSL-2 laboratory plans to (a) streak plates of a BSL-2 bacterium, (b) vortex a dense suspension of it, (c) centrifuge 50 mL of the suspension, and (d) record results at a desk inside the laboratory. Which steps need a cabinet or other containment, and what else applies? + +**Step (a).** Streaking makes little aerosol, but under the risk assessment it is usually done in the cabinet as well. Coat, gloves and eye protection are worn either way. + +**Step (b).** Vortexing a dense suspension creates aerosols, so it is done in the cabinet with the tube capped. + +**Step (c).** Large volumes at high concentration require sealed rotors or safety cups, loaded and opened in the cabinet. + +**Step (d).** No food or drink at the desk, gloves removed before writing on shared surfaces, and hands washed before leaving the laboratory. + +## Limits of this lesson + +This lesson summarizes selected BSL-1 and BSL-2 criteria; the full lists in BMBL Section IV, your institution's biosafety manual and your biosafety officer govern real work. BSL-3, BSL-4 and the animal biosafety levels are not covered. diff --git a/content/courses/lab-biosafety/modules/03-biological-safety-cabinets.md b/content/courses/lab-biosafety/modules/03-biological-safety-cabinets.md new file mode 100644 index 0000000..393fe02 --- /dev/null +++ b/content/courses/lab-biosafety/modules/03-biological-safety-cabinets.md @@ -0,0 +1,87 @@ +# Biological safety cabinets: classes, selection and certification + +The biological safety cabinet (BSC) is the primary barrier for most work with infectious material, and also one of the most misunderstood pieces of laboratory equipment. A laminar-flow clean bench looks similar and protects only the product; a chemical fume hood protects the worker but not the culture. This lesson explains how each class of BSC moves air, which protections each gives, how to choose one, and what certification checks. It follows Appendix A of BMBL, 6th edition (CDC and NIH, 2020), a US federal work adapted here with attribution. How to work inside a cabinet is practised in the [biological safety cabinet simulation](#/bench/class-ii-bsc-session-with-spill). + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Distinguish personnel, product and environmental protection, and state which ones Class I, Class II and Class III cabinets, clean benches and fume hoods provide. +2. Describe the airflow of Class II Types A1, A2, B1 and B2, including inflow velocity and the share of air recirculated or exhausted. +3. Calculate the inflow volume of a cabinet from its face velocity and opening, and select a cabinet type for biological work with or without volatile chemicals. +4. State when a cabinet must be certified, what UV lamps can and cannot do, and how cabinet placement and room activity affect containment. + +## Three kinds of protection + +- **Personnel protection**: room air is drawn in at the front opening, so aerosols made inside do not reach the worker. +- **Product protection**: HEPA-filtered air flows down over the work surface, so room contaminants do not reach the culture. +- **Environmental protection**: air leaving the cabinet passes through a HEPA filter. + +A **horizontal laminar-flow clean bench** blows HEPA-filtered air across the work surface toward the worker. It is not a BSC and protects only the product. BMBL says clean benches should never be used for cell culture materials, drug formulations, potentially infectious materials or other hazardous materials, because the worker is exposed to whatever is on the bench. They suit dust-free assembly of sterile equipment. A **chemical fume hood** draws room air in and exhausts it unfiltered: it gives personnel protection from vapours but no product protection and no HEPA filtration. + +## The classes + +**Class I.** Unfiltered room air is drawn in through the front opening at a minimum of 75 linear feet per minute (lfm) and leaves through a HEPA filter. It gives personnel and environmental protection but no product protection, so it is used mostly for enclosing equipment such as centrifuges or for aerosol-generating procedures where sterility does not matter. + +**Class II.** Room air enters the front grille, HEPA-filtered air flows down over the work surface, and the downflow splits about halfway between the front and rear grilles, two to six inches above the surface. Class II cabinets give all three protections. The types differ in how much air they recirculate: + +| Type | Minimum inflow | Air handling | Volatile toxic chemicals | +| --- | --- | --- | --- | +| A1 | 75 lfm | about 70% recirculated through the supply HEPA filter, 30% exhausted through the exhaust HEPA filter | not suitable | +| A2 | 100 lfm | as A1, with every positive-pressure contaminated plenum surrounded by negative pressure | small quantities only if exhausted outside through a canopy connection with an alarm | +| B1 | 100 lfm | about 70% of the downflow exhausted through the rear grille to the building exhaust, 30% through the front grille | small quantities, worked toward the rear | +| B2 | 100 lfm | total exhaust; nothing recirculated (up to about 1,200 cubic feet per minute of room air) | small quantities | + +Type A1 and A2 cabinets can be connected to the building exhaust only through a **canopy** with a small air gap, never hard-ducted, because fluctuations in the building exhaust would upset the cabinet's balance. Type B cabinets are hard-ducted and depend on the building exhaust. If that exhaust fails, a B2 cabinet becomes pressurized and blows air from the work area back into the room, so B cabinets need exhaust alarms and interlocks. + +**Class III.** A gas-tight enclosure with a non-opening window and heavy rubber gloves sealed to ports. Materials pass in and out through a dunk tank or a double-door pass-through that can be decontaminated. Supply air is HEPA-filtered, exhaust air passes through two HEPA filters (or a HEPA filter and an incinerator) to a dedicated exhaust, and the cabinet is held at a negative pressure of at least 0.5 inches of water. It gives the highest protection and is used for the most hazardous agents. + +## Choosing a cabinet + +BMBL's selection table matches cabinets to Biosafety Levels: Class I for personnel and environmental protection at BSL-1 to BSL-3; Class II for all three protections at BSL-1 to BSL-3; Class III, or Class II in a suit laboratory, at BSL-4. Within Class II the choice usually turns on chemicals. For cell culture and microbiology with no volatile toxic chemicals, a Type A2 is the usual choice. Work with small amounts of volatile toxic chemicals or radionuclides needs a cabinet that exhausts outside: an A2 with a canopy connection and alarm, or a Type B. A B2 costs the most to run, so BMBL says its use should be justified by the risk assessment. + +## Inflow volume + +The inflow that protects the worker is a velocity through an opening, so the volume of air drawn in is + + Q = v × A, + +with v the face velocity and A the area of the front opening. A cabinet with a 4.0 ft wide opening and an 8.0 in (0.667 ft) sash height has A = 2.67 ft². At the Type A2 minimum of 100 lfm, Q = 100 × 2.67 = 267 cubic feet per minute. Raising the sash to 10 in at the same fan setting spreads the same airflow over 3.33 ft², so the face velocity falls to 267/3.33 = 80 lfm, below the A2 minimum. That is why the sash must stay at its certified, marked height, and why a cabinet alarms when it is raised. + +## Placement, room activity and certification + +Containment depends on the air curtain at the front opening, which room activity can disrupt. BMBL warns about rapid arm movements, walking traffic, room fans and doors opening near the cabinet face. Cabinets need clearance around and above them for maintenance and exhaust-filter testing. + +Every cabinet must be field-certified, to NSF/ANSI 49 Annex F, by experienced, qualified personnel, preferably accredited certifiers: + +- when it is installed, before it is put into service; +- at least annually thereafter; +- whenever a HEPA or ULPA filter is replaced or internal parts are repaired; +- whenever the cabinet is moved, because relocation can break filter seals. + +**UV lamps.** BMBL says UV lamps should not be used as the sole disinfection method in a BSC. If they are installed, they need regular cleaning and checking with a UV meter, and replacing when output falls below 40 µW/cm². Unshielded lamps must be off when the room is occupied, and the sash is closed while the lamp is on. + +## Common mistakes + +- Using a laminar-flow clean bench for infectious work because it "has a HEPA filter". +- Raising the sash for more room to work, which lowers the face velocity. +- Hard-ducting a Type A cabinet to the building exhaust. +- Using volatile solvents in a recirculating A1 or A2 cabinet. +- Trusting a UV lamp in place of wiping surfaces with disinfectant. +- Continuing to use a cabinet past its certification date, or after moving it, without recertification. + +## Worked example + +**Problem.** A stem-cell laboratory needs a cabinet for human iPSC culture at BSL-2. A collaborator also wants to fix cells with small volumes of paraformaldehyde and handle a volatile solvent in the same cabinet. The cabinet's opening is 5.0 ft wide with a 10.0 in sash. What inflow volume must it draw to meet the Type A2 minimum, and what type should the laboratory choose? + +**Step 1: opening.** A = 5.0 ft × (10.0/12) ft = 4.17 ft². + +**Step 2: inflow.** Q = 100 lfm × 4.17 ft² = 417 cubic feet per minute. + +**Step 3: type.** Culture alone suits an A2 recirculating to the room. Volatile chemicals need exhaust to the outside: an A2 with a canopy connection and exhaust alarm (small quantities), or a Type B. Often the better answer is to move the solvent work to a chemical fume hood and keep the cabinet for culture. + +**Step 4: before use.** Check the certification label, and recertify after installation. + +## Limits of this lesson + +Airflow descriptions are simplified from BMBL Appendix A, which in turn refers to NSF/ANSI 49-2018 (not read for this course). Real selection depends on the risk assessment, the manufacturer's specifications and your biosafety officer. Type C1 cabinets and the details of field-certification tests are not covered. diff --git a/content/courses/lab-biosafety/modules/04-decontamination-disinfection-and-sterilization.md b/content/courses/lab-biosafety/modules/04-decontamination-disinfection-and-sterilization.md new file mode 100644 index 0000000..ceec995 --- /dev/null +++ b/content/courses/lab-biosafety/modules/04-decontamination-disinfection-and-sterilization.md @@ -0,0 +1,98 @@ +# Decontamination, disinfection and sterilization + +Everything that touches infectious material, from benchtops and pipettes to cultures and waste, has to be made safe before it leaves the laboratory or is reused. The words for doing this are often used loosely, but they mean different things and promise different results. This lesson follows Appendix B of BMBL, 6th edition (CDC and NIH, 2020), a US federal work adapted here with attribution. It covers the definitions, what makes disinfection succeed or fail, how to choose and dilute a chemical disinfectant, and how steam sterilization is verified. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Define cleaning, disinfection, sterilization and decontamination, and state what each does and does not guarantee. +2. Rank classes of microorganisms by their resistance to chemical disinfectants, and list the factors that determine whether disinfection works. +3. Choose a disinfectant by activity level and prepare a sodium hypochlorite solution of a target concentration from household bleach. +4. Use D-values to relate exposure time to log reduction and sterility assurance, and describe how autoclave cycles are monitored and validated. + +## Four words, four promises + +- **Cleaning** physically removes gross contamination (blood, tissue, medium) so that later processing can work. It may have no antimicrobial effect, but it is often a prerequisite: organic matter protects microorganisms and uses up disinfectant. +- **Disinfection** eliminates nearly all recognized pathogens, but not necessarily all microbial forms. It may leave bacterial spores, and it has no sterility margin. +- **Sterilization** makes an item free of all living microorganisms, including spores and viruses. Because a single survivor can never be ruled out, sterilization is defined operationally: a process after which the probability of any microorganism surviving on an item is less than one in a million, a **sterility assurance level (SAL)** of 10⁻⁶. +- **Decontamination** makes an item, area or material safe to handle, reasonably free of risk of disease transmission. In a laboratory, waste and used materials are often decontaminated by a sterilization process such as steam autoclaving. + +## What makes disinfection work + +BMBL lists five factors that determine the result: + +1. the nature and number of contaminating organisms, especially bacterial spores; +2. the amount of organic matter (soil, blood, medium); +3. the type and condition of the surfaces or items; +4. temperature; +5. contact (exposure) time. + +Organisms differ widely in resistance. BMBL ranks them, from most to least resistant to disinfectant chemicals: + +1. prions; +2. bacterial spores; +3. mycobacteria; +4. non-enveloped or small viruses; +5. fungi; +6. vegetative bacteria; +7. enveloped or medium-size viruses, which include HBV, HCV and HIV. + +There are exceptions. Bacteria in biofilms, for example, can approach the resistance of spores. A disinfectant that kills the agent you work with, at the concentration and contact time on its label, is the one to use. For routine work BMBL recommends a broad-spectrum product with a tuberculocidal (mycobactericidal) claim. In the United States, surface disinfectants are EPA-registered products and must be used according to their labels. + +## Choosing a liquid disinfectant + +BMBL tabulates the activity levels of generic chemical disinfectants, including: + +| Chemical | Concentration | Activity level | +| --- | --- | --- | +| Hypochlorites (bleach) | 500–6000 mg/L free available chlorine | intermediate to high-level disinfection | +| Alcohols (ethyl, isopropyl) | 70% | intermediate-level disinfection | +| Hydrogen peroxide | 3–6% | intermediate to high-level disinfection | +| Phenolics | 0.5–3% | low to intermediate | +| Iodophors | 30–50 mg/L free iodine | low to intermediate | +| Quaternary ammonium compounds | variable | low-level disinfection | + +Seventy percent ethanol evaporates quickly and does not reliably kill spores. It is useful for wiping cabinet surfaces and items entering a cabinet, but the surface has to stay wet for the contact time. Bleach is broad-spectrum, cheap and corrosive, so stainless steel needs a sterile-water wipe afterwards, as the [cabinet lesson](03-biological-safety-cabinets.md) notes. + +**Diluting bleach.** For dilute aqueous solutions, a percentage of sodium hypochlorite converts to parts per million by multiplying by 10,000: household bleach labelled 6.0% holds about 60,000 ppm. Using C₁V₁ = C₂V₂ from the [laboratory-measurement package](../../lab-measurement/modules/02-dilutions-and-dilution-series.md), 1.00 L of 5,000 ppm needs 1.00 × 5,000/60,000 = 0.0833 L, or 83.3 mL of bleach made up to 1 L. A tenfold dilution of 6.0% bleach gives 6,000 ppm, the top of BMBL's range. Diluted bleach loses available chlorine over time, so laboratories set how often it must be remade. + +## Steam sterilization and its verification + +Moist heat under pressure is the workhorse for media, glassware and waste. BMBL gives an example: a steam cycle for **pre-cleaned** items can be 20 minutes at 121 °C. Waste with a high bioburden and no pre-cleaning needs longer cycles, verified and validated for the typical load, because organisms can be shielded from steam and the centre of a dense load heats slowly. + +**D-values.** Heat kills microorganisms roughly exponentially. The D-value is the time at a stated temperature to reduce a population tenfold, so after time t, + + N = N₀ × 10^(−t/D). + +Biological indicators (BIs) carry a known population of resistant spores, commonly *Geobacillus stearothermophilus* for steam. In a synthetic example with N₀ = 10⁶ spores and D₁₂₁ = 2.0 min, reducing the BI to one expected survivor takes 6 × 2.0 = 12 min. Reaching a SAL of 10⁻⁶, twelve log reductions in all, takes 24 min at temperature. That exposure begins only when the slowest-heating point of the load reaches 121 °C. + +**Monitoring and validation.** BMBL describes two levels. + +- **Routine verification.** Record each cycle's time, pressure and temperature, and place BIs within loads. +- **Validation.** For a typical waste load, use thermocouples and BIs placed throughout the load to show that steam penetrates and the required exposure is reached everywhere. + +Chemical indicator tape shows that an item was exposed to heat, not that the load was sterilized. + +Practical points for waste in autoclave bags: close bags loosely so steam can enter, and carry them in a leak-proof tray or pan. Decide by testing whether water must be added. Never overload the chamber. + +## Common mistakes + +- Spraying 70% ethanol and wiping it off at once, before any contact time. +- Disinfecting a surface still covered in spilled medium, without first absorbing and cleaning it. +- Using an old bleach dilution, or diluting by eye. +- Assuming a cycle on the autoclave display sterilized the centre of a dense waste bag. +- Reading autoclave tape as proof of sterility. +- Using a low-level quaternary ammonium product against spores or non-enveloped viruses. + +## Worked example + +**Problem.** A laboratory decontaminates spills of a BSL-2 culture with 5,000 ppm hypochlorite and validates its waste cycle. (a) How do you make 500 mL of 5,000 ppm from bleach labelled 8.25%? (b) A thermocouple at the centre of a typical waste load reaches 121 °C 18 min after the chamber does. With BIs of 10⁶ spores and D₁₂₁ = 2.0 min, what minimum chamber time at 121 °C gives a SAL of 10⁻⁶ at the centre? + +**Step (a).** 8.25% is about 82,500 ppm. V₁ = 500 × 5,000/82,500 = 30.3 mL of bleach, made up to 500 mL. + +**Step (b).** The centre needs 12 × 2.0 = 24 min at temperature, and gets there 18 min after the chamber, so the chamber needs at least 18 + 24 = 42 min at 121 °C. The cycle is then confirmed with BIs placed at the centre of real loads. + +## Limits of this lesson + +The D-value, lag time and bleach concentrations in examples are synthetic or typical values for illustration. Real disinfectant choice follows the product label, the agent and your institution's SOPs. Space decontamination with formaldehyde, hydrogen peroxide vapour or chlorine dioxide, the inactivation of prions, and the validated inactivation procedures of BMBL Appendix K are only mentioned. diff --git a/content/courses/lab-biosafety/modules/05-human-materials-bloodborne-pathogens-and-exposures.md b/content/courses/lab-biosafety/modules/05-human-materials-bloodborne-pathogens-and-exposures.md new file mode 100644 index 0000000..57ab106 --- /dev/null +++ b/content/courses/lab-biosafety/modules/05-human-materials-bloodborne-pathogens-and-exposures.md @@ -0,0 +1,76 @@ +# Human cells and tissues, bloodborne pathogens and exposure response + +Stem-cell, regenerative-medicine and aging laboratories work with human blood, tissue and cell lines every day. These materials can carry pathogens nobody has tested for. This lesson combines two US federal sources, adapted with attribution. Appendix H of BMBL, 6th edition (CDC and NIH, 2020) sets out how to assess the risk of human and other primate cells. The OSHA Bloodborne Pathogens Standard, 29 CFR 1910.1030, states what employers must provide when workers are exposed to human blood and other potentially infectious materials. The lesson ends with what to do in the first minutes after an exposure. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Assess the relative risk of human, non-human primate and other mammalian cell material from its source, cell type and culture type, and state the minimum containment for human and primate cells. +2. Explain universal precautions and identify materials covered by the Bloodborne Pathogens Standard. +3. List what the standard requires of employers: the exposure control plan, engineering and work-practice controls, PPE, hepatitis B vaccination, training and records, with their timing. +4. Carry out the immediate response to a needlestick, splash or cut, and explain why reporting it at once matters. + +## Risk from human and primate material + +BMBL Appendix H says work with eukaryotic cell cultures is generally low-risk, but that risk rises with human and other primate cells and with primary cells. A risk assessment considers three things. + +- **Source.** The closer the species is to humans, the higher the risk, because pathogens are often species-specific. Old World primate material, such as macaque tissue, may carry Macacine herpesvirus 1 (herpes B), which is usually mild in macaques but can be fatal in humans, so it is always treated as potentially infected. +- **Cell or tissue type.** Hematopoietic and lymphoid cells can have tumorigenic potential and carry more risk. Epithelial cells and fibroblasts carry the least. +- **Culture type.** Primary cells, taken straight from tissue, have the highest chance of carrying undetected pathogens. Continuous lines can be better characterized, but lines immortalized with viral agents such as EBV or SV40 carry viral genetic material, and permissive lines can support viral contamination. BMBL notes a reported case of tumour development after an accidental needlestick. + +Recombinant material expressed by a line, pathogens added on purpose, and agents picked up by passage through animals all change the assessment. BMBL's minimum is clear: **human and other primate cells are treated as potentially infectious and handled with BSL-2 practices, engineering controls and facilities**, with culturing done in a biological safety cabinet. Waste culture material is decontaminated before disposal. Staff working with these cells should be in an occupational medicine program for bloodborne pathogens. + +## Universal precautions and what the standard covers + +The OSHA standard applies to occupational exposure to blood and **other potentially infectious materials (OPIM)**. BMBL notes that OPIM includes body fluids, tissues and primary cell lines. Its central rule, paragraph (d)(1), is **universal precautions**: all such materials are handled as if infectious, and where body fluid types are hard to tell apart, all body fluids are treated as potentially infectious. Nobody decides that a sample "looks safe". + +## What the employer must provide + +| Requirement | What 29 CFR 1910.1030 says (paraphrased) | +| --- | --- | +| Exposure Control Plan, (c)(1) | A written plan to eliminate or minimize exposure, accessible to employees, reviewed and updated at least annually and when tasks change, documenting consideration of safer devices | +| Engineering and work-practice controls, (d)(2) | Hand washing at once after removing gloves and after skin contact; contaminated needles and sharps not bent, recapped or removed unless there is no feasible alternative, and never sheared or broken; no eating, drinking, cosmetics or contact-lens handling where exposure is likely; no mouth pipetting | +| Sharps disposal, (d)(4)(iii)(A) | Contaminated sharps discarded at once in containers that are closable, puncture-resistant, leak-proof on the sides and bottom, and labelled or colour-coded | +| Personal protective equipment, (d)(3) | Provided, cleaned, repaired and replaced at no cost to the employee; garments penetrated by blood removed at once | +| Decontamination, (d)(4)(ii)(A) | Work surfaces decontaminated after procedures and immediately after overt contamination or spills | +| Hepatitis B vaccination, (f) | Made available after training and within 10 working days of initial assignment to all occupationally exposed employees | +| Post-exposure evaluation, (f)(3) | After a reported exposure incident, a confidential medical evaluation and follow-up made available immediately | +| Training, (g)(2) | At initial assignment and at least annually thereafter | +| Records, (h) | Medical records kept for the duration of employment plus 30 years; training records for 3 years | + +Research laboratories and production facilities working with HIV and HBV have additional requirements in paragraph (e), including a biosafety manual reviewed at least annually and BSCs certified when installed, when moved and at least annually. + +## The first minutes after an exposure + +Exposures are emergencies with a short window for action. The standard requires employees to wash skin with soap and water, or flush mucous membranes with water, immediately or as soon as feasible after contact. A typical laboratory sequence: + +1. **Stop and decontaminate.** Needlestick or cut: wash the wound with soap and water. Eye splash: flush at the eyewash for the time set by your institution's procedure. Mouth or nose: rinse with water. Follow your occupational health service's instructions for anything beyond this first wash. +2. **Make the work safe.** Cap or contain the material if you can do so quickly, then leave the work area. +3. **Report immediately** to your supervisor and the occupational health service, following your laboratory's exposure procedure. The standard makes a confidential medical evaluation available immediately after a reported exposure, including post-exposure prophylaxis when medically indicated as recommended by the U.S. Public Health Service. That care is time-sensitive, which is why reporting cannot wait until the end of the day. +4. **Record** what happened: the material, the agent if known, the procedure and the route. +5. **Follow up** with the medical evaluation, and help review the incident so the risk assessment can be updated. + +## Common mistakes + +- Treating a "screened" or commercially sourced human cell line as free of risk. +- Culturing primary human cells on an open bench. +- Recapping a needle by hand after drawing blood. +- Finishing the experiment before reporting a needlestick. +- Assuming the hepatitis B vaccine is optional for the employer to offer to exposed staff. + +## Worked example + +**Problem.** A researcher starts a project deriving induced pluripotent stem cells from donor blood. List the risk factors and the controls required. + +**Step 1: risk factors.** Human source; primary blood cells, which BMBL notes are hematopoietic with possible tumorigenic potential; donors whose infection status may be unknown; reprogramming vectors that may be viral. + +**Step 2: containment.** BSL-2 practices and facilities at a minimum, with all open handling in a Class II cabinet. Viral vectors add to the risk assessment and need Institutional Biosafety Committee review. + +**Step 3: OSHA program.** The researcher is covered by the exposure control plan, trained before starting and annually, offered hepatitis B vaccination within 10 working days of assignment, and given sharps containers at the point of use. + +**Step 4: exposure readiness.** The researcher knows where the eyewash is, the exposure procedure and whom to call, and reports any incident at once. + +## Limits of this lesson + +The OSHA requirements are paraphrased for teaching. The regulation's text, your employer's exposure control plan and your occupational health service govern real obligations. Post-exposure medical care is not described beyond the first steps, and nothing here is medical advice. Requirements outside the United States differ. diff --git a/content/courses/lab-biosafety/modules/06-chemical-hygiene-hazard-communication-and-transport.md b/content/courses/lab-biosafety/modules/06-chemical-hygiene-hazard-communication-and-transport.md new file mode 100644 index 0000000..ec4ea86 --- /dev/null +++ b/content/courses/lab-biosafety/modules/06-chemical-hygiene-hazard-communication-and-transport.md @@ -0,0 +1,95 @@ +# Chemical hygiene, hazard communication and transport of infectious substances + +Biological laboratories are also chemical laboratories: fixatives, solvents, acids, stains and disinfectants all carry their own hazards. Samples also travel between laboratories, and that is regulated too. This lesson covers three US federal sources, adapted with attribution: the OSHA Laboratory Standard (29 CFR 1910.1450), the OSHA Hazard Communication Standard (29 CFR 1910.1200) and Appendix C of BMBL, 6th edition, on transporting infectious substances. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. State what a Chemical Hygiene Plan must contain under the OSHA Laboratory Standard, who is responsible for it, and how often it is reviewed. +2. Read a hazard-communication label, naming its six elements and the meaning of the two signal words. +3. Find information quickly on a 16-section safety data sheet. +4. Distinguish Category A and Category B infectious substances, and describe triple packaging and the training required before anyone ships one. + +## The Chemical Hygiene Plan + +Where hazardous chemicals are used in a laboratory, the OSHA Laboratory Standard requires the employer to develop and carry out a written **Chemical Hygiene Plan** (CHP) that protects employees from the health hazards of the chemicals they use. Among its required elements are: + +- standard operating procedures for work with hazardous chemicals; +- criteria for using control measures such as fume hoods and PPE; +- a requirement that **fume hoods and other protective equipment function properly**, with specific measures to ensure it; +- provisions for employee information and training; +- the circumstances requiring prior approval before a procedure; +- provisions for medical consultation and examination; +- designation of the people responsible, including a **Chemical Hygiene Officer**; +- additional protection for particularly hazardous substances, such as select carcinogens, reproductive toxins and highly toxic chemicals. + +The employer reviews the CHP's effectiveness **at least annually** and updates it as needed. Employees must have information and training at their **initial assignment** to a work area with hazardous chemicals and before assignments involving new exposures, and they must be told where reference material such as safety data sheets is kept. Labels on incoming containers must not be removed or defaced. Exposures to OSHA-regulated substances must not exceed their permissible exposure limits, and exposure is monitored where there is reason to believe it routinely exceeds the action level. + +## Reading a label + +Under the Hazard Communication Standard, a shipped container of a hazardous chemical carries six label elements: + +1. product identifier; +2. **signal word**: "Danger" for the more severe hazards, "Warning" for the less severe; +3. hazard statement(s), describing the nature of the hazard; +4. pictogram(s); +5. precautionary statement(s), describing how to prevent or respond to exposure; +6. name, US address and telephone number of the manufacturer, importer or other responsible party. + +The signal word, hazard statements and pictograms must appear together on the label. When you decant a chemical into a secondary container, your laboratory's labelling procedure applies. An unlabelled bottle of clear liquid is an incident waiting to happen. + +## Using a safety data sheet + +Employers must have a safety data sheet (SDS) for each hazardous chemical used. The standard fixes 16 sections in a set order, which makes an SDS fast to search once you know the layout: + +| Section | Heading | Look here for | +| --- | --- | --- | +| 1 | Identification | product, supplier, emergency phone | +| 2 | Hazard(s) identification | classification, signal word, pictograms | +| 3 | Composition/information on ingredients | what is in a mixture | +| 4 | First-aid measures | what to do after exposure | +| 5 | Fire-fighting measures | suitable extinguishers | +| 6 | Accidental release measures | spill response | +| 7 | Handling and storage | incompatibilities, storage conditions | +| 8 | Exposure controls/personal protection | exposure limits, ventilation, gloves and eye protection | +| 9 | Physical and chemical properties | flash point, vapour pressure | +| 10 | Stability and reactivity | reactions to avoid | +| 11 | Toxicological information | health effects | +| 12–15 | Ecological, disposal, transport, regulatory | (OSHA does not enforce 12–15) | +| 16 | Other information | date of preparation or last revision | + +Before using a chemical for the first time, read sections 2, 4, 7 and 8 at least, and check section 16 to see how current the sheet is. + +## Shipping infectious substances + +BMBL Appendix C explains that infectious substances are regulated as hazardous materials in transport, by the US Department of Transportation and, for air, under ICAO and the IATA Dangerous Goods Regulations. Shippers and carriers must be **trained** before they prepare a shipment. + +- **Category A** (UN 2814, affecting humans; UN 2900, affecting animals only): substances transported in a form capable of causing permanent disability or life-threatening or fatal disease in otherwise healthy humans or animals if exposure occurs. +- **Category B** (UN 3373, "Biological substance, Category B"): infectious substances that do not meet the Category A criteria. + +Both use **triple packaging**: a leak-proof (Category A: watertight) primary receptacle, leak-proof secondary packaging, and rigid outer packaging. For liquids, the secondary packaging holds enough absorbent material for the entire contents of all primary receptacles, and for Category A a list of contents is placed on or near the secondary packaging. Category A packages must pass drop and water-spray tests, withstand a pressure differential of at least 95 kPa and temperatures from −40 °C to +55 °C, and carry a Division 6.2 label with shipping papers and emergency response information. For air transport, Category B primary or secondary packaging must also withstand a 95 kPa pressure differential. + +Classifying a material, choosing packaging and marking a package are tasks for trained, certified shippers working under their institution's procedures. This lesson explains what the categories and packaging layers are for. It does not make anyone qualified to ship. + +## Common mistakes + +- Treating the CHP as a binder on a shelf rather than the procedures you follow. +- Using a fume hood without checking its flow indicator or certification sticker. +- Decanting a reagent into an unlabelled bottle. +- Reading only the pictogram and skipping the hazard and precautionary statements. +- Packing a frozen sample for courier pick-up without being trained and certified to ship. + +## Worked example + +**Problem.** You are about to make a 4% formaldehyde fixative from paraformaldehyde for the first time, and a collaborator asks you to send them some fixed tissue and some live virus-transduced cells. What do you check, and what do you hand off? + +**Step 1: the chemical.** Read the SDS: section 2 for the hazards (formaldehyde is classified as a carcinogen), section 8 for ventilation and gloves, section 7 for storage, section 4 for first aid. Work in a chemical fume hood whose flow indicator reads normal. Follow the CHP's provisions for particularly hazardous substances, which may require prior approval. + +**Step 2: the label.** Label the fixative bottle with its contents and hazards following your laboratory's procedure. + +**Step 3: the shipment.** Live transduced human cells may be infectious substances and need classification by a trained shipper; adequately fixed tissue may be classified differently. Hand both to your institution's trained shipping personnel instead of packing them yourself. + +## Limits of this lesson + +The regulatory requirements are paraphrased for teaching; the regulation text, your employer's CHP and hazard-communication program, and certified shipping personnel govern real work. GHS hazard classification criteria, the pictogram set, exemptions in the transport regulations and requirements outside the United States are not covered. diff --git a/content/courses/lab-biosafety/question-banks/practice.json b/content/courses/lab-biosafety/question-banks/practice.json new file mode 100644 index 0000000..ca7700d --- /dev/null +++ b/content/courses/lab-biosafety/question-banks/practice.json @@ -0,0 +1,2177 @@ +{ + "schema_version": "1.0", + "course_id": "lab-biosafety", + "license": "CC-BY-4.0", + "questions": [ + { + "id": "lab-biosafety-1:risk-score", + "type": "numeric", + "prompt": "On 1–5 scales, a task is scored probability 4 and consequence 3. What is its relative risk score (probability × consequence)? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-1-objective-1" + ], + "feedback": { + "hint": "Multiply the two scores.", + "solution": "4 × 3 = 12, which ranks it above a task scored 2 × 3 = 6.", + "lesson_ids": [ + "lab-biosafety-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 12, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-1:residual-score", + "type": "numeric", + "prompt": "Sealed centrifuge safety cups opened only in a cabinet lower that task's probability score from 4 to 2, leaving consequence at 3. What is the residual risk score? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-1-objective-3" + ], + "feedback": { + "hint": "Recompute the product with the new probability.", + "solution": "2 × 3 = 6. Controls change the probability of exposure, not the severity of the disease.", + "lesson_ids": [ + "lab-biosafety-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 6, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-1:aerosol-release", + "type": "numeric", + "prompt": "In a synthetic model, a procedure releases a fraction 10⁻⁶ of the suspension it handles as respirable aerosol. How many organisms are released from 2.0 mL of a culture at 5 × 10⁸ organisms per mL? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-1-objective-2" + ], + "feedback": { + "hint": "Organisms handled = concentration × volume; multiply by the released fraction.", + "solution": "5 × 10⁸ × 2.0 × 10⁻⁶ = 1000 organisms; the same procedure on a sample at 10³ per mL releases essentially none.", + "lesson_ids": [ + "lab-biosafety-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 1000, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-1:hazard-risk", + "type": "single_choice", + "prompt": "Which statement distinguishes a hazard from a risk, as BMBL uses the terms?", + "points": 1, + "objective_ids": [ + "lab-biosafety-1-objective-1" + ], + "feedback": { + "hint": "Think of a closed tube in a freezer versus the same culture on a vortexer.", + "solution": "Risk depends on interaction: probability of exposure and its consequences. The same hazard carries different risks in different procedures.", + "lesson_ids": [ + "lab-biosafety-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "A hazard can cause harm; risk arises when people interact with it and depends on the probability and consequence of an adverse event.", + "A hazard is a biological agent; a risk is a chemical.", + "Risk is fixed by the agent's Risk Group regardless of the procedure.", + "Hazards and risks are the same thing at BSL-2." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-1:risk-group-bsl", + "type": "single_choice", + "prompt": "How does an agent's Risk Group relate to the Biosafety Level for work with it?", + "points": 1, + "objective_ids": [ + "lab-biosafety-1-objective-2" + ], + "feedback": { + "hint": "BMBL treats the Risk Group as a starting estimate.", + "solution": "BMBL states that the four Risk Groups correlate with, but do not equate to, Biosafety Levels; large volumes, aerosols or sharps can raise the containment needed.", + "lesson_ids": [ + "lab-biosafety-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Risk Groups correlate with but do not equate to Biosafety Levels; the procedure and setting can change the level needed.", + "Risk Group 2 always means BSL-2, whatever the procedure.", + "The Biosafety Level is always one higher than the Risk Group.", + "Risk Groups apply only to animal work." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-1:lai-routes", + "type": "multiple_select", + "prompt": "Which are routes of laboratory-associated infection identified in investigations that BMBL summarizes? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-biosafety-1-objective-2" + ], + "feedback": { + "hint": "BMBL lists five routes; animal bites and scratches are the fifth.", + "solution": "Sharps, splashes, mouth pipetting, bites and scratches, and aerosols. Most infections have no identified incident, and aerosols are a probable source of many.", + "lesson_ids": [ + "lab-biosafety-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Parenteral inoculation with needles or other contaminated sharps.", + "Spills and splashes onto skin and mucous membranes.", + "Ingestion through mouth pipetting.", + "Inhalation of infectious aerosols.", + "Transmission through the building's water supply." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2, + 3 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-biosafety-1:hierarchy", + "type": "single_choice", + "prompt": "In what order does the hierarchy of controls apply?", + "points": 1, + "objective_ids": [ + "lab-biosafety-1-objective-3" + ], + "feedback": { + "hint": "PPE protects only the wearer and fails silently.", + "solution": "Removing or reducing the hazard is most effective; engineering controls such as BSCs act regardless of behaviour; administrative controls and PPE address what remains.", + "lesson_ids": [ + "lab-biosafety-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Eliminate or reduce the hazard, then engineering and administrative controls, then personal protective equipment.", + "PPE first, because it is quickest to put on.", + "Administrative controls only, since training covers everything.", + "Engineering controls only after an exposure has happened." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-1:inherent-residual", + "type": "single_choice", + "prompt": "What is the difference between inherent and residual risk?", + "points": 1, + "objective_ids": [ + "lab-biosafety-1-objective-3" + ], + "feedback": { + "hint": "Step 1 assesses one; step 3 reduces it to the other.", + "solution": "BMBL defines inherent risk as the risk in the absence of mitigating factors; controls bring it down to a residual risk the institution accepts.", + "lesson_ids": [ + "lab-biosafety-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Inherent risk is the risk before any mitigation; residual risk is what remains after controls are applied.", + "Inherent risk is from chemicals and residual risk from biological agents.", + "Residual risk is always zero after a risk assessment.", + "They are measured in different units." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-1:review-step", + "type": "single_choice", + "prompt": "Before new controls are implemented, who should review the risk assessment and chosen safeguards, according to BMBL's fourth step?", + "points": 1, + "objective_ids": [ + "lab-biosafety-1-objective-4" + ], + "feedback": { + "hint": "The fourth step brings in independent expertise.", + "solution": "Review by biosafety professionals, subject experts and the IBC catches hazards the planner missed.", + "lesson_ids": [ + "lab-biosafety-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "A biosafety professional, a subject matter expert and the Institutional Biosafety Committee or equivalent.", + "Only the person doing the work.", + "The equipment manufacturer.", + "Nobody, if the agent is Risk Group 2." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-1:revisit", + "type": "multiple_select", + "prompt": "When should a risk assessment be revisited? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-biosafety-1-objective-4" + ], + "feedback": { + "hint": "BMBL's sixth step makes the process cyclical.", + "solution": "Assessments are revisited regularly and whenever work, agents, equipment or people change; approval does not end the process.", + "lesson_ids": [ + "lab-biosafety-1" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Regularly, as part of an ongoing cycle.", + "When the procedure, scale or equipment changes.", + "When a new strain or a genetically modified version of the agent is used.", + "When staff change or training gaps are found.", + "Never, once the Institutional Biosafety Committee has approved it." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2, + 3 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-biosafety-2:bsl2-differences", + "type": "multiple_select", + "prompt": "Which are the three main ways BSL-2 differs from BSL-1, according to BMBL? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-biosafety-2-objective-2" + ], + "feedback": { + "hint": "Suits and separate buildings belong to the highest containment levels.", + "solution": "Training and supervision, restricted access and containment of aerosol- and splash-generating procedures.", + "lesson_ids": [ + "lab-biosafety-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Personnel have specific training in handling pathogenic agents and are supervised by competent scientists.", + "Access to the laboratory is restricted when work is being conducted.", + "Procedures that can create infectious aerosols or splashes are done in BSCs or other containment equipment.", + "All work requires a positive-pressure suit.", + "The laboratory must be in a separate building." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-biosafety-2:barriers", + "type": "single_choice", + "prompt": "Which is a secondary barrier?", + "points": 1, + "objective_ids": [ + "lab-biosafety-2-objective-1" + ], + "feedback": { + "hint": "Secondary barriers are features of the facility.", + "solution": "Facility design protects people outside the laboratory and the environment; cabinets, sealed rotors and PPE are primary barriers.", + "lesson_ids": [ + "lab-biosafety-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "A self-closing laboratory door and a handwashing sink near the exit.", + "A Class II biological safety cabinet.", + "A sealed centrifuge rotor.", + "Gloves and a laboratory coat." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-2:sign", + "type": "multiple_select", + "prompt": "Which information is posted at the entrance of a BSL-2 laboratory when infectious materials are present? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-biosafety-2-objective-2" + ], + "feedback": { + "hint": "The sign tells anyone entering what they need to know.", + "solution": "BMBL lists the Biosafety Level, the responsible person and phone number, PPE, occupational health requirements and entry and exit procedures, with the biohazard symbol.", + "lesson_ids": [ + "lab-biosafety-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "The laboratory's Biosafety Level.", + "The supervisor's or responsible person's name and telephone number.", + "PPE requirements and procedures for entering and exiting.", + "General occupational health requirements such as immunizations.", + "Each worker's home address." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2, + 3 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-biosafety-2:gloves", + "type": "single_choice", + "prompt": "Which glove practice follows BSL-2 standard practices?", + "points": 1, + "objective_ids": [ + "lab-biosafety-2-objective-3" + ], + "feedback": { + "hint": "Gloves are contaminated waste once used.", + "solution": "BMBL: gloves chosen by risk assessment, not worn outside the laboratory, changed when contaminated or compromised, never washed or reused.", + "lesson_ids": [ + "lab-biosafety-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Change gloves when contaminated or damaged, never wash or reuse disposable gloves, and do not wear them outside the laboratory.", + "Wash disposable gloves in ethanol and reuse them during the day.", + "Keep gloves on to answer the phone in the corridor so you can return quickly.", + "Choose gloves by colour rather than by risk assessment." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-2:needles", + "type": "single_choice", + "prompt": "A researcher needs to remove a needle from a syringe to avoid lysing cells. What does BMBL allow?", + "points": 1, + "objective_ids": [ + "lab-biosafety-2-objective-3" + ], + "feedback": { + "hint": "Needles are not bent, sheared, recapped or removed by hand.", + "solution": "If removal is necessary, a hands-free device or comparable procedure is used; used needles go straight into a puncture-resistant container near the point of use.", + "lesson_ids": [ + "lab-biosafety-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Only with a hands-free device or comparable safe procedure, such as a needle remover on a sharps container.", + "Bending the needle by hand first, then removing it.", + "Recapping by hand with two hands.", + "Never; the work must stop." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-2:broken-glass", + "type": "single_choice", + "prompt": "How is broken glassware picked up?", + "points": 1, + "objective_ids": [ + "lab-biosafety-2-objective-3" + ], + "feedback": { + "hint": "Gloves do not stop glass.", + "solution": "BMBL: broken glassware is not handled directly; mechanical means are used.", + "lesson_ids": [ + "lab-biosafety-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "With a brush and dustpan, tongs or forceps, never by hand.", + "With gloved hands, carefully.", + "By sweeping it into the regular trash.", + "By vacuuming it with the house vacuum." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-2:bsc-needed", + "type": "multiple_select", + "prompt": "Which procedures with a BSL-2 agent should be done in a BSC or other containment device? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-biosafety-2-objective-4" + ], + "feedback": { + "hint": "BMBL lists procedures that create aerosols or splashes, and high concentrations or volumes.", + "solution": "Pipetting, centrifuging, grinding, blending, shaking, mixing, sonicating and opening containers of infectious material, and work at high concentration or volume.", + "lesson_ids": [ + "lab-biosafety-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Vortexing or sonicating a suspension.", + "Opening containers of infectious material.", + "Handling high concentrations or large volumes.", + "Reading a sealed, decontaminated plate count.", + "Writing results in a notebook at the desk." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-biosafety-2:centrifuge", + "type": "single_choice", + "prompt": "How can a high-concentration BSL-2 culture be centrifuged in the open laboratory?", + "points": 1, + "objective_ids": [ + "lab-biosafety-2-objective-4" + ], + "feedback": { + "hint": "Containment must hold at the point where the tubes are opened.", + "solution": "Sealed rotors or safety cups contain any aerosol from a broken tube; opening them in the cabinet keeps that aerosol contained.", + "lesson_ids": [ + "lab-biosafety-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "In sealed rotors or centrifuge safety cups that are loaded and unloaded in the BSC.", + "In open buckets if the lid is closed.", + "Only on a bench far from the door.", + "It cannot be centrifuged at all." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-2:lab-coats", + "type": "single_choice", + "prompt": "Which statement about protective clothing at BSL-2 is correct?", + "points": 1, + "objective_ids": [ + "lab-biosafety-2-objective-4" + ], + "feedback": { + "hint": "Clothing that leaves the laboratory carries contamination with it.", + "solution": "BMBL: protective clothing is removed before non-laboratory areas and laundered by the institution or disposed of appropriately.", + "lesson_ids": [ + "lab-biosafety-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Laboratory coats are worn for work, removed before going to offices or cafeterias, and not taken home.", + "Coats can be worn anywhere in the building if buttoned.", + "Coats are washed at home with other laundry.", + "Coats are optional when gloves are worn." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-2:incident", + "type": "single_choice", + "prompt": "What must happen after an incident that may have exposed someone to infectious material?", + "points": 1, + "objective_ids": [ + "lab-biosafety-2-objective-4" + ], + "feedback": { + "hint": "Delay helps nobody.", + "solution": "BMBL's special practices require immediate evaluation, reporting and records for incidents that may result in exposure.", + "lesson_ids": [ + "lab-biosafety-2" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "It is evaluated immediately per institutional policy, reported to the supervisor and recorded.", + "It is reported only if symptoms develop.", + "It is recorded at the end of the month.", + "It is reported only for Risk Group 3 agents." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-3:inflow-volume", + "type": "numeric", + "prompt": "A cabinet has a 6.0 ft wide front opening and an 8.0 in sash height. What inflow volume, in cubic feet per minute, gives the Type A2 minimum face velocity of 100 lfm? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-3-objective-3" + ], + "feedback": { + "hint": "Q = v × A, with the opening area in square feet.", + "solution": "A = 6.0 × 8.0/12 = 4.00 ft²; Q = 100 × 4.00 = 400 cfm.", + "lesson_ids": [ + "lab-biosafety-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 400, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-3:raised-sash", + "type": "numeric", + "prompt": "The same 400 cfm inflow is spread over the opening when the sash is raised to 12.0 in. What is the face velocity, in lfm? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-biosafety-3-objective-3" + ], + "feedback": { + "hint": "v = Q/A with the new area.", + "solution": "A = 6.0 × 12.0/12 = 6.00 ft²; v = 400/6.00 = 66.7 lfm, below the A2 minimum, which is why the sash alarm sounds.", + "lesson_ids": [ + "lab-biosafety-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 66.7, + "unit": "", + "tolerance": 0.016754282498168897, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-biosafety-3:a1-exhaust", + "type": "numeric", + "prompt": "In a Class II Type A1 cabinet about 30% of the air passing the filters is exhausted. If 600 cfm circulates past the filters, about how many cfm are exhausted? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-3-objective-2" + ], + "feedback": { + "hint": "Exhaust share × total.", + "solution": "0.30 × 600 = 180 cfm; the other 70% recirculates through the supply HEPA filter.", + "lesson_ids": [ + "lab-biosafety-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 180, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-3:uv-threshold", + "type": "numeric", + "prompt": "BMBL says a UV lamp should be replaced when its fluence rate falls below what value, in µW/cm²? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-3-objective-4" + ], + "feedback": { + "hint": "Check Appendix A's paragraph on UV lamps.", + "solution": "40 µW/cm², measured with a UV meter; UV is never the sole disinfection method.", + "lesson_ids": [ + "lab-biosafety-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 40, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-3:clean-bench", + "type": "single_choice", + "prompt": "A laminar-flow clean bench blows HEPA-filtered air across the work surface toward the user. What protection does it give?", + "points": 1, + "objective_ids": [ + "lab-biosafety-3-objective-1" + ], + "feedback": { + "hint": "Where does the air go after it crosses the work?", + "solution": "Air flows toward the worker, so the worker is exposed; BMBL says clean benches are not BSCs and must never be used for these materials.", + "lesson_ids": [ + "lab-biosafety-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Product protection only; it must not be used for cell culture, infectious or hazardous materials.", + "Personnel, product and environmental protection, like a Class II BSC.", + "Personnel protection only.", + "Environmental protection only." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-3:class-i", + "type": "single_choice", + "prompt": "What protection does a Class I BSC give?", + "points": 1, + "objective_ids": [ + "lab-biosafety-3-objective-1" + ], + "feedback": { + "hint": "Room air is drawn in unfiltered over the work.", + "solution": "Unfiltered room air sweeps the work surface, so products are not protected; exhaust passes through a HEPA filter.", + "lesson_ids": [ + "lab-biosafety-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Personnel and environmental protection, but no product protection.", + "Product protection only.", + "All three protections.", + "No protection unless ducted." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-3:volatile", + "type": "single_choice", + "prompt": "A procedure in a BSL-2 cabinet needs a small amount of a volatile toxic solvent. Which cabinet arrangement is suitable?", + "points": 1, + "objective_ids": [ + "lab-biosafety-3-objective-3" + ], + "feedback": { + "hint": "Recirculated air would return the vapour to the work area and room.", + "solution": "Volatile chemicals need exhaust to the outside; often the better choice is to do the chemical step in a fume hood.", + "lesson_ids": [ + "lab-biosafety-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "A Type B cabinet hard-ducted to the building exhaust, or an A2 exhausting outside through a canopy connection with an alarm.", + "A Type A1 cabinet recirculating to the room.", + "A laminar-flow clean bench.", + "Any Class II cabinet, as long as the UV lamp is on." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-3:hard-duct", + "type": "single_choice", + "prompt": "Why must a Class II Type A1 or A2 cabinet never be hard-ducted to the building exhaust?", + "points": 1, + "objective_ids": [ + "lab-biosafety-3-objective-2" + ], + "feedback": { + "hint": "Type A cabinets rely on their own fan to balance inflow and downflow.", + "solution": "BMBL: fluctuations common to building exhaust systems disturb the balance of a Type A cabinet; canopy connections isolate it.", + "lesson_ids": [ + "lab-biosafety-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Fluctuations in the building exhaust would upset the cabinet's internal air balance; a canopy connection with an air gap is used instead.", + "Hard ducts are illegal in all laboratories.", + "It would make the cabinet too quiet.", + "A1 and A2 cabinets have no exhaust filter." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-3:certification", + "type": "multiple_select", + "prompt": "When must a Class II BSC be field-certified? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-biosafety-3-objective-4" + ], + "feedback": { + "hint": "Relocation can break filter seals.", + "solution": "BMBL: at installation, at least annually, and after filter replacement, internal repairs or relocation, by qualified, preferably accredited, certifiers.", + "lesson_ids": [ + "lab-biosafety-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "At installation, before use.", + "At least annually.", + "After a HEPA filter is replaced or internal parts are repaired.", + "After the cabinet is moved.", + "Only when the user notices a smell." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2, + 3 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-biosafety-3:b2-failure", + "type": "single_choice", + "prompt": "What happens if the building exhaust fails while a Class II Type B2 cabinet is running, and no interlock stops the supply blower?", + "points": 1, + "objective_ids": [ + "lab-biosafety-3-objective-2" + ], + "feedback": { + "hint": "A B2 cabinet exhausts everything through the building system.", + "solution": "With no exhaust, the supply blower pushes air out through the front opening, which is why B cabinets need exhaust monitoring and interlocks.", + "lesson_ids": [ + "lab-biosafety-3" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "The cabinet becomes pressurized and air flows from the work area back into the laboratory.", + "Nothing; the cabinet recirculates internally.", + "The UV lamp switches on automatically.", + "The inflow velocity doubles." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-4:bleach-volume", + "type": "numeric", + "prompt": "How many millilitres of household bleach labelled 6.0% sodium hypochlorite (about 60,000 ppm) are needed for 2.00 L of 1,000 ppm solution? Give mL. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-biosafety-4-objective-3" + ], + "feedback": { + "hint": "C₁V₁ = C₂V₂.", + "solution": "V₁ = 2000 mL × 1000/60,000 = 33.3 mL, made up to 2.00 L.", + "lesson_ids": [ + "lab-biosafety-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 33.3, + "unit": "", + "tolerance": 0.008364581816926899, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-biosafety-4:bleach-ppm", + "type": "numeric", + "prompt": "Bleach labelled 7.5% is diluted 1:50 (one part in fifty parts total). What is the hypochlorite concentration in ppm? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-4-objective-3" + ], + "feedback": { + "hint": "7.5% is about 75,000 ppm; divide by the dilution factor.", + "solution": "75,000/50 = 1,500 ppm, inside BMBL's 500–6,000 ppm range.", + "lesson_ids": [ + "lab-biosafety-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 1500, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-4:sal-time", + "type": "numeric", + "prompt": "A biological indicator carries 10⁶ spores with D₁₂₁ = 1.8 min. How many minutes at 121 °C are needed to reach a sterility assurance level of 10⁻⁶ (twelve log reductions)? Give minutes. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-biosafety-4-objective-4" + ], + "feedback": { + "hint": "Each D-value is one tenfold reduction.", + "solution": "12 × 1.8 = 21.6 min at temperature.", + "lesson_ids": [ + "lab-biosafety-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 21.6, + "unit": "", + "tolerance": 0.005425674692060692, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-biosafety-4:survivors", + "type": "numeric", + "prompt": "With 10⁶ spores and D₁₂₁ = 1.8 min, how many spores are expected to survive 9.0 min at 121 °C? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-4-objective-4" + ], + "feedback": { + "hint": "N = N₀ × 10^(−t/D).", + "solution": "9.0/1.8 = 5 log reductions, so 10⁶ × 10⁻⁵ = 10 survivors: far from sterile.", + "lesson_ids": [ + "lab-biosafety-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 10, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-4:lag", + "type": "numeric", + "prompt": "The centre of a waste load reaches 121 °C 22 min after the chamber does. With D₁₂₁ = 1.5 min and a twelve-log requirement at the centre, what minimum chamber time at 121 °C is needed? Give minutes. Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-4-objective-4" + ], + "feedback": { + "hint": "Add the heating lag to the exposure needed at the centre.", + "solution": "22 + 12 × 1.5 = 22 + 18 = 40 min.", + "lesson_ids": [ + "lab-biosafety-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 40.0, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-4:definitions", + "type": "single_choice", + "prompt": "An item is free of all living microorganisms, with a probability of a survivor below one in a million. Which term applies?", + "points": 1, + "objective_ids": [ + "lab-biosafety-4-objective-1" + ], + "feedback": { + "hint": "Only one of the four is categorical.", + "solution": "Sterilization is defined operationally by a sterility assurance level of 10⁻⁶; disinfection may leave spores, and cleaning may not kill anything.", + "lesson_ids": [ + "lab-biosafety-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Sterilized.", + "Disinfected.", + "Cleaned.", + "Decontaminated but not sterile." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-4:resistance", + "type": "single_choice", + "prompt": "Which list is in BMBL's order from most to least resistant to chemical disinfectants?", + "points": 1, + "objective_ids": [ + "lab-biosafety-4-objective-2" + ], + "feedback": { + "hint": "Enveloped viruses such as HIV and HBV are among the easiest to kill.", + "solution": "Prions and spores are hardest; enveloped viruses are the least resistant.", + "lesson_ids": [ + "lab-biosafety-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Prions, bacterial spores, mycobacteria, non-enveloped viruses, fungi, vegetative bacteria, enveloped viruses.", + "Enveloped viruses, vegetative bacteria, fungi, spores, prions.", + "Mycobacteria, prions, spores, fungi, viruses.", + "All microorganisms are equally resistant to 70% ethanol." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-4:factors", + "type": "multiple_select", + "prompt": "Which factors determine whether a disinfection procedure works, according to BMBL? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-biosafety-4-objective-2" + ], + "feedback": { + "hint": "BMBL lists five factors; the type and condition of the surface is the fifth.", + "solution": "Organisms and their number, organic load, surface type and condition, temperature and contact time.", + "lesson_ids": [ + "lab-biosafety-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "The nature and number of contaminating organisms.", + "The amount of organic matter present.", + "Temperature.", + "Contact time.", + "The colour of the disinfectant bottle." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2, + 3 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-biosafety-4:tape", + "type": "single_choice", + "prompt": "What does a stripe change on autoclave indicator tape show?", + "points": 1, + "objective_ids": [ + "lab-biosafety-4-objective-4" + ], + "feedback": { + "hint": "Compare a chemical indicator with a biological indicator.", + "solution": "Tape confirms exposure only; biological indicators placed in the load, with recorded time, temperature and pressure, verify the process.", + "lesson_ids": [ + "lab-biosafety-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "That the item was exposed to heat, not that the load was sterilized.", + "That every organism in the load is dead.", + "That the cycle reached a sterility assurance level of 10⁻⁶.", + "That the biological indicator passed." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-4:ethanol", + "type": "multiple_select", + "prompt": "Which statements about 70% ethanol as a disinfectant are correct? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-biosafety-4-objective-3" + ], + "feedback": { + "hint": "Check BMBL's activity table and the resistance ranking.", + "solution": "Alcohols are intermediate-level disinfectants without reliable sporicidal action; prions and spores need far harsher treatment.", + "lesson_ids": [ + "lab-biosafety-4" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "It is rated an intermediate-level disinfectant.", + "It does not reliably kill bacterial spores.", + "Surfaces must stay wet for the contact time, which is hard because it evaporates quickly.", + "It sterilizes surfaces within seconds.", + "It is the right choice for inactivating prions." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-biosafety-5:hbv-days", + "type": "numeric", + "prompt": "Under 29 CFR 1910.1030, within how many working days of initial assignment must the hepatitis B vaccination series be made available to occupationally exposed employees (after training)? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-5-objective-3" + ], + "feedback": { + "hint": "See paragraph (f)(2).", + "solution": "Within 10 working days of initial assignment, after the required training.", + "lesson_ids": [ + "lab-biosafety-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 10, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-5:medical-records", + "type": "numeric", + "prompt": "An employee covered by the standard works for 8 years. For how many years in total must the employer keep their medical records? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-5-objective-3" + ], + "feedback": { + "hint": "Records are kept for the duration of employment plus a fixed period.", + "solution": "Duration of employment plus 30 years: 8 + 30 = 38 years.", + "lesson_ids": [ + "lab-biosafety-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 38, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-5:training-records", + "type": "numeric", + "prompt": "For how many years must training records under the Bloodborne Pathogens Standard be kept from the date of training? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-5-objective-3" + ], + "feedback": { + "hint": "See paragraph (h)(2).", + "solution": "3 years from the date on which the training occurred.", + "lesson_ids": [ + "lab-biosafety-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 3, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-5:universal", + "type": "single_choice", + "prompt": "What does universal precautions mean under the Bloodborne Pathogens Standard?", + "points": 1, + "objective_ids": [ + "lab-biosafety-5-objective-2" + ], + "feedback": { + "hint": "No one decides that a sample looks safe.", + "solution": "Paragraph (d)(1): universal precautions are observed to prevent contact with blood or OPIM; where body fluids cannot be told apart, all are treated as potentially infectious.", + "lesson_ids": [ + "lab-biosafety-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "All human blood and other potentially infectious materials are handled as if infectious.", + "Only samples that test positive are handled carefully.", + "Everyone wears a respirator.", + "Precautions apply only in hospitals." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-5:culture-risk", + "type": "single_choice", + "prompt": "Which cell material carries the highest risk of undetected pathogens, according to BMBL Appendix H?", + "points": 1, + "objective_ids": [ + "lab-biosafety-5-objective-1" + ], + "feedback": { + "hint": "Consider how much is known about the material's history.", + "solution": "Primary cultures come straight from tissue and have the least characterization; established, tested lines are generally safer but never risk-free.", + "lesson_ids": [ + "lab-biosafety-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Primary cells taken directly from human tissue.", + "A well-characterized, tested continuous cell line.", + "Insect cell lines.", + "Bacterial plasmid preparations." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-5:minimum", + "type": "single_choice", + "prompt": "What is BMBL's minimum containment for work with human and other primate cells?", + "points": 1, + "objective_ids": [ + "lab-biosafety-5-objective-1" + ], + "feedback": { + "hint": "Appendix H gives a floor; the risk assessment can raise it.", + "solution": "Human and primate cells are treated as potentially infectious at BSL-2 at minimum; lines harbouring Risk Group 3 or 4 agents need more.", + "lesson_ids": [ + "lab-biosafety-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "BSL-2 practices, engineering controls and facilities, with culturing in a biological safety cabinet.", + "BSL-1 on an open bench.", + "BSL-3 for all human cells.", + "No containment if the cells are commercially purchased." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-5:ecp", + "type": "multiple_select", + "prompt": "Which statements about the Exposure Control Plan are correct? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-biosafety-5-objective-3" + ], + "feedback": { + "hint": "See paragraph (c)(1).", + "solution": "The plan must be accessible to employees, reviewed annually, and updated with changes; it records consideration of safer devices.", + "lesson_ids": [ + "lab-biosafety-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "It is written and accessible to employees.", + "It is reviewed and updated at least annually.", + "It documents consideration of safer medical devices.", + "It is updated when tasks or procedures affecting exposure change.", + "It is kept confidential from employees." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2, + 3 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-biosafety-5:immediate", + "type": "single_choice", + "prompt": "You get a needlestick while working with primary human cells. What do you do first?", + "points": 1, + "objective_ids": [ + "lab-biosafety-5-objective-4" + ], + "feedback": { + "hint": "The standard calls for washing immediately, and evaluation is made available immediately after a report.", + "solution": "Washing and immediate reporting allow a confidential medical evaluation, including prophylaxis when medically indicated, without delay.", + "lesson_ids": [ + "lab-biosafety-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Wash the site with soap and water, make the work safe, then report immediately to your supervisor and occupational health.", + "Finish the passage, then report at the end of the day.", + "Apply a bandage and say nothing unless you feel ill.", + "Wait to see whether the donor tests positive." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-5:hbv-cost", + "type": "single_choice", + "prompt": "Who pays for the hepatitis B vaccination and post-exposure evaluation under the standard?", + "points": 1, + "objective_ids": [ + "lab-biosafety-5-objective-3" + ], + "feedback": { + "hint": "Paragraph (f)(1).", + "solution": "The employer must make the vaccine and post-exposure evaluation and follow-up available at no cost to the employee.", + "lesson_ids": [ + "lab-biosafety-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "The employer makes them available at no cost to the employee.", + "The employee.", + "Health insurance only.", + "The equipment supplier." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-5:sharps-container", + "type": "multiple_select", + "prompt": "Which properties must a container for contaminated sharps have? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-biosafety-5-objective-3" + ], + "feedback": { + "hint": "Paragraph (d)(4)(iii)(A).", + "solution": "Closable, puncture resistant, leakproof on sides and bottom, and labelled or colour-coded; placed close to the point of use.", + "lesson_ids": [ + "lab-biosafety-5" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Closable.", + "Puncture resistant.", + "Leakproof on the sides and bottom.", + "Labelled or colour-coded.", + "Made of glass." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2, + 3 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-biosafety-6:sds-ppe", + "type": "numeric", + "prompt": "Which section number of a safety data sheet gives exposure controls and personal protection? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-6-objective-3" + ], + "feedback": { + "hint": "The 16 sections follow a fixed order.", + "solution": "Section 8, Exposure controls/personal protection.", + "lesson_ids": [ + "lab-biosafety-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 8, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-6:sds-spill", + "type": "numeric", + "prompt": "Which SDS section number describes accidental release (spill) measures? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-6-objective-3" + ], + "feedback": { + "hint": "Between fire-fighting and handling.", + "solution": "Section 6, Accidental release measures.", + "lesson_ids": [ + "lab-biosafety-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 6, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-6:un-category-b", + "type": "numeric", + "prompt": "What UN number is used for 'Biological substance, Category B'? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-6-objective-4" + ], + "feedback": { + "hint": "See BMBL Appendix C.", + "solution": "UN 3373; Category A uses UN 2814 (humans) or UN 2900 (animals only).", + "lesson_ids": [ + "lab-biosafety-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 3373, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-6:pressure", + "type": "numeric", + "prompt": "Category A packaging must withstand without leakage an internal pressure producing a differential of not less than how many kilopascals? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-6-objective-4" + ], + "feedback": { + "hint": "See BMBL Appendix C.", + "solution": "95 kPa (0.95 bar, 14 psi), with temperatures from −40 °C to +55 °C.", + "lesson_ids": [ + "lab-biosafety-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 95, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-6:signal-word", + "type": "single_choice", + "prompt": "A label shows the signal word 'Danger'. What does it mean compared with 'Warning'?", + "points": 1, + "objective_ids": [ + "lab-biosafety-6-objective-2" + ], + "feedback": { + "hint": "Signal words rank severity.", + "solution": "Under the Hazard Communication Standard, 'Danger' marks the more severe hazards and 'Warning' the less severe.", + "lesson_ids": [ + "lab-biosafety-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "The more severe hazard category; 'Warning' is used for less severe hazards.", + "The chemical is safe if gloves are worn.", + "It is a transport label only.", + "The two words mean the same." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-6:label-elements", + "type": "multiple_select", + "prompt": "Which elements must a shipped container label carry under the Hazard Communication Standard? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-biosafety-6-objective-2" + ], + "feedback": { + "hint": "Six elements in all.", + "solution": "Product identifier, signal word, hazard statements, pictograms, precautionary statements and the responsible party's contact details.", + "lesson_ids": [ + "lab-biosafety-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Product identifier and signal word.", + "Hazard statements and pictograms.", + "Precautionary statements.", + "Name, US address and phone number of the responsible party.", + "The purchase price." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2, + 3 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-biosafety-6:chp-review", + "type": "single_choice", + "prompt": "How often must the employer review the effectiveness of the Chemical Hygiene Plan?", + "points": 1, + "objective_ids": [ + "lab-biosafety-6-objective-1" + ], + "feedback": { + "hint": "Paragraph (e)(4).", + "solution": "At least annually, with updates as necessary.", + "lesson_ids": [ + "lab-biosafety-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "At least annually, updating it as necessary.", + "Only when OSHA inspects.", + "Every five years.", + "Never after it is written." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-6:chp-elements", + "type": "multiple_select", + "prompt": "Which are required elements of a Chemical Hygiene Plan? Select all that apply.", + "points": 2, + "objective_ids": [ + "lab-biosafety-6-objective-1" + ], + "feedback": { + "hint": "Paragraph (e)(3) lists eight elements.", + "solution": "SOPs, control criteria, functioning hoods and equipment, training, prior approval, medical consultation, responsible people including a Chemical Hygiene Officer, and extra protection for particularly hazardous substances.", + "lesson_ids": [ + "lab-biosafety-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Standard operating procedures for work with hazardous chemicals.", + "Measures ensuring fume hoods and other protective equipment function properly.", + "Designation of a Chemical Hygiene Officer.", + "Additional protection for particularly hazardous substances such as select carcinogens.", + "A list of employees' favourite chemicals." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2, + 3 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "lab-biosafety-6:triple", + "type": "single_choice", + "prompt": "Which describes triple packaging for a Category B liquid specimen?", + "points": 1, + "objective_ids": [ + "lab-biosafety-6-objective-4" + ], + "feedback": { + "hint": "Each layer has a job.", + "solution": "Primary, secondary with absorbent for liquids, and rigid outer packaging; for air transport the primary or secondary also withstands 95 kPa.", + "lesson_ids": [ + "lab-biosafety-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "A leak-proof primary receptacle, leak-proof secondary packaging with enough absorbent for the entire contents, and rigid outer packaging.", + "One sealed bag inside a padded envelope.", + "A primary tube taped inside a box.", + "Three primary tubes in one bag." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-6:who-ships", + "type": "single_choice", + "prompt": "A collaborator asks you to ship live virus-transduced human cells today. What should you do?", + "points": 1, + "objective_ids": [ + "lab-biosafety-6-objective-4" + ], + "feedback": { + "hint": "Shippers must be trained before they prepare a shipment.", + "solution": "Classification, packaging, marking and documentation are regulated; only trained people may prepare infectious-substance shipments.", + "lesson_ids": [ + "lab-biosafety-6" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "options": [ + "Have the material classified and packed by trained, certified shipping personnel following your institution's procedures.", + "Pack them in a cooler with ice and call a courier.", + "Ship them as ordinary laboratory reagents.", + "Mail them inside a padded envelope marked 'fragile'." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "lab-biosafety-project:rehearsal-ranking", + "type": "numeric", + "prompt": "Opening centrifuge tubes of donor cells on the open bench is scored probability 4 and consequence 3. What is its risk score? Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-1-objective-1" + ], + "feedback": { + "hint": "Probability × consequence.", + "solution": "4 × 3 = 12; opening sealed cups in the cabinet instead scores 2 × 3 = 6.", + "lesson_ids": [ + "lab-biosafety-project-guide" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 12, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "lab-biosafety-project:rehearsal-inflow", + "type": "numeric", + "prompt": "A cabinet opening is 5.0 ft wide with a 10.0 in sash. What inflow, in cubic feet per minute, meets the Type A2 minimum of 100 lfm? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-biosafety-3-objective-3" + ], + "feedback": { + "hint": "Q = v × A with the area in square feet.", + "solution": "A = 5.0 × 10.0/12 = 4.17 ft²; Q = 100 × 4.17 = 417 cubic feet per minute.", + "lesson_ids": [ + "lab-biosafety-project-guide" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 417.0, + "unit": "", + "tolerance": 0.10474566419394947, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-biosafety-project:rehearsal-bleach", + "type": "numeric", + "prompt": "How many millilitres of 7.5% bleach (about 75,000 ppm) make 500 mL of 5,000 ppm hypochlorite? Give mL. Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "lab-biosafety-4-objective-3" + ], + "feedback": { + "hint": "C₁V₁ = C₂V₂.", + "solution": "500 × 5,000/75,000 = 33.3 mL, made up to 500 mL.", + "lesson_ids": [ + "lab-biosafety-project-guide" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 33.3, + "unit": "", + "tolerance": 0.008364581816926899, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "lab-biosafety-project:rehearsal-autoclave", + "type": "numeric", + "prompt": "The centre of a waste load reaches 121 °C 20 min after the chamber. With D₁₂₁ = 1.5 min and a twelve-log requirement at the centre, what is the minimum chamber time at 121 °C? Give minutes. Enter the number only.", + "points": 1, + "objective_ids": [ + "lab-biosafety-4-objective-4" + ], + "feedback": { + "hint": "Heating lag plus twelve D-values.", + "solution": "20 + 12 × 1.5 = 38 min.", + "lesson_ids": [ + "lab-biosafety-project-guide" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original biosafety practice developed with substantial AI assistance from BMBL 6th edition and OSHA standards (US federal works); no external problem imported." + ] + }, + "solution_spec": { + "answer": 38, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + } + ] +} diff --git a/content/courses/lab-biosafety/question-banks/retrieval-cards.json b/content/courses/lab-biosafety/question-banks/retrieval-cards.json new file mode 100644 index 0000000..4140a06 --- /dev/null +++ b/content/courses/lab-biosafety/question-banks/retrieval-cards.json @@ -0,0 +1,266 @@ +{ + "schema_version": "1.0", + "course_id": "lab-biosafety", + "cards": [ + { + "id": "lab-biosafety-1-card-1", + "lesson_id": "lab-biosafety-1", + "front": "Hazard and risk", + "back": "A hazard can cause harm; risk arises when people interact with it, as probability × consequence.", + "objective_ids": [ + "lab-biosafety-1-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-1-card-2", + "lesson_id": "lab-biosafety-1", + "front": "Routes of laboratory-associated infection", + "back": "Sharps, splashes, mouth pipetting, bites and scratches, and aerosols; most infections have no identified incident.", + "objective_ids": [ + "lab-biosafety-1-objective-2" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-1-card-3", + "lesson_id": "lab-biosafety-1", + "front": "Hierarchy of controls", + "back": "Eliminate or reduce the hazard, then engineering and administrative controls, then PPE.", + "objective_ids": [ + "lab-biosafety-1-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-1-card-4", + "lesson_id": "lab-biosafety-1", + "front": "Six-step risk management", + "back": "Agent hazards, procedure hazards, choose BSL and precautions, review, check proficiency and equipment, revisit.", + "objective_ids": [ + "lab-biosafety-1-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-2-card-1", + "lesson_id": "lab-biosafety-2", + "front": "Primary and secondary barriers", + "back": "Primary: at the hazard (BSCs, sealed rotors, PPE). Secondary: the facility (doors, sinks, cleanable surfaces).", + "objective_ids": [ + "lab-biosafety-2-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-2-card-2", + "lesson_id": "lab-biosafety-2", + "front": "BSL-2 versus BSL-1", + "back": "Specific training and supervision, restricted access, and containment of aerosol- and splash-generating work.", + "objective_ids": [ + "lab-biosafety-2-objective-2" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-2-card-3", + "lesson_id": "lab-biosafety-2", + "front": "Sharps at BSL-2", + "back": "No bending, shearing, recapping or removing by hand; puncture-resistant container near the point of use.", + "objective_ids": [ + "lab-biosafety-2-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-2-card-4", + "lesson_id": "lab-biosafety-2", + "front": "When to use the cabinet at BSL-2", + "back": "Any procedure that can make aerosols or splashes, and high concentrations or large volumes.", + "objective_ids": [ + "lab-biosafety-2-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-3-card-1", + "lesson_id": "lab-biosafety-3", + "front": "Three protections", + "back": "Personnel (inflow), product (HEPA downflow), environment (HEPA exhaust); a clean bench gives product protection only.", + "objective_ids": [ + "lab-biosafety-3-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-3-card-2", + "lesson_id": "lab-biosafety-3", + "front": "Class II types", + "back": "A1 75 lfm and A2 100 lfm recirculate about 70%; B1 exhausts about 70%; B2 exhausts all air.", + "objective_ids": [ + "lab-biosafety-3-objective-2" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-3-card-3", + "lesson_id": "lab-biosafety-3", + "front": "Inflow volume", + "back": "Q = v × A; raising the sash spreads the same flow over a larger area and lowers face velocity.", + "objective_ids": [ + "lab-biosafety-3-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-3-card-4", + "lesson_id": "lab-biosafety-3", + "front": "BSC certification", + "back": "At installation, at least annually, and after filter changes, internal repair or relocation.", + "objective_ids": [ + "lab-biosafety-3-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-4-card-1", + "lesson_id": "lab-biosafety-4", + "front": "Sterility assurance level", + "back": "A process after which the probability of a surviving microorganism is below 10⁻⁶.", + "objective_ids": [ + "lab-biosafety-4-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-4-card-2", + "lesson_id": "lab-biosafety-4", + "front": "Resistance ranking", + "back": "Prions > spores > mycobacteria > non-enveloped viruses > fungi > vegetative bacteria > enveloped viruses.", + "objective_ids": [ + "lab-biosafety-4-objective-2" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-4-card-3", + "lesson_id": "lab-biosafety-4", + "front": "Diluting bleach", + "back": "6% bleach ≈ 60,000 ppm; use C₁V₁ = C₂V₂ for a target of 500–6,000 ppm.", + "objective_ids": [ + "lab-biosafety-4-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-4-card-4", + "lesson_id": "lab-biosafety-4", + "front": "D-value", + "back": "Time at a temperature for a tenfold reduction: N = N₀ × 10^(−t/D).", + "objective_ids": [ + "lab-biosafety-4-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-5-card-1", + "lesson_id": "lab-biosafety-5", + "front": "Human cells minimum containment", + "back": "Treat as potentially infectious: BSL-2 practices and facilities, culture in a BSC.", + "objective_ids": [ + "lab-biosafety-5-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-5-card-2", + "lesson_id": "lab-biosafety-5", + "front": "Universal precautions", + "back": "Handle all human blood and OPIM as if infectious.", + "objective_ids": [ + "lab-biosafety-5-objective-2" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-5-card-3", + "lesson_id": "lab-biosafety-5", + "front": "Hepatitis B vaccination", + "back": "Made available after training and within 10 working days of initial assignment, at no cost.", + "objective_ids": [ + "lab-biosafety-5-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-5-card-4", + "lesson_id": "lab-biosafety-5", + "front": "After a needlestick", + "back": "Wash with soap and water, make the work safe, report immediately for medical evaluation.", + "objective_ids": [ + "lab-biosafety-5-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-6-card-1", + "lesson_id": "lab-biosafety-6", + "front": "Chemical Hygiene Plan", + "back": "Written plan with SOPs, control criteria, working hoods, training, a Chemical Hygiene Officer; reviewed at least annually.", + "objective_ids": [ + "lab-biosafety-6-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-6-card-2", + "lesson_id": "lab-biosafety-6", + "front": "Signal words", + "back": "'Danger' for more severe hazards, 'Warning' for less severe.", + "objective_ids": [ + "lab-biosafety-6-objective-2" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-6-card-3", + "lesson_id": "lab-biosafety-6", + "front": "SDS sections to read first", + "back": "2 hazards, 4 first aid, 7 handling and storage, 8 exposure controls and PPE; 16 for the revision date.", + "objective_ids": [ + "lab-biosafety-6-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-6-card-4", + "lesson_id": "lab-biosafety-6", + "front": "Category A and B", + "back": "A: UN 2814/UN 2900, can cause permanent disability or life-threatening disease; B: UN 3373; both triple-packed by trained shippers.", + "objective_ids": [ + "lab-biosafety-6-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-project-1", + "lesson_id": "lab-biosafety-project-guide", + "front": "A biosafety plan", + "back": "Risk table before and after controls, containment level and key practices, cabinet checks, decontamination and waste with calculations, exposure and shipping procedures.", + "objective_ids": [ + "lab-biosafety-1-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "lab-biosafety-project-2", + "lesson_id": "lab-biosafety-project-guide", + "front": "Expired cabinet certification", + "back": "Do not start work: arrange recertification and use another certified cabinet in the meantime.", + "objective_ids": [ + "lab-biosafety-3-objective-4" + ], + "license": "CC-BY-4.0" + } + ] +} diff --git a/content/courses/lab-biosafety/rubrics/case-studio.json b/content/courses/lab-biosafety/rubrics/case-studio.json new file mode 100644 index 0000000..3b5599f --- /dev/null +++ b/content/courses/lab-biosafety/rubrics/case-studio.json @@ -0,0 +1,209 @@ +{ + "schema_version": "1.0", + "course_id": "lab-biosafety", + "cases": [ + { + "id": "lab-biosafety:case", + "prompt": "Your laboratory will start culturing primary human fibroblasts from skin biopsies and transducing them with a lentiviral vector. Write the risk assessment and control plan: hazards, containment level, cabinet and centrifuge practice, disinfectant and waste handling, the occupational health program, and what happens after an exposure.", + "mode": "self-assessment", + "objective_ids": [ + "lab-biosafety-1-objective-4", + "lab-biosafety-2-objective-4", + "lab-biosafety-4-objective-3", + "lab-biosafety-5-objective-1", + "lab-biosafety-5-objective-4" + ], + "rubric": [ + { + "id": "lab-biosafety-case-criterion-1", + "criterion": "Identifies the human primary material and the viral vector as separate hazards and ranks the procedures", + "points": 0, + "levels": [] + }, + { + "id": "lab-biosafety-case-criterion-2", + "criterion": "Sets BSL-2 as the minimum, with open work in a certified Class II cabinet and sealed centrifuge cups opened in it", + "points": 0, + "levels": [] + }, + { + "id": "lab-biosafety-case-criterion-3", + "criterion": "Chooses a disinfectant with a stated concentration and contact time and a validated waste method", + "points": 0, + "levels": [] + }, + { + "id": "lab-biosafety-case-criterion-4", + "criterion": "Covers the exposure control plan, training, hepatitis B vaccination and the immediate exposure response", + "points": 0, + "levels": [] + }, + { + "id": "lab-biosafety-case-criterion-5", + "criterion": "Routes the plan through the biosafety officer and Institutional Biosafety Committee before work begins", + "points": 0, + "levels": [] + } + ], + "limitations": [ + "A zero-point self-check; it is not a grade, an approved plan or a competency record." + ] + }, + { + "id": "lab-biosafety:project-m1-risk", + "prompt": "Biosafety plan milestone 1 (suggested week 6). List agent and procedure hazards for the iPSC project, score and rank them, apply controls in hierarchy order and rescore, and name who reviews the assessment (project guide, milestone 1).", + "mode": "self-assessment", + "objective_ids": [ + "lab-biosafety-1-objective-1", + "lab-biosafety-1-objective-3", + "lab-biosafety-1-objective-4" + ], + "rubric": [ + { + "id": "project-m1-risk-criterion-1", + "criterion": "I separated agent hazards from procedure hazards.", + "points": 0, + "levels": [] + }, + { + "id": "project-m1-risk-criterion-2", + "criterion": "I rescored tasks after controls applied in hierarchy order.", + "points": 0, + "levels": [] + }, + { + "id": "project-m1-risk-criterion-3", + "criterion": "I named the biosafety officer and Institutional Biosafety Committee review and when to revisit.", + "points": 0, + "levels": [] + } + ], + "limitations": [ + "A zero-point self-check of one project milestone; it is not a grade or a competency record." + ] + }, + { + "id": "lab-biosafety:project-m2-containment", + "prompt": "Biosafety plan milestone 2. Justify the containment level from BMBL Appendix H, list the key BSL-2 practices and cabinet procedures, and state the Bloodborne Pathogens Standard obligations with their timing (project guide, milestone 2).", + "mode": "self-assessment", + "objective_ids": [ + "lab-biosafety-2-objective-4", + "lab-biosafety-5-objective-1", + "lab-biosafety-5-objective-3" + ], + "rubric": [ + { + "id": "project-m2-containment-criterion-1", + "criterion": "I stated BSL-2 as the minimum for human cells and why.", + "points": 0, + "levels": [] + }, + { + "id": "project-m2-containment-criterion-2", + "criterion": "I placed centrifugation in sealed cups opened in the cabinet.", + "points": 0, + "levels": [] + }, + { + "id": "project-m2-containment-criterion-3", + "criterion": "I listed training, the exposure control plan and hepatitis B vaccination within 10 working days.", + "points": 0, + "levels": [] + } + ], + "limitations": [ + "A zero-point self-check of one project milestone; it is not a grade or a competency record." + ] + }, + { + "id": "lab-biosafety:project-m3-cabinet", + "prompt": "Biosafety plan milestone 3. Compute the inflow and the face velocity with a raised sash, and decide what to do about a cabinet last certified 13 months ago (project guide, milestone 3).", + "mode": "self-assessment", + "objective_ids": [ + "lab-biosafety-3-objective-3", + "lab-biosafety-3-objective-4" + ], + "rubric": [ + { + "id": "project-m3-cabinet-criterion-1", + "criterion": "I computed Q = v × A and the reduced face velocity.", + "points": 0, + "levels": [] + }, + { + "id": "project-m3-cabinet-criterion-2", + "criterion": "I refused to start work until the cabinet is recertified.", + "points": 0, + "levels": [] + } + ], + "limitations": [ + "A zero-point self-check of one project milestone; it is not a grade or a competency record." + ] + }, + { + "id": "lab-biosafety:project-m4-decontamination", + "prompt": "Biosafety plan milestone 4. Plan surface disinfection and spill response with hypochlorite, compute the bleach volume and the minimum validated autoclave time, and describe routine cycle verification (project guide, milestone 4).", + "mode": "self-assessment", + "objective_ids": [ + "lab-biosafety-4-objective-3", + "lab-biosafety-4-objective-4" + ], + "rubric": [ + { + "id": "project-m4-decontamination-criterion-1", + "criterion": "I computed the bleach volume with C₁V₁ = C₂V₂.", + "points": 0, + "levels": [] + }, + { + "id": "project-m4-decontamination-criterion-2", + "criterion": "I added the heating lag to twelve D-values.", + "points": 0, + "levels": [] + }, + { + "id": "project-m4-decontamination-criterion-3", + "criterion": "I listed time, temperature and pressure records and biological indicators for routine cycles.", + "points": 0, + "levels": [] + } + ], + "limitations": [ + "A zero-point self-check of one project milestone; it is not a grade or a competency record." + ] + }, + { + "id": "lab-biosafety:project-m5-exposure-shipping", + "prompt": "Biosafety plan milestone 5. Write the first-minutes response to a needlestick and an eye splash, and describe who classifies and packs the iPSC shipment and what triple packaging is (project guide, milestone 5).", + "mode": "self-assessment", + "objective_ids": [ + "lab-biosafety-5-objective-4", + "lab-biosafety-6-objective-4" + ], + "rubric": [ + { + "id": "project-m5-exposure-shipping-criterion-1", + "criterion": "I wash or flush first, then report immediately.", + "points": 0, + "levels": [] + }, + { + "id": "project-m5-exposure-shipping-criterion-2", + "criterion": "I assigned shipping to trained, certified personnel.", + "points": 0, + "levels": [] + }, + { + "id": "project-m5-exposure-shipping-criterion-3", + "criterion": "I described primary, secondary and outer packaging with absorbent for liquids.", + "points": 0, + "levels": [] + } + ], + "limitations": [ + "A zero-point self-check of one project milestone; it is not a grade or a competency record." + ] + } + ] +} diff --git a/content/courses/lab-biosafety/scope-crosswalk.md b/content/courses/lab-biosafety/scope-crosswalk.md new file mode 100644 index 0000000..3ec845c --- /dev/null +++ b/content/courses/lab-biosafety/scope-crosswalk.md @@ -0,0 +1,29 @@ +# Laboratory Safety and Biosafety scope crosswalk + +This crosswalk compares the local package, version 0.1.0, with the [ABSA International Certified Biological Safety Professional (CBSP) Examination Content: Domain & Task List](https://absa.org/wp-content/uploads/2018/05/CBSP_Examination_Content_Domains.pdf) (updated 2026; seven domains, task numbers as published). It was compiled with AI assistance on 2026-10-31 from the task titles only. It is an agent-run comparison, not a review by the owner, a biosafety professional or ABSA. The outline is copyrighted and used here as a scope reference only; nothing is copied beyond domain names and task numbers. Topic alignment establishes no equivalence, eligibility, credit or endorsement. ABSA's credentials also require education and documented biosafety experience. + +The CBSP is a credential for biosafety professionals who run institutional programs. This package is written for the people who work at the bench, so the program-management and facility-review tasks of Domains V and VII are mostly outside it by design. Status labels: **covered** means a local reading develops the topic with a worked example and practice; **shallow** means part of it is taught; **absent** means no local instruction. + +| CBSP domain and tasks | Local lessons | Status | +| --- | --- | --- | +| I. Disinfection, decontamination, sterilization: definitions and monitoring (1); disinfectant hazards and use (2); methods including steam, chemicals and UV (3) | [4](modules/04-decontamination-disinfection-and-sterilization.md), [3](modules/03-biological-safety-cabinets.md) | Covered for definitions, chemical activity levels, hypochlorite dilution, D-values, autoclave verification and UV limits; dry heat, irradiation, filtration and gas sterilization are named only | +| II. Work practices: good microbiological and aseptic technique (4); exposure control procedures (5); aerosol release from equipment (6); sharps (9); PPE (10); spills (12); exposure documentation and incident reports (13) | [2](modules/02-biosafety-levels-1-and-2.md), [3](modules/03-biological-safety-cabinets.md), [5](modules/05-human-materials-bloodborne-pathogens-and-exposures.md); the [cabinet simulation](#/bench/class-ii-bsc-session-with-spill) | Covered at bench level; aseptic technique belongs to the planned cell-culture layer | +| II. Large-scale audits (7); exposure monitoring equipment (8); respiratory equipment (11) | — | Absent: respiratory protection is mentioned only as a risk-assessment decision | +| III. Personal risk factors (15, 22); occupational infection risk (16); routes and hazard categories (17); risk versus consequence (23); point-source aerosols from equipment (25) | [1](modules/01-biological-risk-assessment.md), [2](modules/02-biosafety-levels-1-and-2.md) | Covered for the six-step process, routes of infection, probability × consequence and aerosol-generating equipment | +| III. Community risk (18); microbial toxins (19); microbial characteristics (20); service personnel (21); building aerosols (24); recombinant DNA (26); infected animals (27) | [1](modules/01-biological-risk-assessment.md) | Shallow or absent: genetically modified agents and animal work are named, not taught | +| IV. OSHA Bloodborne Pathogens Standard (29); risk-group classification (30); packaging and shipping (32); OSHA standards for biohazards (37); BMBL (40) | [5](modules/05-human-materials-bloodborne-pathogens-and-exposures.md), [6](modules/06-chemical-hygiene-hazard-communication-and-transport.md), [1](modules/01-biological-risk-assessment.md) | Covered for the employer obligations of 1910.1030, Category A/B packaging and BMBL practices; shipping is awareness only | +| IV. NIH Guidelines (28); tuberculosis (31); import and export (33); animal, plant and large-scale rules (34, 35, 66); NSF/ANSI 49 (36); medical waste (38); agencies (39); select agents (41) | — | Absent, except that NSF/ANSI 49 certification is named in lesson 3 | +| V. Program management (42–52, 67) | [1](modules/01-biological-risk-assessment.md) (IBC review step) | Absent by design: institutional program tasks | +| VI. Autoclaves (53, 59); BSC use, certification and design (54, 55); HEPA filters (57); limits of fume hoods and clean benches (58) | [3](modules/03-biological-safety-cabinets.md), [4](modules/04-decontamination-disinfection-and-sterilization.md) | Covered at user level | +| VI. Air-measuring instruments (56); space decontamination (60); animal containment equipment (61) | [4](modules/04-decontamination-disinfection-and-sterilization.md) | Shallow or absent | +| VII. Primary and secondary barriers (62); balancing containment and protection (63) | [2](modules/02-biosafety-levels-1-and-2.md), [3](modules/03-biological-safety-cabinets.md) | Covered for BSL-1 and BSL-2 | +| VII. Reviewing plans and verifying facilities (64, 65) | — | Absent by design | +| Chemical hygiene and hazard communication | — | Local extension: OSHA 1910.1450 and 1910.1200 are not CBSP tasks but apply to every biological laboratory | + +## Remaining gaps + +- Respiratory protection programs, exposure monitoring and tuberculosis guidance. +- The NIH Guidelines and Institutional Biosafety Committee review of recombinant work, which the planned molecular and cell-culture layers need. +- Select agents, import and export permits, animal and plant pathogens, and large-scale biosafety. +- BSL-3 and the animal biosafety levels. +- Review by a qualified biosafety professional, measured workload and any graded assessment. diff --git a/content/courses/lab-biosafety/source-map.json b/content/courses/lab-biosafety/source-map.json new file mode 100644 index 0000000..3201986 --- /dev/null +++ b/content/courses/lab-biosafety/source-map.json @@ -0,0 +1,115 @@ +{ + "schema_version": "1.0", + "course_id": "lab-biosafety", + "license": "CC-BY-4.0", + "modules": [ + { + "module_id": "lab-biosafety-module-1", + "lesson_id": "lab-biosafety-1", + "reading": "modules/01-biological-risk-assessment.md", + "source_ids": [ + "cdc-nih-bmbl-6", + "absa-cbsp-examination-content" + ], + "relationship": "original-instruction-adapting-us-federal-guidance", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original instruction, synthetic examples and practice developed with substantial AI assistance; guidance from US federal works is paraphrased and attributed; no figure or copyrighted text imported." + ] + }, + { + "module_id": "lab-biosafety-module-2", + "lesson_id": "lab-biosafety-2", + "reading": "modules/02-biosafety-levels-1-and-2.md", + "source_ids": [ + "cdc-nih-bmbl-6", + "absa-cbsp-examination-content" + ], + "relationship": "original-instruction-adapting-us-federal-guidance", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original instruction, synthetic examples and practice developed with substantial AI assistance; guidance from US federal works is paraphrased and attributed; no figure or copyrighted text imported." + ] + }, + { + "module_id": "lab-biosafety-module-3", + "lesson_id": "lab-biosafety-3", + "reading": "modules/03-biological-safety-cabinets.md", + "source_ids": [ + "cdc-nih-bmbl-6", + "absa-cbsp-examination-content" + ], + "relationship": "original-instruction-adapting-us-federal-guidance", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original instruction, synthetic examples and practice developed with substantial AI assistance; guidance from US federal works is paraphrased and attributed; no figure or copyrighted text imported." + ] + }, + { + "module_id": "lab-biosafety-module-4", + "lesson_id": "lab-biosafety-4", + "reading": "modules/04-decontamination-disinfection-and-sterilization.md", + "source_ids": [ + "cdc-nih-bmbl-6", + "absa-cbsp-examination-content" + ], + "relationship": "original-instruction-adapting-us-federal-guidance", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original instruction, synthetic examples and practice developed with substantial AI assistance; guidance from US federal works is paraphrased and attributed; no figure or copyrighted text imported." + ] + }, + { + "module_id": "lab-biosafety-module-5", + "lesson_id": "lab-biosafety-5", + "reading": "modules/05-human-materials-bloodborne-pathogens-and-exposures.md", + "source_ids": [ + "cdc-nih-bmbl-6", + "osha-29cfr1910-1030-bloodborne-pathogens", + "absa-cbsp-examination-content" + ], + "relationship": "original-instruction-adapting-us-federal-guidance", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original instruction, synthetic examples and practice developed with substantial AI assistance; guidance from US federal works is paraphrased and attributed; no figure or copyrighted text imported." + ] + }, + { + "module_id": "lab-biosafety-module-6", + "lesson_id": "lab-biosafety-6", + "reading": "modules/06-chemical-hygiene-hazard-communication-and-transport.md", + "source_ids": [ + "osha-29cfr1910-1450-laboratory-standard", + "osha-29cfr1910-1200-hazard-communication", + "cdc-nih-bmbl-6", + "absa-cbsp-examination-content" + ], + "relationship": "original-instruction-adapting-us-federal-guidance", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original instruction, synthetic examples and practice developed with substantial AI assistance; guidance from US federal works is paraphrased and attributed; no figure or copyrighted text imported." + ] + }, + { + "module_id": "lab-biosafety-module-project", + "lesson_id": "lab-biosafety-project-guide", + "reading": "assignments/ipsc-project-biosafety-plan-guide.md", + "source_ids": [ + "cdc-nih-bmbl-6", + "absa-cbsp-examination-content" + ], + "relationship": "original-instruction-adapting-us-federal-guidance", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original synthetic project and rehearsal practice developed with substantial AI assistance." + ] + } + ] +} diff --git a/content/courses/lab-biosafety/syllabus.md b/content/courses/lab-biosafety/syllabus.md new file mode 100644 index 0000000..d093d09 --- /dev/null +++ b/content/courses/lab-biosafety/syllabus.md @@ -0,0 +1,47 @@ +# Laboratory Safety and Biosafety + +**Maturity: partial. Version: 0.1.0.** + +Assess biological risk, apply Biosafety Level 1 and 2 practices, select and use biological safety cabinets, decontaminate and sterilize with verification, work safely with human materials and hazardous chemicals, and respond to exposures. This is layer 1 of the wet-lab practice track: the safety foundation the cell-culture and molecular layers assume. + +This package was written with substantial AI assistance. It has not been reviewed by a biosafety professional or any certifying body, confers no credit, competency record or certification, and does not replace the safety training your employer or institution must provide. + +## Prerequisites + +Laboratory Measurement and Foundations of Cell and Molecular Biology recommended. This package establishes no university prerequisite equivalency. + +## Authored outcomes + +- Assess biological risk from agent and procedure hazards and choose controls in hierarchy order. +- Apply BSL-1 and BSL-2 practices and select, use and verify biological safety cabinets. +- Decontaminate, disinfect and sterilize correctly, and verify that the process worked. +- Work safely with human materials and hazardous chemicals, respond to exposures, and recognise when shipping requires trained personnel. + +## Proposed six-week schedule + +This is a proposed scope, not a measured or reviewed schedule. + +| Week | Readings and activities | +| --- | --- | +| 1 | [Biological risk assessment](modules/01-biological-risk-assessment.md) | +| 2 | [Biosafety Levels 1 and 2](modules/02-biosafety-levels-1-and-2.md) | +| 3 | [Biological safety cabinets](modules/03-biological-safety-cabinets.md); [bench practice: a Class II cabinet session with a spill](#/bench/class-ii-bsc-session-with-spill) | +| 4 | [Decontamination, disinfection and sterilization](modules/04-decontamination-disinfection-and-sterilization.md) | +| 5 | [Human cells and tissues, bloodborne pathogens and exposure response](modules/05-human-materials-bloodborne-pathogens-and-exposures.md) | +| 6 | [Chemical hygiene, hazard communication and transport](modules/06-chemical-hygiene-hazard-communication-and-transport.md); [optional biosafety plan project guide](assignments/ipsc-project-biosafety-plan-guide.md) | + +## Optional biosafety plan project (ungraded) + +The [project guide](assignments/ipsc-project-biosafety-plan-guide.md) asks for a biosafety plan for deriving iPSCs from donor blood: a ranked risk assessment, the containment level and practices, cabinet checks, a decontamination and waste plan with its calculations, and exposure and shipping procedures. Four rehearsal items sit in week 6, and five Case studio milestones give zero-point self-checks. The scenario and numbers are synthetic. + +## Assessment and study policy + +Every lesson has public formative items (numeric, single-choice and multiple-select) mapped to its objectives, plus retrieval cards. One zero-point case-studio prompt asks for a risk assessment and control plan. Nothing is graded. + +## Current limitations + +The schedule is proposed and unmeasured, regulations are paraphrased for teaching, and examples are synthetic. Hand skills (PPE, cabinet work, spill response) need supervised practice. Higher containment levels, animal work, select agents, recombinant-DNA rules and respiratory protection are not taught. + +## Sources and licensing + +Readings, examples, items and cards are original and licensed CC BY 4.0. They adapt, with attribution, guidance from US federal works: *Biosafety in Microbiological and Biomedical Laboratories*, 6th edition (CDC and NIH), and OSHA standards 29 CFR 1910.1030, 1910.1450 and 1910.1200. The scope was compared with the [ABSA International CBSP examination content outline](https://absa.org/wp-content/uploads/2018/05/CBSP_Examination_Content_Domains.pdf) (link only; see the [scope crosswalk](scope-crosswalk.md)). No ABSA text is copied, and the comparison establishes no equivalence, eligibility or endorsement. diff --git a/content/curriculum-map.json b/content/curriculum-map.json index 9452888..cba1d66 100644 --- a/content/curriculum-map.json +++ b/content/curriculum-map.json @@ -734,11 +734,12 @@ ], "recommended_course_ids": [ "bioreactors", - "lab-measurement" + "lab-measurement", + "lab-biosafety" ], "concurrent_course_ids": [], "knowledge": [], - "statement": "Recommended sequence: Foundations of Cell and Molecular Biology; Stem Cell Biology; Bioreactors & Tissue Culture Engineering (recommended); Laboratory Measurement and Solution Preparation (recommended). These project-planned dependencies do not establish any university's admission or course-equivalency rules." + "statement": "Recommended sequence: Foundations of Cell and Molecular Biology; Stem Cell Biology; Bioreactors & Tissue Culture Engineering (recommended); Laboratory Measurement and Solution Preparation (recommended); Laboratory Safety and Biosafety (recommended). These project-planned dependencies do not establish any university's admission or course-equivalency rules." }, "related_package_ids": [], "relation_note": null @@ -958,6 +959,7 @@ "biomaterials", "bioreactors", "lab-measurement", + "lab-biosafety", "cell-culture-stem-cell-lab", "tissue-engineered-drug-discovery", "gene-therapy", diff --git a/content/sources/registry.json b/content/sources/registry.json index a403816..7743ffd 100644 --- a/content/sources/registry.json +++ b/content/sources/registry.json @@ -12136,9 +12136,19 @@ "attribution_requirements": [ "Identify Biosafety in Microbiological and Biomedical Laboratories, 6th Edition (CDC/NIH, 2020), the section and page, and the URL. Do not imply CDC or NIH endorsement." ], - "third_party_notes": "Work practices, times and the spill procedure in Appendix A (Primary Containment for Biohazards), Part 5 and the field-certification section, are adapted with page references into the bench protocol class-ii-bsc-session-with-spill. Read from the full PDF linked on the CDC page; no figures are reproduced.", - "mapped_courses": [], - "mapped_modules": [], + "third_party_notes": "Work practices, times and the spill procedure in Appendix A (Primary Containment for Biohazards), Part 5 and the field-certification section, are adapted with page references into the bench protocol class-ii-bsc-session-with-spill. Read from the full PDF linked on the CDC page; no figures are reproduced. Sections II–IV and Appendices B, C and H were also read on 2026-10-30 and adapted with page references in the lab-biosafety readings.", + "mapped_courses": [ + "lab-biosafety" + ], + "mapped_modules": [ + "lab-biosafety-module-1", + "lab-biosafety-module-2", + "lab-biosafety-module-3", + "lab-biosafety-module-4", + "lab-biosafety-module-5", + "lab-biosafety-module-6", + "lab-biosafety-module-project" + ], "version": "6th edition, June 2020 (PDF linked from the CDC page, page dated 18 March 2026); Appendix A pp. 382–392 read on 2026-10-30", "checksum": null, "imported_assets": [], @@ -12236,6 +12246,127 @@ "checksum": null, "imported_assets": [], "rights_notes": "Comparator mapping does not establish equivalent scope, credit, endorsement or review." + }, + { + "id": "osha-29cfr1910-1030-bloodborne-pathogens", + "title": "29 CFR 1910.1030, Bloodborne pathogens", + "institution": "US Department of Labor, Occupational Safety and Health Administration (eCFR, Title 29)", + "url": "https://www.ecfr.gov/current/title-29/subtitle-B/chapter-XVII/part-1910/subpart-Z/section-1910.1030", + "accessed_at": "2026-10-30", + "license": "US federal government work, not subject to copyright in the United States (17 U.S.C. 105).", + "reuse_mode": "adapted", + "permissions": { + "linking": true, + "quotation": true, + "adaptation": true, + "redistribution": false + }, + "attribution_requirements": [ + "Identify 29 CFR 1910.1030, Bloodborne pathogens, the paragraph and the eCFR URL. Do not imply OSHA endorsement." + ], + "third_party_notes": "Employer obligations (exposure control plan, universal precautions, controls, PPE, sharps, hepatitis B vaccination, post-exposure evaluation, training and records) are paraphrased with paragraph references in lab-biosafety lesson 5.", + "mapped_courses": [ + "lab-biosafety" + ], + "mapped_modules": [ + "lab-biosafety-module-5" + ], + "version": "Current eCFR text retrieved 2026-10-30 through the eCFR renderer API and read for paragraphs (b)–(h)", + "checksum": null, + "imported_assets": [], + "rights_notes": "Paraphrase for teaching does not establish compliance; the regulation text governs." + }, + { + "id": "osha-29cfr1910-1450-laboratory-standard", + "title": "29 CFR 1910.1450, Occupational exposure to hazardous chemicals in laboratories", + "institution": "US Department of Labor, Occupational Safety and Health Administration (eCFR, Title 29)", + "url": "https://www.ecfr.gov/current/title-29/subtitle-B/chapter-XVII/part-1910/subpart-Z/section-1910.1450", + "accessed_at": "2026-10-30", + "license": "US federal government work, not subject to copyright in the United States (17 U.S.C. 105).", + "reuse_mode": "adapted", + "permissions": { + "linking": true, + "quotation": true, + "adaptation": true, + "redistribution": false + }, + "attribution_requirements": [ + "Identify 29 CFR 1910.1450, Occupational exposure to hazardous chemicals in laboratories, the paragraph and the eCFR URL. Do not imply OSHA endorsement." + ], + "third_party_notes": "Chemical Hygiene Plan elements, annual review, training and labels are paraphrased in lab-biosafety lesson 6.", + "mapped_courses": [ + "lab-biosafety" + ], + "mapped_modules": [ + "lab-biosafety-module-6" + ], + "version": "Current eCFR text retrieved 2026-10-30 and read for paragraphs (c)–(f)", + "checksum": null, + "imported_assets": [], + "rights_notes": "Paraphrase for teaching does not establish compliance; the regulation text governs." + }, + { + "id": "osha-29cfr1910-1200-hazard-communication", + "title": "29 CFR 1910.1200, Hazard communication", + "institution": "US Department of Labor, Occupational Safety and Health Administration (eCFR, Title 29)", + "url": "https://www.ecfr.gov/current/title-29/subtitle-B/chapter-XVII/part-1910/subpart-Z/section-1910.1200", + "accessed_at": "2026-10-30", + "license": "US federal government work, not subject to copyright in the United States (17 U.S.C. 105).", + "reuse_mode": "adapted", + "permissions": { + "linking": true, + "quotation": true, + "adaptation": true, + "redistribution": false + }, + "attribution_requirements": [ + "Identify 29 CFR 1910.1200, Hazard communication, the paragraph and the eCFR URL. Do not imply OSHA endorsement." + ], + "third_party_notes": "Label elements, signal words and the 16 SDS section headings are used in lab-biosafety lesson 6.", + "mapped_courses": [ + "lab-biosafety" + ], + "mapped_modules": [ + "lab-biosafety-module-6" + ], + "version": "Current eCFR text retrieved 2026-10-30 and read for the signal-word definition and paragraphs (f) and (g)", + "checksum": null, + "imported_assets": [], + "rights_notes": "Paraphrase for teaching does not establish compliance; the regulation text governs." + }, + { + "id": "absa-cbsp-examination-content", + "title": "ABSA International CBSP Examination Content: Domain & Task List", + "institution": "ABSA International", + "url": "https://absa.org/wp-content/uploads/2018/05/CBSP_Examination_Content_Domains.pdf", + "accessed_at": "2026-10-31", + "license": "Copyright ABSA International; link-only comparator selected", + "reuse_mode": "link-only-comparator", + "permissions": { + "linking": true, + "quotation": false, + "adaptation": false, + "redistribution": false + }, + "attribution_requirements": [ + "Identify the document title, ABSA International, its update year and the URL without implying endorsement." + ], + "third_party_notes": "Domain names and task numbers (updated 2026) were read on 2026-10-31 to set the scope crosswalk of lab-biosafety. No task text beyond domain names is reproduced.", + "mapped_courses": [ + "lab-biosafety" + ], + "mapped_modules": [ + "lab-biosafety-module-1", + "lab-biosafety-module-2", + "lab-biosafety-module-3", + "lab-biosafety-module-4", + "lab-biosafety-module-5", + "lab-biosafety-module-6" + ], + "version": "Updated 2026; PDF read 2026-10-31", + "checksum": null, + "imported_assets": [], + "rights_notes": "Comparator mapping does not establish equivalence, eligibility, credit or endorsement." } ] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 496cf06..e448679 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## Milestone 1 foundation -The static reader is preserved. Versioned JSON manifests, Markdown and validators separate content from application code. All 26 packages are partial. Browser migration retains the previous key and backup; browser checks are unverified practice evidence. +The static reader is preserved. Versioned JSON manifests, Markdown and validators separate content from application code. All 27 packages are partial. Browser migration retains the previous key and backup; browser checks are unverified practice evidence. ## Milestone 2: implemented full stack diff --git a/docs/ASSESSMENT_CANDIDATE_REPORT.md b/docs/ASSESSMENT_CANDIDATE_REPORT.md index 226b189..4c8d52c 100644 --- a/docs/ASSESSMENT_CANDIDATE_REPORT.md +++ b/docs/ASSESSMENT_CANDIDATE_REPORT.md @@ -22,6 +22,7 @@ No course or candidate is activated by this command. `--check` detects report dr | general-chemistry-2 | 0.6.0 | partial | unreviewed | formative-only | 0 | | genetics | 0.9.0 | partial | unreviewed | formative-only | 0 | | geroscience | 0.7.0 | partial | unreviewed | formative-only | 0 | +| lab-biosafety | 0.1.0 | partial | unreviewed | formative-only | 0 | | lab-measurement | 0.1.0 | partial | unreviewed | formative-only | 0 | | linear-algebra | 0.5.0 | partial | unreviewed | formative-only | 0 | | organic-chemistry | 0.7.0 | partial | unreviewed | formative-only | 0 | diff --git a/docs/COURSE_GAP_REPORT.md b/docs/COURSE_GAP_REPORT.md index 4e2afca..bf4749e 100644 --- a/docs/COURSE_GAP_REPORT.md +++ b/docs/COURSE_GAP_REPORT.md @@ -20,6 +20,7 @@ comprehensive coverage. Every package is still `partial` and unreviewed. | general-chemistry-2 | partial | 23 | 62 | 132 | 77 | 0 | 0 | 0 | 4 | 0 | 0 | | genetics | partial | 31 | 91 | 165 | 110 | 0 | 0 | 0 | 8 | 0 | 0 | | geroscience | partial | 23 | 66 | 105 | 83 | 0 | 0 | 0 | 4 | 0 | 0 | +| lab-biosafety | partial | 7 | 24 | 64 | 26 | 0 | 0 | 0 | 4 | 0 | 0 | | lab-measurement | partial | 7 | 24 | 64 | 26 | 0 | 0 | 0 | 4 | 0 | 0 | | linear-algebra | partial | 22 | 62 | 133 | 79 | 0 | 0 | 0 | 5 | 0 | 0 | | organic-chemistry | partial | 23 | 68 | 155 | 84 | 0 | 0 | 0 | 4 | 0 | 0 | @@ -33,7 +34,7 @@ comprehensive coverage. Every package is still `partial` and unreviewed. | statistics | partial | 27 | 78 | 135 | 92 | 0 | 0 | 0 | 12 | 0 | 0 | | transport | partial | 25 | 68 | 150 | 85 | 0 | 0 | 0 | 7 | 0 | 0 | -Totals: 26 packages, 667 lessons, 1779 objectives, 3852 practice items, 0 lesson objectives with no assessment mapping, 0 with no practice item, 0 linked to no course outcome; 0 of 134 course outcomes that no lesson objective links to, 0 that no assessment lists. +Totals: 27 packages, 674 lessons, 1803 objectives, 3916 practice items, 0 lesson objectives with no assessment mapping, 0 with no practice item, 0 linked to no course outcome; 0 of 138 course outcomes that no lesson objective links to, 0 that no assessment lists. Each assessment with items lists the course outcomes its own items assess (item, then its tagged lesson objective, then that objective's outcome links), and `tools/validate_content.py` rejects an outcome that none of the diff --git a/docs/COURSE_PLAN_COMPLETION.md b/docs/COURSE_PLAN_COMPLETION.md index f1ff1cc..f20af77 100644 --- a/docs/COURSE_PLAN_COMPLETION.md +++ b/docs/COURSE_PLAN_COMPLETION.md @@ -1,12 +1,12 @@ # Existing course plans: learner routes -As of 2026-10-22, all **25 existing course plans** have authored routes for their scheduled readings, practice and proposed projects. A 26th package, the six-week Laboratory Measurement foundation module of the wet-lab practice track, was added on 2026-10-31 with its own optional project route. This completes the existing-plan route stage. The owner subsequently expanded the request to full semester depth across the repository; the delivered authoring pass and remaining depth/review limits are recorded in [SEMESTER_DEPTH_EXPANSION.md](SEMESTER_DEPTH_EXPANSION.md). It does **not** make these complete university courses: all remain **partial, unreviewed and formative-only**. Workload, scientific review, deeper curricular coverage and graded release remain open gates in the [scope audit](COURSE_SCOPE_AUDIT.md) and [roadmap](ROADMAP.md). +As of 2026-10-22, all **25 existing course plans** have authored routes for their scheduled readings, practice and proposed projects. Two packages of the wet-lab practice track were added on 2026-10-31, the six-week Laboratory Measurement and Laboratory Safety and Biosafety modules, each with its own optional project route. This completes the existing-plan route stage. The owner subsequently expanded the request to full semester depth across the repository; the delivered authoring pass and remaining depth/review limits are recorded in [SEMESTER_DEPTH_EXPANSION.md](SEMESTER_DEPTH_EXPANSION.md). It does **not** make these complete university courses: all remain **partial, unreviewed and formative-only**. Workload, scientific review, deeper curricular coverage and graded release remain open gates in the [scope audit](COURSE_SCOPE_AUDIT.md) and [roadmap](ROADMAP.md). This is a personal hobby and learning project developed with substantial AI assistance. The new guides, synthetic datasets and practice were authored and checked by Codex, an AI coding agent. Automated checks are recorded in [VALIDATION_REPORT.md](VALIDATION_REPORT.md); the owner has not been recorded as having performed or approved this walkthrough. Original exercises use link-only research/software references recorded in each source map and the [source registry](../content/sources/registry.json). ## Current plans -“Blocks” means grouped calendar entries, not measured study weeks. General Chemistry II has 11, Organic Chemistry I has 12 and the Laboratory Measurement foundation module has 6; the other plans, including the expanded General Chemistry I sequence, have 14. Every project has a rehearsal and private Case studio milestones. Cell Biology and Biochemistry expose their eight original homework packets and cumulative reviews; Biochemistry also has two new bridge reports. The semester authoring pass adds 66 foundations and 86 connected assignments across 24 packages; later Circuits batches add two digital foundations and the AC-power/final-audit completion (0.7.0–0.8.0), including a fifth and sixth connected report. Course-specific content, sources and remaining limits are in the [semester record](SEMESTER_DEPTH_EXPANSION.md). No project or practice sequence is a protected exam or a reviewed university course. +“Blocks” means grouped calendar entries, not measured study weeks. General Chemistry II has 11, Organic Chemistry I has 12 and the Laboratory Measurement and Laboratory Safety and Biosafety modules have 6 each; the other plans, including the expanded General Chemistry I sequence, have 14. Every project has a rehearsal and private Case studio milestones. Cell Biology and Biochemistry expose their eight original homework packets and cumulative reviews; Biochemistry also has two new bridge reports. The semester authoring pass adds 66 foundations and 86 connected assignments across 24 packages; later Circuits batches add two digital foundations and the AC-power/final-audit completion (0.7.0–0.8.0), including a fifth and sixth connected report. Course-specific content, sources and remaining limits are in the [semester record](SEMESTER_DEPTH_EXPANSION.md). No project or practice sequence is a protected exam or a reviewed university course. | Course and syllabus | Version | Calendar blocks | Project guide block | Guide | | --- | --- | ---: | ---: | --- | @@ -24,6 +24,7 @@ This is a personal hobby and learning project developed with substantial AI assi | [General Chemistry II](../content/courses/general-chemistry-2/syllabus.md) | 0.6.0 | 11 | 8 | [Project guide](../content/courses/general-chemistry-2/assignments/redox-cell-project-guide.md) | | [Genetics & Genomics](../content/courses/genetics/syllabus.md) | 0.9.0 | 14 | 6 | [Project guide](../content/courses/genetics/assignments/three-point-mapping-project-guide.md) | | [Geroscience & Regenerative Biology](../content/courses/geroscience/syllabus.md) | 0.7.0 | 14 | 14 | [Project guide](../content/courses/geroscience/assignments/surrogate-evidence-project-guide.md) | +| [Laboratory Safety and Biosafety](../content/courses/lab-biosafety/syllabus.md) | 0.1.0 | 6 | 6 | [Project guide](../content/courses/lab-biosafety/assignments/ipsc-project-biosafety-plan-guide.md) | | [Laboratory Measurement and Solution Preparation](../content/courses/lab-measurement/syllabus.md) | 0.1.0 | 6 | 6 | [Project guide](../content/courses/lab-measurement/assignments/reagent-and-assay-qualification-project-guide.md) | | [Linear Algebra for Modeling & Robotics](../content/courses/linear-algebra/syllabus.md) | 0.5.0 | 14 | 12 | [Project guide](../content/courses/linear-algebra/assignments/hidden-state-reconstruction-project-guide.md) | | [Organic Chemistry I for Life Sciences](../content/courses/organic-chemistry/syllabus.md) | 0.7.0 | 12 | 11 | [Project guide](../content/courses/organic-chemistry/assignments/aldol-identity-project-guide.md) | diff --git a/docs/COURSE_SCOPE_AUDIT.md b/docs/COURSE_SCOPE_AUDIT.md index 3234c0c..275a757 100644 --- a/docs/COURSE_SCOPE_AUDIT.md +++ b/docs/COURSE_SCOPE_AUDIT.md @@ -32,6 +32,7 @@ Local anchors: [Calculus I shape analysis](../content/courses/calculus-1/modules | [General Chemistry II](../content/courses/general-chemistry-2/course.json), 0.6.0 | Same [5.111SC syllabus](https://ocw.mit.edu/courses/5-111sc-principles-of-chemical-science-fall-2014/pages/syllabus/) and [Units III–V](GENERAL_CHEMISTRY_SEQUENCE_CROSSWALK.md) | Acid/base speciation, free-energy reaction direction, kinetics, electrochemistry, coordination, ligand models and solubility are present. The 11-week local schedule groups compact buffer, kinetics, redox and coordination primers with expanded treatment, retaining all IDs and the synthetic lab. | **Delivered paired scope crosswalk:** maps five MIT units to both local packages, distinguishes the introductory buffer/equilibrium bridge from later application, and records remaining scope limits. No lesson, question or card was duplicated or added. **Delivered bounded batch (0.4.8):** a titration-curve reading, following 5.111SC Unit III Lectures 23–24, is grouped with week-3 coupled speciation: equivalence and endpoint, weak- and strong-acid curve regions checked against an exact charge balance, indicators and diprotic equivalence volumes (eleven items, four cards). Rotate to another course before revisiting General Chemistry II. **Delivered (0.5.0):** an optional week-8 guide, rehearsal practice and Case studio milestones make the fictional redox-cell project usable as ungraded practice, and the relaxation lab's dataset link is package-pinned. The scope boundary above is unchanged. **Delivered coupled/evidence depth (0.6.0, 2026-10-22):** three foundations, three connected reports and eighteen checkpoints/cards. [Actual instruction and remaining limits](SEMESTER_DEPTH_EXPANSION.md#general-chemistry-ii-060). | | [Organic Chemistry I](../content/courses/organic-chemistry/course.json), 0.7.0 | [5.12, Spring 2005 calendar](https://ocw.mit.edu/courses/5-12-organic-chemistry-i-spring-2005/pages/calendar/); second comparator [UCI OpenCourseWare Chem 51A, Fall 2009](https://open.uci.edu/courses/chem_51a_organic_chemistry_nowick.html) lecture titles (2026-10-26) | Resonance, stereochemistry, substitution/elimination, selected alkene/alkyne additions, carbonyl/enolate reactions and spectroscopy are present in twelve grouped blocks. New 0.6.0 readings now develop introductory benzyne elimination–addition/blocked-ring atom maps and multistep forward-compatibility/protection/stereochemical/yield reasoning, with two paper assignments. Reagent breadth, broad synthesis/protecting-group scope and experimentally established selectivity remain limited. No full 5.12 equivalence, measured workload or qualified approval is implied. | **Delivered bounded units:** alkene/alkyne mechanism, regioselectivity, stereochemistry, hydration and selective reduction; then an SNAr strand integrated into the existing aromaticity lesson, with seven mapped items and three cards. Rotate to another course before revisiting Organic Chemistry. **Delivered (0.5.0, 2026-10-22):** optional crossed-aldol identity brief guide, separate worked example, five rehearsal items, five retrieval cards and five private milestone self-checks; scoped evidence and remaining depth limits are in the [completion index](COURSE_PLAN_COMPLETION.md#final-eight-routes-instructional-scope-comparison). **Delivered deeper mechanisms/synthesis (0.6.0, 2026-10-22):** two original readings, two connected paper assignments and twelve checkpoints/cards. [Actual instructional comparison and remaining limits](SEMESTER_DEPTH_EXPANSION.md#organic-chemistry-060). **Delivered second-comparator batch (0.7.0, 2026-10-27):** a [scope crosswalk](../content/courses/organic-chemistry/scope-crosswalk.md) against 5.12 and UCI Chem 51A found reaction energetics (5.12 sessions 8–11 and 14; Chem 51A lectures 18–20) absent; an original week-2 reading closes it with 13 items and four cards. Alkane nomenclature and alcohol/phenol reactions, each named by one comparator, remain recorded gaps. Rotate to another course before revisiting Organic Chemistry. | | [Laboratory Measurement and Solution Preparation](../content/courses/lab-measurement/course.json), 0.1.0 | [5.310 Laboratory Chemistry, Fall 2019 syllabus](https://ocw.mit.edu/courses/5-310-laboratory-chemistry-fall-2019/pages/syllabus/) and [calendar](https://ocw.mit.edu/courses/5-310-laboratory-chemistry-fall-2019/pages/calendar/) | Added 2026-10-31 as layer 0 of the wet-lab practice track: a six-week module on solutions, dilutions, volume and mass measurement, pH and spectrophotometry, with two bench simulations. 5.310 overlaps on pH measurement, UV–visible spectrophotometry and error analysis; its synthesis, chromatography and instrumental spectroscopy are deliberately outside this module (see the package's scope crosswalk). | Laboratory notebooks and data integrity, titration, and a formal uncertainty treatment; then the layer-1 biosafety package. | +| [Laboratory Safety and Biosafety](../content/courses/lab-biosafety/course.json), 0.1.0 | No OCW course; [ABSA CBSP examination content outline (2026)](https://absa.org/wp-content/uploads/2018/05/CBSP_Examination_Content_Domains.pdf), link only | Added 2026-10-31 as layer 1 of the wet-lab track, adapting BMBL 6th edition and OSHA 1910.1030, 1910.1450 and 1910.1200: risk assessment, BSL-1/2, cabinets, decontamination, human materials and exposures, chemical hygiene and transport. CBSP program-management and facility-review tasks are outside it by design (see the scope crosswalk). | Respiratory protection, NIH Guidelines and IBC review, select agents and BSL-3; more bench protocols (autoclave validation, exposure response). | Local anchors: [Cell Biology syllabus](../content/courses/cell-biology/syllabus.md), [Genetics regulation](../content/courses/genetics/modules/03-gene-regulation-and-causal-perturbation.md), [Physiology hemodynamics](../content/courses/physiology/modules/10-hemodynamics-pressure-flow-resistance-compliance.md), [Biochemistry binding/cooperativity](../content/courses/biochemistry/modules/07-binding-equilibria-and-cooperativity.md), [Geroscience intervention evidence comparison](../content/courses/geroscience/modules/09-reading-an-aging-intervention-paper.md), [Geroscience syllabus](../content/courses/geroscience/syllabus.md), [Chemistry I atoms/photons](../content/courses/general-chemistry-1/modules/07-atoms-amount-and-photon-accounting.md), [Chemistry II coordination](../content/courses/general-chemistry-2/modules/13-ligand-balance-and-coordination-models.md), and [Organic Chemistry syllabus](../content/courses/organic-chemistry/syllabus.md). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fe8c76d..79ef3ed 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Repository-wide roadmap -The active scope is all 26 partial course packages and their shared learning platform. No package is complete or independently reviewed. Historical authoring and validation records remain in [PROGRESS_REVIEW.md](PROGRESS_REVIEW.md), [VALIDATION_REPORT.md](VALIDATION_REPORT.md), and the course review packets; they are not the current work queue. +The active scope is all 27 partial course packages and their shared learning platform. No package is complete or independently reviewed. Historical authoring and validation records remain in [PROGRESS_REVIEW.md](PROGRESS_REVIEW.md), [VALIDATION_REPORT.md](VALIDATION_REPORT.md), and the course review packets; they are not the current work queue. ## Owner priority (2026-10-22): expand to semester depth @@ -30,7 +30,7 @@ Delivered: guest/account access; version-pinned enrollment; server-backed notes, The case studio supports private drafts, version/prompt snapshots, conflict protection, earlier response history, account export/deletion and read-only archived cases. Historical feature-batch verification is recorded in the linked review reports. Since 2026-10-13 each batch runs the full root, backend and frontend suites; earlier batches recorded "no test suite was run", and the drift they left was repaired in one pass. Platform verification is not course review or protected-exam release approval. -Bench practice (owner request, 2026-10-30) follows [the wet-lab practice plan](WET_LAB_PRACTICE_PLAN.md). The replay engine, three protocols and a React bench view are delivered: micropipette gravimetric verification, decimal dilutions with the BAM aerobic plate count, and a Class II biosafety cabinet session with a spill. The layer-0 Laboratory Measurement package (0.1.0, six weeks, with an optional qualification project) is delivered. Next: the layer-1 biosafety package and more protocols, layer by layer. +Bench practice (owner request, 2026-10-30) follows [the wet-lab practice plan](WET_LAB_PRACTICE_PLAN.md). The replay engine, three protocols and a React bench view are delivered: micropipette gravimetric verification, decimal dilutions with the BAM aerobic plate count, and a Class II biosafety cabinet session with a spill. The layer-0 Laboratory Measurement package (0.1.0, six weeks, with an optional qualification project) is delivered. The layer-1 Laboratory Safety and Biosafety package (0.1.0, six weeks, BMBL and OSHA sources, an optional iPSC biosafety plan project) is delivered. Next: more bench protocols for layers 0–1, then the layer-2 cell-culture package. The searchable notebook reuses the existing private note store, exposes case-insensitive literal search with course filtering and pagination, and links matches back to enrolled course lessons. Remaining shared work should address a demonstrated learner workflow or blocker and fit alongside the instructional rotation. Avoid adding isolated grader types while course foundations remain thin. @@ -42,6 +42,6 @@ Develop coherent instruction, appropriately challenging assessment, source suppo ## Catalog and operation -The catalog contains 26 authored partial packages, 37 planning nodes and eight pathway maps. Validate prerequisite edges and subject boundaries against appropriate references before promoting planning nodes to instruction. Preserve historical packages and assessment evidence during consolidation. +The catalog contains 27 authored partial packages, 37 planning nodes and eight pathway maps. Validate prerequisite edges and subject boundaries against appropriate references before promoting planning nodes to instruction. Preserve historical packages and assessment evidence during consolidation. Operational work remains bounded by verified needs: deployment/backup and archive recovery, privacy and learner isolation, distributed throttling, accessibility and protected-delivery validation. The existing [historical assessment archive recovery runbook](HISTORICAL_ASSESSMENT_ARCHIVE_RECOVERY.md) documents conservative handling of unverifiable records. Local test passes do not establish public deployment readiness or independently reviewed courses. diff --git a/tests/content/test_assessment_candidates.py b/tests/content/test_assessment_candidates.py index 88843fd..30a8f21 100644 --- a/tests/content/test_assessment_candidates.py +++ b/tests/content/test_assessment_candidates.py @@ -39,7 +39,7 @@ def packet(tmp_path): def test_real_report_is_current_and_does_not_open_private_stores(): report = MODULE.audit(ROOT) - assert len(report["packages"]) == 26 + assert len(report["packages"]) == 27 assert sum(len(p["candidates"]) for p in report["packages"]) == 26 assert MODULE.render(report) == (ROOT / "docs/ASSESSMENT_CANDIDATE_REPORT.md").read_text() assert "private_source_ref" not in json.dumps(report) diff --git a/tests/content/test_content_validation.py b/tests/content/test_content_validation.py index 7c6a5a3..dab4a7d 100644 --- a/tests/content/test_content_validation.py +++ b/tests/content/test_content_validation.py @@ -175,7 +175,7 @@ def test_preserved_inventory_is_honest_partial(repository): # General Chemistry II, Programming, Biomaterials, Bioreactors, Cellular Biomechanics, Transport, Calculus II and # Calculus III, Differential Equations, Linear Algebra and the final eight project routes, # plus six E&M milestones. - assert result.inventory["courses"] == 26 and result.inventory["cases"] == 157 + assert result.inventory["courses"] == 27 and result.inventory["cases"] == 163 assert sum(warning["code"] == "legacy-depth" for warning in result.warnings) == 0 # Assessments list the course outcomes their items assess, so no package has an unmapped outcome. assert not [warning for warning in result.warnings if warning["code"] == "objective-coverage"] diff --git a/tests/content/test_course_plan_completion.py b/tests/content/test_course_plan_completion.py index 0a65d65..ef7e54d 100644 --- a/tests/content/test_course_plan_completion.py +++ b/tests/content/test_course_plan_completion.py @@ -12,7 +12,7 @@ @pytest.mark.parametrize("path", MANIFESTS, ids=lambda path: path.parent.name) def test_existing_plan_has_usable_project_and_current_index(path): manifest = json.loads(path.read_text(encoding="utf-8")) - assert len(MANIFESTS) == 26 + assert len(MANIFESTS) == 27 assert manifest["maturity"] == "partial" assert manifest["review"]["status"] == "unreviewed" assert manifest["grading_policy"]["mode"] == "formative-only" diff --git a/tests/content/test_lab_biosafety_package.py b/tests/content/test_lab_biosafety_package.py new file mode 100644 index 0000000..408fca8 --- /dev/null +++ b/tests/content/test_lab_biosafety_package.py @@ -0,0 +1,143 @@ +"""Laboratory Safety and Biosafety 0.1.0: recomputed keys, stated source facts, schedule, project route and grading. + +Numeric keys are recomputed here (probability × consequence, Q = v × A, C₁V₁ = C₂V₂, N = N₀ × 10^(−t/D) with heating +lag) or checked against the paragraph of the federal text the item cites. Same-AI checks, not human review. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +import pytest + +from courselab.grading import grade +from courselab.schemas import AttemptRequest + +ROOT = Path(__file__).resolve().parents[2] +COURSE = ROOT / "content/courses/lab-biosafety" +MANIFEST = json.loads((COURSE / "course.json").read_text(encoding="utf-8")) +BANK = {q["id"]: q for q in json.loads((COURSE / "question-banks/practice.json").read_text(encoding="utf-8"))["questions"]} +CASES = json.loads((COURSE / "rubrics/case-studio.json").read_text(encoding="utf-8"))["cases"] +READINGS = {lesson["id"]: (COURSE / lesson["reading"]).read_text(encoding="utf-8") + for module in MANIFEST["modules"] for lesson in module["lessons"]} +CROSSWALK = (COURSE / "scope-crosswalk.md").read_text(encoding="utf-8") +REGISTRY = {s["id"]: s for s in json.loads((ROOT / "content/sources/registry.json").read_text(encoding="utf-8"))["sources"]} + + +def stated(text, *phrases): + missing = [phrase for phrase in phrases if phrase not in text] + assert not missing, missing + + +# Recomputed key and a named misconception for every numeric item. +KEYS = { + "1:risk-score": (4 * 3, 4 + 3), # adding the scores + "1:residual-score": (2 * 3, 4 * 3), # forgetting the control + "1:aerosol-release": (5e8 * 2.0 * 1e-6, 5e8 * 1e-6), # ignoring the volume + "3:inflow-volume": (100 * 6.0 * 8.0 / 12, 100 * 6.0 * 8.0), # inches left as feet + "3:raised-sash": (400 / (6.0 * 12.0 / 12), 100.0), # assuming velocity is unchanged + "3:a1-exhaust": (0.30 * 600, 0.70 * 600), # the recirculated share + "3:uv-threshold": (40, 400), + "4:bleach-volume": (2000 * 1000 / 60000, 2000 * 1000 / 6000), # 6% read as 6,000 ppm + "4:bleach-ppm": (75000 / 50, 7.5 / 50 * 1000), + "4:sal-time": (12 * 1.8, 6 * 1.8), # to one survivor only + "4:survivors": (1e6 * 10 ** (-9.0 / 1.8), 1e6 * 10 ** (-1.8 / 9.0)), # inverted exponent + "4:lag": (22 + 12 * 1.5, 12 * 1.5), # ignoring the lag + "5:hbv-days": (10, 30), + "5:medical-records": (8 + 30, 30), # forgetting the employment period + "5:training-records": (3, 30), + "6:sds-ppe": (8, 2), + "6:sds-spill": (6, 4), + "6:un-category-b": (3373, 2814), # the Category A number + "6:pressure": (95, 9.5), + "project:rehearsal-ranking": (12, 7), + "project:rehearsal-inflow": (100 * 5.0 * 10.0 / 12, 100 * 5.0 * 10.0), + "project:rehearsal-bleach": (500 * 5000 / 75000, 500 * 5000 / 7500), + "project:rehearsal-autoclave": (20 + 12 * 1.5, 12 * 1.5), +} + + +def item_id(key): + lesson, slug = key.split(":") + return f"lab-biosafety-{lesson}:{slug}" + + +def shown(value, sig): + if not sig: + return f"{value:g}" + return f"{value:.{max(0, sig - 1 - math.floor(math.log10(abs(value))))}f}" + + +def test_every_numeric_item_has_a_recomputed_key(): + assert {qid for qid, q in BANK.items() if q["type"] == "numeric"} == {item_id(key) for key in KEYS} + + +@pytest.mark.parametrize("key", sorted(KEYS)) +def test_numeric_keys_and_misconceptions(key): + item = BANK[item_id(key)] + spec = item["solution_spec"] + sig = spec["significant_figures"] + value, wrong = KEYS[key] + if sig: + assert float(shown(value, sig)) == pytest.approx(spec["answer"], rel=1e-9) + assert item["prompt"].endswith("to three significant figures.") + assert not grade(item, AttemptRequest(response=shown(spec["answer"], sig) + "0")).correct + else: + assert value == pytest.approx(spec["answer"]) and item["prompt"].endswith("Enter the number only.") + assert grade(item, AttemptRequest(response=shown(spec["answer"], sig))).correct + assert not grade(item, AttemptRequest(response=shown(wrong, sig))).correct + + +def test_choice_items_accept_only_the_key(): + for qid, item in BANK.items(): + if item["type"] == "single_choice": + accepted = [i for i in range(len(item["options"])) if grade(item, AttemptRequest(response=str(i))).correct] + assert accepted == [item["solution_spec"]["answer"]], qid + elif item["type"] == "multiple_select": + key = item["solution_spec"]["answer"] + wrong = next(i for i in range(len(item["options"])) if i not in key) + assert grade(item, AttemptRequest(response=key)).score == pytest.approx(2.0) + assert grade(item, AttemptRequest(response=[*key, wrong])).score < 2.0, qid + + +def test_readings_state_the_sourced_facts(): + stated(READINGS["lab-biosafety-1"], "six-step", "less than 20%", "correlate with, but do not equate to") + stated(READINGS["lab-biosafety-2"], "immunocompetent adult humans", "self-closing, lockable doors", "in-line HEPA filters") + stated(READINGS["lab-biosafety-3"], "75 linear feet per minute", "100 lfm", "1,200 cubic feet per minute", "0.5 inches of water", + "40 µW/cm²", "never be used for cell culture materials", "Q = 100 × 2.67 = 267", "80 lfm") + stated(READINGS["lab-biosafety-4"], "20 minutes at 121 °C", "less than one in a million", "500–6000 mg/L", "83.3 mL", "30.3 mL", + "18 + 24 = 42 min") + stated(READINGS["lab-biosafety-5"], "within 10 working days", "duration of employment plus 30 years", "training records for 3 years", + "at least annually", "(d)(4)(iii)(A)", "BSL-2 practices, engineering controls and facilities") + stated(READINGS["lab-biosafety-6"], "at least annually", "Chemical Hygiene Officer", "UN 2814", "UN 3373", "95 kPa", "−40 °C to +55 °C", + "| 8 | Exposure controls/personal protection |") + + +def test_worked_example_arithmetic(): + assert round(100 * 5.0 * 10.0 / 12) == 417 and round(100 * 4.0 * 8.0 / 12) == 267 + assert round(267 / (4.0 * 10.0 / 12)) == 80 + assert round(1000 * 5000 / 60000, 1) == 83.3 and round(500 * 5000 / 82500, 1) == 30.3 + assert 18 + 12 * 2.0 == 42 and 15 + 12 * 2.0 == 39 and 20 + 12 * 1.5 == 38 + stated(READINGS["lab-biosafety-project-guide"], "417 cubic feet per minute", "33.3 mL", "38 min", "4 × 3 = 12", "2 × 3 = 6") + + +def test_schedule_project_and_policy(): + weeks = MANIFEST["duration"]["weeks"] + scheduled = [lesson for week in weeks for lesson in week["lesson_ids"]] + lessons = [lesson["id"] for module in MANIFEST["modules"] for lesson in module["lessons"]] + assert len(weeks) == 6 and sorted(scheduled) == sorted(lessons) == sorted(set(lessons)) + assert MANIFEST["maturity"] == "partial" and MANIFEST["grading_policy"]["mode"] == "formative-only" + assert len([c for c in CASES if ":project-m" in c["id"]]) == 5 + assert all(c["points"] == 0 for case in CASES for c in case["rubric"]) + assert "#/bench/class-ii-bsc-session-with-spill" in READINGS["lab-biosafety-3"] + + +def test_sources_and_comparator(): + for sid in ("osha-29cfr1910-1030-bloodborne-pathogens", "osha-29cfr1910-1450-laboratory-standard", + "osha-29cfr1910-1200-hazard-communication", "cdc-nih-bmbl-6"): + assert REGISTRY[sid]["reuse_mode"] == "adapted" and "lab-biosafety" in REGISTRY[sid]["mapped_courses"] + comparator = REGISTRY["absa-cbsp-examination-content"] + assert comparator["reuse_mode"] == "link-only-comparator" and not comparator["permissions"]["quotation"] + stated(CROSSWALK, "agent-run comparison", "Absent by design", "establishes no equivalence, eligibility, credit or endorsement") diff --git a/tests/content/test_robotics_recalculations.py b/tests/content/test_robotics_recalculations.py index c3808e3..b134960 100644 --- a/tests/content/test_robotics_recalculations.py +++ b/tests/content/test_robotics_recalculations.py @@ -670,7 +670,7 @@ def test_schedule_once_formative_labels_and_all_package_schedules(): json.loads(p.read_text(encoding="utf-8")) for p in (ROOT / "content/courses").glob("*/course.json") ] - assert len(courses) == 26 and all(c["duration"]["weeks"] for c in courses) + assert len(courses) == 27 and all(c["duration"]["weeks"] for c in courses) def test_computed_torque_lesson_statements_match_newton_euler_and_simulation(): From 62dc32f263bade53539eb6edba116c9c59112194 Mon Sep 17 00:00:00 2001 From: Dylan Stechmann Date: Sat, 31 Oct 2026 02:18:25 +0000 Subject: [PATCH 5/7] Add an autoclave waste-load validation bench simulation A fourth protocol (layer 1): prepare a typical waste load, place thermocouples and biological indicators, run a steam cycle, incubate the indicators with a positive control and report the centre heating lag, the verdict and the validated exposure time. Hidden heating lag and indicator D-values change from run to run; a sealed bag traps air so the centre never reaches temperature. Bagging, tray, indicator and thermocouple placement, 121 C and routine cycle records follow BMBL 6th edition Appendices A and B; other values are labelled practice or model values. Six new backend tests. Linked from lab-biosafety lesson 4 and its syllabus; README, roadmap, plan and a new docs/WET_LAB_LAYER_OUTLINES.md (layers 0-5) updated. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- backend/courselab/bench/__init__.py | 3 +- backend/courselab/bench/autoclave.py | 212 +++++++++ backend/tests/test_bench.py | 81 ++++ .../autoclave-waste-load-validation.json | 427 ++++++++++++++++++ ...mination-disinfection-and-sterilization.md | 2 + content/courses/lab-biosafety/syllabus.md | 2 +- docs/ROADMAP.md | 2 +- docs/WET_LAB_LAYER_OUTLINES.md | 62 +++ docs/WET_LAB_PRACTICE_PLAN.md | 2 +- 10 files changed, 790 insertions(+), 5 deletions(-) create mode 100644 backend/courselab/bench/autoclave.py create mode 100644 content/bench/protocols/autoclave-waste-load-validation.json create mode 100644 docs/WET_LAB_LAYER_OUTLINES.md diff --git a/README.md b/README.md index 9e5efae..143ffc5 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Single-choice, multiple-select, numeric, bounded symbolic-expression, narrow dat The React reader now includes six scientific calculators under **Study tools**, available without enrollment: dilution, buffer pH, enzyme kinetics, RC charging, diffusion timescale, and Ohm's law. Each shows units and model assumptions and links to related courses. Calculator scratch work is ungraded. -**Bench practice** replays simulations of real bench procedures (micropipette gravimetric verification, decimal dilutions with the FDA BAM aerobic plate count, and a Class II biosafety cabinet session with a spill) from the learner's own choices of action, with a checkpoint debrief at the end. Runs are formative, unrecorded and cannot train hand skill; see the [wet-lab practice plan](docs/WET_LAB_PRACTICE_PLAN.md). +**Bench practice** replays simulations of real bench procedures (micropipette gravimetric verification, decimal dilutions with the FDA BAM aerobic plate count, a Class II biosafety cabinet session with a spill, and validating an autoclave waste cycle with biological indicators) from the learner's own choices of action, with a checkpoint debrief at the end. Runs are formative, unrecorded and cannot train hand skill; see the [wet-lab practice plan](docs/WET_LAB_PRACTICE_PLAN.md). The React **Case studio** also exposes the authored prompts and self-check criteria across all 27 partial courses. Enrolled learners can save a private written response and checklist marks; drafts stay bound to the course version and prompt, remain available in account exports, and do not affect grades. Archived cases are read-only. diff --git a/backend/courselab/bench/__init__.py b/backend/courselab/bench/__init__.py index 7091c62..2f508bc 100644 --- a/backend/courselab/bench/__init__.py +++ b/backend/courselab/bench/__init__.py @@ -8,13 +8,14 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator +from .autoclave import AutoclaveValidation from .bsc import BiosafetyCabinetSession from .engine import ActionRejected, ProtocolInvalid, ProtocolModel, replay from .pipette import PipetteVerification from .plate_count import AerobicPlateCount MODELS: dict[str, type[ProtocolModel]] = { - model.protocol_id: model for model in (PipetteVerification, AerobicPlateCount, BiosafetyCabinetSession) + model.protocol_id: model for model in (PipetteVerification, AerobicPlateCount, BiosafetyCabinetSession, AutoclaveValidation) } __all__ = ["ActionRejected", "BenchLibrary", "ProtocolInvalid", "replay"] diff --git a/backend/courselab/bench/autoclave.py b/backend/courselab/bench/autoclave.py new file mode 100644 index 0000000..795ba3b --- /dev/null +++ b/backend/courselab/bench/autoclave.py @@ -0,0 +1,212 @@ +"""Validating a steam-autoclave cycle for a typical biohazardous waste load.""" + +from __future__ import annotations + +from .engine import ActionRejected, Event, ProtocolModel, coach + +POSITIONS = ("centre", "top", "drain") + + +class AutoclaveValidation(ProtocolModel): + protocol_id = "autoclave-waste-load-validation" + + def checkpoint_ids(self): + return ["bag-loosely-closed", "leak-proof-tray", "within-capacity", "thermocouple-at-centre", "bi-at-centre", + "positive-control", "exposure-sufficient", "bi-incubation", "cycle-record", "report-lag", "report-verdict", + "report-cycle-time"] + + def start(self, rng): + p = self.parameters + return { + "hidden": {"d_value": round(rng.uniform(*p["d_value_range_min"]), 2), + "lag_per_kg": round(rng.uniform(*p["lag_per_kg_range_min"]), 2)}, + "load": None, "probes": {}, "bis": {}, "control_bi": False, "cycle": None, "results": None, + "bi_incubation": None, "bi_read": None, "report": None, + } + + # Actions ------------------------------------------------------------------- + + def act_prepare_load(self, state, rng, mass_kg, bag_closure, water_added, in_tray): + if state["cycle"] is not None: + raise ActionRejected("The load has already been run") + state["load"] = {"mass": mass_kg, "closure": bag_closure, "water": water_added, "tray": in_tray} + events = [Event("info", f"A {mass_kg:g} kg waste load is bagged ({bag_closure.replace('_', ' ')}), " + f"{'with' if water_added else 'without'} added water, {'in a leak-proof tray' if in_tray else 'directly on the chamber shelf'}.")] + if bag_closure == "tightly_sealed": + coach(state, events, "A tightly sealed bag traps air and keeps steam out; close it loosely.") + if not in_tray: + coach(state, events, "Carry and autoclave bags in a leak-proof tray or pan.") + if mass_kg > self.parameters["chamber_capacity_kg"]: + coach(state, events, "The load exceeds the chamber's rated capacity; overloading blocks steam circulation.") + state["clock"] += 5 + return events + + def act_place_thermocouple(self, state, rng, position): + if state["load"] is None: + raise ActionRejected("Prepare the load first") + state["probes"][position] = True + return [Event("info", f"A thermocouple is placed at the {position} of the load.")] + + def act_place_bi(self, state, rng, position): + if state["load"] is None: + raise ActionRejected("Prepare the load first") + state["bis"][position] = True + return [Event("info", f"A biological indicator (10⁶ Geobacillus stearothermophilus spores) is placed at the {position} of the load.")] + + def act_keep_control_bi(self, state, rng): + state["control_bi"] = True + return [Event("info", "One indicator from the same lot is kept aside, unprocessed, as the positive control.")] + + def act_run_cycle(self, state, rng, temperature_c, exposure_min): + p = self.parameters + if state["load"] is None: + raise ActionRejected("Prepare the load first") + if state["cycle"] is not None: + raise ActionRejected("The cycle has already been run") + load, hidden = state["load"], state["hidden"] + lag = {"chamber": 0.0} + base = hidden["lag_per_kg"] * load["mass"] * (0.8 if load["water"] else 1.0) + if load["mass"] > p["chamber_capacity_kg"]: + base *= 1.6 + for position, factor in (("centre", 1.0), ("top", 0.45), ("drain", 0.3)): + lag[position] = round(base * factor, 1) + penetrates = load["closure"] == "loosely_closed" + reached = {} + for position, value in lag.items(): + if position != "chamber" and not penetrates and position == "centre": + reached[position] = None # air pocket: the centre never reaches the set temperature + else: + reached[position] = value if value < exposure_min or position == "chamber" else None + state["cycle"] = {"temperature": temperature_c, "exposure": exposure_min, "lag": lag, "reached": reached, + "penetrates": penetrates} + state["clock"] += exposure_min + 20 + rows = {} + for position in ("chamber",) + POSITIONS: + if position != "chamber" and position not in state["probes"]: + continue + r = reached[position] + rows[position] = "never reached the set temperature" if r is None else f"reached {temperature_c:g} °C after {r:g} min" + events = [Event("readout", f"Cycle complete: {temperature_c:g} °C for {exposure_min:g} min. Thermocouples: " + + "; ".join(f"{k} {v}" for k, v in rows.items()) + ".", {"thermocouples": rows})] + if not penetrates and "centre" in state["probes"]: + events.append(Event("consequence", "The centre thermocouple plateaued near 100 °C: air was trapped in the sealed bag.")) + return events + + def _survives(self, state, position): + p, cycle, hidden = self.parameters, state["cycle"], state["hidden"] + if cycle["temperature"] < p["set_temperature_c"]: + return True + reached = cycle["reached"][position] + if reached is None: + return True + time_at_temp = cycle["exposure"] - reached + log_reduction = time_at_temp / hidden["d_value"] + survivors = p["bi_population"] * 10 ** (-log_reduction) + return survivors >= 0.5 # an expected half spore or more counts as growth in this simple model + + def act_incubate_bis(self, state, rng, temperature_c, hours): + if state["cycle"] is None: + raise ActionRejected("Run the cycle first") + state["bi_incubation"] = {"temperature": temperature_c, "hours": hours} + state["clock"] += hours * 60 + return [Event("info", f"Indicators incubated at {temperature_c:g} °C for {hours:g} h.")] + + def act_read_bis(self, state, rng): + p = self.parameters + inc = state["bi_incubation"] + if inc is None: + raise ActionRejected("Incubate the indicators first") + ok_incubation = (p["bi_incubation_c"][0] <= inc["temperature"] <= p["bi_incubation_c"][1] + and inc["hours"] >= p["bi_incubation_h"]) + results = {} + for position in state["bis"]: + grew = self._survives(state, position) if ok_incubation else False + results[position] = "growth" if grew else "no growth" + if state["control_bi"]: + results["control"] = "growth" if ok_incubation else "no growth" + state["bi_read"] = results + return [Event("readout", "Indicator results: " + "; ".join(f"{k} {v}" for k, v in results.items()) + ".", results)] + + def act_record_cycle(self, state, rng): + if state["cycle"] is None: + raise ActionRejected("Run the cycle first") + state["cycle"]["recorded"] = True + return [Event("info", "Cycle time, temperature and pressure are recorded in the autoclave log with the load description.")] + + def act_submit_report(self, state, rng, centre_lag_min, verdict, validated_exposure_min): + if state["bi_read"] is None: + raise ActionRejected("Read the indicators before reporting") + state["report"] = {"lag": centre_lag_min, "verdict": verdict, "exposure": validated_exposure_min} + state["finished"] = True + return [Event("info", "The validation report is submitted.")] + + # Views and evaluation ------------------------------------------------------ + + def visible(self, state): + load = state["load"] + rows = [["Load", "not prepared" if load is None else + f"{load['mass']:g} kg, bag {load['closure'].replace('_', ' ')}, {'water added' if load['water'] else 'no water'}, " + f"{'in tray' if load['tray'] else 'no tray'}"], + ["Thermocouples", ", ".join(state["probes"]) or "none"], + ["Biological indicators", ", ".join(state["bis"]) or "none"], + ["Positive control kept", "yes" if state["control_bi"] else "no"]] + if state["bis"]: + rows.append(["Indicator lot label", f"10⁶ spores, D₁₂₁ = {state['hidden']['d_value']:g} min"]) + panels = [{"title": "Validation set-up", "rows": rows}] + if state["cycle"]: + c = state["cycle"] + panels.append({"title": "Cycle", "rows": [["Set point", f"{c['temperature']:g} °C"], ["Exposure", f"{c['exposure']:g} min"]]}) + if state["bi_read"]: + panels.append({"title": "Indicator results", "rows": [[k, v] for k, v in state["bi_read"].items()]}) + return panels + + def evaluate(self, state): + p = self.parameters + load, cycle, report, hidden = state["load"], state["cycle"], state["report"], state["hidden"] + r = {} + r["bag-loosely-closed"] = (bool(load) and load["closure"] == "loosely_closed", "Bags are closed loosely so steam can enter.") + r["leak-proof-tray"] = (bool(load) and load["tray"], "Waste is autoclaved in a leak-proof tray or pan.") + r["within-capacity"] = (bool(load) and load["mass"] <= p["chamber_capacity_kg"], + f"Load {load['mass'] if load else 0:g} kg against a rated capacity of {p['chamber_capacity_kg']:g} kg.") + r["thermocouple-at-centre"] = ("centre" in state["probes"], "The slowest-heating point, the centre, needs a thermocouple.") + r["bi-at-centre"] = ("centre" in state["bis"], "An indicator at the centre tests the hardest place to sterilize.") + r["positive-control"] = (state["control_bi"], "An unprocessed indicator from the same lot must grow, or negative results mean nothing.") + if cycle: + lag = cycle["lag"]["centre"] + needed = lag + p["required_log_reduction"] * hidden["d_value"] + enough = cycle["penetrates"] and cycle["temperature"] >= p["set_temperature_c"] and cycle["exposure"] >= needed + r["exposure-sufficient"] = (enough, f"With a centre lag of {lag:g} min and D₁₂₁ = {hidden['d_value']:g} min, twelve log reductions need " + f"at least {needed:.1f} min; the cycle ran {cycle['exposure']:g} min.") + r["cycle-record"] = (bool(cycle.get("recorded")), "Each cycle's time, temperature and pressure are recorded.") + else: + r["exposure-sufficient"] = (False, "No cycle was run.") + r["cycle-record"] = (False, "No cycle was run.") + inc = state["bi_incubation"] + r["bi-incubation"] = (bool(inc) and p["bi_incubation_c"][0] <= inc["temperature"] <= p["bi_incubation_c"][1] + and inc["hours"] >= p["bi_incubation_h"], + f"Incubate steam indicators at {p['bi_incubation_c'][0]:g}–{p['bi_incubation_c'][1]:g} °C for at least {p['bi_incubation_h']:g} h.") + if report is None or cycle is None: + for key in ("report-lag", "report-verdict", "report-cycle-time"): + r[key] = (False, "No report was submitted.") + return r + true_lag = cycle["lag"]["centre"] + observed = cycle["reached"]["centre"] if "centre" in state["probes"] else None + r["report-lag"] = ((observed is not None and abs(report["lag"] - observed) <= 0.5) if "centre" in state["probes"] else False, + f"The centre thermocouple reached temperature after {true_lag:g} min." if observed is not None + else "The centre lag was not measured or never reached temperature.") + results = state["bi_read"] + valid = state["control_bi"] and results.get("control") == "growth" and "centre" in results + expected = "pass" if valid and all(v == "no growth" for k, v in results.items() if k != "control") else "fail" + r["report-verdict"] = (report["verdict"] == expected, + f"With these indicator and control results the cycle should be reported as '{expected}'.") + if observed is None: + r["report-cycle-time"] = (None, "No validated time can be set until steam reaches the centre.") + else: + needed = observed + p["required_log_reduction"] * hidden["d_value"] + r["report-cycle-time"] = (report["exposure"] >= needed, + f"A validated exposure must be at least the lag plus twelve D-values, {needed:.1f} min.") + return r + + def reveal(self, state): + h = state["hidden"] + return [f"The indicator lot had D₁₂₁ = {h['d_value']:g} min; heating lag was {h['lag_per_kg']:g} min per kg at the centre before adjustments."] diff --git a/backend/tests/test_bench.py b/backend/tests/test_bench.py index 200bd8e..4a3ab9e 100644 --- a/backend/tests/test_bench.py +++ b/backend/tests/test_bench.py @@ -1,4 +1,5 @@ import json +import math import random from pathlib import Path @@ -470,6 +471,86 @@ def test_bsc_large_spill_with_open_drain(library): assert any("floor" in risk for risk in state["hidden"]["personnel_risk"]) + +# Autoclave validation --------------------------------------------------------- + +AUTOCLAVE = "autoclave-waste-load-validation" + + +def autoclave_run(model, seed, closure="loosely_closed", control=True, exposure_extra_d=2, mass=6.0, verdict=None, + report_exposure=None): + h = hidden(model, seed) + lag = round(h["lag_per_kg"] * mass * 0.8, 1) + exposure = math.ceil(lag + (12 + exposure_extra_d) * h["d_value"]) + actions = [act("prepare_load", mass_kg=mass, bag_closure=closure, water_added=True, in_tray=True), + act("place_thermocouple", position="centre"), act("place_thermocouple", position="top"), + act("place_bi", position="centre"), act("place_bi", position="top")] + if control: + actions.append(act("keep_control_bi")) + actions += [act("run_cycle", temperature_c=121, exposure_min=exposure), act("record_cycle"), + act("incubate_bis", temperature_c=57, hours=48), act("read_bis")] + state, _ = run_state(model, seed, "practice", actions) + results = state["bi_read"] + expected = "pass" if control and results.get("control") == "growth" and all( + v == "no growth" for k, v in results.items() if k != "control") else "fail" + observed = state["cycle"]["reached"]["centre"] + actions.append(act("submit_report", centre_lag_min=observed if observed is not None else 0, + verdict=verdict or expected, validated_exposure_min=report_exposure or exposure)) + return replay(model, seed, "practice", actions).view(), state, expected + + +def test_autoclave_correct_validation(library): + model = library.get(AUTOCLAVE) + view, state, expected = autoclave_run(model, 3) + assert expected == "pass" and state["bi_read"]["centre"] == "no growth" + assert all(v for v in checkpoint_map(view).values() if v is not None), checkpoint_map(view) + assert "D₁₂₁" in json.dumps(view["panels"], ensure_ascii=False) + + +def test_autoclave_sealed_bag_keeps_steam_out(library): + model = library.get(AUTOCLAVE) + view, state, expected = autoclave_run(model, 3, closure="tightly_sealed") + assert state["cycle"]["reached"]["centre"] is None and state["bi_read"]["centre"] == "growth" + met = checkpoint_map(view) + assert expected == "fail" and met["bag-loosely-closed"] is False and met["exposure-sufficient"] is False + assert met["report-cycle-time"] is None and met["report-verdict"] + assert any("100 °C" in e["text"] for entry in view["log"] for e in entry["events"]) + + +def test_autoclave_short_exposure_fails_at_the_centre(library): + model = library.get(AUTOCLAVE) + view, state, expected = autoclave_run(model, 5, exposure_extra_d=-7) + assert state["bi_read"]["centre"] == "growth" and expected == "fail" + assert checkpoint_map(view)["exposure-sufficient"] is False + + +def test_autoclave_needs_a_positive_control(library): + model = library.get(AUTOCLAVE) + view, _, expected = autoclave_run(model, 3, control=False) + met = checkpoint_map(view) + assert expected == "fail" and met["positive-control"] is False and met["report-verdict"] + wrong, _, _ = autoclave_run(model, 3, control=False, verdict="pass") + assert checkpoint_map(wrong)["report-verdict"] is False + + +def test_autoclave_cold_incubation_hides_survivors(library): + model = library.get(AUTOCLAVE) + h = hidden(model, 5) + actions = [act("prepare_load", mass_kg=6.0, bag_closure="loosely_closed", water_added=True, in_tray=True), + act("place_bi", position="centre"), act("keep_control_bi"), + act("run_cycle", temperature_c=121, exposure_min=5), act("incubate_bis", temperature_c=37, hours=12), + act("read_bis")] + state, _ = run_state(model, 5, "practice", actions) + assert state["bi_read"] == {"centre": "no growth", "control": "no growth"} + assert h["d_value"] > 0 + + +def test_autoclave_short_validated_time_is_flagged(library): + model = library.get(AUTOCLAVE) + view, _, _ = autoclave_run(model, 3, report_exposure=10) + assert checkpoint_map(view)["report-cycle-time"] is False + + # API ------------------------------------------------------------------------------ @pytest.fixture diff --git a/content/bench/protocols/autoclave-waste-load-validation.json b/content/bench/protocols/autoclave-waste-load-validation.json new file mode 100644 index 0000000..7a5ea84 --- /dev/null +++ b/content/bench/protocols/autoclave-waste-load-validation.json @@ -0,0 +1,427 @@ +{ + "schema_version": "1.0", + "id": "autoclave-waste-load-validation", + "version": "0.1.0", + "title": "Validating an autoclave cycle for a biohazardous waste load", + "layer": 1, + "summary": "Prepare a typical waste load, place thermocouples and biological indicators, run a steam cycle, incubate and read the indicators with a positive control, and report whether the cycle is validated and what exposure time the load needs.", + "scenario": "Your laboratory must validate the autoclave cycle it uses for bags of solid BSL-2 waste. You have a typical load, autoclave bags, leak-proof trays, thermocouples, self-contained biological indicators and an incubator. The indicator lot's D-value is printed on its label.", + "guided_steps": [ + "Bag the waste and close the bag loosely so steam can enter; place it in a leak-proof tray; do not exceed the chamber's rated load.", + "Place a thermocouple and a biological indicator at the centre of the load, the slowest point to heat, and others at the top and near the drain.", + "Keep one indicator from the same lot unprocessed as a positive control.", + "Run the cycle at 121 °C with an exposure long enough for the centre: its heating lag plus twelve D-values.", + "Record the cycle's time, temperature and pressure in the autoclave log.", + "Incubate the processed indicators and the control at 55–60 °C for at least 24 h, then read them.", + "The cycle passes only if the control grows and no processed indicator grows. Report the centre lag and the validated exposure time." + ], + "parameters": [ + { + "name": "set_temperature_c", + "value": 121, + "unit": "°C", + "label": "Steam temperature", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix B, Decontamination: 'a steam cycle used to sterilize pre-cleaned items can be 20 minutes at 121°C'" + } + }, + { + "name": "chamber_capacity_kg", + "value": 10, + "unit": "kg", + "label": "Rated load for this chamber", + "source": { + "kind": "simulation-typical", + "note": "A practice value; real capacity comes from the manufacturer and the validation." + } + }, + { + "name": "bi_population", + "value": 1000000, + "unit": "spores", + "label": "Spores per indicator", + "source": { + "kind": "simulation-typical" + } + }, + { + "name": "required_log_reduction", + "value": 12, + "unit": "log10", + "label": "Log reductions required at the centre", + "source": { + "kind": "simulation-typical", + "note": "Twelve log reductions of a 10⁶ population correspond to a sterility assurance level of 10⁻⁶, as defined in BMBL Appendix B." + } + }, + { + "name": "bi_incubation_c", + "value": [ + 55, + 60 + ], + "unit": "°C", + "label": "Indicator incubation temperature", + "source": { + "kind": "simulation-typical", + "note": "Typical for Geobacillus stearothermophilus indicators; follow the indicator manufacturer's instructions." + } + }, + { + "name": "bi_incubation_h", + "value": 24, + "unit": "h", + "label": "Minimum indicator incubation", + "source": { + "kind": "simulation-typical", + "note": "Follow the indicator manufacturer's instructions." + } + }, + { + "name": "d_value_range_min", + "value": [ + 1.5, + 2.5 + ], + "unit": "min", + "label": "Range of indicator D-values", + "source": { + "kind": "simulation-model" + } + }, + { + "name": "lag_per_kg_range_min", + "value": [ + 2.0, + 5.0 + ], + "unit": "min/kg", + "label": "Heating lag per kilogram at the centre", + "source": { + "kind": "simulation-model" + } + } + ], + "actions": [ + { + "name": "prepare_load", + "label": "Prepare the waste load", + "params": [ + { + "name": "mass_kg", + "label": "Load mass", + "type": "number", + "min": 0.5, + "max": 20, + "unit": "kg" + }, + { + "name": "bag_closure", + "label": "Bag closure", + "type": "choice", + "options": [ + { + "value": "loosely_closed", + "label": "Loosely closed" + }, + { + "value": "tightly_sealed", + "label": "Tightly sealed" + } + ] + }, + { + "name": "water_added", + "label": "Add a little water to the bag", + "type": "boolean" + }, + { + "name": "in_tray", + "label": "Place in a leak-proof tray", + "type": "boolean" + } + ] + }, + { + "name": "place_thermocouple", + "label": "Place a thermocouple", + "params": [ + { + "name": "position", + "label": "Position", + "type": "choice", + "options": [ + { + "value": "centre", + "label": "Centre of the load (slowest to heat)" + }, + { + "value": "top", + "label": "Top of the load" + }, + { + "value": "drain", + "label": "Near the chamber drain" + } + ] + } + ] + }, + { + "name": "place_bi", + "label": "Place a biological indicator", + "params": [ + { + "name": "position", + "label": "Position", + "type": "choice", + "options": [ + { + "value": "centre", + "label": "Centre of the load (slowest to heat)" + }, + { + "value": "top", + "label": "Top of the load" + }, + { + "value": "drain", + "label": "Near the chamber drain" + } + ] + } + ] + }, + { + "name": "keep_control_bi", + "label": "Keep an unprocessed indicator as positive control", + "params": [] + }, + { + "name": "run_cycle", + "label": "Run the steam cycle", + "params": [ + { + "name": "temperature_c", + "label": "Set temperature", + "type": "number", + "min": 100, + "max": 135, + "unit": "°C" + }, + { + "name": "exposure_min", + "label": "Exposure time", + "type": "number", + "min": 5, + "max": 120, + "unit": "min" + } + ] + }, + { + "name": "record_cycle", + "label": "Record the cycle in the autoclave log", + "params": [] + }, + { + "name": "incubate_bis", + "label": "Incubate the indicators", + "params": [ + { + "name": "temperature_c", + "label": "Temperature", + "type": "number", + "min": 20, + "max": 70, + "unit": "°C" + }, + { + "name": "hours", + "label": "Time", + "type": "number", + "min": 1, + "max": 168, + "unit": "h" + } + ] + }, + { + "name": "read_bis", + "label": "Read the indicators", + "params": [] + }, + { + "name": "submit_report", + "label": "Submit the validation report", + "params": [ + { + "name": "centre_lag_min", + "label": "Centre heating lag", + "type": "number", + "min": 0, + "max": 120, + "unit": "min" + }, + { + "name": "verdict", + "label": "Verdict", + "type": "choice", + "options": [ + { + "value": "pass", + "label": "Validated" + }, + { + "value": "fail", + "label": "Not validated" + } + ] + }, + { + "name": "validated_exposure_min", + "label": "Exposure time to use for this load", + "type": "number", + "min": 0, + "max": 180, + "unit": "min" + } + ] + } + ], + "checkpoints": [ + { + "id": "bag-loosely-closed", + "severity": "critical", + "description": "Close autoclave bags loosely so steam can enter.", + "rationale": "A sealed bag traps air, and the centre never reaches steam temperature.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 386: 'The bag should be loosely closed (to allow steam to enter the bag)'" + } + }, + { + "id": "leak-proof-tray", + "severity": "major", + "description": "Autoclave bags in a leak-proof tray or pan.", + "rationale": "Bags can leak or split during the cycle.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix A, Part 5, p. 387: 'The bag should be transported and autoclaved in a leak-proof tray or pan'" + } + }, + { + "id": "within-capacity", + "severity": "minor", + "description": "Do not exceed the chamber's rated load.", + "rationale": "Overloading blocks steam circulation and lengthens heating.", + "source": { + "kind": "simulation-typical" + } + }, + { + "id": "thermocouple-at-centre", + "severity": "major", + "description": "Place a thermocouple at the centre of the load.", + "rationale": "Validation must show that steam reaches the slowest point.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix B, Decontamination: 'thermocouples and biological indicators (BIs) placed throughout the load'" + } + }, + { + "id": "bi-at-centre", + "severity": "critical", + "description": "Place a biological indicator at the centre of the load.", + "rationale": "The centre is the hardest place to sterilize, so it decides the cycle.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix B, Decontamination" + } + }, + { + "id": "positive-control", + "severity": "major", + "description": "Incubate an unprocessed indicator from the same lot as a positive control.", + "rationale": "Without a growing control, a 'no growth' result could mean dead indicators or a failed incubator.", + "source": { + "kind": "simulation-typical" + } + }, + { + "id": "exposure-sufficient", + "severity": "critical", + "description": "Run an exposure of at least the centre lag plus twelve D-values at 121 °C.", + "rationale": "Exposure counts only once the slowest point reaches temperature.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix B: cycle times for high-bioburden waste 'should be verified and validated for the typical load'" + } + }, + { + "id": "bi-incubation", + "severity": "major", + "description": "Incubate indicators at 55–60 °C for at least 24 h.", + "rationale": "Spores that survive need the right conditions to grow and show the failure.", + "source": { + "kind": "simulation-typical" + } + }, + { + "id": "cycle-record", + "severity": "minor", + "description": "Record the cycle's time, temperature and pressure.", + "rationale": "Routine monitoring of cycles is part of verification.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Appendix B: 'routine monitoring of the steam sterilization cycles (i.e., cycle times, pressure, temperature)'" + } + }, + { + "id": "report-lag", + "severity": "major", + "description": "Report the centre heating lag measured by the thermocouple.", + "rationale": "The lag sets the minimum cycle for this load.", + "source": { + "kind": "simulation-typical" + } + }, + { + "id": "report-verdict", + "severity": "critical", + "description": "Report 'validated' only if the control grows and no processed indicator grows.", + "rationale": "Any growth, or a control that fails to grow, means the cycle is not shown to work.", + "source": { + "kind": "simulation-typical" + } + }, + { + "id": "report-cycle-time", + "severity": "critical", + "description": "Set the exposure for this load at no less than the lag plus twelve D-values.", + "rationale": "A shorter routine cycle would leave the centre under-treated.", + "source": { + "kind": "simulation-typical" + } + } + ], + "sources": [ + { + "source_id": "cdc-nih-bmbl-6", + "use": "Bagging, tray, thermocouple and indicator placement, 121 °C, and routine cycle monitoring, adapted from a US federal government work." + } + ], + "limitations": [ + "Heating lag and indicator D-values come from a simple model; real loads are validated by measurement for each load type.", + "Survival is modelled as first-order kill once the centre reaches temperature; heating and cooling ramps are ignored.", + "Chamber capacity and indicator incubation conditions are practice values; follow the autoclave and indicator manufacturers' instructions and your institution's procedures." + ] +} \ No newline at end of file diff --git a/content/courses/lab-biosafety/modules/04-decontamination-disinfection-and-sterilization.md b/content/courses/lab-biosafety/modules/04-decontamination-disinfection-and-sterilization.md index ceec995..0e366a6 100644 --- a/content/courses/lab-biosafety/modules/04-decontamination-disinfection-and-sterilization.md +++ b/content/courses/lab-biosafety/modules/04-decontamination-disinfection-and-sterilization.md @@ -74,6 +74,8 @@ Biological indicators (BIs) carry a known population of resistant spores, common Chemical indicator tape shows that an item was exposed to heat, not that the load was sterilized. +Validating a waste cycle is practised in the [autoclave validation simulation](#/bench/autoclave-waste-load-validation), where the heating lag and the indicator lot change from run to run. + Practical points for waste in autoclave bags: close bags loosely so steam can enter, and carry them in a leak-proof tray or pan. Decide by testing whether water must be added. Never overload the chamber. ## Common mistakes diff --git a/content/courses/lab-biosafety/syllabus.md b/content/courses/lab-biosafety/syllabus.md index d093d09..1c52865 100644 --- a/content/courses/lab-biosafety/syllabus.md +++ b/content/courses/lab-biosafety/syllabus.md @@ -26,7 +26,7 @@ This is a proposed scope, not a measured or reviewed schedule. | 1 | [Biological risk assessment](modules/01-biological-risk-assessment.md) | | 2 | [Biosafety Levels 1 and 2](modules/02-biosafety-levels-1-and-2.md) | | 3 | [Biological safety cabinets](modules/03-biological-safety-cabinets.md); [bench practice: a Class II cabinet session with a spill](#/bench/class-ii-bsc-session-with-spill) | -| 4 | [Decontamination, disinfection and sterilization](modules/04-decontamination-disinfection-and-sterilization.md) | +| 4 | [Decontamination, disinfection and sterilization](modules/04-decontamination-disinfection-and-sterilization.md); [bench practice: validating an autoclave waste cycle](#/bench/autoclave-waste-load-validation) | | 5 | [Human cells and tissues, bloodborne pathogens and exposure response](modules/05-human-materials-bloodborne-pathogens-and-exposures.md) | | 6 | [Chemical hygiene, hazard communication and transport](modules/06-chemical-hygiene-hazard-communication-and-transport.md); [optional biosafety plan project guide](assignments/ipsc-project-biosafety-plan-guide.md) | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 79ef3ed..c9f935d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,7 +30,7 @@ Delivered: guest/account access; version-pinned enrollment; server-backed notes, The case studio supports private drafts, version/prompt snapshots, conflict protection, earlier response history, account export/deletion and read-only archived cases. Historical feature-batch verification is recorded in the linked review reports. Since 2026-10-13 each batch runs the full root, backend and frontend suites; earlier batches recorded "no test suite was run", and the drift they left was repaired in one pass. Platform verification is not course review or protected-exam release approval. -Bench practice (owner request, 2026-10-30) follows [the wet-lab practice plan](WET_LAB_PRACTICE_PLAN.md). The replay engine, three protocols and a React bench view are delivered: micropipette gravimetric verification, decimal dilutions with the BAM aerobic plate count, and a Class II biosafety cabinet session with a spill. The layer-0 Laboratory Measurement package (0.1.0, six weeks, with an optional qualification project) is delivered. The layer-1 Laboratory Safety and Biosafety package (0.1.0, six weeks, BMBL and OSHA sources, an optional iPSC biosafety plan project) is delivered. Next: more bench protocols for layers 0–1, then the layer-2 cell-culture package. +Bench practice (owner request, 2026-10-30) follows [the wet-lab practice plan](WET_LAB_PRACTICE_PLAN.md). The replay engine, three protocols and a React bench view are delivered: micropipette gravimetric verification, decimal dilutions with the BAM aerobic plate count, and a Class II biosafety cabinet session with a spill. The layer-0 Laboratory Measurement package (0.1.0, six weeks, with an optional qualification project) is delivered. The layer-1 Laboratory Safety and Biosafety package (0.1.0, six weeks, BMBL and OSHA sources, an optional iPSC biosafety plan project) is delivered. A fourth protocol, autoclave waste-load validation with thermocouples and biological indicators, is delivered. Next: an exposure-response protocol, then the layer-2 cell-culture package ([layer outlines](WET_LAB_LAYER_OUTLINES.md)). The searchable notebook reuses the existing private note store, exposes case-insensitive literal search with course filtering and pagination, and links matches back to enrolled course lessons. Remaining shared work should address a demonstrated learner workflow or blocker and fit alongside the instructional rotation. Avoid adding isolated grader types while course foundations remain thin. diff --git a/docs/WET_LAB_LAYER_OUTLINES.md b/docs/WET_LAB_LAYER_OUTLINES.md new file mode 100644 index 0000000..87a6916 --- /dev/null +++ b/docs/WET_LAB_LAYER_OUTLINES.md @@ -0,0 +1,62 @@ +# Wet-lab practice track: layer outlines + +Drafted with AI assistance on 2026-10-30 as a planning record for the [wet-lab practice plan](WET_LAB_PRACTICE_PLAN.md). These are outlines, not instruction: no lesson below exists yet unless marked as drafted, and nothing here has been reviewed by a laboratory professional, a biosafety officer or a certifying body. Scope sources are named for each layer; public-domain US federal works may be adapted with attribution, and everything else is link-only. + +## Layer 0: `lab-measurement` (built, 0.1.0) + +Six lessons: concentration and solution preparation; dilutions and dilution series; measuring volume and checking pipettes; balances and weighing; pH measurement and buffer preparation; spectrophotometry and standard curves. Bench protocols: micropipette gravimetric verification; decimal dilutions and the aerobic plate count. + +## Layer 1: `lab-biosafety` (built, 0.1.0) + +Scope: BMBL 6th edition (Sections II–IV, Appendices A, B, C, H), OSHA 29 CFR 1910.1030, 1910.1450 and 1910.1200; all US federal works. + +| Lesson | Main source | Bench protocol | +| --- | --- | --- | +| 1. Biological risk assessment: hazard and risk, agent and procedure hazards, routes of laboratory-associated infection, hierarchy of controls, the six-step process | BMBL Section II | — | +| 2. Biosafety Levels 1 and 2: containment elements, BSL-2 standard and special practices, safety equipment and facilities, entrance signs | BMBL Sections III–IV | — | +| 3. Biological safety cabinets: protections, Class I/II/III and Class II types, inflow volume, selection, placement, certification and UV | BMBL Appendix A | Class II BSC session with a spill (built) | +| 4. Decontamination, disinfection and sterilization: definitions, resistance ranking, disinfectant activity, bleach dilution, D-values and SAL, autoclave verification | BMBL Appendix B | Autoclave waste-load validation (built) | +| 5. Human cells and tissues, bloodborne pathogens and exposure response | BMBL Appendix H; OSHA 1910.1030 | Planned: needlestick or splash response timeline | +| 6. Chemical hygiene, hazard communication and transport of infectious substances | OSHA 1910.1450 and 1910.1200; BMBL Appendix C | Planned: read an SDS and a label under time pressure | + +Built with 64 practice items, 26 cards, a case, an optional iPSC biosafety plan project and a crosswalk against the ABSA CBSP examination outline. Still planned: the exposure-response and SDS protocols. + +## Layer 2: `cell-culture-practice` (outline only) + +Scope: BMBL Appendix H (human and primate cells); ISSCR *Standards for Human Stem Cell Use in Research* (2023, link only); manufacturer-neutral practice. Candidate open protocols are to be checked one by one for license and parameters. + +| Lesson | Topics | Bench protocol (planned) | +| --- | --- | --- | +| 1. Aseptic technique | working order in the cabinet, what breaks sterility, media and reagent handling, contamination routes | Aseptic medium change with hidden contamination events | +| 2. Media, supplements and reagent preparation | osmolality and pH of media, serum and serum-free systems, filter sterilization, storage and expiry | Filter-sterilize and QC a supplemented medium | +| 3. Passaging adherent and suspension cells | confluence, dissociation agents, split ratios, seeding density arithmetic, population doublings | Passage an adherent line to a target density | +| 4. Counting cells and viability | hemocytometer geometry, trypan blue, counting rules, Poisson error, automated counters | Hemocytometer count with sampling error | +| 5. Cryopreservation and thawing | cryoprotectants, cooling rate, storage in liquid nitrogen vapour, thaw and recovery | Freeze and thaw with viability outcomes | +| 6. Contamination: bacteria, fungi, mycoplasma and cross-contamination | recognition, mycoplasma testing methods, quarantine, authentication by STR profiling | Diagnose a contaminated culture from clues | +| 7. Human pluripotent stem cells | feeder-free culture on matrix, colony morphology and differentiation, passaging as clumps, karyotype and pluripotency checks (link-only ISSCR reporting standards) | Maintain an hPSC culture without spontaneous differentiation | +| 8. Records and traceability | cell-bank tiers, passage records, lot tracking, labelling | — | + +## Layer 3: `molecular-lab-practice` (outline only) + +Scope: ASCP BOC MB(ASCP) examination content guideline, molecular techniques and laboratory operations sections (link only; the March 2023 revision was read from an archived copy, and the current revision must be compared before authoring). + +| Lesson | Topics | Bench protocol (planned) | +| --- | --- | --- | +| 1. Nucleic acid isolation | manual and automated methods, lysis chemistry, column and bead binding | Extract DNA and RNA with yield outcomes | +| 2. Quantity and quality of nucleic acid | A260 conversions, A260/A280 and A260/A230 ratios, fluorometry, integrity numbers | Read a spectrum and decide whether to proceed | +| 3. Gel and capillary electrophoresis | agarose percentage and resolution, ladders, staining, capillary sizing | Run and interpret a gel with hidden errors | +| 4. PCR design and optimization | primer design rules, Tm, annealing, Mg²⁺, controls, gradient PCR | Optimize a failing PCR | +| 5. Real-time PCR and RT-qPCR | efficiency from standard curves, Cq, ΔΔCq, melt curves, reference genes, no-template and no-RT controls | Analyse a qPCR run with contamination clues | +| 6. Sequencing | Sanger chemistry and traces, next-generation library basics, coverage and quality scores | Read a Sanger trace and call a variant | +| 7. Contamination control and one-way workflow | pre- and post-amplification areas, UNG, decontamination, monitoring | Trace the source of amplicon contamination | +| 8. Assay validation and troubleshooting | accuracy, precision, LOD, reportable range, controls and proficiency testing | — | + +## Layer 4: `lab-quality-systems` (outline only) + +Scope: ASCP MB laboratory-operations section (link only); 21 CFR Part 58 (GLP) and 42 CFR Part 493 (CLIA), both US federal works. + +Lessons: documentation and notebooks (ALCOA principles of record integrity); SOP and document control; equipment qualification, calibration and maintenance records; quality control and proficiency testing; deviations, investigations and corrective and preventive action; competency assessment and training records; test-system categories (RUO, ASR, IVD, LDT) and who regulates them. + +## Layer 5 and the MB applications package + +Protein methods, microscopy and flow cytometry, a CRISPR workflow, organoids, and a cell-therapy manufacturing awareness lesson remain to be scoped one topic at a time. The applications third of the MB outline (infectious disease, oncology, genetic disorders, histocompatibility, identity, engraftment and pharmacogenomics) would follow layer 3 and reuse the genetics package where it can. diff --git a/docs/WET_LAB_PRACTICE_PLAN.md b/docs/WET_LAB_PRACTICE_PLAN.md index f2b0393..599a897 100644 --- a/docs/WET_LAB_PRACTICE_PLAN.md +++ b/docs/WET_LAB_PRACTICE_PLAN.md @@ -2,7 +2,7 @@ Status: drafted with AI assistance on 2026-10-30. Nothing here has been reviewed by a laboratory professional, a biosafety officer or a certifying body. -Built so far (2026-10-30, agent-run, unreviewed): the replay engine and API, the protocol schema, three protocols and a React bench view. These are items 1–3 of the first milestone below. The protocols are `micropipette-gravimetric-verification` (layer 0), `decimal-dilution-aerobic-plate-count` (layer 0, adapted from FDA BAM Chapter 3, January 2026 edition) and `class-ii-bsc-session-with-spill` (layer 1, adapted from BMBL 6th edition, Appendix A). Item 4, the layer-0 course package, is next. +Built so far (2026-10-30, agent-run, unreviewed): the replay engine and API, the protocol schema, three protocols and a React bench view. These are items 1–3 of the first milestone below. The protocols are `micropipette-gravimetric-verification` (layer 0), `decimal-dilution-aerobic-plate-count` (layer 0, adapted from FDA BAM Chapter 3, January 2026 edition) and `class-ii-bsc-session-with-spill` (layer 1, adapted from BMBL 6th edition, Appendix A). Item 4 followed: the layer-0 `lab-measurement` and layer-1 `lab-biosafety` packages are built (0.1.0 each), with a fourth protocol, `autoclave-waste-load-validation`. Per-layer outlines are in [WET_LAB_LAYER_OUTLINES.md](WET_LAB_LAYER_OUTLINES.md). ## Goal From db4a7c59fc66c86f7db343cc1223d0678163b513 Mon Sep 17 00:00:00 2001 From: Dylan Stechmann Date: Sat, 31 Oct 2026 02:47:52 +0000 Subject: [PATCH 6/7] Add an exposure-incident first-response bench simulation A fifth protocol (layer 1): a needlestick, cut or eye splash with primary human cells is chosen at random, and the learner must decontaminate at once by the right method, make the work safe, report immediately, contact occupational health, document the route and source material and complete the incident record. Checkpoints follow OSHA 29 CFR 1910.1030 (d)(2)(vi) and (f)(3) and BMBL BSL-2 special practice 7; the 15-minute eye flush and the 30-minute reporting window are labelled practice values. Six new backend tests. Linked from lab-biosafety lesson 5 and its syllabus; README, roadmap and layer outlines updated. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- backend/courselab/bench/__init__.py | 3 +- backend/courselab/bench/exposure.py | 155 +++++++++++ backend/tests/test_bench.py | 49 ++++ .../exposure-incident-first-response.json | 260 ++++++++++++++++++ ...ials-bloodborne-pathogens-and-exposures.md | 2 + content/courses/lab-biosafety/syllabus.md | 2 +- docs/ROADMAP.md | 2 +- docs/WET_LAB_LAYER_OUTLINES.md | 4 +- 9 files changed, 473 insertions(+), 6 deletions(-) create mode 100644 backend/courselab/bench/exposure.py create mode 100644 content/bench/protocols/exposure-incident-first-response.json diff --git a/README.md b/README.md index 143ffc5..5f262cd 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Single-choice, multiple-select, numeric, bounded symbolic-expression, narrow dat The React reader now includes six scientific calculators under **Study tools**, available without enrollment: dilution, buffer pH, enzyme kinetics, RC charging, diffusion timescale, and Ohm's law. Each shows units and model assumptions and links to related courses. Calculator scratch work is ungraded. -**Bench practice** replays simulations of real bench procedures (micropipette gravimetric verification, decimal dilutions with the FDA BAM aerobic plate count, a Class II biosafety cabinet session with a spill, and validating an autoclave waste cycle with biological indicators) from the learner's own choices of action, with a checkpoint debrief at the end. Runs are formative, unrecorded and cannot train hand skill; see the [wet-lab practice plan](docs/WET_LAB_PRACTICE_PLAN.md). +**Bench practice** replays simulations of real bench procedures (micropipette gravimetric verification, decimal dilutions with the FDA BAM aerobic plate count, a Class II biosafety cabinet session with a spill, validating an autoclave waste cycle with biological indicators, and the first response to a needlestick, cut or eye splash) from the learner's own choices of action, with a checkpoint debrief at the end. Runs are formative, unrecorded and cannot train hand skill; see the [wet-lab practice plan](docs/WET_LAB_PRACTICE_PLAN.md). The React **Case studio** also exposes the authored prompts and self-check criteria across all 27 partial courses. Enrolled learners can save a private written response and checklist marks; drafts stay bound to the course version and prompt, remain available in account exports, and do not affect grades. Archived cases are read-only. diff --git a/backend/courselab/bench/__init__.py b/backend/courselab/bench/__init__.py index 2f508bc..61226c7 100644 --- a/backend/courselab/bench/__init__.py +++ b/backend/courselab/bench/__init__.py @@ -11,11 +11,12 @@ from .autoclave import AutoclaveValidation from .bsc import BiosafetyCabinetSession from .engine import ActionRejected, ProtocolInvalid, ProtocolModel, replay +from .exposure import ExposureResponse from .pipette import PipetteVerification from .plate_count import AerobicPlateCount MODELS: dict[str, type[ProtocolModel]] = { - model.protocol_id: model for model in (PipetteVerification, AerobicPlateCount, BiosafetyCabinetSession, AutoclaveValidation) + model.protocol_id: model for model in (PipetteVerification, AerobicPlateCount, BiosafetyCabinetSession, AutoclaveValidation, ExposureResponse) } __all__ = ["ActionRejected", "BenchLibrary", "ProtocolInvalid", "replay"] diff --git a/backend/courselab/bench/exposure.py b/backend/courselab/bench/exposure.py new file mode 100644 index 0000000..8b82e3c --- /dev/null +++ b/backend/courselab/bench/exposure.py @@ -0,0 +1,155 @@ +"""The first minutes after an exposure to human cell material: decontaminate, secure, report, document.""" + +from __future__ import annotations + +from .engine import ActionRejected, Event, ProtocolModel, coach + +EXPOSURES = { + "needlestick": "A used needle slips while you aspirate medium from a flask of primary human cells and punctures your gloved finger.", + "eye-splash": "Medium from a tube of primary human cells splashes into your eye as you open it outside the cabinet.", + "cut": "A glass Pasteur pipette used with primary human cells breaks and cuts your hand through the glove.", +} +SKIN = {"needlestick", "cut"} + + +class ExposureResponse(ProtocolModel): + protocol_id = "exposure-incident-first-response" + + def checkpoint_ids(self): + return ["decontaminate-immediately", "right-method", "flush-duration", "secure-material", "report-immediately", + "occupational-health", "document-route", "identify-source", "incident-record", "no-return-before-report"] + + def start(self, rng): + return {"hidden": {"exposure": rng.choice(sorted(EXPOSURES))}, "announced": False, "washed": None, "flushed": None, + "secured": None, "reported": None, "occ_health": None, "route_documented": False, "source_documented": False, + "record": False, "returned_before_report": False, "returned": False} + + def _announce(self, state, events): + if not state["announced"]: + state["announced"] = True + events.insert(0, Event("consequence", EXPOSURES[state["hidden"]["exposure"]], {"exposure": state["hidden"]["exposure"]})) + + def act_begin_work(self, state, rng): + if state["announced"]: + raise ActionRejected("The incident has already happened") + events = [Event("info", "You start passaging the culture.")] + self._announce(state, events) + return events + + def _need_incident(self, state): + if not state["announced"]: + raise ActionRejected("Begin the work first") + + def act_wash_with_soap_and_water(self, state, rng, seconds): + self._need_incident(state) + state["washed"] = {"at": state["clock"], "seconds": seconds} + state["clock"] += seconds / 60 + events = [Event("info", f"The area is washed with soap and running water for {seconds:g} s.")] + if state["hidden"]["exposure"] not in SKIN: + coach(state, events, "For an eye splash, flush the eye with water at the eyewash.") + return events + + def act_flush_at_eyewash(self, state, rng, minutes): + self._need_incident(state) + state["flushed"] = {"at": state["clock"], "minutes": minutes} + state["clock"] += minutes + events = [Event("info", f"The eye is flushed at the eyewash for {minutes:g} min, holding the eyelids open.")] + if state["hidden"]["exposure"] in SKIN: + coach(state, events, "A wound or skin exposure is washed with soap and water.") + return events + + def act_secure_material(self, state, rng): + self._need_incident(state) + state["secured"] = state["clock"] + state["clock"] += 1 + return [Event("info", "The flask is capped, sharps go into the sharps container, and the area is marked so no one else is exposed.")] + + def act_notify_supervisor(self, state, rng): + self._need_incident(state) + state["reported"] = state["clock"] + state["clock"] += 2 + return [Event("info", "Your supervisor is told what happened and takes over making the area safe.")] + + def act_contact_occupational_health(self, state, rng): + self._need_incident(state) + state["occ_health"] = state["clock"] + state["clock"] += 5 + events = [Event("info", "Occupational health arranges a confidential medical evaluation straight away.")] + if state["reported"] is None: + coach(state, events, "Tell your supervisor as well; both must know at once.") + return events + + def act_document_route_and_circumstances(self, state, rng): + self._need_incident(state) + state["route_documented"] = True + state["clock"] += 3 + return [Event("info", "The route of exposure and the circumstances are written down for the medical evaluation.")] + + def act_identify_source_material(self, state, rng): + self._need_incident(state) + state["source_documented"] = True + state["clock"] += 2 + return [Event("info", "The cell line, its donor code and passage, and the culture vessel are identified and recorded.")] + + def act_complete_incident_record(self, state, rng): + self._need_incident(state) + state["record"] = True + state["clock"] += 10 + return [Event("info", "The laboratory incident record is completed for follow-up and review of the risk assessment.")] + + def act_wait(self, state, rng, minutes): + state["clock"] += minutes + return [Event("info", f"{minutes:g} min pass.")] + + def act_return_to_work(self, state, rng): + self._need_incident(state) + if state["reported"] is None: + state["returned_before_report"] = True + state["returned"] = True + state["finished"] = True + return [Event("info", "You return to the bench and the run ends.")] + + def act_end_response(self, state, rng): + self._need_incident(state) + state["finished"] = True + return [Event("info", "The first response is complete; follow-up continues with occupational health.")] + + def visible(self, state): + rows = [["Clock since incident", f"{state['clock']:.1f} min" if state["announced"] else "work not started"]] + for label, key in (("Washed", "washed"), ("Eye flushed", "flushed"), ("Material secured", "secured"), + ("Supervisor told", "reported"), ("Occupational health", "occ_health")): + rows.append([label, "no" if state[key] is None else "yes"]) + rows += [["Route documented", "yes" if state["route_documented"] else "no"], + ["Source identified", "yes" if state["source_documented"] else "no"], + ["Incident record", "yes" if state["record"] else "no"]] + return [{"title": "Response so far", "rows": rows}] + + def evaluate(self, state): + p = self.parameters + kind = state["hidden"]["exposure"] + first = state["washed"] if kind in SKIN else state["flushed"] + r = {} + r["decontaminate-immediately"] = (first is not None and first["at"] <= p["decontaminate_within_min"], + "Wash or flush first, before anything else." if first is None + else f"First wash or flush began {first['at']:.1f} min after the incident.") + wrong = state["flushed"] if kind in SKIN else state["washed"] + r["right-method"] = (first is not None and wrong is None, + "Soap and water for skin and wounds; water at the eyewash for eyes and mucous membranes.") + if kind == "eye-splash": + r["flush-duration"] = (first is not None and first["minutes"] >= p["eye_flush_min"], + f"Flush for at least {p['eye_flush_min']:g} min.") + else: + r["flush-duration"] = (None, "No eye exposure in this run.") + r["secure-material"] = (state["secured"] is not None, "Make the material and sharps safe so no one else is exposed.") + r["report-immediately"] = (state["reported"] is not None and state["reported"] <= p["report_within_min"], + "The incident was not reported." if state["reported"] is None + else f"Reported {state['reported']:.1f} min after the incident.") + r["occupational-health"] = (state["occ_health"] is not None, "Contact occupational health for the medical evaluation.") + r["document-route"] = (state["route_documented"], "Document the route and circumstances of the exposure.") + r["identify-source"] = (state["source_documented"], "Identify and record the source material.") + r["incident-record"] = (state["record"], "Complete the laboratory incident record.") + r["no-return-before-report"] = (not state["returned_before_report"], "Never go back to work before reporting.") + return r + + def reveal(self, state): + return [f"This run's exposure was: {state['hidden']['exposure'].replace('-', ' ')}."] diff --git a/backend/tests/test_bench.py b/backend/tests/test_bench.py index 4a3ab9e..d3944b6 100644 --- a/backend/tests/test_bench.py +++ b/backend/tests/test_bench.py @@ -551,6 +551,55 @@ def test_autoclave_short_validated_time_is_flagged(library): assert checkpoint_map(view)["report-cycle-time"] is False + +# Exposure first response --------------------------------------------------------- + +EXPOSURE = "exposure-incident-first-response" + + +def exposure_good(kind): + first = [act("flush_at_eyewash", minutes=15)] if kind == "eye-splash" else [act("wash_with_soap_and_water", seconds=60)] + return [act("begin_work"), *first, act("secure_material"), act("notify_supervisor"), act("contact_occupational_health"), + act("document_route_and_circumstances"), act("identify_source_material"), act("complete_incident_record"), + act("end_response")] + + +@pytest.mark.parametrize("kind", ["needlestick", "eye-splash", "cut"]) +def test_exposure_correct_response(library, kind): + model = library.get(EXPOSURE) + seed = seed_where(model, lambda h: h["exposure"] == kind) + view = replay(model, seed, "practice", exposure_good(kind)).view() + met = checkpoint_map(view) + assert all(v for v in met.values() if v is not None), met + assert (met["flush-duration"] is None) == (kind != "eye-splash") + assert view["log"][0]["events"][0]["kind"] == "consequence" + + +def test_exposure_wrong_method_late_report_and_early_return(library): + model = library.get(EXPOSURE) + seed = seed_where(model, lambda h: h["exposure"] == "eye-splash") + actions = [act("begin_work"), act("wait", minutes=10), act("wash_with_soap_and_water", seconds=20), + act("wait", minutes=30), act("return_to_work")] + met = checkpoint_map(replay(model, seed, "practice", actions).view()) + for key in ("decontaminate-immediately", "right-method", "flush-duration", "report-immediately", "occupational-health", + "no-return-before-report", "document-route", "identify-source"): + assert met[key] is False, key + + +def test_exposure_short_eye_flush_is_flagged(library): + model = library.get(EXPOSURE) + seed = seed_where(model, lambda h: h["exposure"] == "eye-splash") + actions = exposure_good("eye-splash") + actions[1] = act("flush_at_eyewash", minutes=2) + assert checkpoint_map(replay(model, seed, "practice", actions).view())["flush-duration"] is False + + +def test_exposure_actions_need_the_incident_first(library): + model = library.get(EXPOSURE) + with pytest.raises(ActionRejected, match="Begin the work"): + replay(model, 1, "practice", [act("notify_supervisor")]) + + # API ------------------------------------------------------------------------------ @pytest.fixture diff --git a/content/bench/protocols/exposure-incident-first-response.json b/content/bench/protocols/exposure-incident-first-response.json new file mode 100644 index 0000000..5aa1629 --- /dev/null +++ b/content/bench/protocols/exposure-incident-first-response.json @@ -0,0 +1,260 @@ +{ + "schema_version": "1.0", + "id": "exposure-incident-first-response", + "version": "0.1.0", + "title": "The first minutes after an exposure to human cell material", + "layer": 1, + "summary": "Respond to a needlestick, cut or eye splash during work with primary human cells: decontaminate at once by the right method, make the work safe, report immediately, and document what occupational health needs.", + "scenario": "You are passaging primary human cells of unknown infection status in a BSL-2 laboratory. Something will go wrong. Respond as your exposure control plan expects.", + "guided_steps": [ + "Wash a wound or skin exposure at once with soap and running water; flush an eye at the eyewash with water.", + "For an eye, keep flushing for at least 15 minutes with the eyelids held open.", + "Make the material and any sharps safe so no one else is exposed.", + "Report immediately to your supervisor and contact occupational health for a confidential medical evaluation.", + "Document the route and circumstances of the exposure and identify the source material.", + "Complete the laboratory incident record. Do not return to work before the incident is reported." + ], + "parameters": [ + { + "name": "decontaminate_within_min", + "value": 1, + "unit": "min", + "label": "Time to start washing or flushing", + "source": { + "kind": "cited", + "source_id": "osha-29cfr1910-1030-bloodborne-pathogens", + "location": "(d)(2)(vi): wash or flush 'immediately or as soon as feasible'" + } + }, + { + "name": "eye_flush_min", + "value": 15, + "unit": "min", + "label": "Eye flush duration", + "source": { + "kind": "simulation-typical", + "note": "A common eyewash duration; follow your institution's procedure and the eyewash instructions." + } + }, + { + "name": "report_within_min", + "value": 30, + "unit": "min", + "label": "Time to report", + "source": { + "kind": "simulation-typical", + "note": "Practice threshold for 'immediately' that allows a 15-minute eye flush first; OSHA (f)(3) makes evaluation available immediately after a report." + } + } + ], + "actions": [ + { + "name": "begin_work", + "label": "Begin passaging the culture", + "params": [] + }, + { + "name": "wash_with_soap_and_water", + "label": "Wash with soap and running water", + "params": [ + { + "name": "seconds", + "label": "Duration", + "type": "number", + "min": 5, + "max": 900, + "unit": "s" + } + ] + }, + { + "name": "flush_at_eyewash", + "label": "Flush the eye at the eyewash", + "params": [ + { + "name": "minutes", + "label": "Duration", + "type": "number", + "min": 0.5, + "max": 30, + "unit": "min" + } + ] + }, + { + "name": "secure_material", + "label": "Make the material and sharps safe", + "params": [] + }, + { + "name": "notify_supervisor", + "label": "Tell your supervisor", + "params": [] + }, + { + "name": "contact_occupational_health", + "label": "Contact occupational health", + "params": [] + }, + { + "name": "document_route_and_circumstances", + "label": "Document the route and circumstances", + "params": [] + }, + { + "name": "identify_source_material", + "label": "Identify and record the source material", + "params": [] + }, + { + "name": "complete_incident_record", + "label": "Complete the incident record", + "params": [] + }, + { + "name": "wait", + "label": "Wait", + "params": [ + { + "name": "minutes", + "label": "Minutes", + "type": "number", + "min": 1, + "max": 240, + "unit": "min" + } + ] + }, + { + "name": "return_to_work", + "label": "Return to the bench", + "params": [] + }, + { + "name": "end_response", + "label": "End the first response", + "params": [] + } + ], + "checkpoints": [ + { + "id": "decontaminate-immediately", + "severity": "critical", + "description": "Begin washing or flushing immediately, before anything else.", + "rationale": "Every minute of contact increases the chance of infection.", + "source": { + "kind": "cited", + "source_id": "osha-29cfr1910-1030-bloodborne-pathogens", + "location": "(d)(2)(vi)" + } + }, + { + "id": "right-method", + "severity": "major", + "description": "Use soap and water for skin and wounds, and water flushing for eyes and mucous membranes.", + "rationale": "The standard names both methods for their exposure types.", + "source": { + "kind": "cited", + "source_id": "osha-29cfr1910-1030-bloodborne-pathogens", + "location": "(d)(2)(vi): 'wash hands and any other skin with soap and water, or flush mucous membranes with water'" + } + }, + { + "id": "flush-duration", + "severity": "major", + "description": "Flush an exposed eye for at least 15 minutes.", + "rationale": "A brief rinse leaves material in the eye.", + "source": { + "kind": "simulation-typical" + } + }, + { + "id": "secure-material", + "severity": "minor", + "description": "Make the material and sharps safe.", + "rationale": "A second person should not be exposed by what caused the first exposure.", + "source": { + "kind": "simulation-typical" + } + }, + { + "id": "report-immediately", + "severity": "critical", + "description": "Report the incident to your supervisor immediately.", + "rationale": "Medical evaluation, and prophylaxis where indicated, is time-sensitive.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Section IV, BSL-2 special practice 7: incidents 'are immediately evaluated' and reported to the supervisor" + } + }, + { + "id": "occupational-health", + "severity": "critical", + "description": "Contact occupational health for a confidential medical evaluation.", + "rationale": "The employer must make evaluation and follow-up available immediately after a report.", + "source": { + "kind": "cited", + "source_id": "osha-29cfr1910-1030-bloodborne-pathogens", + "location": "(f)(3)" + } + }, + { + "id": "document-route", + "severity": "major", + "description": "Document the route of exposure and the circumstances.", + "rationale": "The medical evaluation begins with this documentation.", + "source": { + "kind": "cited", + "source_id": "osha-29cfr1910-1030-bloodborne-pathogens", + "location": "(f)(3)(i)" + } + }, + { + "id": "identify-source", + "severity": "major", + "description": "Identify and record the source material.", + "rationale": "Identification of the source is part of the evaluation; for cell material, the line, donor code and vessel.", + "source": { + "kind": "cited", + "source_id": "osha-29cfr1910-1030-bloodborne-pathogens", + "location": "(f)(3)(ii)" + } + }, + { + "id": "incident-record", + "severity": "minor", + "description": "Complete the laboratory incident record.", + "rationale": "Records allow follow-up and a review of the risk assessment.", + "source": { + "kind": "cited", + "source_id": "cdc-nih-bmbl-6", + "location": "Section IV, BSL-2 special practice 7: 'Appropriate records are maintained'" + } + }, + { + "id": "no-return-before-report", + "severity": "critical", + "description": "Do not return to work before the incident is reported.", + "rationale": "Delay removes the chance of timely evaluation.", + "source": { + "kind": "simulation-typical" + } + } + ], + "sources": [ + { + "source_id": "osha-29cfr1910-1030-bloodborne-pathogens", + "use": "Washing and flushing, and the elements of post-exposure evaluation, adapted from a US federal regulation." + }, + { + "source_id": "cdc-nih-bmbl-6", + "use": "Immediate evaluation, reporting and records of incidents at BSL-2, adapted from a US federal work." + } + ], + "limitations": [ + "The order and timing of steps are practice rules; your exposure control plan and occupational health service govern a real response.", + "Medical evaluation, testing and prophylaxis decisions are not simulated and nothing here is medical advice.", + "The simulation cannot train the speed and calm a real incident needs; drills and supervision do." + ] +} \ No newline at end of file diff --git a/content/courses/lab-biosafety/modules/05-human-materials-bloodborne-pathogens-and-exposures.md b/content/courses/lab-biosafety/modules/05-human-materials-bloodborne-pathogens-and-exposures.md index 57ab106..ff6f3a1 100644 --- a/content/courses/lab-biosafety/modules/05-human-materials-bloodborne-pathogens-and-exposures.md +++ b/content/courses/lab-biosafety/modules/05-human-materials-bloodborne-pathogens-and-exposures.md @@ -51,6 +51,8 @@ Exposures are emergencies with a short window for action. The standard requires 4. **Record** what happened: the material, the agent if known, the procedure and the route. 5. **Follow up** with the medical evaluation, and help review the incident so the risk assessment can be updated. +The [exposure-response simulation](#/bench/exposure-incident-first-response) rehearses these steps for a needlestick, a cut or an eye splash, chosen at random. + ## Common mistakes - Treating a "screened" or commercially sourced human cell line as free of risk. diff --git a/content/courses/lab-biosafety/syllabus.md b/content/courses/lab-biosafety/syllabus.md index 1c52865..709e2d0 100644 --- a/content/courses/lab-biosafety/syllabus.md +++ b/content/courses/lab-biosafety/syllabus.md @@ -27,7 +27,7 @@ This is a proposed scope, not a measured or reviewed schedule. | 2 | [Biosafety Levels 1 and 2](modules/02-biosafety-levels-1-and-2.md) | | 3 | [Biological safety cabinets](modules/03-biological-safety-cabinets.md); [bench practice: a Class II cabinet session with a spill](#/bench/class-ii-bsc-session-with-spill) | | 4 | [Decontamination, disinfection and sterilization](modules/04-decontamination-disinfection-and-sterilization.md); [bench practice: validating an autoclave waste cycle](#/bench/autoclave-waste-load-validation) | -| 5 | [Human cells and tissues, bloodborne pathogens and exposure response](modules/05-human-materials-bloodborne-pathogens-and-exposures.md) | +| 5 | [Human cells and tissues, bloodborne pathogens and exposure response](modules/05-human-materials-bloodborne-pathogens-and-exposures.md); [bench practice: the first minutes after an exposure](#/bench/exposure-incident-first-response) | | 6 | [Chemical hygiene, hazard communication and transport](modules/06-chemical-hygiene-hazard-communication-and-transport.md); [optional biosafety plan project guide](assignments/ipsc-project-biosafety-plan-guide.md) | ## Optional biosafety plan project (ungraded) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c9f935d..571ec9e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,7 +30,7 @@ Delivered: guest/account access; version-pinned enrollment; server-backed notes, The case studio supports private drafts, version/prompt snapshots, conflict protection, earlier response history, account export/deletion and read-only archived cases. Historical feature-batch verification is recorded in the linked review reports. Since 2026-10-13 each batch runs the full root, backend and frontend suites; earlier batches recorded "no test suite was run", and the drift they left was repaired in one pass. Platform verification is not course review or protected-exam release approval. -Bench practice (owner request, 2026-10-30) follows [the wet-lab practice plan](WET_LAB_PRACTICE_PLAN.md). The replay engine, three protocols and a React bench view are delivered: micropipette gravimetric verification, decimal dilutions with the BAM aerobic plate count, and a Class II biosafety cabinet session with a spill. The layer-0 Laboratory Measurement package (0.1.0, six weeks, with an optional qualification project) is delivered. The layer-1 Laboratory Safety and Biosafety package (0.1.0, six weeks, BMBL and OSHA sources, an optional iPSC biosafety plan project) is delivered. A fourth protocol, autoclave waste-load validation with thermocouples and biological indicators, is delivered. Next: an exposure-response protocol, then the layer-2 cell-culture package ([layer outlines](WET_LAB_LAYER_OUTLINES.md)). +Bench practice (owner request, 2026-10-30) follows [the wet-lab practice plan](WET_LAB_PRACTICE_PLAN.md). The replay engine, three protocols and a React bench view are delivered: micropipette gravimetric verification, decimal dilutions with the BAM aerobic plate count, and a Class II biosafety cabinet session with a spill. The layer-0 Laboratory Measurement package (0.1.0, six weeks, with an optional qualification project) is delivered. The layer-1 Laboratory Safety and Biosafety package (0.1.0, six weeks, BMBL and OSHA sources, an optional iPSC biosafety plan project) is delivered. A fourth protocol, autoclave waste-load validation with thermocouples and biological indicators, is delivered. A fifth, the first response to an exposure incident, is delivered. Next: the layer-2 cell-culture package ([layer outlines](WET_LAB_LAYER_OUTLINES.md)). The searchable notebook reuses the existing private note store, exposes case-insensitive literal search with course filtering and pagination, and links matches back to enrolled course lessons. Remaining shared work should address a demonstrated learner workflow or blocker and fit alongside the instructional rotation. Avoid adding isolated grader types while course foundations remain thin. diff --git a/docs/WET_LAB_LAYER_OUTLINES.md b/docs/WET_LAB_LAYER_OUTLINES.md index 87a6916..d2b254b 100644 --- a/docs/WET_LAB_LAYER_OUTLINES.md +++ b/docs/WET_LAB_LAYER_OUTLINES.md @@ -16,10 +16,10 @@ Scope: BMBL 6th edition (Sections II–IV, Appendices A, B, C, H), OSHA 29 CFR 1 | 2. Biosafety Levels 1 and 2: containment elements, BSL-2 standard and special practices, safety equipment and facilities, entrance signs | BMBL Sections III–IV | — | | 3. Biological safety cabinets: protections, Class I/II/III and Class II types, inflow volume, selection, placement, certification and UV | BMBL Appendix A | Class II BSC session with a spill (built) | | 4. Decontamination, disinfection and sterilization: definitions, resistance ranking, disinfectant activity, bleach dilution, D-values and SAL, autoclave verification | BMBL Appendix B | Autoclave waste-load validation (built) | -| 5. Human cells and tissues, bloodborne pathogens and exposure response | BMBL Appendix H; OSHA 1910.1030 | Planned: needlestick or splash response timeline | +| 5. Human cells and tissues, bloodborne pathogens and exposure response | BMBL Appendix H; OSHA 1910.1030 | Exposure incident first response (built) | | 6. Chemical hygiene, hazard communication and transport of infectious substances | OSHA 1910.1450 and 1910.1200; BMBL Appendix C | Planned: read an SDS and a label under time pressure | -Built with 64 practice items, 26 cards, a case, an optional iPSC biosafety plan project and a crosswalk against the ABSA CBSP examination outline. Still planned: the exposure-response and SDS protocols. +Built with 64 practice items, 26 cards, a case, an optional iPSC biosafety plan project and a crosswalk against the ABSA CBSP examination outline. Still planned: an SDS and label protocol. ## Layer 2: `cell-culture-practice` (outline only) From 489902a3f3f150b3ddaa73e688e67a3d007a54b0 Mon Sep 17 00:00:00 2001 From: Dylan Stechmann Date: Sat, 31 Oct 2026 03:29:10 +0000 Subject: [PATCH 7/7] Compare Statistics with a second university; add categorical outcomes and one-way ANOVA (0.7.0) A new scope crosswalk sets MIT OCW 18.05 Spring 2022 beside the University of South Carolina STAT 205 Fall 2011 schedule (link-only comparator record). Both teach chi-square methods and STAT 205 also teaches 2 x 2 tables, stratification and one-way ANOVA, which the package lacked. Two original readings: categorical outcomes in week 5 (risk difference, risk ratio and odds ratio with a log-scale interval, chi-square and Fisher's exact tests, Simpson's paradox and the Mantel-Haenszel odds ratio) and one-way analysis of variance in week 6 (sums of squares, F, eta squared, pooled-variance comparisons with a Bonferroni adjustment, ANOVA from summaries). 20 practice items and eight cards; all data synthetic. The crosswalk records that likelihood estimation and normal conjugacy, listed as limited in the older audit, were already taught. Logistic regression and ROC curves remain absent. Snapshot (0.6.0 archived), handoff, progress record, audit, plan index, roadmap, reports and version pins updated; the new items are added to the independent statistics recalculation table. Co-Authored-By: Claude Opus 5.5 --- content/courses/statistics/course.json | 232 +++++- ...-categorical-outcomes-two-by-two-tables.md | 88 +++ .../27-one-way-analysis-of-variance.md | 80 +++ .../statistics/question-banks/practice.json | 658 ++++++++++++++++++ .../question-banks/retrieval-cards.json | 80 +++ content/courses/statistics/scope-crosswalk.md | 36 + content/courses/statistics/source-map.json | 30 + content/courses/statistics/syllabus.md | 8 +- content/sources/registry.json | 38 +- docs/ASSESSMENT_CANDIDATE_REPORT.md | 2 +- docs/COURSE_GAP_REPORT.md | 8 +- docs/COURSE_PLAN_COMPLETION.md | 2 +- docs/COURSE_SCOPE_AUDIT.md | 2 +- docs/PROGRESS_REVIEW.md | 4 + docs/ROADMAP.md | 2 +- docs/STATISTICS_REVIEW_HANDOFF.md | 6 +- docs/STATISTICS_REVIEW_SNAPSHOT-0.6.0.md | 12 + docs/STATISTICS_REVIEW_SNAPSHOT.md | 12 +- tests/content/test_ai_assisted_checks.py | 16 + ..._new_geroscience_and_statistics_lessons.py | 2 +- ...tatistics_categorical_and_anova_lessons.py | 155 +++++ ...t_statistics_confirmatory_project_draft.py | 2 +- .../test_statistics_semester_expansion.py | 4 +- 23 files changed, 1449 insertions(+), 30 deletions(-) create mode 100644 content/courses/statistics/modules/26-categorical-outcomes-two-by-two-tables.md create mode 100644 content/courses/statistics/modules/27-one-way-analysis-of-variance.md create mode 100644 content/courses/statistics/scope-crosswalk.md create mode 100644 docs/STATISTICS_REVIEW_SNAPSHOT-0.6.0.md create mode 100644 tests/content/test_statistics_categorical_and_anova_lessons.py diff --git a/content/courses/statistics/course.json b/content/courses/statistics/course.json index 6df0152..ca91120 100644 --- a/content/courses/statistics/course.json +++ b/content/courses/statistics/course.json @@ -7,7 +7,7 @@ "domain": "Quantitative methods", "level": "Year 2", "maturity": "partial", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT OR CC-BY-4.0", "content_origin": { "kind": "legacy-prototype", @@ -775,6 +775,78 @@ "statistics-outcome-10", "statistics-outcome-11" ] + }, + { + "id": "statistics-26-objective-1", + "description": "Arrange binary outcomes in a 2 × 2 table and compute the risk difference, risk ratio and odds ratio with a log-scale interval for the odds ratio.", + "bloom": "apply", + "course_outcome_ids": [ + "statistics-outcome-2", + "statistics-outcome-3" + ] + }, + { + "id": "statistics-26-objective-2", + "description": "Compute expected counts and Pearson's χ² statistic for independence, and state when it is unreliable.", + "bloom": "apply", + "course_outcome_ids": [ + "statistics-outcome-2", + "statistics-outcome-3" + ] + }, + { + "id": "statistics-26-objective-3", + "description": "Compute an exact p-value with Fisher's test from the hypergeometric distribution.", + "bloom": "apply", + "course_outcome_ids": [ + "statistics-outcome-2", + "statistics-outcome-3" + ] + }, + { + "id": "statistics-26-objective-4", + "description": "Recognize Simpson's paradox and combine strata with the Mantel–Haenszel odds ratio.", + "bloom": "analyze", + "course_outcome_ids": [ + "statistics-outcome-2", + "statistics-outcome-3" + ] + }, + { + "id": "statistics-27-objective-1", + "description": "Partition variation into between- and within-group sums of squares and compute the mean squares, F and degrees of freedom.", + "bloom": "apply", + "course_outcome_ids": [ + "statistics-outcome-2", + "statistics-outcome-3" + ] + }, + { + "id": "statistics-27-objective-2", + "description": "State the null hypothesis and assumptions of one-way ANOVA and interpret η².", + "bloom": "understand", + "course_outcome_ids": [ + "statistics-outcome-2", + "statistics-outcome-3" + ] + }, + { + "id": "statistics-27-objective-3", + "description": "Explain family-wise error inflation and compute adjusted pairwise comparisons with the pooled variance.", + "bloom": "evaluate", + "course_outcome_ids": [ + "statistics-outcome-2", + "statistics-outcome-3" + ] + }, + { + "id": "statistics-27-objective-4", + "description": "Compute a one-way ANOVA from group means, standard deviations and sizes.", + "bloom": "apply", + "course_outcome_ids": [ + "statistics-outcome-2", + "statistics-outcome-3" + ] } ], "duration": { @@ -831,25 +903,29 @@ }, { "week": 5, - "title": "Comparing two groups: Welch and paired t statistics and intervals for a difference", + "title": "Comparing two groups: Welch and paired t statistics and intervals for a difference; categorical outcomes and 2 × 2 tables", "lesson_ids": [ - "statistics-10" + "statistics-10", + "statistics-26" ], "assessment_ids": [ - "statistics-10-practice" + "statistics-10-practice", + "statistics-26-practice" ] }, { "week": 6, - "title": "Hypothesis tests, multiple comparisons and the false discovery rate", + "title": "Hypothesis tests, multiple comparisons and the false discovery rate; one-way analysis of variance", "lesson_ids": [ "statistics-3", "statistics-7", + "statistics-27", "statistics-23" ], "assessment_ids": [ "statistics-3-practice", "statistics-7-practice", + "statistics-27-practice", "statistics-23-practice" ] }, @@ -1859,6 +1935,86 @@ ] } ] + }, + { + "id": "statistics-module-26", + "title": "Categorical outcomes: proportions, 2 × 2 tables and confounding", + "source_ids": [ + "statistics-usc-stat205", + "statistics-semester-calendar" + ], + "lessons": [ + { + "id": "statistics-26", + "title": "Categorical outcomes: proportions, 2 × 2 tables and confounding", + "reading": "modules/26-categorical-outcomes-two-by-two-tables.md", + "objectives": [ + "statistics-26-objective-1", + "statistics-26-objective-2", + "statistics-26-objective-3", + "statistics-26-objective-4" + ], + "worked_example": "In a synthetic study 12 of 40 treated and 24 of 40 vehicle mice develop lesions: RR = 0.50, OR = 0.286 with a 95% interval of 0.113 to 0.721, and χ² = 7.27 on 1 df (p = 0.0070) from expected counts of 18 and 22.", + "question_ids": [ + "statistics-26:risk-ratio", + "statistics-26:odds-ratio", + "statistics-26:risk-difference", + "statistics-26:expected", + "statistics-26:chi-square", + "statistics-26:log-or-se", + "statistics-26:fisher-point", + "statistics-26:which-test", + "statistics-26:simpson", + "statistics-26:or-vs-rr" + ], + "card_ids": [ + "statistics-26-card-1", + "statistics-26-card-2", + "statistics-26-card-3", + "statistics-26-card-4" + ] + } + ] + }, + { + "id": "statistics-module-27", + "title": "Comparing several groups: one-way analysis of variance", + "source_ids": [ + "statistics-usc-stat205", + "statistics-semester-calendar" + ], + "lessons": [ + { + "id": "statistics-27", + "title": "Comparing several groups: one-way analysis of variance", + "reading": "modules/27-one-way-analysis-of-variance.md", + "objectives": [ + "statistics-27-objective-1", + "statistics-27-objective-2", + "statistics-27-objective-3", + "statistics-27-objective-4" + ], + "worked_example": "Three dose groups of six cultures with means 50, 55 and 60 give SS_B = 300 and SS_W = 104, so F = 150/6.93 = 21.6 on 2 and 15 degrees of freedom; pooled-variance comparisons with a Bonferroni adjustment all stay below 0.05.", + "question_ids": [ + "statistics-27:ssb", + "statistics-27:ssw", + "statistics-27:f-stat", + "statistics-27:df-within", + "statistics-27:eta-squared", + "statistics-27:fwer", + "statistics-27:pairwise-se", + "statistics-27:null", + "statistics-27:follow-up", + "statistics-27:assumptions" + ], + "card_ids": [ + "statistics-27-card-1", + "statistics-27-card-2", + "statistics-27-card-3", + "statistics-27-card-4" + ] + } + ] } ], "assessments": [ @@ -2494,6 +2650,62 @@ "statistics-25:depth-2" ], "points": 8 + }, + { + "id": "statistics-26-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: categorical outcomes: proportions, 2 × 2 tables and confounding", + "path": "question-banks/practice.json", + "objective_ids": [ + "statistics-26-objective-1", + "statistics-26-objective-2", + "statistics-26-objective-3", + "statistics-26-objective-4", + "statistics-outcome-2", + "statistics-outcome-3" + ], + "question_ids": [ + "statistics-26:risk-ratio", + "statistics-26:odds-ratio", + "statistics-26:risk-difference", + "statistics-26:expected", + "statistics-26:chi-square", + "statistics-26:log-or-se", + "statistics-26:fisher-point", + "statistics-26:which-test", + "statistics-26:simpson", + "statistics-26:or-vs-rr" + ], + "points": 11 + }, + { + "id": "statistics-27-practice", + "type": "practice", + "mode": "practice", + "title": "Practice: comparing several groups: one-way analysis of variance", + "path": "question-banks/practice.json", + "objective_ids": [ + "statistics-27-objective-1", + "statistics-27-objective-2", + "statistics-27-objective-3", + "statistics-27-objective-4", + "statistics-outcome-2", + "statistics-outcome-3" + ], + "question_ids": [ + "statistics-27:ssb", + "statistics-27:ssw", + "statistics-27:f-stat", + "statistics-27:df-within", + "statistics-27:eta-squared", + "statistics-27:fwer", + "statistics-27:pairwise-se", + "statistics-27:null", + "statistics-27:follow-up", + "statistics-27:assumptions" + ], + "points": 11 } ], "grading_policy": { @@ -2613,6 +2825,11 @@ "version": "0.6.0", "date": "2026-10-22", "summary": "Added four original joint-law/total-variance, censored-likelihood/MLE, normal-conjugacy/prediction and multiple-regression foundations with four connected reports and twenty-four checkpoints/cards. Developed exact finite coverage/randomization and conditional-versus-empirical evidence. All inputs are synthetic; package remains partial, unreviewed and formative-only." + }, + { + "version": "0.7.0", + "date": "2026-10-31", + "summary": "Compared the package with the University of South Carolina STAT 205 (Fall 2011) schedule as well as the MIT 18.05 Spring 2022 calendar, and added two original readings both teach and the package lacked: categorical outcomes (2 × 2 tables, risk difference, risk ratio and odds ratio, χ² and Fisher's exact tests, Simpson's paradox and the Mantel–Haenszel odds ratio) in week 5 and one-way analysis of variance in week 6, with 20 practice items and eight retrieval cards, and a scope crosswalk. All data are synthetic. The package remains partial, unreviewed and formative-only." } ], "limitations": [ @@ -2628,10 +2845,11 @@ "The optional confirmatory-protocol dossier project is ungraded. Its milestone drafts are private self-checks; no software check evaluates the learner’s written derivation, diagram or interpretation. The proposed schedule and time estimate have not been measured.", "Four foundations and four connected open reports deepen supported joint laws/total variance, selected censored exponential likelihood and binomial MLE, known-variance normal conjugacy, multiple-regression algebra/prediction and exact finite coverage/randomization. These selected models do not reproduce the comparator’s full probability/distribution/theory/problem-set/studio/exam sequence; measured workload and qualified review remain open.", "All records and normalized tables are invented. Censoring is stipulated noninformative and parameter-independent; normal observation variance is known by assumption. Constructed least-squares residuals do not establish empirical independent normal errors. Checkpoints evaluate selected responses, not code, proofs, plots, reports, causal identification or real biological model validity.", - "Of the 147 numerical answers, 2 require units, 69 check significant figures and 1 check dimensions. New continuous responses request three significant figures in named reporting units; exact allocation and degree counts have no significant-figure requirement." + "Of the 161 numerical answers, 2 require units, 80 check significant figures and 1 checks dimensions. New continuous responses request three significant figures in named reporting units; exact allocation and degree counts have no significant-figure requirement." ], "intentionally_omitted": [ - "Graded assessments and a laboratory sequence are not authored yet (one synthetic-data virtual lab exists); the original lessons extend a partial package." + "Graded assessments and a laboratory sequence are not authored yet (one synthetic-data virtual lab exists); the original lessons extend a partial package.", + "Named by the USC STAT 205 comparator but not taught here: logistic regression, ROC curves, the Cochran–Mantel–Haenszel test statistic and statistical computing in R. Tukey's procedure, two-way and repeated-measures ANOVA and tables larger than 2 × 2 are also omitted. See the scope crosswalk." ], "learner_resources": [ "assignments/confirmatory-protocol-rehearsal.csv", diff --git a/content/courses/statistics/modules/26-categorical-outcomes-two-by-two-tables.md b/content/courses/statistics/modules/26-categorical-outcomes-two-by-two-tables.md new file mode 100644 index 0000000..f1ad251 --- /dev/null +++ b/content/courses/statistics/modules/26-categorical-outcomes-two-by-two-tables.md @@ -0,0 +1,88 @@ +# Categorical outcomes: proportions, 2 × 2 tables and confounding + +Many outcomes are counts of yes or no: whether a mouse developed a lesion, whether a culture was contaminated, whether a patient responded. Comparing two groups on such an outcome means comparing proportions. This lesson sets out the 2 × 2 table, three ways of measuring the difference (risk difference, risk ratio and odds ratio), the χ² test and Fisher's exact test, and what happens when a third variable is ignored. All data are synthetic. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Arrange binary outcomes for two groups in a 2 × 2 table and compute the risk difference, risk ratio and odds ratio, with a confidence interval for the odds ratio. +2. Compute expected counts and the Pearson χ² statistic for independence, and state when it is unreliable. +3. Compute an exact p-value with Fisher's test from the hypergeometric distribution for a small table. +4. Recognize Simpson's paradox, and combine strata with the Mantel–Haenszel odds ratio. + +## The 2 × 2 table and three measures + +In a synthetic study, 40 aged mice receive a treatment and 40 receive vehicle, and lesions are counted at the end: + +| | Lesion | No lesion | Total | +| --- | --- | --- | --- | +| Treated | a = 12 | b = 28 | 40 | +| Vehicle | c = 24 | d = 16 | 40 | +| Total | 36 | 44 | 80 | + +The risks are p₁ = 12/40 = 0.30 and p₂ = 24/40 = 0.60. + +- **Risk difference:** p₁ − p₂ = −0.30, thirty fewer lesions per hundred mice. Its approximate 95% interval uses SE = √(p₁(1 − p₁)/n₁ + p₂(1 − p₂)/n₂) = 0.106, giving −0.51 to −0.09. +- **Risk ratio:** p₁/p₂ = 0.50, half the risk. +- **Odds ratio:** (a × d)/(b × c) = (12 × 16)/(28 × 24) = 0.286. The odds of a lesion are 12/28 = 0.43 with treatment and 24/16 = 1.5 with vehicle. + +The odds ratio is close to the risk ratio only when the outcome is rare; here, with a common outcome, 0.286 overstates the risk ratio's halving. Odds ratios are still widely used because they are what logistic regression and case-control studies estimate. A confidence interval is computed on the log scale: SE(ln OR) = √(1/a + 1/b + 1/c + 1/d) = 0.472, so the 95% interval is exp(ln 0.286 ± 1.96 × 0.472) = 0.113 to 0.721. The interval excludes 1, the value for no association. + +## The χ² test of independence + +If treatment and outcome were independent, each cell would be expected to hold row total × column total ÷ grand total: 40 × 36/80 = 18 lesions in each group, and 22 without. Pearson's statistic compares observed with expected counts: + + χ² = Σ (O − E)²/E = (12 − 18)²/18 + (28 − 22)²/22 + (24 − 18)²/18 + (16 − 22)²/22 = 7.27. + +A 2 × 2 table has (2 − 1)(2 − 1) = 1 degree of freedom, and χ² = 7.27 on 1 df gives p = 0.0070. For one degree of freedom, χ² is the square of a z statistic, so p = erfc(√(χ²/2)). + +The χ² distribution is an approximation that needs adequate expected counts. A common rule asks for every expected count to be at least 5; when it fails, use an exact test. + +## Fisher's exact test + +A small pilot gives a lesion in 1 of 8 treated mice and 6 of 8 vehicle mice. The expected counts are 3.5, below 5. Fisher's test conditions on the margins: with 7 lesions among 16 mice and 8 mice treated, the number of treated lesions X follows a hypergeometric distribution, + + P(X = x) = C(7, x) × C(9, 8 − x)/C(16, 8). + +P(X = 1) = 7 × 36/12,870 = 0.0196 and P(X = 0) = 1 × 9/12,870 = 0.0007, so the one-sided p-value is 0.0203. The two-sided p-value sums the probabilities of all tables at least as unlikely as the one observed, which here adds the mirror-image tables X = 6 and X = 7: p = 0.0406. + +## Simpson's paradox and stratification + +A third variable can reverse an association when it is ignored. In a synthetic observational dataset, a drug is used more often in severe cases, and severity affects improvement: + +| Stratum | Drug improved | Placebo improved | Odds ratio | +| --- | --- | --- | --- | +| Mild | 81/87 = 93.1% | 234/270 = 86.7% | 2.08 | +| Severe | 192/263 = 73.0% | 55/80 = 68.8% | 1.23 | +| Pooled | 273/350 = 78.0% | 289/350 = 82.6% | 0.75 | + +The drug does better in each stratum, yet worse in the pooled table, because most drug-treated cases are severe. Severity is a **confounder**: associated with both treatment and outcome. The **Mantel–Haenszel** estimator combines the strata into one adjusted odds ratio: + + OR_MH = Σ(aᵢdᵢ/nᵢ)/Σ(bᵢcᵢ/nᵢ) = 1.45, + +where the sums run over strata. It weights strata by size and is appropriate when the odds ratio is similar across strata; when the stratum odds ratios differ a lot, report them separately. Randomization prevents this kind of confounding in experiments, which is one reason randomized designs are preferred. + +## Common mistakes + +- Reporting an odds ratio as if it were a risk ratio when the outcome is common. +- Using the χ² test with expected counts well below 5. +- Building the table from individual measurements when the unit is the animal or the culture, which is the pseudoreplication of the [nested-designs lab](../labs/01-pseudoreplication-and-nested-designs.md). +- Pooling across a confounder and reading the pooled association as causal. +- Computing a confidence interval for an odds ratio symmetrically on the ratio scale instead of the log scale. + +## Worked example + +**Problem.** In a synthetic screen, 9 of 60 treated cultures and 21 of 60 control cultures became contaminated. Compute the risk ratio and odds ratio, the χ² statistic, and decide whether χ² is appropriate. + +**Step 1: risks.** p₁ = 9/60 = 0.150, p₂ = 21/60 = 0.350, so RR = 0.429. + +**Step 2: odds ratio.** (9 × 39)/(51 × 21) = 351/1071 = 0.328. + +**Step 3: expected counts.** Contaminated: 60 × 30/120 = 15 per group; clean: 45 per group. All exceed 5, so χ² is appropriate. + +**Step 4: χ².** (9 − 15)²/15 + (51 − 45)²/45 + (21 − 15)²/15 + (39 − 45)²/45 = 2.40 + 0.80 + 2.40 + 0.80 = 6.40, on 1 df, p = 0.011. + +## Limits of this lesson + +All data are synthetic. Continuity corrections, exact intervals for proportions, tables larger than 2 × 2, McNemar's test for paired binary data, tests of homogeneity of odds ratios and logistic regression are not covered. diff --git a/content/courses/statistics/modules/27-one-way-analysis-of-variance.md b/content/courses/statistics/modules/27-one-way-analysis-of-variance.md new file mode 100644 index 0000000..85baeb8 --- /dev/null +++ b/content/courses/statistics/modules/27-one-way-analysis-of-variance.md @@ -0,0 +1,80 @@ +# Comparing several groups: one-way analysis of variance + +A dose-response experiment, a comparison of three culture media or four donor lines all compare more than two groups. Running a t test for every pair inflates the chance of a false positive and ignores information shared across groups. One-way analysis of variance (ANOVA) asks one question first: is there more variation between the group means than variation within the groups would produce by chance? This lesson builds the ANOVA table, reads its F statistic, and follows a significant F with planned comparisons. All data are synthetic. + +## Learning objectives + +By the end of this lesson, you should be able to: + +1. Partition the total variation into between-group and within-group sums of squares, and compute the mean squares, the F statistic and its degrees of freedom. +2. State the null hypothesis of one-way ANOVA and its assumptions, and interpret η² as an effect size. +3. Explain why separate pairwise t tests inflate the family-wise error rate, and compute pairwise comparisons that use the pooled within-group variance with a Bonferroni adjustment. +4. Compute an ANOVA from group means, standard deviations and sizes alone. + +## Partitioning variation + +Eighteen cultures are randomized to vehicle, a low dose and a high dose, six per group, and a viability index is measured: + +| Group | Values | Mean | +| --- | --- | --- | +| Vehicle | 48, 52, 50, 55, 47, 48 | 50.0 | +| Low dose | 53, 58, 51, 57, 55, 56 | 55.0 | +| High dose | 60, 57, 63, 59, 62, 59 | 60.0 | + +The grand mean is 55.0. Each value's deviation from the grand mean splits into its group mean's deviation from the grand mean and its own deviation from its group mean, and the squared parts add up: + +- **Between groups:** SS_B = Σ nⱼ(ȳⱼ − ȳ)² = 6(−5)² + 6(0)² + 6(5)² = 300, with k − 1 = 2 degrees of freedom. +- **Within groups:** SS_W = Σ Σ (yᵢⱼ − ȳⱼ)² = 104, with N − k = 15 degrees of freedom. + +| Source | SS | df | Mean square | F | +| --- | --- | --- | --- | --- | +| Between | 300 | 2 | 150.0 | 21.6 | +| Within | 104 | 15 | 6.93 | | +| Total | 404 | 17 | | | + +The mean squares are SS/df. The within-group mean square, MS_W = 6.93, is the pooled variance, the same quantity the [two-group lesson](10-comparing-two-groups-t-tests-and-pairing.md) pools for two groups. Under the null hypothesis that all group means are equal, MS_B also estimates that variance, so F = MS_B/MS_W should be near 1. Here F = 150.0/6.93 = 21.6 on 2 and 15 degrees of freedom, with p = 3.8 × 10⁻⁵. + +The effect size η² = SS_B/SS_total = 300/404 = 0.743: about three quarters of the variation in these data lies between groups. + +## What a significant F does and does not say + +The null hypothesis is that every group mean is equal; the alternative is only that at least one differs. F does not say which. ANOVA assumes independent observations (one per culture, not repeated measurements from the same well), approximately normal residuals within groups and similar group variances. With equal group sizes it is fairly robust to moderate departures; with very unequal variances, Welch's version of ANOVA is safer. + +## Following up without inflating error + +Three pairwise t tests at α = 0.05 each would, if the tests were independent and every null were true, give a family-wise chance of at least one false positive of 1 − 0.95³ = 0.143, as in the [multiple-testing lesson](07-multiple-testing-and-false-discovery-rate.md). Better follow-up comparisons use the pooled MS_W, which has more degrees of freedom than any pair alone, and adjust for the number of comparisons. + +For a pair of groups, SE = √(MS_W(1/nᵢ + 1/nⱼ)) = √(6.93 × 2/6) = 1.52 and t has 15 degrees of freedom: + +- high versus vehicle: t = 10.0/1.52 = 6.58, p = 8.8 × 10⁻⁶; +- low versus vehicle and high versus low: t = 5.0/1.52 = 3.29, p = 0.0050. + +With a Bonferroni adjustment for three comparisons, each p is multiplied by 3, giving 2.6 × 10⁻⁵ and 0.015, so all three differences remain below 0.05. Tukey's procedure, designed for all pairwise comparisons, is a common alternative. A comparison planned before seeing the data, such as high dose against vehicle, is the strongest evidence; comparisons chosen after looking are exploratory. + +## ANOVA from summary statistics + +Published papers often give only means, standard deviations and sizes, which are enough. SS_B = Σ nⱼ(ȳⱼ − ȳ)² as before, and SS_W = Σ (nⱼ − 1)sⱼ², because each group's sum of squared deviations is (nⱼ − 1)sⱼ². + +## Common mistakes + +- Running every pairwise t test and reporting the significant ones. +- Treating replicate wells from one culture as independent observations. +- Concluding from a significant F that every group differs from every other. +- Reading a non-significant F as proof that the means are equal; see the [power lesson](11-power-sample-size-and-small-studies.md). +- Using one-way ANOVA when the groups are ordered doses and a trend is the real question; a regression on dose answers that more directly. + +## Worked example + +**Problem.** Four media are each tested on five independent cultures. The means are 10.0, 12.0, 13.0 and 15.0, with variances 4.0, 5.0, 3.0 and 4.0. Build the ANOVA table and compute F. + +**Step 1: grand mean.** With equal group sizes, ȳ = (10.0 + 12.0 + 13.0 + 15.0)/4 = 12.5. + +**Step 2: between groups.** SS_B = 5[(−2.5)² + (−0.5)² + (0.5)² + (2.5)²] = 5 × 13.0 = 65.0, with 3 degrees of freedom, so MS_B = 21.7. + +**Step 3: within groups.** SS_W = 4 × (4.0 + 5.0 + 3.0 + 4.0) = 64.0, with 20 − 4 = 16 degrees of freedom, so MS_W = 4.00. + +**Step 4: F.** F = 21.7/4.00 = 5.42 on 3 and 16 degrees of freedom, p = 0.0092. At least one medium differs; planned comparisons say which. + +## Limits of this lesson + +All data are synthetic. Two-way and repeated-measures ANOVA, random effects, Tukey's procedure in detail, Welch's ANOVA, rank-based alternatives such as the Kruskal–Wallis test and the regression formulation of ANOVA are not covered. diff --git a/content/courses/statistics/question-banks/practice.json b/content/courses/statistics/question-banks/practice.json index bf53d7e..b0da681 100644 --- a/content/courses/statistics/question-banks/practice.json +++ b/content/courses/statistics/question-banks/practice.json @@ -6670,6 +6670,664 @@ "Original synthetic probability and statistics practice developed with substantial AI assistance; no external problem, external proof, problem or dataset imported." ] } + }, + { + "id": "statistics-26:risk-ratio", + "type": "numeric", + "prompt": "In a synthetic study, 18 of 50 treated and 30 of 50 control animals develop the outcome. What is the risk ratio (treated/control)? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "statistics-26-objective-1" + ], + "feedback": { + "hint": "Divide the treated proportion by the control proportion.", + "solution": "0.36/0.60 = 0.600.", + "lesson_ids": [ + "statistics-26" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 0.6, + "unit": "", + "tolerance": 0.0005, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "statistics-26:odds-ratio", + "type": "numeric", + "prompt": "For the same table (18 of 50 treated, 30 of 50 control), what is the odds ratio (treated/control)? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "statistics-26-objective-1" + ], + "feedback": { + "hint": "OR = (a × d)/(b × c).", + "solution": "(18 × 20)/(32 × 30) = 360/960 = 0.375.", + "lesson_ids": [ + "statistics-26" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 0.375, + "unit": "", + "tolerance": 0.0005, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "statistics-26:risk-difference", + "type": "numeric", + "prompt": "For the same table, what is the risk difference, treated minus control? Give a proportion (negative if lower). Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "statistics-26-objective-1" + ], + "feedback": { + "hint": "Subtract the control proportion from the treated proportion.", + "solution": "0.36 − 0.60 = −0.240.", + "lesson_ids": [ + "statistics-26" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": -0.24, + "unit": "", + "tolerance": 0.0005, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "statistics-26:expected", + "type": "numeric", + "prompt": "For the same table, what is the expected number of treated animals with the outcome if treatment and outcome are independent? Enter the number only.", + "points": 1, + "objective_ids": [ + "statistics-26-objective-2" + ], + "feedback": { + "hint": "Row total × column total ÷ grand total.", + "solution": "50 × 48/100 = 24.0.", + "lesson_ids": [ + "statistics-26" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 24.0, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "statistics-26:chi-square", + "type": "numeric", + "prompt": "For the same table, what is Pearson's χ² statistic (expected counts 24 and 26 in each row)? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "statistics-26-objective-2" + ], + "feedback": { + "hint": "Σ(O − E)²/E over the four cells.", + "solution": "36/24 + 36/26 + 36/24 + 36/26 = 5.77.", + "lesson_ids": [ + "statistics-26" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 5.77, + "unit": "", + "tolerance": 0.005, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "statistics-26:log-or-se", + "type": "numeric", + "prompt": "For the same table, what is the standard error of ln(OR), √(1/a + 1/b + 1/c + 1/d)? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "statistics-26-objective-1" + ], + "feedback": { + "hint": "Add the reciprocals of all four counts, then take the square root.", + "solution": "√(1/18 + 1/32 + 1/30 + 1/20) = 0.412.", + "lesson_ids": [ + "statistics-26" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 0.412, + "unit": "", + "tolerance": 0.0005, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "statistics-26:fisher-point", + "type": "numeric", + "prompt": "With 7 events among 16 animals and 8 treated, what is the hypergeometric probability that exactly 1 of the events is in the treated group, C(7,1)C(9,7)/C(16,8)? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "statistics-26-objective-3" + ], + "feedback": { + "hint": "C(9,7) = 36 and C(16,8) = 12,870.", + "solution": "252/12,870 = 0.0196.", + "lesson_ids": [ + "statistics-26" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 0.0196, + "unit": "", + "tolerance": 5e-05, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "statistics-26:which-test", + "type": "single_choice", + "prompt": "A 2 × 2 table has expected counts of 3.5 in every cell. Which test is appropriate?", + "points": 1, + "objective_ids": [ + "statistics-26-objective-3" + ], + "feedback": { + "hint": "Check the expected-count rule.", + "solution": "Expected counts below 5 make the χ² approximation unreliable; Fisher's test is exact.", + "lesson_ids": [ + "statistics-26" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "options": [ + "Fisher's exact test.", + "Pearson's χ² test without change.", + "A two-sample t test on the counts.", + "No test can be done." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "statistics-26:simpson", + "type": "single_choice", + "prompt": "Within each severity stratum a drug improves outcomes, but in the pooled table it looks harmful. What is the best explanation?", + "points": 1, + "objective_ids": [ + "statistics-26-objective-4" + ], + "feedback": { + "hint": "Look at how treatment was distributed across strata.", + "solution": "This is Simpson's paradox; stratify, or use a Mantel–Haenszel estimate, and prefer randomized designs.", + "lesson_ids": [ + "statistics-26" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "options": [ + "Severity is a confounder: it is linked to both treatment choice and outcome, and pooling mixes the strata.", + "The drug is harmful, and the strata are misleading.", + "A calculation error, because pooling cannot reverse an association.", + "The sample sizes are too small for any conclusion." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "statistics-26:or-vs-rr", + "type": "multiple_select", + "prompt": "Which statements about odds ratios and risk ratios are correct? Select all that apply.", + "points": 2, + "objective_ids": [ + "statistics-26-objective-1" + ], + "feedback": { + "hint": "Compare p/(1 − p) with p for small and large p.", + "solution": "Odds and risks are close when p is small; for common outcomes the OR exaggerates the RR; intervals use ln OR. A risk ratio is directly estimable in a cohort.", + "lesson_ids": [ + "statistics-26" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "options": [ + "The odds ratio approximates the risk ratio when the outcome is rare.", + "With a common outcome the odds ratio lies farther from 1 than the risk ratio.", + "A confidence interval for the odds ratio is usually computed on the log scale.", + "An odds ratio of 0.5 always means half the risk.", + "Risk ratios cannot be computed from a cohort study." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } + }, + { + "id": "statistics-27:ssb", + "type": "numeric", + "prompt": "Three groups of five cultures give means 14.0, 18.0 and 12.0 (grand mean 14.67). What is the between-group sum of squares? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "statistics-27-objective-1" + ], + "feedback": { + "hint": "SS_B = Σ nⱼ(ȳⱼ − ȳ)².", + "solution": "5[(−0.667)² + (3.333)² + (−2.667)²] = 93.3.", + "lesson_ids": [ + "statistics-27" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 93.3, + "unit": "", + "tolerance": 0.05, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "statistics-27:ssw", + "type": "numeric", + "prompt": "Each of those groups has a sample variance of 2.5. What is the within-group sum of squares? Enter the number only.", + "points": 1, + "objective_ids": [ + "statistics-27-objective-1" + ], + "feedback": { + "hint": "SS_W = Σ (nⱼ − 1)sⱼ².", + "solution": "3 groups × 4 × 2.5 = 30.", + "lesson_ids": [ + "statistics-27" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 30.0, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "statistics-27:f-stat", + "type": "numeric", + "prompt": "With SS_B as in the first item and SS_W = 30 on 12 degrees of freedom, what is F? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "statistics-27-objective-1" + ], + "feedback": { + "hint": "F = (SS_B/(k − 1))/(SS_W/(N − k)).", + "solution": "(93.33/2)/(30/12) = 18.7.", + "lesson_ids": [ + "statistics-27" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 18.7, + "unit": "", + "tolerance": 0.05, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "statistics-27:df-within", + "type": "numeric", + "prompt": "One-way ANOVA compares 4 groups of 6 observations each. How many within-group degrees of freedom are there? Enter the number only.", + "points": 1, + "objective_ids": [ + "statistics-27-objective-1" + ], + "feedback": { + "hint": "Within-group degrees of freedom are N − k.", + "solution": "24 − 4 = 20.", + "lesson_ids": [ + "statistics-27" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 20, + "unit": "", + "tolerance": 0, + "unit_required": false, + "significant_figures": null, + "dimensions": null + } + }, + { + "id": "statistics-27:eta-squared", + "type": "numeric", + "prompt": "An ANOVA has SS_B = 120 and SS_W = 280. What is η²? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "statistics-27-objective-2" + ], + "feedback": { + "hint": "η² = SS_B/SS_total.", + "solution": "120/(120 + 280) = 0.300.", + "lesson_ids": [ + "statistics-27" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 0.3, + "unit": "", + "tolerance": 0.0005, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "statistics-27:fwer", + "type": "numeric", + "prompt": "If five independent tests are each run at α = 0.05 and every null hypothesis is true, what is the chance of at least one false positive? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "statistics-27-objective-3" + ], + "feedback": { + "hint": "The chance of no false positive in m independent tests is (1 − α)^m.", + "solution": "1 − 0.95⁵ = 0.226.", + "lesson_ids": [ + "statistics-27" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 0.226, + "unit": "", + "tolerance": 0.0005, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "statistics-27:pairwise-se", + "type": "numeric", + "prompt": "With MS_W = 2.5 and groups of 5, what is the standard error of a difference between two group means, √(MS_W(1/nᵢ + 1/nⱼ))? Keep full precision in intermediate calculations. Enter the number only, to three significant figures.", + "points": 1, + "objective_ids": [ + "statistics-27-objective-3" + ], + "feedback": { + "hint": "Use the pooled within-group mean square.", + "solution": "√(2.5 × 0.4) = 1.000.", + "lesson_ids": [ + "statistics-27" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "solution_spec": { + "answer": 1.0, + "unit": "", + "tolerance": 0.005, + "unit_required": false, + "significant_figures": 3, + "dimensions": null + } + }, + { + "id": "statistics-27:null", + "type": "single_choice", + "prompt": "What does a significant one-way ANOVA F test establish?", + "points": 1, + "objective_ids": [ + "statistics-27-objective-2" + ], + "feedback": { + "hint": "Read the alternative hypothesis.", + "solution": "F tests the single null that all means are equal; follow-up comparisons say which differ.", + "lesson_ids": [ + "statistics-27" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "options": [ + "That at least one group mean differs from another.", + "That every group mean differs from every other.", + "That the highest group differs from the lowest by a meaningful amount.", + "That the residuals are normal." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "statistics-27:follow-up", + "type": "single_choice", + "prompt": "After a significant F with three groups, which follow-up is most defensible?", + "points": 1, + "objective_ids": [ + "statistics-27-objective-3" + ], + "feedback": { + "hint": "Control the family-wise error rate.", + "solution": "Pooled variance and Bonferroni or Tukey adjustment control error; planned contrasts are strongest.", + "lesson_ids": [ + "statistics-27" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "options": [ + "Pairwise comparisons using the pooled MS_W with an adjustment for the number of comparisons, or comparisons planned in advance.", + "Unadjusted t tests between every pair, reporting the significant ones.", + "Dropping the middle group and repeating the ANOVA.", + "Reporting only the comparison with the smallest p-value." + ], + "solution_spec": { + "answer": 0 + } + }, + { + "id": "statistics-27:assumptions", + "type": "multiple_select", + "prompt": "Which are assumptions of one-way ANOVA? Select all that apply.", + "points": 2, + "objective_ids": [ + "statistics-27-objective-2" + ], + "feedback": { + "hint": "Equal means are the null hypothesis, not an assumption.", + "solution": "Independence, approximate normality and similar variances; Welch's ANOVA relaxes the last.", + "lesson_ids": [ + "statistics-27" + ], + "misconception_ids": [] + }, + "visibility": "public-practice-authoring", + "provenance": { + "kind": "original", + "modifications": [ + "Original synthetic biostatistics practice developed with substantial AI assistance; no external problem, dataset or published figure imported." + ] + }, + "options": [ + "Observations are independent.", + "Residuals within groups are approximately normal.", + "Group variances are similar.", + "Every group has the same mean.", + "The groups must be ordered doses." + ], + "solution_spec": { + "answer": [ + 0, + 1, + 2 + ], + "partial_credit": "correct-minus-incorrect-clamped-v1" + } } ] } diff --git a/content/courses/statistics/question-banks/retrieval-cards.json b/content/courses/statistics/question-banks/retrieval-cards.json index cc93b27..17b66f0 100644 --- a/content/courses/statistics/question-banks/retrieval-cards.json +++ b/content/courses/statistics/question-banks/retrieval-cards.json @@ -905,6 +905,86 @@ "statistics-25-objective-2" ], "license": "CC-BY-4.0" + }, + { + "id": "statistics-26-card-1", + "lesson_id": "statistics-26", + "front": "Odds ratio from a 2 × 2 table", + "back": "(a × d)/(b × c); close to the risk ratio only when the outcome is rare.", + "objective_ids": [ + "statistics-26-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "statistics-26-card-2", + "lesson_id": "statistics-26", + "front": "Expected count", + "back": "Row total × column total ÷ grand total; χ² needs expected counts of about 5 or more.", + "objective_ids": [ + "statistics-26-objective-2" + ], + "license": "CC-BY-4.0" + }, + { + "id": "statistics-26-card-3", + "lesson_id": "statistics-26", + "front": "Fisher's exact test", + "back": "Conditions on the margins; the hypergeometric distribution gives the exact p-value for small tables.", + "objective_ids": [ + "statistics-26-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "statistics-26-card-4", + "lesson_id": "statistics-26", + "front": "Simpson's paradox", + "back": "An association reverses when a confounder is pooled over; stratify or use the Mantel–Haenszel estimate.", + "objective_ids": [ + "statistics-26-objective-4" + ], + "license": "CC-BY-4.0" + }, + { + "id": "statistics-27-card-1", + "lesson_id": "statistics-27", + "front": "One-way ANOVA F", + "back": "F = MS_B/MS_W on k − 1 and N − k degrees of freedom; near 1 when all means are equal.", + "objective_ids": [ + "statistics-27-objective-1" + ], + "license": "CC-BY-4.0" + }, + { + "id": "statistics-27-card-2", + "lesson_id": "statistics-27", + "front": "η²", + "back": "SS_B/SS_total: the share of variation between groups.", + "objective_ids": [ + "statistics-27-objective-2" + ], + "license": "CC-BY-4.0" + }, + { + "id": "statistics-27-card-3", + "lesson_id": "statistics-27", + "front": "After a significant F", + "back": "Use pooled MS_W with Bonferroni or Tukey adjustment, or comparisons planned in advance.", + "objective_ids": [ + "statistics-27-objective-3" + ], + "license": "CC-BY-4.0" + }, + { + "id": "statistics-27-card-4", + "lesson_id": "statistics-27", + "front": "ANOVA from summaries", + "back": "SS_B = Σnⱼ(ȳⱼ − ȳ)²; SS_W = Σ(nⱼ − 1)sⱼ².", + "objective_ids": [ + "statistics-27-objective-4" + ], + "license": "CC-BY-4.0" } ] } diff --git a/content/courses/statistics/scope-crosswalk.md b/content/courses/statistics/scope-crosswalk.md new file mode 100644 index 0000000..b174526 --- /dev/null +++ b/content/courses/statistics/scope-crosswalk.md @@ -0,0 +1,36 @@ +# Probability, Biostatistics & Experimental Design scope crosswalk + +This crosswalk compares the local package, version 0.7.0, with two published courses: MIT OpenCourseWare 18.05 Introduction to Probability and Statistics, Spring 2022 ([calendar](https://ocw.mit.edu/courses/18-05-introduction-to-probability-and-statistics-spring-2022/pages/calendar/)), and the University of South Carolina STAT 205 Elementary Statistics for the Biological and Life Sciences, Fall 2011 ([syllabus and schedule](https://people.stat.sc.edu/hansont/stat205/SyllabusF11.pdf), taught by Dr. Tim Hanson). It was compiled with AI assistance on 2026-10-31 from session titles and the syllabus's topic list. It is an agent-run comparison, not a review by the owner or a statistics instructor, and topic alignment establishes no equivalence, credit or endorsement. + +18.05 is a calculus-based course with a strong Bayesian unit. STAT 205 is a non-calculus course for life-science students, with a unit on 2 × 2 tables and diagnostic testing. Status labels: **covered** means a local reading develops the topic with a worked example and practice; **shallow** means part of it is taught; **absent** means no local instruction; **local extension** means neither comparator names it. + +| Topic | MIT 18.05 Spring 2022 | USC STAT 205 Fall 2011 | Local lessons | Status | +| --- | --- | --- | --- | --- | +| Probability rules, random variables, distributions | Classes 1–6b | Sep 6–15 | [1](modules/01-probability-distributions-and-sampling.md) | Covered | +| Joint distributions, covariance, conditioning | Class 7 | — | [18](modules/joint-laws-conditioning-and-total-variance.md) | Covered | +| Likelihood and maximum likelihood | Class 10 | — | [19](modules/likelihood-estimation-and-censored-exposure.md) | Covered | +| Bayesian updating, conjugate priors, normal conjugacy | Classes 11–16 | — | [16](modules/16-finite-bayesian-updating-and-predictive-checks.md), [17](modules/17-beta-binomial-conjugacy-and-prediction.md), [20](modules/normal-conjugacy-and-shared-prediction.md) | Covered | +| Confidence intervals, effect sizes, sample size | Classes 22–23 | Sep 27–29 | [2](modules/02-estimation-confidence-intervals-and-effect-sizes.md), [8](modules/08-effect-sizes-intervals-and-regression-to-the-mean.md), [11](modules/11-power-sample-size-and-small-studies.md) | Covered | +| Significance testing, t tests, paired designs | Classes 17–18, 20 | Oct 4–18 | [3](modules/03-hypothesis-tests-and-multiple-comparisons.md), [10](modules/10-comparing-two-groups-t-tests-and-pairing.md), [14](modules/14-base-rates-positive-predictive-value-and-replication.md) | Covered | +| Rank tests, permutation and bootstrap | Classes 24 and Studio 10 | Oct 4, 13 | [13](modules/13-resampling-permutation-and-rank-tests.md) | Covered | +| χ² tests and 2 × 2 tables; risk difference, risk ratio and odds ratio; Fisher's exact test | Class 19 (χ²) | Nov 1–3 | [26](modules/26-categorical-outcomes-two-by-two-tables.md) | Covered in 0.7.0; previously absent | +| Stratified 2 × 2 tables, Mantel–Haenszel, Simpson's paradox | — | Nov 8 | [26](modules/26-categorical-outcomes-two-by-two-tables.md) | Covered in 0.7.0 for the Mantel–Haenszel odds ratio; the Cochran–Mantel–Haenszel test statistic is not developed | +| One-way analysis of variance | Class 19 (ANOVA) | Nov 10 | [27](modules/27-one-way-analysis-of-variance.md) | Covered in 0.7.0; previously absent | +| Simple and multiple linear regression | Class 26 | Nov 15–17 | [12](modules/12-regression-calibration-and-residuals.md), [21](modules/multiple-regression-residualization-and-prediction.md) | Covered | +| Logistic regression | — | Nov 22 | — | Absent | +| Survival analysis | — | Nov 29 | [6](modules/06-time-to-event-data-and-kaplan-meier.md) | Covered | +| Sensitivity, specificity, predictive values | — | Dec 1 | [14](modules/14-base-rates-positive-predictive-value-and-replication.md) | Covered for predictive values; ROC curves are absent | +| Multiple testing and false discovery rate; experimental design and pseudoreplication | — | — | [7](modules/07-multiple-testing-and-false-discovery-rate.md), [4](modules/04-experimental-design-regression-and-reproducibility.md), [the nested-designs lab](labs/01-pseudoreplication-and-nested-designs.md) | Local extension | +| Computing in R | Class 25, studios | Throughout | — | Absent by design: calculations are by hand or calculator | +| Problem sets and exams | Eleven problem sets, two exams and a final | Weekly homework and three exams | Open practice and four ungraded assignments | Absent by design: formative only | + +## What 0.7.0 changed + +Both comparators teach χ² methods; STAT 205 also teaches 2 × 2 tables in depth and ANOVA, and 18.05 pairs χ² with ANOVA in one class. The package had neither. A new week-5 reading, lesson 26, develops the 2 × 2 table, the three measures of association with an interval for the odds ratio, the χ² test with its expected-count rule, Fisher's exact test from the hypergeometric distribution, and Simpson's paradox with the Mantel–Haenszel odds ratio. A new week-6 reading, lesson 27, develops one-way ANOVA from the partition of sums of squares, the F test and η², follow-up comparisons with pooled variance and a Bonferroni adjustment, and ANOVA from summary statistics. + +## Remaining gaps + +- Logistic regression and ROC curves (STAT 205, Nov 22 and Dec 1). +- The Cochran–Mantel–Haenszel test, McNemar's test, and tables larger than 2 × 2. +- Tukey's procedure, two-way and repeated-measures ANOVA. +- Statistical computing in R, graded work, measured workload and review by a qualified person. diff --git a/content/courses/statistics/source-map.json b/content/courses/statistics/source-map.json index b067a8c..1544685 100644 --- a/content/courses/statistics/source-map.json +++ b/content/courses/statistics/source-map.json @@ -390,6 +390,36 @@ "modifications": [ "Original joint-law, likelihood, normal-update and regression derivations and connected assignments developed with substantial AI assistance. No source text, figure, problem, sequence or data imported." ] + }, + { + "module_id": "statistics-module-26", + "lesson_id": "statistics-26", + "reading": "modules/26-categorical-outcomes-two-by-two-tables.md", + "source_ids": [ + "statistics-usc-stat205", + "statistics-semester-calendar" + ], + "relationship": "original-instruction-with-link-only-scope-comparators", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original synthetic examples, derivations and practice developed with substantial AI assistance. No source text, problem or data imported." + ] + }, + { + "module_id": "statistics-module-27", + "lesson_id": "statistics-27", + "reading": "modules/27-one-way-analysis-of-variance.md", + "source_ids": [ + "statistics-usc-stat205", + "statistics-semester-calendar" + ], + "relationship": "original-instruction-with-link-only-scope-comparators", + "original_content": true, + "license": "CC-BY-4.0", + "modifications": [ + "Original synthetic examples, derivations and practice developed with substantial AI assistance. No source text, problem or data imported." + ] } ] } diff --git a/content/courses/statistics/syllabus.md b/content/courses/statistics/syllabus.md index 4fe9635..12a7b18 100644 --- a/content/courses/statistics/syllabus.md +++ b/content/courses/statistics/syllabus.md @@ -1,6 +1,6 @@ # Probability, Biostatistics & Experimental Design -**Maturity: partial. Version: 0.6.0.** +**Maturity: partial. Version: 0.7.0.** Probability, estimation, hypothesis testing, regression, and design principles for biological and engineering experiments, with reproducibility and uncertainty at the center. @@ -33,8 +33,8 @@ This is a proposed scope, not a measured or reviewed semester. Each week lists i | 2 | [Sources of variability: biological variation, technical error and the standard error](modules/09-variability-technical-error-and-pseudoreplication.md); [Joint laws, conditioning and total variance](modules/joint-laws-conditioning-and-total-variance.md) | 11 | 8 | | 3 | [Descriptive differences and inferential limits](modules/05-descriptive-evidence-and-causal-limits.md); [Assignment 1: probability ledgers](assignments/coursework-01-probability-ledgers.md) | 4 | 5 | | 4 | [Estimation, confidence intervals, and effect sizes](modules/02-estimation-confidence-intervals-and-effect-sizes.md); [Effect sizes, confidence intervals and regression to the mean: reading a result for what it can support](modules/08-effect-sizes-intervals-and-regression-to-the-mean.md); [Likelihood estimation and censored exposure](modules/likelihood-estimation-and-censored-exposure.md) | 12 | 10 | -| 5 | [Comparing two groups: the t statistic, Welch's test, paired designs and confidence intervals for a difference](modules/10-comparing-two-groups-t-tests-and-pairing.md) | 7 | 4 | -| 6 | [Hypothesis tests and multiple comparisons](modules/03-hypothesis-tests-and-multiple-comparisons.md); [Many tests at once: family-wise error, Bonferroni and the false discovery rate](modules/07-multiple-testing-and-false-discovery-rate.md); [Assignment 2: exact coverage and randomization](assignments/coursework-02-exact-coverage-and-randomization.md) | 11 | 8 | +| 5 | [Comparing two groups: the t statistic, Welch's test, paired designs and confidence intervals for a difference](modules/10-comparing-two-groups-t-tests-and-pairing.md); [Categorical outcomes: proportions, 2 × 2 tables and confounding](modules/26-categorical-outcomes-two-by-two-tables.md) | 17 | 8 | +| 6 | [Hypothesis tests and multiple comparisons](modules/03-hypothesis-tests-and-multiple-comparisons.md); [Many tests at once: family-wise error, Bonferroni and the false discovery rate](modules/07-multiple-testing-and-false-discovery-rate.md); [Assignment 2: exact coverage and randomization](assignments/coursework-02-exact-coverage-and-randomization.md); [Comparing several groups: one-way analysis of variance](modules/27-one-way-analysis-of-variance.md) | 21 | 12 | | 7 | [Power and sample size: what an experiment can detect, and what a non-significant result means](modules/11-power-sample-size-and-small-studies.md) | 7 | 4 | | 8 | [Experimental design, regression, and reproducibility](modules/04-experimental-design-regression-and-reproducibility.md) | 3 | 2 | | 9 | [Regression and calibration: least squares, residuals, R² and prediction](modules/12-regression-calibration-and-residuals.md); [Multiple regression and prediction](modules/multiple-regression-residualization-and-prediction.md) | 11 | 8 | @@ -71,3 +71,5 @@ The proposed schedule is unmeasured and unreviewed. There are no graded homework The source map links MIT OCW 18.05 Spring 2022 as a curriculum comparator. Its Reading 15 is a link-only technical reference for the conjugate-prior topic; no source assets are copied or adapted. Read [content/SOURCES_AND_LICENSES.md](../../SOURCES_AND_LICENSES.md) for the software/content license boundary. No affiliation or equivalency is implied. The 0.6.0 expansion compares actual introductory probability, estimation, simple-regression and finite/Beta-prior readings with [MIT OCW 18.05 Spring 2022 calendar](https://ocw.mit.edu/courses/18-05-introduction-to-probability-and-statistics-spring-2022/pages/calendar/) and [syllabus](https://ocw.mit.edu/courses/18-05-introduction-to-probability-and-statistics-spring-2022/pages/syllabus/): classes 1–7 counting/joint laws, 10 likelihood, 14–16 continuous-data/prior/conjugacy, 17–24 tests/intervals/bootstrap and 26 multiple regression. The existing conditional-probability and simple-regression examples were covered; explicit joint-density, likelihood, normal-conjugacy and multiple-regression derivations were absent or shallow. The local algebra/calculus-recommended prerequisite differs from the source's required 18.02. Four open reports do not reproduce its eleven problem sets, two midterms, R quiz, comprehensive final or group/studio format. No source text, problem, figure, code or data is imported. Broader distribution families, unknown-variance/hierarchical inference, coursework depth, qualified review and measured workload remain open. + +The 0.7.0 expansion compares the package with the [University of South Carolina STAT 205 Fall 2011 schedule](https://people.stat.sc.edu/hansont/stat205/SyllabusF11.pdf) as well as MIT 18.05, adding categorical-outcome and one-way ANOVA readings; see the [scope crosswalk](scope-crosswalk.md). No comparator material is copied, and the comparison establishes no equivalence or credit. diff --git a/content/sources/registry.json b/content/sources/registry.json index 7743ffd..1a27581 100644 --- a/content/sources/registry.json +++ b/content/sources/registry.json @@ -10190,7 +10190,7 @@ "title": "MIT OCW 18.05 Introduction to Probability and Statistics Spring 2022: Calendar", "institution": "Massachusetts Institute of Technology / MIT OpenCourseWare", "url": "https://ocw.mit.edu/courses/18-05-introduction-to-probability-and-statistics-spring-2022/pages/calendar/", - "accessed_at": "2026-10-22", + "accessed_at": "2026-10-31", "license": "CC-BY-NC-SA-4.0", "reuse_mode": "link-only-comparator", "permissions": { @@ -10215,9 +10215,11 @@ "statistics-module-22", "statistics-module-23", "statistics-module-24", - "statistics-module-25" + "statistics-module-25", + "statistics-module-26", + "statistics-module-27" ], - "version": "18.05 Spring 2022; actual introductory probability,estimation,simple-regression and Beta-update readings compared with classes1–7,10,14–16,17–24,26 on 2026-10-22. Source18.02 prerequisite differs from local algebra/calculus recommendation. Original selected censored/coverage examples; no imported assets or equivalent workload claimed.", + "version": "18.05 Spring 2022; actual introductory probability,estimation,simple-regression and Beta-update readings compared with classes1–7,10,14–16,17–24,26 on 2026-10-22. Source18.02 prerequisite differs from local algebra/calculus recommendation. Original selected censored/coverage examples; no imported assets or equivalent workload claimed. Re-read 2026-10-31 for the second-comparator crosswalk: Class 19 (chi-square and ANOVA) was compared with the new readings.", "checksum": null, "imported_assets": [], "rights_notes": "Permission flags record this repository's selected link-only use, not the complete legal permissions of all source material. Applicable unexcluded OCW content may permit reuse under CC BY-NC-SA 4.0 after asset review; excluded material does not inherit that license. Comparator mapping does not establish equivalent scope, credit, endorsement, or review." @@ -12367,6 +12369,36 @@ "checksum": null, "imported_assets": [], "rights_notes": "Comparator mapping does not establish equivalence, eligibility, credit or endorsement." + }, + { + "id": "statistics-usc-stat205", + "title": "University of South Carolina STAT 205: Elementary Statistics for the Biological and Life Sciences, Fall 2011 syllabus", + "institution": "University of South Carolina, Department of Statistics", + "url": "https://people.stat.sc.edu/hansont/stat205/SyllabusF11.pdf", + "accessed_at": "2026-10-31", + "license": "Third-party reuse rights not cleared; link-only comparator selected", + "reuse_mode": "link-only-comparator", + "permissions": { + "linking": true, + "quotation": false, + "adaptation": false, + "redistribution": false + }, + "attribution_requirements": [ + "Display the course title, institution, term, instructor and URL without implying endorsement." + ], + "third_party_notes": "Course description, learning objectives and class schedule (instructor Dr. Tim Hanson) read on 2026-10-31 to set the second-comparator scope of the statistics package. No syllabus text, problem or dataset is imported.", + "mapped_courses": [ + "statistics" + ], + "mapped_modules": [ + "statistics-module-26", + "statistics-module-27" + ], + "version": "Fall 2011 syllabus PDF read 2026-10-31", + "checksum": null, + "imported_assets": [], + "rights_notes": "Comparator mapping does not establish equivalent scope, credit, endorsement or review." } ] } diff --git a/docs/ASSESSMENT_CANDIDATE_REPORT.md b/docs/ASSESSMENT_CANDIDATE_REPORT.md index 4c8d52c..db49493 100644 --- a/docs/ASSESSMENT_CANDIDATE_REPORT.md +++ b/docs/ASSESSMENT_CANDIDATE_REPORT.md @@ -33,7 +33,7 @@ No course or candidate is activated by this command. `--check` detects report dr | robotics | 0.6.0 | partial | unreviewed | formative-only | 0 | | signals-control | 0.6.0 | partial | unreviewed | formative-only | 0 | | statics-materials | 0.6.0 | partial | unreviewed | formative-only | 0 | -| statistics | 0.6.0 | partial | unreviewed | formative-only | 0 | +| statistics | 0.7.0 | partial | unreviewed | formative-only | 0 | | transport | 0.7.0 | partial | unreviewed | formative-only | 0 | ## Candidate inventory diff --git a/docs/COURSE_GAP_REPORT.md b/docs/COURSE_GAP_REPORT.md index bf4749e..3c52787 100644 --- a/docs/COURSE_GAP_REPORT.md +++ b/docs/COURSE_GAP_REPORT.md @@ -31,13 +31,17 @@ comprehensive coverage. Every package is still `partial` and unreviewed. | robotics | partial | 23 | 66 | 136 | 81 | 0 | 0 | 0 | 4 | 0 | 0 | | signals-control | partial | 26 | 76 | 151 | 89 | 0 | 0 | 0 | 6 | 0 | 0 | | statics-materials | partial | 21 | 57 | 128 | 75 | 0 | 0 | 0 | 4 | 0 | 0 | -| statistics | partial | 27 | 78 | 135 | 92 | 0 | 0 | 0 | 12 | 0 | 0 | +| statistics | partial | 29 | 86 | 155 | 100 | 0 | 1 | 0 | 12 | 0 | 0 | | transport | partial | 25 | 68 | 150 | 85 | 0 | 0 | 0 | 7 | 0 | 0 | -Totals: 27 packages, 674 lessons, 1803 objectives, 3916 practice items, 0 lesson objectives with no assessment mapping, 0 with no practice item, 0 linked to no course outcome; 0 of 138 course outcomes that no lesson objective links to, 0 that no assessment lists. +Totals: 27 packages, 676 lessons, 1811 objectives, 3936 practice items, 0 lesson objectives with no assessment mapping, 1 with no practice item, 0 linked to no course outcome; 0 of 138 course outcomes that no lesson objective links to, 0 that no assessment lists. Each assessment with items lists the course outcomes its own items assess (item, then its tagged lesson objective, then that objective's outcome links), and `tools/validate_content.py` rejects an outcome that none of the assessment's items assesses. A listed outcome means at least one item touches it, not that it is assessed in depth. Objectives linked to no outcome are mostly those of the short prototype units; linking them is a judgement for a subject-matter reviewer, so this report lists them rather than doing it. + +## statistics + +No practice item: `statistics-27-objective-4` diff --git a/docs/COURSE_PLAN_COMPLETION.md b/docs/COURSE_PLAN_COMPLETION.md index f20af77..8a899f1 100644 --- a/docs/COURSE_PLAN_COMPLETION.md +++ b/docs/COURSE_PLAN_COMPLETION.md @@ -35,7 +35,7 @@ This is a personal hobby and learning project developed with substantial AI assi | [Robotics & Mechatronics](../content/courses/robotics/syllabus.md) | 0.6.0 | 14 | 13 | [Project guide](../content/courses/robotics/assignments/trajectory-margin-project-guide.md) | | [Signals, Systems & Feedback Control](../content/courses/signals-control/syllabus.md) | 0.6.0 | 14 | 13 | [Project guide](../content/courses/signals-control/assignments/sampled-loop-project-guide.md) | | [Statics & Mechanics of Materials](../content/courses/statics-materials/syllabus.md) | 0.6.0 | 14 | 8 | [Project guide](../content/courses/statics-materials/assignments/beam-strength-and-stiffness-project-guide.md) | -| [Probability, Biostatistics & Experimental Design](../content/courses/statistics/syllabus.md) | 0.6.0 | 14 | 14 | [Project guide](../content/courses/statistics/assignments/confirmatory-protocol-project-guide.md) | +| [Probability, Biostatistics & Experimental Design](../content/courses/statistics/syllabus.md) | 0.7.0 | 14 | 14 | [Project guide](../content/courses/statistics/assignments/confirmatory-protocol-project-guide.md) | | [Thermodynamics & Transport in Bioengineering](../content/courses/transport/syllabus.md) | 0.7.0 | 14 | 13 | [Project guide](../content/courses/transport/assignments/heat-generation-slab-project-guide.md) | ## Work through a plan diff --git a/docs/COURSE_SCOPE_AUDIT.md b/docs/COURSE_SCOPE_AUDIT.md index 275a757..fc54248 100644 --- a/docs/COURSE_SCOPE_AUDIT.md +++ b/docs/COURSE_SCOPE_AUDIT.md @@ -15,7 +15,7 @@ Initial audit inventory (2026-10-11): **25 partial courses, 446 lessons, 2,906 f | [Linear Algebra](../content/courses/linear-algebra/course.json), 0.5.0 | [18.06SC, Fall 2011 syllabus](https://ocw.mit.edu/courses/18-06sc-linear-algebra-fall-2011/pages/syllabus/) and [four-subspaces session](https://ocw.mit.edu/courses/18-06sc-linear-algebra-fall-2011/pages/ax-b-and-the-four-subspaces/the-four-fundamental-subspaces/) | The week-3 reading now computes all four subspaces for a 3 × 4 matrix, including both rank-nullity identities, orthogonality, consistency and a least-squares residual. MIT 18.06SC Unit II gives determinants three sessions before eigenvalues; a new week-7 reading now develops volume scaling, row-operation rules, elimination/cofactor computation, invertibility and the characteristic equation. LU factorization, Jordan form and pseudoinverses remain absent or thin. Other topics retain their prior boundary; course practice and workload remain unreviewed/unmeasured. | **Delivered bounded units:** four fundamental subspaces and consistency/residual geometry; then a determinants reading grouped with eigenvalues, with eleven formative items and four cards. Continue to a distinct course before further Linear Algebra expansion. **Delivered (0.4.0):** an optional week-12 guide, rehearsal practice and Case studio milestones make the hidden-state reconstruction project usable as ungraded practice, and the sensor lab's dataset link is package-pinned. **Delivered proof/map/dynamics depth (0.5.0, 2026-10-22):** two foundations, four connected proof/application reports and twenty checkpoints/cards. [Actual instruction and remaining limits](SEMESTER_DEPTH_EXPANSION.md#linear-algebra-050). | | [Physics I: Mechanics](../content/courses/physics-mechanics/course.json), 0.7.0 | [8.01SC Fall 2016 undergraduate syllabus](https://ocw.mit.edu/courses/8-01sc-classical-mechanics-fall-2016/pages/syllabus/) and [Week 6 continuous mass transfer](https://ocw.mit.edu/courses/8-01sc-classical-mechanics-fall-2016/pages/week-6-continuous-mass-transfer/); second comparator [Open Yale Courses PHYS 200](https://oyc.yale.edu/physics/phys-200) lecture list (2026-10-28) | Core forces, energy, collisions, rotation, damping and a bounded variable-mass momentum sequence are present. Creep/compliance are specializations. The centrifuge reading is bounded to ideal circular/rigid-body mechanics and excludes sedimentation-time or operating claims. | **Delivered bounded unit:** derives the inertial-frame momentum-flux balance, applies relative exhaust speed to an ideal rocket and distinguishes retained-mass capture. Eight original formative items and four cards check signs, units and limits. Pressure thrust, propulsion design and multidimensional flow remain out of scope. **Delivered bounded batch (0.4.9):** universal gravitation and orbits were absent. An original reading on the inverse-square law, field strength, circular orbits, Kepler's third law, orbital energy and escape speed is now grouped with the week-10 circular-motion lesson (eleven items, four cards). Elliptical orbits remain out of scope. **Delivered (0.5.0):** an optional week-11 guide, rehearsal practice and Case studio milestones make the rotor trade-off project usable as ungraded practice; the draft's candidate table is corrected to match its data, and the compliance lab's dataset link is package-pinned. **Delivered coupled/contact depth (0.6.0, 2026-10-22):** two foundations, four connected report/simulation assignments and sixteen checkpoints/cards. [Actual instruction and remaining limits](SEMESTER_DEPTH_EXPANSION.md#physics-mechanics-060). **Delivered second-comparator batch (0.7.0, 2026-10-28):** a [scope crosswalk](../content/courses/physics-mechanics/scope-crosswalk.md) against 8.01SC and PHYS 200 found the center of mass (8.01SC Week 5 Lesson 17; Yale Lecture 8) and the parallel-axis theorem (8.01SC Week 10 Lesson 29; Yale Lecture 10) absent; an original week-8 reading closes them, with the center-of-mass frame, the translation-plus-spin energy split, 13 items and four cards. Resistive forces and gyroscopes (MIT only) and waves and fluid statics (Yale only) remain recorded gaps. Rotate to another course before revisiting Mechanics. | | [Physics II: E&M](../content/courses/physics-em/course.json), 0.7.0 | [8.02, Spring 2007 class topics](https://ocw.mit.edu/courses/8-02-physics-ii-electricity-and-magnetism-spring-2007/pages/class-topics/) and [interference problem-solving note](https://ocw.mit.edu/courses/8-02-physics-ii-electricity-and-magnetism-spring-2007/resources/solving11/); second comparator [Open Yale Courses PHYS 201](https://oyc.yale.edu/physics/phys-201) lecture titles and overviews (2026-10-26) | Electrostatics including electric dipoles, circuits including LC oscillation and undriven RLC damping, magnetic forces, induction, charging-capacitor displacement current, ideal plane-wave energy and a bounded two-source interference model are now taught. MIT schedules radiation generation and interference after its Maxwell/radiation unit; the local package now selects phase/path conditions, equal-source intensity and far-field fringe geometry, while antenna generation and diffraction remain outside. Driven LRC resonance, which both comparators teach, appears only in the optional project guide. | **Delivered bounded unit:** six fringe/path calculations and two concept selections are grouped with the week-11 Maxwell/wave lesson, with four retrieval cards. Stable earlier lesson IDs remain. **Delivered bounded batch (0.4.8):** the wire and solenoid formulas had been stipulated without their sources. A Biot–Savart, Ampère's-law and magnetic-dipole reading, following 8.02 sessions 14–19, is now grouped with the week-8 magnetic-vectors lesson (eleven items, four cards). **Delivered bounded batch (0.5.0):** the planned series-resonance project is now usable as an optional guide with a rehearsal circuit, six open practice items and six Case studio milestone self-checks; the loading lab's dataset is package-pinned. Rotate to another course before revisiting Physics II. **Delivered field/induction/wave-boundary depth (0.6.0, 2026-10-22):** three foundations, four reports and twenty checkpoints/cards. [Actual instruction and remaining limits](SEMESTER_DEPTH_EXPANSION.md#physics-em-060). **Delivered second-comparator batch (0.7.0, 2026-10-27):** a [scope crosswalk](../content/courses/physics-em/scope-crosswalk.md) against 8.02 and Open Yale PHYS 201 found electric dipoles and LC/undriven LRC circuits absent although both comparators teach them; original week-1 dipole and week-9 LC/RLC readings close those gaps with 21 items and eight cards. A core driven-resonance reading is the next named gap. Rotate to another course before revisiting Physics II. | -| [Probability, Biostatistics & Experimental Design](../content/courses/statistics/course.json), 0.6.0 | [18.05, Spring 2022 calendar](https://ocw.mit.edu/courses/18-05-introduction-to-probability-and-statistics-spring-2022/pages/calendar/) and [Reading 15](https://ocw.mit.edu/courses/18-05-introduction-to-probability-and-statistics-spring-2022/resources/mit18_05_s22_class15-prep_pdf/) | Conditional-event Bayes, diagnostic predictive value, NHST, design, regression, resampling and finite-prior updating are present. A new week-13 Beta–Binomial lesson derives a continuous-prior posterior and integrated predictive count, with a prior-sensitivity comparison. MIT 18.05 also includes normal conjugacy and broader Bayesian/MLE material, which remain limited locally; the 14-week package is not a full 18.05 equivalent. | **Delivered bounded unit:** one original continuous-prior lesson, nine formative items and four cards, integrated with finite-prior updating in the existing week. **Delivered (0.5.0, 2026-10-22):** optional confirmatory study protocol guide, separate worked example, five rehearsal items, five retrieval cards and five private milestone self-checks; scoped evidence and remaining depth limits are in the [completion index](COURSE_PLAN_COMPLETION.md#final-eight-routes-instructional-scope-comparison). **Delivered joint-law/likelihood/regression depth (0.6.0,2026-10-22):** four foundations, four reports and twenty-four checkpoints/cards. [Actual instruction and remaining limits](SEMESTER_DEPTH_EXPANSION.md#statistics-060). | +| [Probability, Biostatistics & Experimental Design](../content/courses/statistics/course.json), 0.7.0; second comparator [USC STAT 205, Fall 2011](https://people.stat.sc.edu/hansont/stat205/SyllabusF11.pdf) (2026-10-31: categorical outcomes and one-way ANOVA added; logistic regression and ROC remain absent; see the package scope crosswalk) | [18.05, Spring 2022 calendar](https://ocw.mit.edu/courses/18-05-introduction-to-probability-and-statistics-spring-2022/pages/calendar/) and [Reading 15](https://ocw.mit.edu/courses/18-05-introduction-to-probability-and-statistics-spring-2022/resources/mit18_05_s22_class15-prep_pdf/) | Conditional-event Bayes, diagnostic predictive value, NHST, design, regression, resampling and finite-prior updating are present. A new week-13 Beta–Binomial lesson derives a continuous-prior posterior and integrated predictive count, with a prior-sensitivity comparison. MIT 18.05 also includes normal conjugacy and broader Bayesian/MLE material, which remain limited locally; the 14-week package is not a full 18.05 equivalent. | **Delivered bounded unit:** one original continuous-prior lesson, nine formative items and four cards, integrated with finite-prior updating in the existing week. **Delivered (0.5.0, 2026-10-22):** optional confirmatory study protocol guide, separate worked example, five rehearsal items, five retrieval cards and five private milestone self-checks; scoped evidence and remaining depth limits are in the [completion index](COURSE_PLAN_COMPLETION.md#final-eight-routes-instructional-scope-comparison). **Delivered joint-law/likelihood/regression depth (0.6.0,2026-10-22):** four foundations, four reports and twenty-four checkpoints/cards. [Actual instruction and remaining limits](SEMESTER_DEPTH_EXPANSION.md#statistics-060). | Local anchors: [Calculus I shape analysis](../content/courses/calculus-1/modules/09-shape-analysis-and-constrained-optimization.md), [Calculus II integration methods](../content/courses/calculus-2/modules/07-integration-methods-and-domain-checks.md), [Calculus II indeterminate limits](../content/courses/calculus-2/modules/14-indeterminate-limits-and-lhopitals-rule.md), [DE forcing](../content/courses/differential-equations/modules/07-forced-first-order-systems-in-a-perfused-chamber.md), [Linear Algebra rank/null space](../content/courses/linear-algebra/modules/07-rank-null-space-and-identifiability.md), [Programming conditioning and residuals](../content/courses/programming/modules/14-linear-system-conditioning-residuals-and-solution-error.md), [centrifuge models](../content/courses/physics-mechanics/modules/06-centrifuge-rotation-and-balance.md), [E&M steady magnetic fields](../content/courses/physics-em/modules/11-magnetic-vectors-fields-and-force-work.md), and [probability introduction](../content/courses/statistics/modules/01-probability-distributions-and-sampling.md). diff --git a/docs/PROGRESS_REVIEW.md b/docs/PROGRESS_REVIEW.md index a040b2f..48fec3d 100644 --- a/docs/PROGRESS_REVIEW.md +++ b/docs/PROGRESS_REVIEW.md @@ -2183,3 +2183,7 @@ Human Physiology for Engineers is the eleventh package compared with a second co ## Genetics & Genomics second-comparator crosswalk, chromosome rearrangements and cancer genetics (2026-10-30) Genetics & Genomics is the twelfth package compared with a second comparator. A new [scope crosswalk](../content/courses/genetics/scope-crosswalk.md) sets the 35 lectures of MIT OCW 7.03 Fall 2004 beside the 24-lecture Columbia University BIOL UN3031 Spring 2023 schedule, using lecture titles. Two topics both teach were missing as readings: chromosome rearrangements (7.03 Chromosome Anomalies, Lec 32–33; Columbia Lec 7–9) and the genetics of cancer (7.03 Lec 34–35; Columbia Lec 24). Version 0.9.0 adds an original week-6 reading after the aneuploidy lesson, on deletions, duplications, inversion loops, translocation segregation and copy-number ratios, and an original week-8 reading after the somatic-mutation lesson, on oncogenes and tumor suppressors, Knudson's two-hit Poisson argument, the multistage model, mutators, clonal expansion and loss of heterozygosity, with eight objectives, 24 practice items and eight cards. Both papers the cancer reading names were resolved with the citation-check script; Knudson's abstract was read and supports the mean of three tumors per carrier, and the 1954 multistage paper has no abstract in its record, so only its title claim is used. Scoping caught three things. The first draft gave the non-carrier two-hit expectation as N(μt)²/2, the ordered-step form; with either copy hit first it is N(μt)², so the key changed from 4.50 to 9.00 per million, and a deliberate check confirmed that the lesson test and the independent recalculation registry both reject the old value. Rereading the existing lessons showed that RNA interference, conditional loxP alleles, frameshift reversion and pathway ordering by double mutants are already taught, so those Columbia topics are labelled shallow or covered rather than absent. The workspace wording scan bans "karyotype", so the reading says "standard chromosome arrangement"; the scan's remaining flag is the syllabus inventory line, whose counts changed while its AI-assistance sentence stayed. Mutation mechanisms, Muller's allelic classes, forward genetic screens, genetic mosaic analysis, small-RNA biology, the recombination mechanism and positional cloning remain recorded gaps. The comparison was compiled with AI assistance and has not been reviewed by the owner or an instructor. The package remains partial, unreviewed and formative-only. + +## Statistics second-comparator crosswalk, categorical outcomes and one-way ANOVA (2026-10-31) + +Probability, Biostatistics & Experimental Design is the thirteenth package compared with a second comparator. A new [scope crosswalk](../content/courses/statistics/scope-crosswalk.md) sets the MIT OCW 18.05 Spring 2022 calendar beside the University of South Carolina STAT 205 Fall 2011 schedule. Both teach χ² methods, and STAT 205 also teaches 2 × 2 tables, stratification and one-way ANOVA, none of which the package had. Two original readings were added: categorical outcomes (week 5) and one-way analysis of variance (week 6), with 20 practice items and eight cards, and the package moves to 0.7.0. The crosswalk also records that likelihood estimation and normal conjugacy, listed as limited in the older scope audit, were already taught. Logistic regression and ROC curves remain absent. A package test recomputes every numeric key and stated number; it is a same-AI check, not review. The package remains partial, unreviewed and formative-only. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 571ec9e..5cf9319 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -22,7 +22,7 @@ Use a demonstrated learner failure or a defined broader boundary to choose the n - Calculus III now integrates theorem choice, boundary orientation, cylindrical volume bounds and direct checks in its existing week-13 Stokes lesson. - Link common Statistics/Programming foundations rather than repeatedly teach experimental units, uncertainty and data validation in every applied course. Retain domain-specific reasoning and distinct worked problems. -The audit retains open work for every package. Cellular Biomechanics, Geroscience, Biochemistry I (second-comparator crosswalk with membrane-structure and signal-transduction readings, 2026-10-29), Human Physiology (second-comparator crosswalk with acid–base-balance and cardiac-cycle/ECG readings, 2026-10-30), Organic Chemistry I (second-comparator crosswalk with a reaction-energetics reading, 2026-10-27), Differential Equations (second-comparator crosswalk with a forced-linear-equations reading, 2026-10-27), Calculus III (second-comparator crosswalk with curve-kinematics and multiple-integral-application readings, 2026-10-28), Calculus II (second-comparator crosswalk with an averages and probability-density reading, 2026-10-28), Calculus I (second-comparator crosswalk with a trigonometric limits and derivatives reading, 2026-10-28), Physics: Mechanics (second-comparator crosswalk with a center-of-mass and parallel-axis reading, 2026-10-28), Statistics, Thermodynamics & Transport (second-comparator crosswalk with a closed-system first-law and ideal-gas reading, 2026-10-28), Programming & Numerical Methods, Cell & Molecular Biology, Physics II: E&M (second-comparator crosswalk with dipole and LC/RLC readings, 2026-10-27), General Chemistry I (second-comparator crosswalk with matter-wave and octet-exception readings, 2026-10-28), General Chemistry II, Biomaterials & Tissue Engineering, Signals & Feedback Control, Circuits (authored to its declared typical-course scope, 2026-10-23) and Genetics (transposition and homologous-targeting foundations with a cumulative audit, 2026-10-24; second-comparator crosswalk with chromosome-rearrangement and cancer-genetics readings, 2026-10-30) have recent bounded deliveries. Future substantive batches should select a demonstrated remaining gap and check the actual course boundary before authoring. +The audit retains open work for every package. Cellular Biomechanics, Geroscience, Biochemistry I (second-comparator crosswalk with membrane-structure and signal-transduction readings, 2026-10-29), Human Physiology (second-comparator crosswalk with acid–base-balance and cardiac-cycle/ECG readings, 2026-10-30), Organic Chemistry I (second-comparator crosswalk with a reaction-energetics reading, 2026-10-27), Differential Equations (second-comparator crosswalk with a forced-linear-equations reading, 2026-10-27), Calculus III (second-comparator crosswalk with curve-kinematics and multiple-integral-application readings, 2026-10-28), Calculus II (second-comparator crosswalk with an averages and probability-density reading, 2026-10-28), Calculus I (second-comparator crosswalk with a trigonometric limits and derivatives reading, 2026-10-28), Physics: Mechanics (second-comparator crosswalk with a center-of-mass and parallel-axis reading, 2026-10-28), Statistics (second-comparator crosswalk with categorical-outcome and one-way ANOVA readings, 2026-10-31), Thermodynamics & Transport (second-comparator crosswalk with a closed-system first-law and ideal-gas reading, 2026-10-28), Programming & Numerical Methods, Cell & Molecular Biology, Physics II: E&M (second-comparator crosswalk with dipole and LC/RLC readings, 2026-10-27), General Chemistry I (second-comparator crosswalk with matter-wave and octet-exception readings, 2026-10-28), General Chemistry II, Biomaterials & Tissue Engineering, Signals & Feedback Control, Circuits (authored to its declared typical-course scope, 2026-10-23) and Genetics (transposition and homologous-targeting foundations with a cumulative audit, 2026-10-24; second-comparator crosswalk with chromosome-rearrangement and cancer-genetics readings, 2026-10-30) have recent bounded deliveries. Future substantive batches should select a demonstrated remaining gap and check the actual course boundary before authoring. ## Shared platform diff --git a/docs/STATISTICS_REVIEW_HANDOFF.md b/docs/STATISTICS_REVIEW_HANDOFF.md index 141e188..dcd2870 100644 --- a/docs/STATISTICS_REVIEW_HANDOFF.md +++ b/docs/STATISTICS_REVIEW_HANDOFF.md @@ -1,6 +1,6 @@ # Probability, Biostatistics & Experimental Design review handoff -This handoff targets Statistics package version 0.6.0. The [review snapshot](STATISTICS_REVIEW_SNAPSHOT.md) identifies the exact public files. The course remains partial, unreviewed and formative-only; no reviewer or approval is recorded. +This handoff targets Statistics package version 0.7.0. The [review snapshot](STATISTICS_REVIEW_SNAPSHOT.md) identifies the exact public files. The course remains partial, unreviewed and formative-only; no reviewer or approval is recorded. ## Review scope @@ -25,3 +25,7 @@ The current guide, separate worked example, rehearsal and five private milestone ## Joint-law/likelihood/regression target (2026-10-22) Current target 0.6.0 adds four foundations,four reports and twenty-four checkpoints/cards. Check triangular support/marginals/conditional moments,total variance and zero-covariance dependence; event/censored exposure,score,curvature and open-domain supremum; known-variance normal completion of the square and shared future covariance; full-rank regression,residualization,reduced slope,residual degrees of freedom and mean-versus-new prediction. Verify exact binomial coverage,all-failure resampling,central-tail conventions and inclusive sharp-null allocation counts. All inputs are synthetic; independent-unit/design assumptions remain explicit. Code/proofs/reports,broader theory,empirical validity,workload and qualified review remain open. Earlier evidence above is historical; the current snapshot pins the new target. + +## Second-comparator target (2026-10-31) + +Current target 0.7.0 adds two readings after a comparison with the University of South Carolina STAT 205 (Fall 2011) schedule: categorical outcomes in week 5 and one-way ANOVA in week 6, with 20 items and eight cards. Recalculate the 2 × 2 measures (RR 0.50, OR 0.286 with interval 0.113–0.721), χ² = 7.27, the Fisher probabilities (0.0196 point, 0.0406 two-sided), the Simpson's-paradox strata and the Mantel–Haenszel odds ratio 1.45, and the ANOVA table (SS_B 300, SS_W 104, F 21.6, η² 0.743) with the Bonferroni-adjusted comparisons. The [package test](../tests/content/test_statistics_categorical_and_anova_lessons.py) recomputes these; it is a same-AI check, not review. Logistic regression and ROC curves remain absent. diff --git a/docs/STATISTICS_REVIEW_SNAPSHOT-0.6.0.md b/docs/STATISTICS_REVIEW_SNAPSHOT-0.6.0.md new file mode 100644 index 0000000..d7e63c7 --- /dev/null +++ b/docs/STATISTICS_REVIEW_SNAPSHOT-0.6.0.md @@ -0,0 +1,12 @@ +# Probability, Biostatistics & Experimental Design review snapshot + +This identifies the public package; a digest does not approve inference or course depth. + +- **Course package:** `statistics` 0.6.0 +- **Public package archive SHA-256:** `c8a84a16c25997be08ae5937ca656e79aa1856fef9b3b2d597b060a2c4520a4a` +- **Manifest SHA-256:** `151b04dccfb85a42f4b1cb7514a478383ba5ddbf99baa278d4ea8bc61fff1f6b` +- **Archive inventory:** 36 files, 587,788 bytes +- **Calculated:** 2026-10-22 +- **Reproduce:** `python tools/course_package_digest.py --course-id statistics` + +Four original joint-law/likelihood/normal-update/regression foundations and four reports add twenty-four checkpoints/cards. All records and tables are synthetic models. Proofs,plots,code,reports,causal identification and empirical validity are not graded. The course remains partial,unreviewed and formative-only; the previous 0.5.0 snapshot is preserved. diff --git a/docs/STATISTICS_REVIEW_SNAPSHOT.md b/docs/STATISTICS_REVIEW_SNAPSHOT.md index d7e63c7..371a6b6 100644 --- a/docs/STATISTICS_REVIEW_SNAPSHOT.md +++ b/docs/STATISTICS_REVIEW_SNAPSHOT.md @@ -2,11 +2,11 @@ This identifies the public package; a digest does not approve inference or course depth. -- **Course package:** `statistics` 0.6.0 -- **Public package archive SHA-256:** `c8a84a16c25997be08ae5937ca656e79aa1856fef9b3b2d597b060a2c4520a4a` -- **Manifest SHA-256:** `151b04dccfb85a42f4b1cb7514a478383ba5ddbf99baa278d4ea8bc61fff1f6b` -- **Archive inventory:** 36 files, 587,788 bytes -- **Calculated:** 2026-10-22 +- **Course package:** `statistics` 0.7.0 +- **Public package archive SHA-256:** `170591407b140b1d0cf2b33e5c0449b98473387b14fcd496653aab4a946675fe` +- **Manifest SHA-256:** `5679c63c23a3b9f4e436a80da17b08424c0e6e1f1b7d9d09a7c8d74c42c13ff0` +- **Archive inventory:** 39 files, 642,251 bytes +- **Calculated:** 2026-10-31 - **Reproduce:** `python tools/course_package_digest.py --course-id statistics` -Four original joint-law/likelihood/normal-update/regression foundations and four reports add twenty-four checkpoints/cards. All records and tables are synthetic models. Proofs,plots,code,reports,causal identification and empirical validity are not graded. The course remains partial,unreviewed and formative-only; the previous 0.5.0 snapshot is preserved. +Two original readings, compared with the University of South Carolina STAT 205 (Fall 2011) schedule as a second comparator, add categorical outcomes (2 × 2 tables, risk and odds ratios, χ² and Fisher's exact tests, Simpson's paradox and the Mantel–Haenszel odds ratio) and one-way analysis of variance, with 20 practice items, eight cards and a scope crosswalk. All data are synthetic. The course remains partial, unreviewed and formative-only; the previous 0.6.0 snapshot is preserved. diff --git a/tests/content/test_ai_assisted_checks.py b/tests/content/test_ai_assisted_checks.py index 4cc7f22..94562f7 100644 --- a/tests/content/test_ai_assisted_checks.py +++ b/tests/content/test_ai_assisted_checks.py @@ -750,6 +750,22 @@ def pooled_variance(samples: list[list[float]]) -> float: math.lgamma(12) + math.lgamma(5) - math.lgamma(17) - (math.lgamma(10) + math.lgamma(4) - math.lgamma(14)) ), "statistics-17:prior-sensitivity-mean": (1 + 8) / (1 + 8 + 1 + 2), + # Lesson 26 (0.7.0): 18 of 50 treated and 30 of 50 control with the outcome; hypergeometric point probability + "statistics-26:risk-ratio": (18 / 50) / (30 / 50), + "statistics-26:odds-ratio": (18 * 20) / (32 * 30), + "statistics-26:risk-difference": 18 / 50 - 30 / 50, + "statistics-26:expected": 50 * 48 / 100, + "statistics-26:chi-square": sum((o - e) ** 2 / e for o, e in zip((18, 32, 30, 20), (24, 26, 24, 26))), + "statistics-26:log-or-se": math.sqrt(1 / 18 + 1 / 32 + 1 / 30 + 1 / 20), + "statistics-26:fisher-point": math.comb(7, 1) * math.comb(9, 7) / math.comb(16, 8), + # Lesson 27 (0.7.0): three groups of five with means 14, 18, 12 and variance 2.5 each + "statistics-27:ssb": 5 * sum((m - 44 / 3) ** 2 for m in (14, 18, 12)), + "statistics-27:ssw": 3 * 4 * 2.5, + "statistics-27:f-stat": (5 * sum((m - 44 / 3) ** 2 for m in (14, 18, 12)) / 2) / (30 / 12), + "statistics-27:df-within": 4 * 6 - 4, + "statistics-27:eta-squared": 120 / (120 + 280), + "statistics-27:fwer": 1 - 0.95 ** 5, + "statistics-27:pairwise-se": math.sqrt(2.5 * (1 / 5 + 1 / 5)), } diff --git a/tests/content/test_new_geroscience_and_statistics_lessons.py b/tests/content/test_new_geroscience_and_statistics_lessons.py index a771843..9ebbd4c 100644 --- a/tests/content/test_new_geroscience_and_statistics_lessons.py +++ b/tests/content/test_new_geroscience_and_statistics_lessons.py @@ -369,7 +369,7 @@ def test_statistics_schedule_places_every_lesson_once_and_stays_partial(): assert [w["week"] for w in weeks] == list(range(1, 15)) scheduled = [lesson for w in weeks for lesson in w["lesson_ids"]] all_lessons = [lesson["id"] for m in manifest["modules"] for lesson in m["lessons"]] - assert len(all_lessons) == 27 + assert len(all_lessons) == 29 assert sorted(scheduled) == sorted(all_lessons) and len(scheduled) == len(set(scheduled)) assessments = {a["id"]: a for a in manifest["assessments"]} assert all(a in assessments for w in weeks for a in w["assessment_ids"]) diff --git a/tests/content/test_statistics_categorical_and_anova_lessons.py b/tests/content/test_statistics_categorical_and_anova_lessons.py new file mode 100644 index 0000000..6c3a20b --- /dev/null +++ b/tests/content/test_statistics_categorical_and_anova_lessons.py @@ -0,0 +1,155 @@ +"""Statistics 0.7.0 lessons 26 (categorical outcomes) and 27 (one-way ANOVA): recomputed numbers, schedule and grading. + +2 × 2 measures, χ² and Fisher probabilities are recomputed from counts and binomial coefficients; the ANOVA table from the +raw values; p-values from the regularized incomplete beta function. Same-AI checks, not human review. +""" + +from __future__ import annotations + +import json +import math +from math import comb +from pathlib import Path + +import mpmath +import pytest + +from courselab.grading import grade +from courselab.schemas import AttemptRequest + +ROOT = Path(__file__).resolve().parents[2] +COURSE = ROOT / "content/courses/statistics" +MANIFEST = json.loads((COURSE / "course.json").read_text(encoding="utf-8")) +BANK = {q["id"]: q for q in json.loads((COURSE / "question-banks/practice.json").read_text(encoding="utf-8"))["questions"]} +R26 = (COURSE / "modules/26-categorical-outcomes-two-by-two-tables.md").read_text(encoding="utf-8") +R27 = (COURSE / "modules/27-one-way-analysis-of-variance.md").read_text(encoding="utf-8") +CROSSWALK = (COURSE / "scope-crosswalk.md").read_text(encoding="utf-8") + + +def stated(text, *phrases): + missing = [p for p in phrases if p not in text] + assert not missing, missing + + +def chi2(table): + (a, b), (c, d) = table + n = a + b + c + d + rows, cols = (a + b, c + d), (a + c, b + d) + return sum((o - rows[i] * cols[j] / n) ** 2 / (rows[i] * cols[j] / n) + for i, row in enumerate(table) for j, o in enumerate(row)) + + +def p_f(f, d1, d2): + return float(mpmath.betainc(d2 / 2, d1 / 2, 0, d2 / (d2 + d1 * f), regularized=True)) + + +def p_t(t, df): + return float(mpmath.betainc(df / 2, 0.5, 0, df / (df + t * t), regularized=True)) + + +def test_lesson_26_two_by_two_numbers(): + a, b, c, d = 12, 28, 24, 16 + odds_ratio = a * d / (b * c) + se = math.sqrt(1 / a + 1 / b + 1 / c + 1 / d) + x2 = chi2(((a, b), (c, d))) + assert (a / 40) / (c / 40) == pytest.approx(0.5) and round(odds_ratio, 3) == 0.286 + assert round(se, 3) == 0.472 + assert round(math.exp(math.log(odds_ratio) - 1.96 * se), 3) == 0.113 and round(math.exp(math.log(odds_ratio) + 1.96 * se), 3) == 0.721 + assert round(x2, 2) == 7.27 and round(math.erfc(math.sqrt(x2 / 2)), 4) == 0.0070 + assert round(math.sqrt(0.3 * 0.7 / 40 + 0.6 * 0.4 / 40), 3) == 0.106 + stated(R26, "= 0.286", "0.113 to 0.721", "= 7.27", "p = 0.0070", "= 0.106, giving −0.51 to −0.09") + + +def test_lesson_26_fisher_and_simpson(): + probs = {x: comb(7, x) * comb(9, 8 - x) / comb(16, 8) for x in range(0, 8)} + observed = probs[1] + assert round(observed, 4) == 0.0196 and round(probs[0] + probs[1], 4) == 0.0203 + assert round(sum(p for p in probs.values() if p <= observed + 1e-12), 4) == 0.0406 + strata = [(81, 6, 234, 36), (192, 71, 55, 25)] + for a, b, c, d in strata: + assert a / (a + b) > c / (c + d) + pooled = [sum(s[i] for s in strata) for i in range(4)] + assert pooled[0] / (pooled[0] + pooled[1]) < pooled[2] / (pooled[2] + pooled[3]) + mh = sum(a * d / sum(s) for (a, b, c, d), s in zip(strata, strata)) / sum(b * c / sum(s) for (a, b, c, d), s in zip(strata, strata)) + assert round(mh, 2) == 1.45 + stated(R26, "7 × 36/12,870 = 0.0196", "1 × 9/12,870 = 0.0007", "0.0203", "p = 0.0406", "= 1.45", "78.0%", "82.6%") + x2 = chi2(((9, 51), (21, 39))) + assert round(x2, 2) == 6.40 and round(math.erfc(math.sqrt(x2 / 2)), 3) == 0.011 + stated(R26, "= 6.40", "p = 0.011", "0.328", "0.429") + + +def test_lesson_27_anova_table_and_comparisons(): + groups = [[48, 52, 50, 55, 47, 48], [53, 58, 51, 57, 55, 56], [60, 57, 63, 59, 62, 59]] + values = [x for g in groups for x in g] + grand = sum(values) / len(values) + means = [sum(g) / len(g) for g in groups] + ssb = sum(len(g) * (m - grand) ** 2 for g, m in zip(groups, means)) + ssw = sum((x - m) ** 2 for g, m in zip(groups, means) for x in g) + f = (ssb / 2) / (ssw / 15) + assert (ssb, ssw) == (300, 104) and round(f, 1) == 21.6 and round(ssb / (ssb + ssw), 3) == 0.743 + assert f"{p_f(f, 2, 15):.1e}" == "3.8e-05" + se = math.sqrt(ssw / 15 * 2 / 6) + assert round(se, 2) == 1.52 + assert round(10 / se, 2) == 6.58 and round(5 / se, 2) == 3.29 and round(p_t(5 / se, 15), 4) == 0.0050 + assert round(3 * p_t(5 / se, 15), 3) == 0.015 and round(1 - 0.95 ** 3, 3) == 0.143 + stated(R27, "SS_B = Σ nⱼ(ȳⱼ − ȳ)² = 6(−5)² + 6(0)² + 6(5)² = 300", "F = 150.0/6.93 = 21.6", "p = 3.8 × 10⁻⁵", "0.743", + "t = 5.0/1.52 = 3.29, p = 0.0050", "0.015", "1 − 0.95³ = 0.143") + means4, var4 = [10.0, 12.0, 13.0, 15.0], [4.0, 5.0, 3.0, 4.0] + ssb4 = 5 * sum((m - 12.5) ** 2 for m in means4) + ssw4 = 4 * sum(var4) + f4 = (ssb4 / 3) / (ssw4 / 16) + assert (ssb4, ssw4) == (65.0, 64.0) and round(f4, 2) == 5.42 and round(p_f(f4, 3, 16), 4) == 0.0092 + stated(R27, "= 65.0", "SS_W = 4 × (4.0 + 5.0 + 3.0 + 4.0) = 64.0", "F = 21.7/4.00 = 5.42", "p = 0.0092") + + +KEYS = { + "26:risk-ratio": (0.36 / 0.60, 0.60 / 0.36), "26:odds-ratio": (18 * 20 / (32 * 30), 0.36 / 0.60), + "26:risk-difference": (0.36 - 0.60, 0.60 - 0.36), "26:expected": (24, 18), + "26:chi-square": (chi2(((18, 32), (30, 20))), 36 / 24 + 36 / 26), "26:log-or-se": (math.sqrt(1 / 18 + 1 / 32 + 1 / 30 + 1 / 20), 1 / 18 + 1 / 32 + 1 / 30 + 1 / 20), + "26:fisher-point": (7 * 36 / 12870, 7 * 9 / 12870), + "27:ssb": (5 * ((14 - 44 / 3) ** 2 + (18 - 44 / 3) ** 2 + (12 - 44 / 3) ** 2), (14 - 44 / 3) ** 2 + (18 - 44 / 3) ** 2 + (12 - 44 / 3) ** 2), + "27:ssw": (30, 7.5), "27:f-stat": ((280 / 3 / 2) / (30 / 12), (280 / 3) / 30), "27:df-within": (20, 23), + "27:eta-squared": (0.3, 120 / 280), "27:fwer": (1 - 0.95 ** 5, 0.25), "27:pairwise-se": (1.0, math.sqrt(2.5 / 5)), +} + + +def shown(value, sig): + if not sig: + return f"{value:g}" + return f"{value:.{max(0, sig - 1 - math.floor(math.log10(abs(value))))}f}" + + +def test_every_new_numeric_item_has_a_key(): + new = {qid for qid, q in BANK.items() if q["type"] == "numeric" and qid.startswith(("statistics-26:", "statistics-27:"))} + assert new == {f"statistics-{k}" for k in KEYS} + + +@pytest.mark.parametrize("key", sorted(KEYS)) +def test_new_numeric_keys(key): + item = BANK[f"statistics-{key}"] + spec = item["solution_spec"] + value, wrong = KEYS[key] + sig = spec["significant_figures"] + assert float(shown(value, sig)) == pytest.approx(spec["answer"]) + assert grade(item, AttemptRequest(response=shown(spec["answer"], sig))).correct + assert not grade(item, AttemptRequest(response=shown(wrong, sig))).correct + + +def test_new_choice_items_accept_only_the_key(): + for qid, item in BANK.items(): + if not qid.startswith(("statistics-26:", "statistics-27:")) or item["type"] == "numeric": + continue + if item["type"] == "single_choice": + assert [i for i in range(len(item["options"])) if grade(item, AttemptRequest(response=str(i))).correct] == [item["solution_spec"]["answer"]] + else: + assert grade(item, AttemptRequest(response=item["solution_spec"]["answer"])).score == pytest.approx(2.0) + + +def test_schedule_version_and_crosswalk(): + assert MANIFEST["version"] == "0.7.0" + weeks = {w["week"]: w for w in MANIFEST["duration"]["weeks"]} + assert weeks[5]["lesson_ids"].index("statistics-26") == weeks[5]["lesson_ids"].index("statistics-10") + 1 + assert weeks[6]["lesson_ids"].index("statistics-27") == weeks[6]["lesson_ids"].index("statistics-7") + 1 + stated(CROSSWALK, "USC STAT 205", "agent-run comparison", "Logistic regression", "Absent") + syllabus = (COURSE / "syllabus.md").read_text(encoding="utf-8") + assert "Version: 0.7.0." in syllabus and "scope-crosswalk.md" in syllabus diff --git a/tests/content/test_statistics_confirmatory_project_draft.py b/tests/content/test_statistics_confirmatory_project_draft.py index 52952fa..c0f0579 100644 --- a/tests/content/test_statistics_confirmatory_project_draft.py +++ b/tests/content/test_statistics_confirmatory_project_draft.py @@ -52,7 +52,7 @@ def test_statistics_review_snapshot_matches_public_package(): snapshot = (ROOT / "docs/STATISTICS_REVIEW_SNAPSHOT.md").read_text(encoding="utf-8") assert actual["course_id"] == "statistics" - assert actual["content_version"] == "0.6.0" + assert actual["content_version"] == "0.7.0" assert re.search( r"Public package archive SHA-256:\*\*\s*`([0-9a-f]{64})`", snapshot ).group(1) == actual["package_sha256"] diff --git a/tests/content/test_statistics_semester_expansion.py b/tests/content/test_statistics_semester_expansion.py index 6e0fe6f..380430a 100644 --- a/tests/content/test_statistics_semester_expansion.py +++ b/tests/content/test_statistics_semester_expansion.py @@ -350,10 +350,10 @@ def test_exact_binomial_coverage_bootstrap_boundary_and_inclusive_randomization( def test_statistical_packets_deliver_as_open_partial_coursework(): assert len(NEW) == 24 - assert MANIFEST["version"] == "0.6.0" and MANIFEST["maturity"] == "partial" + assert MANIFEST["version"] == "0.7.0" and MANIFEST["maturity"] == "partial" assert MANIFEST["review"]["status"] == "unreviewed" scheduled = [lid for w in MANIFEST["duration"]["weeks"] for lid in w["lesson_ids"]] - assert len(scheduled) == len(set(scheduled)) == 27 + assert len(scheduled) == len(set(scheduled)) == 29 repository = ContentRepository(ROOT / "content") for number in range(18, 26): lesson = repository.lesson("statistics", f"statistics-{number}")