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

Software simulation

Source snapshot · 5 November 2026

simulator/engine/depth-advisory.js

// Advisory: processed-EEG depth index below the GA band — TEXT ONLY (owner-approved feature, 2026-11).
// Pure; no DOM; deterministic. EDUCATIONAL SIMULATION — fictional patients — not a medical device,
// not clinical decision support.
//
// The illustrative depth model reads ~34–39 at label-default maintenance doses; the owner chose a
// "lighten anaesthetic" advisory over recalibrating the model. The advisory:
//   - suggests NO drug, dose, rate or concentration (teaching checklist only);
//   - is NOT an alarm: it adds nothing to the alarm list, guided/automatic modes never pause for it,
//     it never acknowledges an alarm, never releases a hold and never changes a dose or rate;
//   - reuses the GA band (engine/timing.js TIMING_CONFIG.GA_DEPTH_BAND, B-Unaware [S52] / BAG-RECALL [S53]);
//     the sustain time and the clearing hysteresis are simulator conventions
//     (TIMING_CONFIG.DEPTH_LOW_ADVISORY_SUSTAIN_SEC, DEPTH_LOW_ADVISORY_CLEAR).
// It applies during maintenance AFTER incision; before incision the pre-incision item ("acknowledge an
// index below the GA band", engine/timing.js preIncisionItems) remains the gate and is not touched here.
// createDepthAdvisor(sim) records what was shown / dismissed (debrief, session JSON); sim.step() calls
// update() after every step; sim._refreshDepthAdvisor() calls update({ refreshOnly: true }) after an action.

import { TIMING_CONFIG } from './timing.js';
import { isFiniteNumber } from './units.js';

const cfg = (k) => TIMING_CONFIG[k].value;
export const DEPTH_BAND_SOURCES = Object.freeze([...TIMING_CONFIG.GA_DEPTH_BAND.sourceIds]);

export const DEPTH_LOW_DISCLAIMER = 'Advisory only — the simulator never changes a dose by itself.';

/** Footnote under the card (text; the source tags are rendered from DEPTH_BAND_SOURCES). */
export function depthLowFootnote() {
  const [lo, hi] = cfg('GA_DEPTH_BAND');
  return `${DEPTH_LOW_DISCLAIMER} Simulated index (illustrative); the ${lo}–${hi} band is the protocol target used in B-Unaware/BAG-RECALL`;
}

/** Teaching checklist (no drug, no dose, no rate, no percentage). */
export const DEPTH_LOW_CHECKLIST = Object.freeze([
  'Review the volatile concentration (end-tidal / MAC) or the propofol infusion rate against the plan.',
  'Consider haemodynamics: if the patient is hypotensive, anaesthetic depth may be contributing.',
  'Confirm the planned depth target and the current surgical stimulus.',
  'Check for sensor artefact (electrode contact, signal quality) before acting on a number.',
]);

/**
 * Is the depth-low advisory armed now? GA plan, airway secured, loss of consciousness reached (the
 * depth-high alarm's LOC marker), maintenance phase after incision, not "agents off" after the end of
 * surgery, usable index. Pure read of the simulation.
 * -> { armed, reason }
 */
export function depthLowArmed(sim) {
  const S = sim.state;
  const no = (reason) => ({ armed: false, reason });
  if (!sim.plan || sim.plan.mode === 'MAC') return no('not general anaesthesia');
  if (S.phase !== 'maintenance') return no(`phase ${S.phase}`);
  if (!S.airway.secured) return no('airway not secured');
  if (sim._locAt === null || sim._locAt === undefined) return no('before loss of consciousness');
  if (!sim.stim || sim.stim.surgeryStartAt === null) return no('before incision (pre-incision verification applies)');
  if ((S.sensors && S.sensors.depth === 'fault') || !isFiniteNumber(S.vitals.depthIndex)) return no('depth reading unusable');
  if (sim.stim.surgeryEndAt !== null && sim._hypnoticSeenRunning && typeof sim._maintenanceHypnoticRunning === 'function' && !sim._maintenanceHypnoticRunning()) return no('maintenance hypnotics stopped after surgery');
  return { armed: true, reason: null };
}

const mmss = (sec) => { const s = Math.max(0, Math.round(sec)); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; };

class DepthLowAdvisor {
  constructor(sim) {
    this.sim = sim;
    this.entries = []; this.seq = 0;
    this.open = null;          // entry of the current episode (also after Dismiss, until the episode ends)
    this.active = false;       // episode in progress (sustained below the band; until the clearing value)
    this.dismissed = false;    // the clinician dismissed this episode's card
    this.runSec = 0;           // consecutive time below the band (before activation)
    this.episodeSec = 0;       // time since the episode started counting
    this.totalBelowSec = 0;    // time below the band while armed (whole case)
    this.lastT = null;
    sim.depthAdvisor = this;
  }

  _t() { return this.sim.state.tSec; }
  _log(msg) { this.sim.logNote('advisory', msg); }
  _end(note) {
    const x = this.open;
    if (x) {
      x.endedAtSec = this._t(); x.episodeSec = Math.round(this.episodeSec);
      if (x.status === 'shown') { x.status = 'expired'; x.resolvedAtSec = this._t(); x.note = note; this._log(`ADVISORY ${x.status}: ${x.id} — ${note}`); }
    }
    this.open = null; this.active = false; this.dismissed = false; this.runSec = 0; this.episodeSec = 0;
  }

  /**
   * Re-evaluates after a step. Records only: never doses, never touches alarms, holds or the runner.
   * `{ refreshOnly: true }` (sim._refreshDepthAdvisor, after an action / drug change / phase change, audit
   * 05l): zero elapsed time — it never accumulates time, never moves lastT, never starts an episode; it only
   * ends an active episode that is now disarmed or recovered. Time is counted only by the post-step update().
   */
  update({ refreshOnly = false } = {}) {
    if (refreshOnly) {
      if (this.active) {
        const { armed, reason } = depthLowArmed(this.sim); const idx = this.sim.state.vitals.depthIndex;
        if (!armed) this._end(`advisory disarmed (${reason})`);
        else if (idx >= cfg('DEPTH_LOW_ADVISORY_CLEAR')) this._end(`index recovered to ${idx} (≥ ${cfg('DEPTH_LOW_ADVISORY_CLEAR')})`);
      }
      return this.view();
    }
    const t = this._t(); const dt = this.lastT === null ? 0 : Math.max(0, t - this.lastT); this.lastT = t;
    const [lo, hi] = cfg('GA_DEPTH_BAND');
    const N = cfg('DEPTH_LOW_ADVISORY_SUSTAIN_SEC'); const clr = cfg('DEPTH_LOW_ADVISORY_CLEAR');
    const { armed, reason } = depthLowArmed(this.sim);
    const idx = this.sim.state.vitals.depthIndex;
    if (armed && idx < lo) this.totalBelowSec += dt;
    if (this.active) {
      if (!armed) this._end(`advisory disarmed (${reason})`);
      else if (idx >= clr) this._end(`index recovered to ${idx} (≥ ${clr})`);
      else this.episodeSec += dt;
      return this.view();
    }
    this.runSec = armed && idx < lo ? this.runSec + dt : 0;
    if (armed && this.runSec >= N - 1e-9) {
      this.active = true; this.dismissed = false; this.episodeSec = this.runSec;
      const x = { id: `DEPTH-${++this.seq}`, kind: 'depth_low', shownAtSec: t, index: idx, status: 'shown', resolvedAtSec: null, endedAtSec: null, episodeSec: null, note: null, sources: [...DEPTH_BAND_SOURCES] };
      this.entries.push(x); this.open = x;
      this._log(`ADVISORY shown: ${x.id} — processed-EEG depth index ${idx} below the GA target band (${lo}–${hi}) for ≥ ${N} s: the anaesthetic may be deeper than needed (text only; no dose changed) [${DEPTH_BAND_SOURCES.join(', ')}].`);
    }
    return this.view();
  }

  /** What the card shows (plain data). */
  view() {
    const [lo, hi] = cfg('GA_DEPTH_BAND'); const N = cfg('DEPTH_LOW_ADVISORY_SUSTAIN_SEC');
    const x = this.open; const idx = this.sim.state.vitals.depthIndex;
    const visible = this.active && !this.dismissed && !!x;
    return {
      kind: this.active ? 'depth_low' : 'none', visible, dismissed: this.dismissed,
      headline: x ? `Depth index ${x.index} below the GA target band (${lo}–${hi}) for ≥ ${N} s — the anaesthetic may be deeper than needed.` : '',
      current: isFiniteNumber(idx) ? `Current index ${idx} (simulated) · below the band for ${mmss(this.episodeSec)} in this episode · ${mmss(this.totalBelowSec)} in this case.` : '',
      checklist: [...DEPTH_LOW_CHECKLIST], footnote: depthLowFootnote(), sources: [...DEPTH_BAND_SOURCES], disclaimer: DEPTH_LOW_DISCLAIMER,
      entry: x ? { ...x, sources: [...x.sources] } : null,
      canDismiss: !!(x && x.status === 'shown' && !this.dismissed),
    };
  }

  /** The clinician dismissed the card (logged); hidden for the rest of this episode. */
  dismiss(id) {
    const x = this.open;
    if (!x || x.id !== id || x.status !== 'shown') return { ok: false, message: `Advisory ${id} is not open.` };
    x.status = 'dismissed'; x.resolvedAtSec = this._t(); x.note = 'dismissed by the clinician';
    this.dismissed = true;
    const msg = `ADVISORY dismissed by the clinician: ${x.id} (depth index below the GA band; text only, no dose changed).`;
    this._log(msg);
    return { ok: true, message: msg };
  }

  /** For sim.summary().depthAdvisories, the debrief and the session JSON. */
  record() {
    const [lo, hi] = cfg('GA_DEPTH_BAND');
    return {
      used: true, shown: this.entries.length, dismissed: this.entries.filter((x) => x.status === 'dismissed').length,
      entries: this.entries.map((x) => ({ ...x, sources: [...x.sources], episodeSec: x.episodeSec === null && x === this.open ? Math.round(this.episodeSec) : x.episodeSec })),
      totalBelowBandSec: Math.round(this.totalBelowSec), band: [lo, hi],
      sustainSec: cfg('DEPTH_LOW_ADVISORY_SUSTAIN_SEC'), clearAt: cfg('DEPTH_LOW_ADVISORY_CLEAR'),
      note: `${DEPTH_LOW_DISCLAIMER} Text-only advisory (no drug, dose or rate suggested); not an alarm. Time below the band counts GA maintenance after incision with the airway secured (simulated index; illustrative).`,
    };
  }
}

export { DepthLowAdvisor };

/** Creates (or returns the existing) depth-low advisor for `sim`; sim.step() then updates it every step. */
export function createDepthAdvisor(sim) {
  if (sim.depthAdvisor instanceof DepthLowAdvisor) return sim.depthAdvisor;
  const a = new DepthLowAdvisor(sim);
  a.update();
  return a;
}
Source and scope

Published from Smart-NOA-Controller/simulator/engine/depth-advisory.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.