HomeMAL' // MANUFACTORUM
datacrypt
MOD + END Calculator / mod_end_calculator.html
mod_end_calculator.html14.9 KB
<!DOCTYPE html>
<!-- Depth limit calculator for breathing gas mixes. -->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MOD + END</title>

<style>
  /* ---------- Page layout ---------- */
  body {
    font-family: system-ui, sans-serif;
    background: #fff;
    display: flex;
    justify-content: center;
    padding: 40px 12px;
    overflow-y:hidden;
  }

  /* ---------- Main container ---------- */
  .app {
    background: #fff;
    width: 420px;
    max-width: 100%;
    padding: 18px;
    border-radius: 8px;
  }

  /* ---------- Result boxes ---------- */
  .box {
    border: 2px solid #000;
    padding: 12px;
    margin-bottom: 14px;
    text-align: center;
    border-radius: 6px;
  }

  /* MOD box emphasized */
  .box.mod {
    border-color: #c33;
  }

  /* MinOD box emphasized */
  .box.minod {
    border-color: #36c;
  }

  /* Numeric result */
  .box .value {
    font-size: 32px;
    font-weight: 700;
  }

  /* Explanatory text */
  .box .sub {
    font-size: 12px;
    opacity: 0.75;
    margin-top: 6px;
    line-height: 1.35;
  }

  /* ---------- Input rows ---------- */
  .row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 96px;
    gap: 8px;
    align-items: center;
    margin: 10px 0;
  }

  input, select {
    width: 100%;
    padding: 8px;
    font-size: 16px;
    box-sizing: border-box;
  }

  /* Right-hand label pill */
  .pill {
    width: 96px;
    padding: 6px 8px;
    border: 1px solid #000;
    border-radius: 6px;
    background: #fff;
    font-size: 14px;
    white-space: nowrap;
    box-sizing: border-box;
    text-align: center;
    justify-self: stretch;
  }

  /* ---------- Number input with unit + arrows ---------- */
  .input-unit {
    position: relative;
    width: 100%;
    min-width: 0;
  }

  /* Hide native number spinners */
  .input-unit input[type="number"] {
    -moz-appearance: textfield;
    appearance: textfield;
  }
  .input-unit input[type="number"]::-webkit-outer-spin-button,
  .input-unit input[type="number"]::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
  }

  /* Reserve space for unit and arrows */
  .input-unit input {
    width: 100%;
    padding-right: 90px;
  }

  /* Unit text inside input */
  .input-unit .unit {
    position: absolute;
    right: 56px;
    top: 50%;
    transform: translateY(-50%);
    font-size: 14px;
    opacity: 0.6;
    pointer-events: none;
  }

  /* Custom spinner arrows */
  .input-unit .stepper {
    position: absolute;
    right: 8px;
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    flex-direction: column;
    gap: 0;
  }

  .input-unit .stepper button {
    width: 24px;
    height: 14px;
    line-height: 12px;
    padding: 0;
    border: none;
    background: transparent;
    font-size: 11px;
    cursor: pointer;
    opacity: 0.7;
  }

  .input-unit .stepper button:hover {
    opacity: 1;
  }

  /* ---------- Gas composition block ---------- */
  .blend {
    border: 1px solid #000;
    border-radius: 6px;
    overflow: hidden;
    margin-top: 12px;
  }

  .gas {
    display: flex;
    gap: 8px;
    align-items: center;
    padding: 10px;
    border-bottom: 1px solid #000;
  }

  .gas:last-child {
    border-bottom: none;
  }

  .gas label {
    width: 64px;
  }

  .box .pp-values {
    font-size: 14px;
    font-weight: bold;
  }

  /* ---------- Error message ---------- */
  .err {
    color: #b00;
    font-size: 12px;
    margin-top: 6px;
    min-height: 16px;
  }
</style>
</head>

<body>
<div class="app">

  <!-- MOD output -->
  <div class="box mod">
    <div>Maximum Operating Depth (MOD)</div>
    <div class="value" id="mod">– m</div>
  </div>

  <!-- MinOD output -->
  <div class="box minod" id="minodBox" style="display:none;">
    <div>Minimum Operating Depth (MinOD)</div>
    <div class="value" id="minod">– m</div>
    <div class="sub">
      Minimum depth where PpO₂ reaches the minimum threshold.
    </div>
  </div>

  <!-- Partial pressures output -->
  <div class="box" id="ppBox" style="display:none;">
    <div>Partial pressures @ MOD</div>
    <div class="sub pp-values" id="ppOut">–</div>
  </div>

  <!-- END output -->
  <div class="box">
    <div>END @ MOD</div>
    <div class="value" id="end">–</div>
    <div class="sub">
      Equivalent narcotic depth evaluated at MOD.<br>
      Referenced to air (N₂ only).
    </div>
  </div>

  <!-- PpO2 max input -->
  <div class="row">
    <div class="input-unit">
      <input id="ppo2" type="text" inputmode="decimal" value="1.4" />
      <span class="unit">bar</span>
      <span class="stepper">
        <button type="button" id="ppo2Up">▲</button>
        <button type="button" id="ppo2Down">▼</button>
      </span>
    </div>
    <span class="pill">PpO₂max</span>
  </div>

  <!-- Salinity selector -->
  <div class="row">
    <select id="salinity">
      <option value="sea">Sea water</option>
      <option value="fresh">Fresh water</option>
      <option value="en13319">EN13319</option>
    </select>
    <span class="pill">Salinity</span>
  </div>

  <!-- Altitude input -->
  <div class="row">
    <div class="input-unit">
      <input id="altitude" type="number" min="0" max="10000" step="10" value="0" />
      <span class="unit">m</span>
      <span class="stepper">
        <button type="button" id="altitudeUp">▲</button>
        <button type="button" id="altitudeDown">▼</button>
      </span>
    </div>
    <span class="pill">Altitude</span>
  </div>

  <!-- Blend preset selector -->
  <div class="row">
    <select id="preset">
      <option value="custom">Custom</option>

      <optgroup label="Basic">
        <option value="air">Air</option>
      </optgroup>

      <optgroup label="Nitrox">
        <option value="nx28">Nitrox 28</option>
        <option value="nx30">Nitrox 30</option>
        <option value="nx32">Nitrox 32</option>
        <option value="nx34">Nitrox 34</option>
        <option value="nx36">Nitrox 36</option>
        <option value="nx40">Nitrox 40</option>
      </optgroup>

      <optgroup label="Deco mixes">
        <option value="nx50">Nitrox 50</option>
        <option value="nx80">Nitrox 80</option>
        <option value="o2">O₂</option>
      </optgroup>

      <optgroup label="Trimix (normoxic)">
        <option value="tx21/35">Trimix 21/35</option>
      </optgroup>

      <optgroup label="Trimix (hypoxic)">
        <option value="tx18/45">Trimix 18/45</option>
        <option value="tx15/55">Trimix 15/55</option>
        <option value="tx12/65">Trimix 12/65</option>
        <option value="tx10/70">Trimix 10/70</option>
        <option value="tx4/90">Trimix 4/90</option>
      </optgroup>

      <optgroup label="Other Helium mixes">
        <option value="hx10/90">Heliox 10/90</option>
        <option value="helitrox 21/20">Helitrox 21/20</option>
      </optgroup>

      <optgroup label="Hydrogen mixes">
        <option value="hydrox 4/96">Hydrox 4/96</option>
        <option value="hydreliox 4/30">Hydreliox 4/30</option>
      </optgroup>
    </select>
    <span class="pill">Blend</span>
  </div>

  <!-- Gas fractions -->
  <div class="blend">
    <div class="gas"><label>O₂</label><input id="o2" type="number" min="0" max="100" step="0.1" value="21"><span>%</span></div>
    <div class="gas"><label>He</label><input id="he" type="number" min="0" max="100" step="0.1" value="0"><span>%</span></div>
    <div class="gas"><label>H₂</label><input id="h2" type="number" min="0" max="100" step="0.1" value="0"><span>%</span></div>
    <div class="gas"><label>N₂</label><input id="n2" disabled><span>%</span></div>
  </div>

  <div class="err" id="err"></div>
</div>

<script>
  /* ---------- DOM helpers ---------- */
  const el = id => document.getElementById(id);

  /* ---------- Inputs ---------- */
  const ppo2El   = el('ppo2');
  const salinityEl = el('salinity');
  const altitudeEl = el('altitude');
  const presetEl = el('preset');
  const o2El     = el('o2');
  const heEl     = el('he');
  const h2El     = el('h2');
  const n2El     = el('n2');

  const ppo2UpEl   = el('ppo2Up');
  const ppo2DownEl = el('ppo2Down');
  const altitudeUpEl = el('altitudeUp');
  const altitudeDownEl = el('altitudeDown');

  /* ---------- Outputs ---------- */
  const modEl = el('mod');
  const minodBoxEl = el('minodBox');
  const minodEl = el('minod');
  const ppBoxEl = el('ppBox');
  const ppOutEl = el('ppOut');
  const endEl = el('end');
  const errEl = el('err');

  /* ---------- Model parameters ---------- */
  const PPO2_MIN = 0.16;
  const PPO2_MAX_MIN = 0.5;
  const PPO2_MAX_MAX = 2.0;

  const SALINITY_FACTORS = {
    sea: { label: 'Sea water', metersPerBar: 100000 / (1025 * 9.80665) },
    fresh: { label: 'Fresh water', metersPerBar: 100000 / (1000 * 9.80665) },
    en13319: { label: 'EN13319', metersPerBar: 10.0 }
  };

  /* ---------- Preset definitions ---------- */
  const PRESETS = {
    air:{o2:21,he:0,h2:0},

    nx28:{o2:28,he:0,h2:0},
    nx30:{o2:30,he:0,h2:0},
    nx32:{o2:32,he:0,h2:0},
    nx34:{o2:34,he:0,h2:0},
    nx36:{o2:36,he:0,h2:0},
    nx40:{o2:40,he:0,h2:0},

    nx50:{o2:50,he:0,h2:0},
    nx80:{o2:80,he:0,h2:0},
    o2:{o2:100,he:0,h2:0},

    "tx21/35":{o2:21,he:35,h2:0},
    "tx18/45":{o2:18,he:45,h2:0},
    "tx15/55":{o2:15,he:55,h2:0},
    "tx12/65":{o2:12,he:65,h2:0},
    "tx10/70":{o2:10,he:70,h2:0},
    "tx4/90": {o2:4, he:90,h2:0},

    "hx10/90":{o2:10,he:90,h2:0},
    "helitrox 21/20":{o2:21,he:20,h2:0},

    "hydrox 4/96":{o2:4,he:0,h2:96},
    "hydreliox 4/30":{o2:4,he:30,h2:66}
  };

  /* ---------- Utility ---------- */
  const parseDecimal = s => parseFloat(String(s).replace(',', '.'));

  const clamp = (v,min,max) =>
    Math.min(max, Math.max(min, Number(v) || 0));

  const formatPercent = v => {
    const rounded1 = Math.round(v * 10) / 10;
    return Number.isInteger(rounded1) ? String(rounded1) : rounded1.toFixed(1);
  };

  const formatPpO2 = v => {
    const r = Math.round(v * 10) / 10;
    return Number.isInteger(r) ? String(r) : r.toFixed(1);
  };

  const clearOutputs = () => {
    modEl.textContent = '– m';
    endEl.textContent = '–';
    minodEl.textContent = '– m';
    ppOutEl.textContent = '–';
    ppBoxEl.style.display = 'none';
    minodBoxEl.style.display = 'none';
  };

  /* ---------- Physics ---------- */
  const surfacePressureBar = altitudeMeters => {
    const h = clamp(altitudeMeters, 0, 10000);
    return Math.pow(1 - 2.25577e-5 * h, 5.25588);
  };

  const modMeters = (ppO2max, fo2, metersPerBar, pSurfaceBar) =>
    ((ppO2max / fo2) - pSurfaceBar) * metersPerBar;

  const minodMeters = (ppO2min, fo2, metersPerBar, pSurfaceBar) =>
    ((ppO2min / fo2) - pSurfaceBar) * metersPerBar;

  const endMeters = (mod, fn2, metersPerBar, pSurfaceBar) =>
    (((mod / metersPerBar + pSurfaceBar) * fn2 / 0.79) - pSurfaceBar) * metersPerBar;

  /* ---------- Recompute ---------- */
  function recompute(changedEl) {
    errEl.textContent = '';

    let o2 = clamp(o2El.value, 0, 100);
    let he = clamp(heEl.value, 0, 100);
    let h2 = clamp(h2El.value, 0, 100);
    const ppO2max = clamp(parseDecimal(ppo2El.value), PPO2_MAX_MIN, PPO2_MAX_MAX);
    const altitude = clamp(altitudeEl.value, 0, 10000);
    const salinity = SALINITY_FACTORS[salinityEl.value] || SALINITY_FACTORS.en13319;
    const metersPerBar = salinity.metersPerBar;
    const pSurfaceBar = surfacePressureBar(altitude);

    // Cross-field clamping: clamp the edited field so that O2+He+H2 ≤ 100.
    if (o2 + he + h2 > 100) {
      if (changedEl === o2El)      { o2 = Math.max(0, 100 - he - h2); }
      else if (changedEl === heEl) { he = Math.max(0, 100 - o2 - h2); }
      else if (changedEl === h2El) { h2 = Math.max(0, 100 - o2 - he); }
    }

    o2El.value = formatPercent(o2);
    heEl.value = formatPercent(he);
    h2El.value = formatPercent(h2);
    ppo2El.value = formatPpO2(ppO2max);
    altitudeEl.value = String(Math.round(altitude));

    const n2 = 100 - o2 - he - h2;
    n2El.value = formatPercent(n2);

    const fo2 = o2 / 100;
    if (fo2 <= 0) {
      errEl.textContent = 'Invalid blend: O₂ fraction must be greater than 0%.';
      clearOutputs();
      return;
    }

    /* MOD */
    const mod = modMeters(ppO2max, fo2, metersPerBar, pSurfaceBar);
    if (mod < 0) {
      errEl.textContent = 'Gas exceeds ppO₂ limit at the surface — no valid operating depth.';
      clearOutputs();
      return;
    }
    modEl.textContent = Math.round(mod) + ' m';

    /* MinOD */
    const rawMinod = minodMeters(PPO2_MIN, fo2, metersPerBar, pSurfaceBar);
    const minod = Math.max(0, rawMinod);
    if (rawMinod >= 0) {
      minodEl.textContent = Math.round(minod) + ' m';
      minodBoxEl.style.display = '';
    } else {
      minodBoxEl.style.display = 'none';
    }

    /* Partial pressures at MOD
       Absolute pressure at depth (bar): Pabs = depth/10 + 1
       Partial pressure (bar): Pp_gas = F_gas × Pabs
       Do not display gases with 0% fraction.
    */
    const pAbs = mod / metersPerBar + pSurfaceBar;

    const parts = [];

    if (o2 > 0) {
      const ppO2 = pAbs * (o2 / 100);
      parts.push(`PpO<sub>2</sub>: ${ppO2.toFixed(2)} bar`);
    }
    if (he > 0) {
      const ppHe = pAbs * (he / 100);
      parts.push(`PpHe: ${ppHe.toFixed(2)} bar`);
    }
    if (h2 > 0) {
      const ppH2 = pAbs * (h2 / 100);
      parts.push(`PpH<sub>2</sub>: ${ppH2.toFixed(2)} bar`);
    }
    if (n2 > 0) {
      const ppN2 = pAbs * (n2 / 100);
      parts.push(`PpN<sub>2</sub>: ${ppN2.toFixed(2)} bar`);
    }

    if (parts.length) {
      ppOutEl.innerHTML = parts.join('<br>');
      ppBoxEl.style.display = '';
    } else {
      ppOutEl.textContent = '–';
      ppBoxEl.style.display = 'none';
    }

    /* END */
    if (n2 <= 0) {
      endEl.textContent = 'Undefined';
      return;
    }

    const end = Math.max(0, endMeters(mod, n2 / 100, metersPerBar, pSurfaceBar));
    endEl.textContent = Math.round(end) + ' m';
  }

  /* ---------- Events ---------- */
  presetEl.onchange = () => {
    const p = PRESETS[presetEl.value];
    if (p) {
      o2El.value = p.o2;
      heEl.value = p.he;
      h2El.value = p.h2;
    }
    recompute();
  };

  [o2El, heEl, h2El].forEach(e =>
    e.oninput = () => {
      if (presetEl.value !== 'custom') presetEl.value = 'custom';
      recompute(e);
    }
  );

  salinityEl.onchange = recompute;
  altitudeEl.oninput = recompute;
  ppo2El.oninput = recompute;
  ppo2UpEl.onclick = () => {
    const v = clamp(parseDecimal(ppo2El.value), PPO2_MAX_MIN, PPO2_MAX_MAX);
    ppo2El.value = formatPpO2(clamp(Math.round((v + 0.1) * 10) / 10, PPO2_MAX_MIN, PPO2_MAX_MAX));
    recompute();
  };
  ppo2DownEl.onclick = () => {
    const v = clamp(parseDecimal(ppo2El.value), PPO2_MAX_MIN, PPO2_MAX_MAX);
    ppo2El.value = formatPpO2(clamp(Math.round((v - 0.1) * 10) / 10, PPO2_MAX_MIN, PPO2_MAX_MAX));
    recompute();
  };
  altitudeUpEl.onclick = () => { altitudeEl.stepUp(); recompute(); };
  altitudeDownEl.onclick = () => { altitudeEl.stepDown(); recompute(); };

  /* ---------- Init ---------- */
  salinityEl.value = 'sea';
  presetEl.value = 'air';
  presetEl.onchange();
</script>
</body>
</html>