LOW KAH YIENGINEER & SYSTEMS THINKER
Appearance
Contact

PUBLIC ENGINEERING / SELECTED SOURCE

Read the model.
Reproduce the result.

These excerpts are imported from the same source files used to build the experiments. They expose the calculations without requiring access to the private repository.

Resilience: heat and service consequences

The heat balance integrates load power and delivered cooling. The illustrative service rule counts seconds with no power to the load or temperature above 27°C.

    const powered = power ? p.load : 0;
    const cooling = Math.min(
      capacity,
      powered + Math.max(0, temperature - 24) * 6000,
    );
    states.load = power ? 'available' : 'unavailable';
    samples.push({ time, temperature, powered, cooling, states });
    if (time < 600) {
      if (!power || temperature > 27) interrupted++;
      unserved += (p.load - powered) / 3600;
      temperature = Math.max(24, temperature + (powered - cooling) / 6000);
    }
  }

Cooling: controller and equipment response

A controller requests cooling; the equipment response and available units constrain what is delivered.

Read the Cooling Lab simulation
export function simulateCooling(
  input: Omit<CoolingSettings, 'equipment'> &
    Partial<Pick<CoolingSettings, 'equipment'>>,
  dt = 0.5,
): CoolingResult {
  const settings = validateCooling(input);
  if (![0.25, 0.5, 1].includes(dt)) throw new Error('Unsupported time step.');
  const [kp, ki] = settings.response === 'responsive' ? [100, 2] : [45, 0.4];
  const lag = settings.equipment === 'fast' ? 12 : 45;
  let temperature: number = thermal.target;
  let cooling: number = thermal.initialLoad;
  let integral: number = thermal.initialLoad;
  let secondsAboveLimit = 0;
  const samples: ThermalSample[] = [];
  for (let i = 0; i <= thermal.duration / dt; i++) {
    const t = i * dt;
    const load = t >= thermal.loadTime ? settings.load : thermal.initialLoad;
    const capacity =
      (t >= thermal.failureTime ? settings.units : 3) * thermal.unitCapacity;
    cooling = Math.min(cooling, capacity);
    const error = temperature - thermal.target;
    const demand = kp * error + integral;
    const command = Math.max(0, Math.min(capacity, demand));
    samples.push({ t, temperature, cooling, load, capacity, command });
    if (i === thermal.duration / dt) break;
    // Conditional integration prevents winding up against equipment capacity.
    if (
      (demand > 0 && demand < capacity) ||
      (demand >= capacity && error < 0) ||
      (demand <= 0 && error > 0)
    )
      integral += ki * error * dt;
    const nextCooling = command + (cooling - command) * Math.exp(-dt / lag);
    // Exact integral of the first-order cooling response during this time step.
    const removedHeat =
      command * dt + (cooling - command) * lag * (1 - Math.exp(-dt / lag));
    const nextTemperature =
      temperature + (load * dt - removedHeat) / thermal.capacitance;
    if (temperature > thermal.limit && nextTemperature > thermal.limit)
      secondsAboveLimit += dt;
    else if (temperature > thermal.limit !== nextTemperature > thermal.limit) {
      const fraction =
        (thermal.limit - temperature) / (nextTemperature - temperature);
      secondsAboveLimit +=
        dt * (temperature > thermal.limit ? fraction : 1 - fraction);
    }
    temperature = nextTemperature;
    cooling = nextCooling;
  }
  const lastOutside = samples.findLastIndex(
    (s) => Math.abs(s.temperature - thermal.target) > 0.5,
  );
  const recoveredAt = Math.max(
    thermal.failureTime,
    samples[lastOutside + 1]?.t ?? Infinity,
  );
  return {
    settings,
    version: COOLING_VERSION,
    samples,
    peak: Math.max(...samples.map((s) => s.temperature)),
    secondsAboveLimit,
    reserve: settings.units * thermal.unitCapacity - settings.load,
    recovery:
      recoveredAt <= thermal.duration - 30
        ? recoveredAt - thermal.failureTime
        : null,
  };
}
Explore the thermal model →

Dependencies: equipment states and recovery

Load power and cooling follow separate dependencies. Equipment restoration and thermal recovery are distinct events. This small model draws the figure in the recovery note; One Plant follows the same ideas through a whole unit.

Read the dependency simulation
export function simulateDependencies(input: DependencySettings) {
  const settings = validateDependency(input);
  const c = dependencyConstants;
  let temperature: number = c.initialTemperature;
  const samples: DependencySample[] = [];
  for (let time = 0; time <= c.duration; time++) {
    const failed = (id: NodeId) =>
      settings.fault === id && time >= c.failure && time < settings.restore;
    const states = Object.fromEntries(
      dependencyNodes.map((n) => [
        n.id,
        failed(n.id) ? 'unavailable' : 'available',
      ]),
    ) as Record<NodeId, NodeState>;
    for (const side of ['a', 'b'] as const) {
      if (!failed(`ups-${side}`) && failed(`feed-${side}`))
        states[`ups-${side}`] =
          time < c.failure + c.battery ? 'battery' : 'unavailable';
      if (failed(`feed-${side}`) || failed('loop'))
        states[`cooling-${side}`] = 'unavailable';
    }
    const poweredLoad =
      states['ups-a'] !== 'unavailable' || states['ups-b'] !== 'unavailable'
        ? c.heat
        : 0;
    states.rack = poweredLoad ? 'available' : 'unavailable';
    const capacity =
      (['cooling-a', 'cooling-b'] as const).filter(
        (id) => states[id] === 'available',
      ).length * c.unit;
    // Idealized modulation: use spare capacity to return toward 24°C, never below it.
    const cooling = Math.min(
      capacity,
      poweredLoad +
        Math.max(0, temperature - c.initialTemperature) * c.thermalMass,
    );
    samples.push({ time, states, cooling, poweredLoad, temperature });
    temperature = Math.max(
      c.initialTemperature,
      temperature + (poweredLoad - cooling) / c.thermalMass,
    );
  }
  const label = dependencyNodes.find((n) => n.id === settings.fault)!.label;
  const events = [
    {
      time: 0,
      message:
        'Both electrical paths and both cooling units are available. The 360 kW load begins at 24°C.',
    },
    {
      time: c.failure,
      message: `${label} becomes unavailable. Recalculate the electrical and cooling dependencies separately.`,
    },
  ];
  if (
    settings.fault.startsWith('feed-') &&
    settings.restore > c.failure + c.battery
  )
    events.push({
      time: c.failure + c.battery,
      message:
        'The affected UPS reaches its 60-second illustrative battery limit. The other supply path still carries the load.',
    });
  events.push({
    time: settings.restore,
    message: `${label} is restored. Available paths return immediately in this simplified model; spare cooling capacity removes accumulated heat.`,
  });
  const recovered = samples.find(
    (s) => s.time >= settings.restore && s.temperature <= 24.05,
  );
  if (recovered)
    events.push({
      time: recovered.time,
      message: 'Zone temperature is back within 0.05°C of its initial value.',
    });
  return {
    settings,
    version: DEPENDENCY_VERSION,
    samples,
    events,
    peak: Math.max(...samples.map((s) => s.temperature)),
    recovered: recovered?.time ?? null,
  };
}
See it in the recovery note →

Redundancy: what an arrangement survives

Interchangeable units are counted against the load in the worst case. A failed path removes its whole group; maintenance and faults then fall on units that would otherwise still be serving.

Read the redundancy count
export function remainingUnits(a: Arrangement, e: Event) {
  const unavailable = e.maintenance + e.faults;
  if (e.pathFaults > 0 && a.sharedPath) return 0;
  const groups = Math.max(0, a.groups - e.pathFaults);
  return Math.max(0, groups * a.unitsPerGroup - unavailable);
}
export const survives = (a: Arrangement, e: Event) =>
  remainingUnits(a, e) >= a.need;
export const installedUnits = (a: Arrangement) => a.groups * a.unitsPerGroup;
Read the note →

Observability: quality and alarm lifecycle

A missing measurement cannot clear an active alarm. Delay suppresses transient excursions; deadband separates raising and clearing. Acknowledgement is recorded separately and never repairs the physical model.

Read the alarm state calculation
export function delayedAlarm(
  values: (number | null)[],
  threshold: number,
  deadband: number,
  delay: number,
) {
  let active = false,
    pending: number | null = null;
  return values.map((value, time) => {
    if (value === null) {
      pending = null;
      return active;
    }
    if (!active) {
      if (value > threshold) {
        pending ??= time;
        if (time - pending >= delay) active = true;
      } else pending = null;
    } else if (value <= threshold - deadband) {
      active = false;
      pending = null;
    }
    return active;
  });
}
Inspect the synthetic evidence →

Alarm Flood: what the operator is shown

Each alarm reuses the delay and deadband rule above. Suppression hides a consequential alarm while its cause is alarmed; it never changes the process or the measurement. Only presented alarms are counted.

Read the alarm presentation calculation
export function simulateAlarms(input: AlarmSettings) {
  const settings = validateAlarms(input);
  const process = simulateProcess(settings.actions);
  const active = {} as Record<TagId, boolean[]>;
  tagIds.forEach((id, index) => {
    const t = tags[id];
    const sign = t.direction === 'high' ? 1 : -1;
    const values = process.map(
      (s, time) => sign * (t.value(s) + t.noise * noise[index]![time]!),
    );
    active[id] = delayedAlarm(
      values,
      sign * t.limit,
      settings.delays ? t.deadband : 0,
      settings.delays ? t.onDelay : 0,
    );
  });
  // A suppressed alarm still exists; it is simply not presented to the operator.
  const shown = {} as Record<TagId, boolean[]>;
  for (const id of tagIds) {
    const parent = tags[id].suppressedBy as TagId | undefined;
    shown[id] = active[id].map(
      (on, time) =>
        on && !(settings.suppression && parent && active[parent][time]),
    );
  }
  const events: AlarmEvent[] = [];
  let suppressed = 0;
  for (let time = 0; time <= HORIZON; time++)
    for (const id of tagIds) {
      const before = time > 0 && shown[id][time - 1];
      if (shown[id][time] && !before)
        events.push({ time, tag: id, action: 'raised' });
      else if (!shown[id][time] && before)
        events.push({ time, tag: id, action: 'cleared' });
      if (active[id][time] && !(time > 0 && active[id][time - 1]))
        if (!shown[id][time]) suppressed++;
    }
  const raised = events.filter((e) => e.action === 'raised');
  const afterUpset = raised.filter((e) => e.time >= UPSET);
  // Chattering: three or more presentations of one alarm within 60 seconds.
  const chattering = tagIds.filter((id) => {
    const times = raised.filter((e) => e.tag === id).map((e) => e.time);
    return times.some((t, i) => i >= 2 && t - times[i - 2]! <= 60);
  });
  const tripAt = process.find((s) => s.tripped)?.time ?? null;
  const pumpRestored = process.find((s) => s.time > UPSET && s.pumpRunning);
  const normalAt =
    tripAt === null && pumpRestored
      ? (process.find((s) => s.time > pumpRestored.time && s.pressure < 330)
          ?.time ?? null)
      : null;
  const outcome =
    tripAt !== null ? 'tripped' : pumpRestored ? 'recovered' : 'held';
  const lostOutput =
    process
      .slice(UPSET, HORIZON)
      .reduce((sum, s) => sum + (1 - s.heater / 100), 0) / 60;
  return {
    version: ALARM_VERSION,
    settings,
    process,
    shown,
    events,
    metrics: {
      alarms: afterUpset.length,
      flood: afterUpset.length > FLOOD_LIMIT,
      chattering,
      suppressed,
      peakPressure: Math.max(...process.map((s) => s.pressure)),
      lostOutput,
    },
    outcome,
    tripAt,
    normalAt,
  };
}
export type AlarmResult = ReturnType<typeof simulateAlarms>;
Play the operator →

One Plant: one timeline for every view

The utilities and the process step forward together, one second at a time. Each pump delivers only if its bus has power and its loop is intact; the pressure controller trims the burner within its limits; the alarm points and every view read the same samples.

Read the plant calculation
export function simulatePlantProcess(settings: PlantSettings) {
  const tune = tunings[settings.tuning];
  const lag = (tau: number) => 1 - Math.exp(-1 / tau);
  const repairAt = settings.repair ? FAULT_AT + settings.repair * 60 : Infinity;
  const actedAt = (id: ActionId) =>
    settings.actions.find((a) => a.action === id)?.at ?? Infinity;
  const tieAt = actedAt('bus-tie'),
    manualStart = actedAt('start-standby'),
    rateAt = actedAt('min-rate');
  let flow = 100,
    firing = NORMAL_FIRING,
    temperature = 120,
    integral = 0,
    tripped = false,
    lowSince: number | null = null,
    autoStartAt: number | null = null,
    standbyFrom: number | null = null,
    standbyFailed = false,
    dutyStopped = false,
    dutyRestart: number | null = null;
  const pressures: number[] = [];
  const samples: PlantSample[] = [];
  for (let time = 0; time <= HORIZON; time++) {
    const fault = (id: FaultId) =>
      settings.fault === id && time >= FAULT_AT && time < repairAt;
    // The bus tie feeds bus A from supply B.
    const busA = !fault('supply-a') || time >= tieAt,
      busB = true;
    const standbyBus = settings.power === 'split' ? busB : busA;
    // A shared loop is one path for both pumps; separate loops fail alone.
    const loopA = !fault('loop-a'),
      loopB = settings.loops === 'shared' ? loopA : true;
    // A stopped duty pump needs a restart once its bus and the pump are back.
    if (!busA || fault('pump')) {
      dutyStopped = true;
      dutyRestart = null;
    } else if (dutyStopped && dutyRestart === null)
      dutyRestart = time + PUMP_START;
    if (dutyStopped && dutyRestart !== null && time >= dutyRestart)
      dutyStopped = false;
    const dutyRunning = !dutyStopped;
    if (flow < 50) lowSince ??= time;
    else lowSince = null;
    if (
      settings.autoStart &&
      autoStartAt === null &&
      lowSince !== null &&
      time - lowSince >= AUTO_START_DELAY
    )
      autoStartAt = time;
    // A start attempt on a dead bus fails; only a later attempt can succeed.
    if (
      standbyFrom === null &&
      (time === autoStartAt || time === manualStart)
    ) {
      if (standbyBus) standbyFrom = time + PUMP_START;
      else standbyFailed = true;
    }
    const standbyRunning =
      standbyFrom !== null && standbyBus && time >= standbyFrom;
    const delivering = (dutyRunning && loopA) || (standbyRunning && loopB);
    const reduced = time >= rateAt;
    const upsA: NodeState = busA
      ? 'available'
      : time < FAULT_AT + UPS_RESERVE
        ? 'battery'
        : 'unavailable';
    // Trip first, so this second's equipment states already show it.
    const pressure = SETPOINT + 4 * (temperature - 120);
    if (!tripped && pressure >= TRIP_PRESSURE) tripped = true;
    const states: Record<NodeId, NodeState> = {
      'supply-a': fault('supply-a') ? 'unavailable' : 'available',
      'supply-b': 'available',
      'bus-a': busA ? 'available' : 'unavailable',
      'bus-b': busB ? 'available' : 'unavailable',
      'ups-a': upsA,
      'ups-b': 'available',
      // Fed from both UPSs: it fails only if both do.
      control: 'available',
      'pump-a': dutyRunning ? 'available' : 'unavailable',
      'pump-b':
        standbyFrom === null
          ? standbyFailed
            ? 'unavailable'
            : 'standby'
          : !standbyBus
            ? 'unavailable'
            : standbyRunning
              ? 'available'
              : 'starting',
      'loop-a': loopA ? 'available' : 'unavailable',
      'loop-b': loopB ? 'available' : 'unavailable',
      vessel: tripped ? 'unavailable' : 'available',
    };
    pressures.push(pressure);
    // The controller sees pressure four seconds late (transmitter and scan).
    const measured = pressures[Math.max(0, time - 4)]!;
    const error = SETPOINT - measured;
    const demand = tripped ? 0 : NORMAL_FIRING + tune.kp * error + integral;
    const [low, high] = reduced ? MIN_RATE_FIRING : [MIN_FIRING, 100];
    const target = tripped ? 0 : Math.min(high, Math.max(low, demand));
    // Integrate only while the burner can follow, so the controller does not
    // wind up against its minimum and overshoot when cooling returns.
    if (!tripped && demand === target) integral += tune.ki * error;
    const removed = 0.65 * flow * (1 + 0.08 * (temperature - 120));
    const ambient = 1.5 * (temperature - 120);
    samples.push({
      time,
      states,
      flow,
      firing,
      removed: removed + ambient,
      temperature,
      pressure,
      demand,
      reduced,
      tripped,
    });
    flow += ((delivering ? 100 : 0) - flow) * lag(4);
    firing += (target - firing) * lag(tripped ? 3 : 6);
    temperature += 0.004 * (firing - removed - ambient);
  }
  return { samples, autoStartAt };
}
Follow one fault →

All models use synthetic data. The verification notebook records expected and computed results for this build.