Independent education · Sources and limitations documented
Portfolio / Smart NOA simulation

Software simulation

Source snapshot · 5 November 2026

simulator/ui/assessment.js

// Step 2 — Assessment (computed, read-only).
import { h, clear, fmtNum } from './dom.js';
import { evaluateContraindications } from '../engine/dosing.js';
import { createSimulation } from '../engine/sim.js';
import { srcTags, sourcesInText } from './sources.js';
import { FLAG_LABELS, CLASS_LABELS, notModeled } from './labels.js';

export function computeLockouts(patient, lib) {
  // The simulation computes the full lockout set (library LOCK rules + Python controller free-text lockouts).
  const sim = createSimulation({ patient, plan: null, seed: 1, interlocks: true, library: lib });
  const locks = sim.state.interlocks.lockouts;
  const rows = [];
  for (const d of lib.DRUGS) {
    const cis = evaluateContraindications(patient, d.id, lib);
    const lockMsgs = locks[d.id] || [];
    const libLocks = cis.filter((c) => c.severity === 'lock');
    const warns = cis.filter((c) => c.severity === 'warn');
    if (!lockMsgs.length && !warns.length) continue;
    rows.push({ drug: d, locked: lockMsgs.length > 0,
      lockItems: lockMsgs.map((m) => ({ message: m, sources: (libLocks.find((c) => c.message === m) || {}).sources || sourcesInText(m) })),
      warnItems: warns.map((c) => ({ message: c.message, sources: c.sources })) });
  }
  return rows;
}

export function renderAssessment(ctx, main) {
  const { lib } = ctx;
  const p = ctx.getPatient();
  if (!p) { ctx.go(1); return; }
  const E = lib.SOURCE_IDS_ENGINE || {};
  const kg = (v) => `${fmtNum(v, 1)} kg`;
  const aw = p.airway;
  const awConcerns = [];
  if (aw.mallampati >= 3) awConcerns.push(`Mallampati class ${aw.mallampati}`);
  if (aw.difficultHistory) awConcerns.push('Previous difficult airway');
  if (aw.limitedMouthOpening) awConcerns.push('Limited mouth opening');
  if (aw.limitedNeck) awConcerns.push('Limited neck movement');
  if (p.flags.includes('aspirationRisk')) awConcerns.push('Aspiration risk');
  if (p.flags.includes('osaRisk')) awConcerns.push('OSA / high STOP-Bang risk (faster desaturation and obstruction in the model)');

  const rows = computeLockouts(p, lib);
  const sb = p.stopBang;
  const sbItems = Object.entries(sb.items || {}).filter(([, v]) => v).map(([k]) => ({ snoring: 'snoring', tired: 'tiredness', observedApnea: 'observed apnoea', pressure: 'high blood pressure', bmiOver35: 'BMI > 35', ageOver50: 'age > 50', neckOver40: 'neck > 40 cm', male: 'male sex' }[k] || k));
  const ap = p.apfel;
  const apItems = Object.entries(ap.factors || {}).filter(([, v]) => v).map(([k]) => ({ female: 'female sex', nonSmoker: 'non-smoker', ponvHistory: 'PONV/motion-sickness history', postopOpioids: 'postoperative opioids' }[k] || k));

  clear(main).append(
    h('div', { class: 'step-head' },
      h('h2', { id: 'step-title', tabindex: '-1' }, '2 · Assessment'),
      h('p', { class: 'muted' }, 'Computed from the patient entries. Read-only — go back to step 1 to change anything.')),
    h('div', { class: 'grid grid--3' },
      h('section', { class: 'card' }, h('h3', {}, 'Body size & weight scalars'),
        h('dl', { class: 'kv' },
          h('dt', {}, 'BMI'), h('dd', {}, `${fmtNum(p.bmi, 1)} kg/m²`),
          h('dt', {}, 'Total body weight (TBW)'), h('dd', {}, kg(p.weightKg)),
          h('dt', {}, 'Ideal body weight (IBW, Devine)'), h('dd', {}, kg(p.ibwKg), ' ', srcTags([E.ibwDevine])),
          h('dt', {}, 'Lean body weight (LBW, Janmahasatian)'), h('dd', {}, kg(p.lbwKg), ' ', srcTags([E.lbwJanmahasatian])),
          h('dt', {}, 'Adjusted body weight (ABW = IBW + 0.4 × (TBW − IBW))'), h('dd', {}, kg(p.abwKg))),
        p.ibwKg > p.weightKg ? h('p', { class: 'hint' }, 'IBW exceeds TBW: scalars are capped at TBW when dosing.') : null),
      h('section', { class: 'card' }, h('h3', {}, 'Haemodynamics & scores'),
        h('dl', { class: 'kv' },
          h('dt', {}, 'Baseline MAP'), h('dd', {}, `${Math.round(p.map)} mmHg (DBP + (SBP − DBP)/3)`),
          h('dt', {}, 'Apfel simplified PONV score'), h('dd', {}, `${ap.score}/4 — ${ap.riskPercent} % risk `, srcTags([E.apfel]),
            h('div', { class: 'hint' }, apItems.length ? `Factors: ${apItems.join(', ')}` : 'No factors')),
          h('dt', {}, 'STOP-Bang'), h('dd', {}, `${sb.score}/8 — ${sb.risk} risk `, srcTags([E.stopBang, E.stopBangRisk]),
            h('div', { class: 'hint' }, sbItems.length ? `Items: ${sbItems.join(', ')}` : 'No items', sb.escalated ? '; intermediate score escalated to high (STOP ≥ 2 plus male/BMI > 35/neck > 40)' : '', sb.neckMeasured ? '' : '; neck not measured')),
          h('dt', {}, 'eGFR'), h('dd', {}, `${p.egfr} mL/min/1.73 m²`))),
      h('section', { class: 'card' }, h('h3', {}, 'Airway concerns'),
        awConcerns.length ? h('ul', { class: 'list' }, awConcerns.map((c) => h('li', {}, c))) : h('p', {}, 'None entered.'),
        h('h4', {}, 'Simulator flags'),
        p.flags.length ? h('ul', { class: 'chips' }, p.flags.map((f) => h('li', { class: 'chip' }, FLAG_LABELS[f] || f))) : h('p', {}, 'No flags.'),
        h('p', { class: 'hint' }, 'Flags are simulator rules that select label adjustments (e.g. elderly dosing) and illustrative model modifiers.'))),
    h('section', { class: 'card' }, h('h3', {}, 'Drug lockouts & warnings for this patient'),
      rows.length ? h('div', { class: 'table-wrap' }, h('table', { class: 'table' },
        h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, 'Drug'), h('th', { scope: 'col' }, 'Status'), h('th', { scope: 'col' }, 'Reason and source'))),
        h('tbody', {}, rows.map((r) => h('tr', { class: r.locked ? 'row--locked' : 'row--warn' },
          h('th', { scope: 'row' }, r.drug.name, h('div', { class: 'hint' }, CLASS_LABELS[r.drug.cls] || r.drug.cls)),
          h('td', {}, r.locked ? h('span', { class: 'badge badge--lock' }, 'LOCKED') : h('span', { class: 'badge badge--warn' }, 'WARNING')),
          h('td', {}, h('ul', { class: 'list list--tight' },
            r.lockItems.map((x) => h('li', {}, x.message, ' ', srcTags(x.sources))),
            r.warnItems.map((x) => h('li', {}, h('span', { class: 'warn-text' }, 'Warning: '), x.message, ' ', srcTags(x.sources))))))))))
        : h('p', {}, 'No lockouts or warnings for this patient.'),
      h('p', { class: 'hint' }, 'Locked drugs cannot be given in the simulator, even with the Smart NOA interlocks switched off.')),
    h('section', { class: 'card card--muted' }, h('h3', {}, 'Not modelled'),
      h('ul', { class: 'chips' }, notModeled(lib, { fluids: ctx.fluidsSupported() }).map((x) => h('li', { class: 'chip chip--muted' }, x)))),
    h('div', { class: 'actions' },
      h('button', { type: 'button', class: 'btn', onclick: () => ctx.go(1) }, '← Back to patient'),
      h('button', { type: 'button', class: 'btn btn--primary', id: 'as-continue', onclick: () => ctx.go(3) }, 'Continue to anesthetic plan →')),
  );
}
Source and scope

Published from Smart-NOA-Controller/simulator/ui/assessment.js at revision 6e5b205e3843. Full source text is retained; HTML presentation does not establish independent clinical review. In-memory simulation with no patient, monitor or pump interface; not clinically validated.