// ================== PLANNER ZHL-16B/C + GF (dt=1s, PH2O=0.0627) ================== $w.onReady(() => { $w('#planBtn').onClick(() => { const { pn, pe } = runTissuesWithDescent(); // Step 1: discesa + fondo showCeilingInfo(pn, pe); // : ceiling iniziale + prima tappa runDecoPlan(pn, pe); // Step 3: risalita a prima tappa + piano deco + consumi + END/Density + CNS }); }); // === Lettura modello B/C dal RadioGroup === function currentModel() { try { const el = $w('#radioGroup1'); if (el && typeof el.value === 'string') { const v = el.value.trim().toUpperCase(); return (v === 'C') ? 'C' : 'B'; } } catch (e) {} return 'B'; } // --- Parametri numerici / fine tuning (arrotondamenti così sono più restrittivo in profondità e più perimissivo in superficie) --- const STEP_SEC = 1; const SHALLOW_THRESHOLD = 15; const CEIL_EPS_DEEP = -0.012; const CEIL_EPS_SHALLOW = +0.001; const ROUNDING = "floor"; const FIRST_STOP_EPS = 0.03; // --- Stile output --- const USE_HTML_COLORS = true; const COLOR_BASE_TEXT = "#ffffff"; const COLOR_DIM_TEXT = "#9aa0a6"; const HILITE_BG = "#64ffda"; const HILITE_FG = "#00332b"; // ==================== Coefficienti ZHL-16B / ZHL-16C ============================== function getCoefficients() { const model = currentModel(); if (model === "B") { return { halftimen2: [4.0, 8.0, 12.5, 18.5, 27.0, 38.3, 54.3, 77.0, 109.0, 146.0, 187.0, 239.0, 305.0, 390.0, 498.0, 635.0], halftimehe: [1.5, 3.0, 4.7, 7.0, 10.2, 14.5, 20.5, 29.1, 41.1, 55.1, 70.6, 90.2, 115.1, 147.2, 187.9, 239.6], n2a: [1.2599, 1.0, 0.8618, 0.7562, 0.6667, 0.5933, 0.5282, 0.4701, 0.4187, 0.3798, 0.3497, 0.3223, 0.2971, 0.2737, 0.2523, 0.2327], n2b: [0.505, 0.6514, 0.7222, 0.7725, 0.8125, 0.8434, 0.8693, 0.891, 0.9092, 0.9222, 0.9319, 0.9403, 0.9477, 0.9544, 0.9602, 0.9653], hea: [1.7435, 1.3838, 1.1925, 1.0465, 0.9226, 0.8211, 0.7309, 0.6506, 0.5794, 0.5256, 0.484, 0.446, 0.4112, 0.3788, 0.3492, 0.322], heb: [0.1911, 0.4295, 0.5446, 0.6265, 0.6917, 0.742, 0.7841, 0.8195, 0.8491, 0.8703, 0.886, 0.8997, 0.9118, 0.9226, 0.9321, 0.9404] }; } else { return { halftimen2: [4.0, 8.0, 12.5, 18.5, 27.0, 38.3, 54.3, 77.0, 109.0, 146.0, 187.0, 239.0, 305.0, 390.0, 498.0, 635.0], halftimehe: [1.51, 3.02, 4.72, 6.99, 10.21, 14.48, 20.53, 29.11, 41.20, 55.19, 70.69, 90.34, 115.29, 147.42, 188.24, 240.03], n2a: [1.1696, 1.0, 0.8618, 0.7562, 0.6667, 0.5933, 0.5282, 0.4701, 0.4187, 0.3798, 0.3497, 0.3223, 0.2971, 0.2737, 0.2523, 0.2327], n2b: [0.5578, 0.6514, 0.7222, 0.7825, 0.8126, 0.8434, 0.8693, 0.8910, 0.9092, 0.9222, 0.9319, 0.9403, 0.9477, 0.9544, 0.9602, 0.9653], hea: [1.6189, 1.3830, 1.1919, 1.0458, 0.9220, 0.8205, 0.7305, 0.6502, 0.5950, 0.5545, 0.5333, 0.5189, 0.5181, 0.5176, 0.5172, 0.5119], heb: [0.4770, 0.5747, 0.6527, 0.7223, 0.7582, 0.7957, 0.8279, 0.8553, 0.8757, 0.8903, 0.8997, 0.9073, 0.9122, 0.9171, 0.9217, 0.9267] }; } } // ===================== ceilingGF ===================== function ceilingGF(pnArr, peArr, GF) { const { n2a, n2b, hea, heb } = getCoefficients(); let cmax = 0; for (let i = 0; i < 16; i++) { const PN2 = pnArr[i], PHe = peArr[i], Pt = PN2 + PHe; if (Pt <= 0) continue; const A = ((n2a[i] * PN2) + (hea[i] * PHe)) / Pt; const B = ((n2b[i] * PN2) + (heb[i] * PHe)) / Pt; const denom = GF - (GF * B) + B; // B + GF*(1-B) const Pmin = (Pt - A * GF) * (B / Math.max(denom, 1e-9)); if (Pmin > cmax) cmax = Pmin; } return cmax; } // ===================== SATURAZIONE: DISCESA + FONDO =============================== function runTissuesWithDescent() { const { halftimen2, halftimehe } = getCoefficients(); const { depth_m, bottomMin, descRate } = getDiveParams(); const gases = getGases(); const PH2O = 0.0627; // iniziale (aria a 1 bar) const PN2_0 = 0.7902 * (1.0 - PH2O); let pn = Array(16).fill(PN2_0); let pe = Array(16).fill(0); const kN2 = halftimen2.map(ht => Math.log(2) / (ht * 60)); const kHe = halftimehe.map(ht => Math.log(2) / (ht * 60)); // DISCESA su gas di fondo integrateTravelTissues(pn, pe, kN2, kHe, gases[0], 0, depth_m, descRate, PH2O); // FONDO su gas di fondo const Pamb = 1 + depth_m / 10; const PiN2 = gases[0].FN2 * (Pamb - PH2O); const PiHe = gases[0].FHe * (Pamb - PH2O); const totalSec = Math.round(bottomMin * 60); for (let t = 0; t < totalSec; t += STEP_SEC) { for (let i = 0; i < 16; i++) { pn[i] += (PiN2 - pn[i]) * (1 - Math.exp(-kN2[i] * STEP_SEC)); pe[i] += (PiHe - pe[i]) * (1 - Math.exp(-kHe[i] * STEP_SEC)); } } // (debug ) let lines = []; lines.push(`\n\n Nerd things:\nTissue saturation after descent + ${bottomMin} min @ ${depth_m} m\n`); lines.push("Idx | PN₂ (bar) | PHe (bar) | Ptot (bar)"); for (let i = 0; i < 16; i++) { lines.push(`${pad2(i+1)} | ${r3(pn[i])} | ${r3(pe[i])} | ${r3(pn[i]+pe[i])}`); } $w('#text27').text = lines.join("\n"); return { pn, pe }; } // integrazione tessuti durante spostamento verticale a gas fisso function integrateTravelTissues(pn, pe, kN2, kHe, gas, fromDepth, toDepth, rate_m_per_min, PH2O) { if (!isFinite(fromDepth) || !isFinite(toDepth) || rate_m_per_min <= 0) return; const delta = toDepth - fromDepth; const dist = Math.abs(delta); if (dist < 1e-6) return; const steps = Math.max(1, Math.ceil(dist)); const stepM = delta / steps; const dtMin = Math.abs(stepM) / rate_m_per_min; const dtSec = dtMin * 60; for (let iStep = 0; iStep < steps; iStep++) { const midDepth = fromDepth + (iStep + 0.5) * stepM; const Pamb = 1 + Math.max(0, midDepth) / 10; const PiN2 = gas.FN2 * (Pamb - PH2O); const PiHe = gas.FHe * (Pamb - PH2O); for (let i = 0; i < 16; i++) { pn[i] += (PiN2 - pn[i]) * (1 - Math.exp(-kN2[i] * dtSec)); pe[i] += (PiHe - pe[i]) * (1 - Math.exp(-kHe[i] * dtSec)); } } } // =============== STEP 2 — Ceiling iniziale + prima tappa =========================== function showCeilingInfo(pn, pe) { const depth_m = n('#depth'); const pressione = 1 + depth_m / 10; const gflow = n('#gfLow') / 100; const gfhi = n('#gfHigh') / 100; // GF info let GFm = (gfhi - gflow) / ((pressione - 1) * 10); let GF = gfhi - GFm * ((pressione - 1) * 10); let gffinal = Math.min(Math.max(GF, gflow), gfhi); // Prima tappa (semper arrotondata a SALIRE al multiplo di 3 m) const ceiling0_raw = ceilingGF(pn, pe, gflow); const ceiling0 = ceiling0_raw + FIRST_STOP_EPS; let primatappamax = Math.ceil(((ceiling0 - 1) * 10) / 3) * 3; if (primatappamax < 3) primatappamax = 3; let lines = []; lines.push(`Model: ZHL-16${currentModel()} + GF ${Math.round(gflow*100)}/${Math.round(gfhi*100)}`); lines.push(`GF (at current Pamb): ${gffinal.toFixed(2)}`); lines.push(`Ceiling: ${(((ceiling0_raw-1)*10)).toFixed(1)} m`); lines.push(`First stop depth: ${primatappamax} m`); $w('#text29').text = lines.join("\n"); } // =============== PIANO DECO + RISALITA ALLA PRIMA TAPPA =========================== function runDecoPlan(pn, pe) { const gflow = n('#gfLow') / 100, gfhi = n('#gfHigh') / 100; const gases = getGases(); const { halftimen2, halftimehe } = getCoefficients(); const PH2O = 0.0627; const { depth_m, ascRate } = getDiveParams(); const kN2 = halftimen2.map(ht => Math.log(2) / (ht * 60)); const kHe = halftimehe.map(ht => Math.log(2) / (ht * 60)); function pickBestGas(depthM) { const Pamb = 1 + depthM / 10; let usable = gases.filter(g => isFinite(g.switchDepth) && depthM <= g.switchDepth); usable = usable.filter(g => g.FO2 * Pamb <= g.ppo2lim + 1e-6); if (!usable.length) return gases[0]; usable.sort((a, b) => b.FO2 - a.FO2); return usable[0]; } // copie tessuti let decopn = pn.slice(), decope = pe.slice(); // prima tappa da ceiling con GF_low const ceiling0_raw = ceilingGF(decopn, decope, gflow); const ceiling0 = ceiling0_raw + FIRST_STOP_EPS; let primatappamax = Math.ceil(((ceiling0 - 1) * 10) / 3) * 3; if (primatappamax < 3) primatappamax = 3; // integra RISALITA fondo -> prima tappa con gas dinamico (coerente col planner) { const from = depth_m, to = primatappamax; if (ascRate > 0 && from > to) { const delta = from - to; const steps = Math.max(1, Math.ceil(delta)); for (let i = 0; i < steps; i++) { const d1 = from - i; const d2 = from - (i + 1); const mid = (d1 + d2) / 2; const gas = pickBestGas(Math.max(0, mid)); integrateTravelTissues(decopn, decope, kN2, kHe, gas, d1, d2, ascRate, PH2O); } } } // retta GF const GFmdeco = (gfhi - gflow) / primatappamax; let counterstep = primatappamax; let stops = []; while (counterstep > 0) { let GF = gfhi - GFmdeco * counterstep; if (GF < gflow) GF = gflow; if (GF > gfhi) GF = gfhi; const gasNow = pickBestGas(counterstep); const PambStop = 1 + counterstep / 10; const PiN2 = gasNow.FN2 * (PambStop - PH2O); const PiHe = gasNow.FHe * (PambStop - PH2O); let holdSec = 0; let decoceiling = ceilingGF(decopn, decope, GF); const nextPamb = PambStop - 0.3; const eps = (counterstep <= SHALLOW_THRESHOLD) ? CEIL_EPS_SHALLOW : CEIL_EPS_DEEP; while (decoceiling > nextPamb + eps) { for (let i = 0; i < 16; i++) { decopn[i] += (PiN2 - decopn[i]) * (1 - Math.exp(-kN2[i] * STEP_SEC)); decope[i] += (PiHe - decope[i]) * (1 - Math.exp(-kHe[i] * STEP_SEC)); } holdSec += STEP_SEC; decoceiling = ceilingGF(decopn, decope, GF); } const holdMin = (ROUNDING === "floor") ? Math.floor(holdSec / 60) : Math.round(holdSec / 60); if (holdMin > 0) { stops.push({ depth: counterstep, time: holdMin, GF, ceiling: decoceiling, gas: gasNow }); } counterstep -= 3; } // ---- Consumi ---- const usage = computeGasUsage(stops, gases); // ---- Runtime cumulativo (min) ---- const { depth_m: D, bottomMin: BT, descRate, ascRate: AR } = getDiveParams(); let runtimeMin = 0; if (isFinite(D) && descRate > 0) runtimeMin += D / descRate; // discesa if (isFinite(BT) && BT > 0) runtimeMin += BT; // fondo let rtLeaveBottom = runtimeMin; // runtime quando si lascia il fondo if (stops.length > 0 && AR > 0) runtimeMin += (D - stops[0].depth) / AR; // alla 1ª tappa const firstStopRt = runtimeMin; for (let i = 0; i < stops.length; i++) { const s = stops[i]; runtimeMin += s.time; s.runtime = runtimeMin; if (i < stops.length - 1 && AR > 0) { const nextDepth = stops[i + 1].depth; runtimeMin += (s.depth - nextDepth) / AR; } } // ---- END & Gas Density (solo gas di fondo) ---- const infoBottom = bottomGasInfo(gases[0], D); // ---- CNS% totale (NOAA) su discesa, fondo, risalite, soste) ---- const cnsTotal = computeCNSPercent(stops, gases); // ===== Output piano (allineato, depth/time evidenziati) ===== const DEP_W = 5, TIME_W = 6, RT_W = 6, GAS_W = 18, PPO2_W = 5; let rows = []; rows.push(spanBase(`PLAN — Single OC (ZHL-16${currentModel()} + GF ${Math.round(gflow*100)}/${Math.round(gfhi*100)})`, COLOR_BASE_TEXT)); rows.push(spanDim(`(ppO₂ shown at stop depth)\n`, COLOR_DIM_TEXT)); rows.push(""); // riga vuota // RIEPILOGO GAS DI FONDO: END & Densità rows.push(spanBase(`Bottom gas: ${gasDisplay(gases[0]).label}`, COLOR_BASE_TEXT)); rows.push(spanDim(`\nEND @ ${D} m: ${infoBottom.ENDm.toFixed(0)} m | Gas density @ ${D} m: ${infoBottom.density_gL.toFixed(2)} g/L\n\n\n`, COLOR_DIM_TEXT)); rows.push(""); // riga vuota // Riga "leave bottom" + runtime fino alla 1ª tappa if (stops.length) { rows.push( spanDim(`Leave bottom (${Math.round(rtLeaveBottom)})`, COLOR_DIM_TEXT) + spanBase(`\n → ascend to first stop @ `, COLOR_BASE_TEXT) + spanHilite(rcol(`${stops[0].depth} m`, DEP_W)) + spanBase(` `, COLOR_BASE_TEXT) + spanDim(`(${Math.round(firstStopRt)})`, COLOR_DIM_TEXT) ); } // Soste for (const s of stops) { const depthStr = `${s.depth} m`; const timeStr = `${s.time} min`; const PambStop = 1 + s.depth / 10; const ppO2 = (s.gas.FO2 * PambStop).toFixed(2); const runStr = `(${Math.round(s.runtime)})`; const gdisp = gasDisplay(s.gas); const gasStr = gdisp.label; const line = spanBase("\nStop @ ", COLOR_BASE_TEXT) + spanHilite(rcol(depthStr, DEP_W)) + spanBase(" ", COLOR_BASE_TEXT) + spanHilite(rcol(timeStr, TIME_W)) + spanBase(" ", COLOR_BASE_TEXT) + spanDim(rcol(runStr, RT_W), COLOR_DIM_TEXT) + spanBase(" ", COLOR_BASE_TEXT) + spanBase("--> ", COLOR_BASE_TEXT) + spanDim(lcol(gasStr, GAS_W), COLOR_DIM_TEXT) + spanBase(" ", COLOR_BASE_TEXT) + spanDim(`ppO₂ ${rcol(ppO2, PPO2_W)}`, COLOR_DIM_TEXT); rows.push(line); } // Consumi rows.push('\n' + spanBase('\nGas usage (surface liters):\n', COLOR_BASE_TEXT)); let totalNL = 0; for (const it of usage) { totalNL += it.liters; const lbl = lcol(it.label, 18); const val = rcol(Math.round(it.liters).toString(), 8); rows.push(spanDim(`• ${lbl} ${val} NL\n`, COLOR_DIM_TEXT)); } rows.push(spanDim(`Total${' '.repeat(17)}${rcol(Math.round(totalNL).toString(), 8)} NL\n`, COLOR_DIM_TEXT)); // CNS rows.push(''); rows.push(spanBase(`\nCNS total: ${cnsTotal.toFixed(0)}%\n\n\n\n\n`, COLOR_BASE_TEXT)); const html = `
` + rows.join("") + ``; if ($w('#planText').html !== undefined) $w('#planText').html = html; else if ($w('#planText').value !== undefined) $w('#planText').value = html; else $w('#planText').text = rows.map(stripTags).join("\n"); // --- Snapshot tessuti immediatamente a superficie (Air 21/79) --- function renderSurfaceTissues(decopn, decope, lastStopDepth, ascRate, kN2, kHe, PH2O) { let pnS = decopn.slice(); let peS = decope.slice(); // Risalita ultima sosta -> superficie con aria (21/79) if (isFinite(lastStopDepth) && lastStopDepth > 0 && ascRate > 0) { const air = { FO2: 0.21, FHe: 0.00, FN2: 0.79 }; integrateTravelTissues(pnS, peS, kN2, kHe, air, lastStopDepth, 0, ascRate, PH2O); } // Snapshot NOW @ surface let lines = []; lines.push("\n\n\nTissue saturation @ surface (Air 21/79)\n"); lines.push("Idx | PN₂ (bar) | PHe (bar) | Ptot (bar)"); for (let i = 0; i < 16; i++) { const PN2 = pnS[i]; const PHe = peS[i]; const Ptot = PN2 + PHe; lines.push(`${pad2(i+1)} | ${r3(PN2)} | ${r3(PHe)} | ${r3(Ptot)}`); } if ($w('#text37').text !== undefined) { $w('#text37').text = lines.join("\n"); } } // --- Snapshot tessuti a superficie --- const lastStopDepth = (stops.length ? stops[stops.length - 1].depth : 0); renderSurfaceTissues(decopn, decope, lastStopDepth, AR, kN2, kHe, PH2O); return stops; } // ================= GAS (robusto a campi vuoti / EAN/O2) ============================ function getGases() { function _raw(id) { return String($w(id).value || '').trim(); } function _num(id) { const s = _raw(id).replace(',', '.'); const v = Number(s); return isFinite(v) ? v : NaN; } function _pct(id, def = 0) { const v = _num(id); return isFinite(v) ? v / 100 : def; } // usa #maxppo2 per TUTTE le miscele const maxppo2 = _num('#maxppo2'); let gases = [ { name: "Bottom", FO2: _pct('#o20', 0), FHe: _pct('#he0', 0), ppo2lim: maxppo2 }, { name: "Deco1", FO2: _pct('#o21', 0), FHe: _pct('#he1', 0), ppo2lim: maxppo2 }, { name: "Deco2", FO2: _pct('#o22', 0), FHe: _pct('#he2', 0), ppo2lim: maxppo2 }, { name: "Deco3", FO2: _pct('#o23', 0), FHe: _pct('#he3', 0), ppo2lim: maxppo2 }, { name: "Deco4", FO2: _pct('#o24', 0), FHe: _pct('#he4', 0), ppo2lim: maxppo2 } ]; gases.forEach(g => { g.FO2 = Math.min(1, Math.max(0, isFinite(g.FO2) ? g.FO2 : 0)); g.FHe = Math.min(1, Math.max(0, isFinite(g.FHe) ? g.FHe : 0)); let sum = g.FO2 + g.FHe; if (sum > 1) { g.FO2 = g.FO2 / sum; g.FHe = g.FHe / sum; } g.FN2 = 1 - g.FO2 - g.FHe; if (g.FO2 > 0 && isFinite(g.ppo2lim) && g.ppo2lim > 0) { const PambMax = g.ppo2lim / g.FO2; g.switchDepth = (PambMax - 1) * 10; } else { g.switchDepth = NaN; } }); return gases.filter(g => isFinite(g.FN2)); } // ===================== GAS USAGE (surface liters) ================================= function getDiveParams() { const depth_m = n('#depth'); const bottomMin = n('#bottomTime'); let descRate = n('#descRate'); let ascRate = n('#ascRate'); const rmv = n('#rmv'); if (!isFinite(descRate) || descRate <= 0) descRate = 20; if (!isFinite(ascRate) || ascRate <= 0) ascRate = 10; return { depth_m, bottomMin, descRate, ascRate, rmv }; } function gasAtDepth(gases, depthM) { const Pamb = 1 + depthM / 10; let usable = gases.filter(g => isFinite(g.switchDepth) && depthM <= g.switchDepth); usable = usable.filter(g => g.FO2 * Pamb <= g.ppo2lim + 1e-6); if (!usable.length) return gases[0]; usable.sort((a, b) => b.FO2 - a.FO2); return usable[0]; } function integrateTravel(gases, fromDepth, toDepth, rate_m_per_min, rmv, accum) { if (!isFinite(fromDepth) || !isFinite(toDepth) || rate_m_per_min <= 0) return; const delta = toDepth - fromDepth, dist = Math.abs(delta); if (dist < 1e-6) return; const steps = Math.max(1, Math.ceil(dist)); const stepM = delta / steps; const dtMin = Math.abs(stepM) / rate_m_per_min; for (let i = 0; i < steps; i++) { const midDepth = fromDepth + (i + 0.5) * stepM; const Pamb = 1 + Math.max(0, midDepth) / 10; const gas = gasAtDepth(gases, Math.max(0, midDepth)); const key = gasKey(gas); const consSL = rmv * Pamb * dtMin; accum[key] = (accum[key] || 0) + consSL; } } function integrateHold(gases, depthM, minutes, rmv, accum) { if (minutes <= 0) return; const Pamb = 1 + depthM / 10; const gas = gasAtDepth(gases, depthM); const key = gasKey(gas); const cons = rmv * Pamb * minutes; accum[key] = (accum[key] || 0) + cons; } // --- integrazione consumi con GAS FORZATO (discesa e fondo) --- function integrateTravelWithGas(gas, fromDepth, toDepth, rate_m_per_min, rmv, accum) { if (!isFinite(fromDepth) || !isFinite(toDepth) || rate_m_per_min <= 0) return; const delta = toDepth - fromDepth, dist = Math.abs(delta); if (dist < 1e-6) return; const steps = Math.max(1, Math.ceil(dist)); const stepM = delta / steps; const dtMin = Math.abs(stepM) / rate_m_per_min; const key = gasKey(gas); for (let i = 0; i < steps; i++) { const midDepth = fromDepth + (i + 0.5) * stepM; const Pamb = 1 + Math.max(0, midDepth) / 10; const consSL = rmv * Pamb * dtMin; accum[key] = (accum[key] || 0) + consSL; } } function integrateHoldWithGas(gas, depthM, minutes, rmv, accum) { if (minutes <= 0) return; const Pamb = 1 + depthM / 10; const key = gasKey(gas); const cons = rmv * Pamb * minutes; accum[key] = (accum[key] || 0) + cons; } function gasKey(g) { return `${g.name}|${Math.round(g.FO2*100)}/${Math.round(g.FHe*100)}`; } function gasLabelFromKey(k) { const [_, mix] = k.split('|'); const [o2s, hes] = mix.split('/').map(Number); if (o2s === 21 && hes === 0) return 'Air'; if (o2s === 100 && hes === 0) return 'Oxygen'; if (hes > 0) return `Trimix ${o2s}/${hes}`; if (o2s > 21) return `Nitrox ${o2s}`; return `Gas ${mix}`; } function computeGasUsage(stops, gases) { const { depth_m, bottomMin, descRate, ascRate, rmv } = getDiveParams(); const accum = {}; const bottomGas = gases[0]; // forza la miscela di fondo // PRIMA: discesa su bottom gas integrateTravelWithGas(bottomGas, 0, depth_m, descRate, rmv, accum); // POI: fondo su bottom gas integrateHoldWithGas(bottomGas, depth_m, bottomMin, rmv, accum); // Risalita fino alla prima tappa: dinamico (switch appena è lecito) const firstStopDepth = (stops.length ? stops[0].depth : 3); integrateTravel(gases, depth_m, firstStopDepth, ascRate, rmv, accum); // Soste + risalite tra tappe: dinamico for (let i = 0; i < stops.length; i++) { const s = stops[i]; integrateHold(gases, s.depth, s.time, rmv, accum); const nextDepth = (i < stops.length - 1) ? stops[i + 1].depth : 0; integrateTravel(gases, s.depth, nextDepth, ascRate, rmv, accum); } const items = Object.entries(accum).map(([k, liters]) => ({ key: k, label: gasLabelFromKey(k), liters })); items.sort((a, b) => a.label.localeCompare(b.label)); return items; } // ===================== END & GAS DENSITY (gas di fondo) =========================== // END (m) assumendo solo N2 come narcotico (O2 non narcotico): END = ((FN2/0.79)*(D+10))-10 function computeENDmeters(bottomGas, depth_m) { const FN2 = bottomGas.FN2; const Pamb = 1 + depth_m / 10; const END_ambient = (FN2 / 0.79) * Pamb; // equivalente air pressure return Math.max(0, (END_ambient - 1) * 10); } // densità mix @ 1 bar (kg/m^3) a ~20°C: He 0.1786, N2 1.2506, O2 1.331 function gasDensityAtDepth(bottomGas, depth_m) { const rhoHe = 0.1786, rhoN2 = 1.2506, rhoO2 = 1.3310; const rho1bar = bottomGas.FHe * rhoHe + bottomGas.FN2 * rhoN2 + bottomGas.FO2 * rhoO2; // kg/m3 const Pamb = 1 + depth_m / 10; const rhoDepth = rho1bar * Pamb; // kg/m3 return rhoDepth * 1.0; // kg/m3 -> kg/m3 (convertiamo dopo in g/L) } function bottomGasInfo(bottomGas, depth_m) { const ENDm = computeENDmeters(bottomGas, depth_m); const rho_kgm3 = gasDensityAtDepth(bottomGas, depth_m); const density_gL = rho_kgm3; // 1 kg/m3 = 1 g/L return { ENDm, density_gL }; } // ===================== CNS NOAA =================================================== // Tabella NOAA (minuti per 100% CNS) a vari ppO2 (bar). Linear interp. Sotto 0.5 bar = 0. const NOAA_TABLE = [ { pp: 1.6, min: 45 }, { pp: 1.5, min: 120 }, { pp: 1.4, min: 150 }, { pp: 1.3, min: 180 }, { pp: 1.2, min: 210 }, { pp: 1.1, min: 240 }, { pp: 1.0, min: 300 }, { pp: 0.9, min: 360 }, { pp: 0.8, min: 450 }, { pp: 0.7, min: 570 }, { pp: 0.6, min: 720 }, { pp: 0.5, min: 1440 } ]; function minutesFor100CNS(pp) { if (pp < 0.5) return Infinity; // clamp in range if (pp >= NOAA_TABLE[0].pp) return NOAA_TABLE[0].min; if (pp <= NOAA_TABLE[NOAA_TABLE.length - 1].pp) return NOAA_TABLE[NOAA_TABLE.length - 1].min; // linear interp between surrounding nodes for (let i = 0; i < NOAA_TABLE.length - 1; i++) { const a = NOAA_TABLE[i], b = NOAA_TABLE[i + 1]; if (pp <= a.pp && pp >= b.pp) { const t = (pp - b.pp) / (a.pp - b.pp); return b.min + t * (a.min - b.min); } } return Infinity; } function cnsIncrement(pp, minutes) { const M = minutesFor100CNS(pp); if (!isFinite(M) || M === Infinity) return 0; return (minutes / M) * 100; } // calcolo CNS% integrando a step di 1 m durante discese/risalite + soste + fondo function computeCNSPercent(stops, gases) { const { depth_m, bottomMin, descRate, ascRate } = getDiveParams(); let cns = 0; // helper: CNS su travel (1 m per step) function cnsTravel(fromDepth, toDepth, rate_m_per_min) { if (!isFinite(fromDepth) || !isFinite(toDepth) || rate_m_per_min <= 0) return; const delta = toDepth - fromDepth, dist = Math.abs(delta); if (dist < 1e-6) return; const steps = Math.max(1, Math.ceil(dist)); const stepM = delta / steps; const dtMin = Math.abs(stepM) / rate_m_per_min; for (let i = 0; i < steps; i++) { const midDepth = fromDepth + (i + 0.5) * stepM; const gas = gasAtDepth(gases, Math.max(0, midDepth)); const ppO2 = gas.FO2 * (1 + Math.max(0, midDepth) / 10); cns += cnsIncrement(ppO2, dtMin); } } // helper: CNS su hold function cnsHold(depthM, minutes) { if (minutes <= 0) return; const gas = gasAtDepth(gases, depthM); const ppO2 = gas.FO2 * (1 + depthM / 10); // ================== PLANNER ZHL-16B/C + GF (dt=1s, PH2O=0.0627) ================== $w.onReady(() => { $w('#planBtn').onClick(() => { const { pn, pe } = runTissuesWithDescent(); // Step 1: discesa + fondo showCeilingInfo(pn, pe); // : ceiling iniziale + prima tappa runDecoPlan(pn, pe); // Step 3: risalita a prima tappa + piano deco + consumi + END/Density + CNS }); }); // === Lettura modello B/C dal RadioGroup === function currentModel() { try { const el = $w('#radioGroup1'); if (el && typeof el.value === 'string') { const v = el.value.trim().toUpperCase(); return (v === 'C') ? 'C' : 'B'; } } catch (e) {} return 'B'; } // --- Parametri numerici / fine tuning (arrotondamenti così sono più restrittivo in profondità e più perimissivo in superficie) --- const STEP_SEC = 1; const SHALLOW_THRESHOLD = 15; const CEIL_EPS_DEEP = -0.012; const CEIL_EPS_SHALLOW = +0.001; const ROUNDING = "floor"; const FIRST_STOP_EPS = 0.03; // --- Stile output --- const USE_HTML_COLORS = true; const COLOR_BASE_TEXT = "#ffffff"; const COLOR_DIM_TEXT = "#9aa0a6"; const HILITE_BG = "#64ffda"; const HILITE_FG = "#00332b"; // ==================== Coefficienti ZHL-16B / ZHL-16C ============================== function getCoefficients() { const model = currentModel(); if (model === "B") { return { halftimen2: [4.0, 8.0, 12.5, 18.5, 27.0, 38.3, 54.3, 77.0, 109.0, 146.0, 187.0, 239.0, 305.0, 390.0, 498.0, 635.0], halftimehe: [1.5, 3.0, 4.7, 7.0, 10.2, 14.5, 20.5, 29.1, 41.1, 55.1, 70.6, 90.2, 115.1, 147.2, 187.9, 239.6], n2a: [1.2599, 1.0, 0.8618, 0.7562, 0.6667, 0.5933, 0.5282, 0.4701, 0.4187, 0.3798, 0.3497, 0.3223, 0.2971, 0.2737, 0.2523, 0.2327], n2b: [0.505, 0.6514, 0.7222, 0.7725, 0.8125, 0.8434, 0.8693, 0.891, 0.9092, 0.9222, 0.9319, 0.9403, 0.9477, 0.9544, 0.9602, 0.9653], hea: [1.7435, 1.3838, 1.1925, 1.0465, 0.9226, 0.8211, 0.7309, 0.6506, 0.5794, 0.5256, 0.484, 0.446, 0.4112, 0.3788, 0.3492, 0.322], heb: [0.1911, 0.4295, 0.5446, 0.6265, 0.6917, 0.742, 0.7841, 0.8195, 0.8491, 0.8703, 0.886, 0.8997, 0.9118, 0.9226, 0.9321, 0.9404] }; } else { return { halftimen2: [4.0, 8.0, 12.5, 18.5, 27.0, 38.3, 54.3, 77.0, 109.0, 146.0, 187.0, 239.0, 305.0, 390.0, 498.0, 635.0], halftimehe: [1.51, 3.02, 4.72, 6.99, 10.21, 14.48, 20.53, 29.11, 41.20, 55.19, 70.69, 90.34, 115.29, 147.42, 188.24, 240.03], n2a: [1.1696, 1.0, 0.8618, 0.7562, 0.6667, 0.5933, 0.5282, 0.4701, 0.4187, 0.3798, 0.3497, 0.3223, 0.2971, 0.2737, 0.2523, 0.2327], n2b: [0.5578, 0.6514, 0.7222, 0.7825, 0.8126, 0.8434, 0.8693, 0.8910, 0.9092, 0.9222, 0.9319, 0.9403, 0.9477, 0.9544, 0.9602, 0.9653], hea: [1.6189, 1.3830, 1.1919, 1.0458, 0.9220, 0.8205, 0.7305, 0.6502, 0.5950, 0.5545, 0.5333, 0.5189, 0.5181, 0.5176, 0.5172, 0.5119], heb: [0.4770, 0.5747, 0.6527, 0.7223, 0.7582, 0.7957, 0.8279, 0.8553, 0.8757, 0.8903, 0.8997, 0.9073, 0.9122, 0.9171, 0.9217, 0.9267] }; } } // ===================== ceilingGF ===================== function ceilingGF(pnArr, peArr, GF) { const { n2a, n2b, hea, heb } = getCoefficients(); let cmax = 0; for (let i = 0; i < 16; i++) { const PN2 = pnArr[i], PHe = peArr[i], Pt = PN2 + PHe; if (Pt <= 0) continue; const A = ((n2a[i] * PN2) + (hea[i] * PHe)) / Pt; const B = ((n2b[i] * PN2) + (heb[i] * PHe)) / Pt; const denom = GF - (GF * B) + B; // B + GF*(1-B) const Pmin = (Pt - A * GF) * (B / Math.max(denom, 1e-9)); if (Pmin > cmax) cmax = Pmin; } return cmax; } // ===================== SATURAZIONE: DISCESA + FONDO =============================== function runTissuesWithDescent() { const { halftimen2, halftimehe } = getCoefficients(); const { depth_m, bottomMin, descRate } = getDiveParams(); const gases = getGases(); const PH2O = 0.0627; // iniziale (aria a 1 bar) const PN2_0 = 0.7902 * (1.0 - PH2O); let pn = Array(16).fill(PN2_0); let pe = Array(16).fill(0); const kN2 = halftimen2.map(ht => Math.log(2) / (ht * 60)); const kHe = halftimehe.map(ht => Math.log(2) / (ht * 60)); // DISCESA su gas di fondo integrateTravelTissues(pn, pe, kN2, kHe, gases[0], 0, depth_m, descRate, PH2O); // FONDO su gas di fondo const Pamb = 1 + depth_m / 10; const PiN2 = gases[0].FN2 * (Pamb - PH2O); const PiHe = gases[0].FHe * (Pamb - PH2O); const totalSec = Math.round(bottomMin * 60); for (let t = 0; t < totalSec; t += STEP_SEC) { for (let i = 0; i < 16; i++) { pn[i] += (PiN2 - pn[i]) * (1 - Math.exp(-kN2[i] * STEP_SEC)); pe[i] += (PiHe - pe[i]) * (1 - Math.exp(-kHe[i] * STEP_SEC)); } } // (debug ) let lines = []; lines.push(`\n\n Nerd things:\nTissue saturation after descent + ${bottomMin} min @ ${depth_m} m\n`); lines.push("Idx | PN₂ (bar) | PHe (bar) | Ptot (bar)"); for (let i = 0; i < 16; i++) { lines.push(`${pad2(i+1)} | ${r3(pn[i])} | ${r3(pe[i])} | ${r3(pn[i]+pe[i])}`); } $w('#text27').text = lines.join("\n"); return { pn, pe }; } // integrazione tessuti durante spostamento verticale a gas fisso function integrateTravelTissues(pn, pe, kN2, kHe, gas, fromDepth, toDepth, rate_m_per_min, PH2O) { if (!isFinite(fromDepth) || !isFinite(toDepth) || rate_m_per_min <= 0) return; const delta = toDepth - fromDepth; const dist = Math.abs(delta); if (dist < 1e-6) return; const steps = Math.max(1, Math.ceil(dist)); const stepM = delta / steps; const dtMin = Math.abs(stepM) / rate_m_per_min; const dtSec = dtMin * 60; for (let iStep = 0; iStep < steps; iStep++) { const midDepth = fromDepth + (iStep + 0.5) * stepM; const Pamb = 1 + Math.max(0, midDepth) / 10; const PiN2 = gas.FN2 * (Pamb - PH2O); const PiHe = gas.FHe * (Pamb - PH2O); for (let i = 0; i < 16; i++) { pn[i] += (PiN2 - pn[i]) * (1 - Math.exp(-kN2[i] * dtSec)); pe[i] += (PiHe - pe[i]) * (1 - Math.exp(-kHe[i] * dtSec)); } } } // =============== STEP 2 — Ceiling iniziale + prima tappa =========================== function showCeilingInfo(pn, pe) { const depth_m = n('#depth'); const pressione = 1 + depth_m / 10; const gflow = n('#gfLow') / 100; const gfhi = n('#gfHigh') / 100; // GF info let GFm = (gfhi - gflow) / ((pressione - 1) * 10); let GF = gfhi - GFm * ((pressione - 1) * 10); let gffinal = Math.min(Math.max(GF, gflow), gfhi); // Prima tappa (semper arrotondata a SALIRE al multiplo di 3 m) const ceiling0_raw = ceilingGF(pn, pe, gflow); const ceiling0 = ceiling0_raw + FIRST_STOP_EPS; let primatappamax = Math.ceil(((ceiling0 - 1) * 10) / 3) * 3; if (primatappamax < 3) primatappamax = 3; let lines = []; lines.push(`Model: ZHL-16${currentModel()} + GF ${Math.round(gflow*100)}/${Math.round(gfhi*100)}`); lines.push(`GF (at current Pamb): ${gffinal.toFixed(2)}`); lines.push(`Ceiling: ${(((ceiling0_raw-1)*10)).toFixed(1)} m`); lines.push(`First stop depth: ${primatappamax} m`); $w('#text29').text = lines.join("\n"); } // =============== PIANO DECO + RISALITA ALLA PRIMA TAPPA =========================== function runDecoPlan(pn, pe) { const gflow = n('#gfLow') / 100, gfhi = n('#gfHigh') / 100; const gases = getGases(); const { halftimen2, halftimehe } = getCoefficients(); const PH2O = 0.0627; const { depth_m, ascRate } = getDiveParams(); const kN2 = halftimen2.map(ht => Math.log(2) / (ht * 60)); const kHe = halftimehe.map(ht => Math.log(2) / (ht * 60)); function pickBestGas(depthM) { const Pamb = 1 + depthM / 10; let usable = gases.filter(g => isFinite(g.switchDepth) && depthM <= g.switchDepth); usable = usable.filter(g => g.FO2 * Pamb <= g.ppo2lim + 1e-6); if (!usable.length) return gases[0]; usable.sort((a, b) => b.FO2 - a.FO2); return usable[0]; } // copie tessuti let decopn = pn.slice(), decope = pe.slice(); // prima tappa da ceiling con GF_low const ceiling0_raw = ceilingGF(decopn, decope, gflow); const ceiling0 = ceiling0_raw + FIRST_STOP_EPS; let primatappamax = Math.ceil(((ceiling0 - 1) * 10) / 3) * 3; if (primatappamax < 3) primatappamax = 3; // integra RISALITA fondo -> prima tappa con gas dinamico (coerente col planner) { const from = depth_m, to = primatappamax; if (ascRate > 0 && from > to) { const delta = from - to; const steps = Math.max(1, Math.ceil(delta)); for (let i = 0; i < steps; i++) { const d1 = from - i; const d2 = from - (i + 1); const mid = (d1 + d2) / 2; const gas = pickBestGas(Math.max(0, mid)); integrateTravelTissues(decopn, decope, kN2, kHe, gas, d1, d2, ascRate, PH2O); } } } // retta GF const GFmdeco = (gfhi - gflow) / primatappamax; let counterstep = primatappamax; let stops = []; while (counterstep > 0) { let GF = gfhi - GFmdeco * counterstep; if (GF < gflow) GF = gflow; if (GF > gfhi) GF = gfhi; const gasNow = pickBestGas(counterstep); const PambStop = 1 + counterstep / 10; const PiN2 = gasNow.FN2 * (PambStop - PH2O); const PiHe = gasNow.FHe * (PambStop - PH2O); let holdSec = 0; let decoceiling = ceilingGF(decopn, decope, GF); const nextPamb = PambStop - 0.3; const eps = (counterstep <= SHALLOW_THRESHOLD) ? CEIL_EPS_SHALLOW : CEIL_EPS_DEEP; while (decoceiling > nextPamb + eps) { for (let i = 0; i < 16; i++) { decopn[i] += (PiN2 - decopn[i]) * (1 - Math.exp(-kN2[i] * STEP_SEC)); decope[i] += (PiHe - decope[i]) * (1 - Math.exp(-kHe[i] * STEP_SEC)); } holdSec += STEP_SEC; decoceiling = ceilingGF(decopn, decope, GF); } const holdMin = (ROUNDING === "floor") ? Math.floor(holdSec / 60) : Math.round(holdSec / 60); if (holdMin > 0) { stops.push({ depth: counterstep, time: holdMin, GF, ceiling: decoceiling, gas: gasNow }); } counterstep -= 3; } // ---- Consumi ---- const usage = computeGasUsage(stops, gases); // ---- Runtime cumulativo (min) ---- const { depth_m: D, bottomMin: BT, descRate, ascRate: AR } = getDiveParams(); let runtimeMin = 0; if (isFinite(D) && descRate > 0) runtimeMin += D / descRate; // discesa if (isFinite(BT) && BT > 0) runtimeMin += BT; // fondo let rtLeaveBottom = runtimeMin; // runtime quando si lascia il fondo if (stops.length > 0 && AR > 0) runtimeMin += (D - stops[0].depth) / AR; // alla 1ª tappa const firstStopRt = runtimeMin; for (let i = 0; i < stops.length; i++) { const s = stops[i]; runtimeMin += s.time; s.runtime = runtimeMin; if (i < stops.length - 1 && AR > 0) { const nextDepth = stops[i + 1].depth; runtimeMin += (s.depth - nextDepth) / AR; } } // ---- END & Gas Density (solo gas di fondo) ---- const infoBottom = bottomGasInfo(gases[0], D); // ---- CNS% totale (NOAA) su discesa, fondo, risalite, soste) ---- const cnsTotal = computeCNSPercent(stops, gases); // ===== Output piano (allineato, depth/time evidenziati) ===== const DEP_W = 5, TIME_W = 6, RT_W = 6, GAS_W = 18, PPO2_W = 5; let rows = []; rows.push(spanBase(`PLAN — Single OC (ZHL-16${currentModel()} + GF ${Math.round(gflow*100)}/${Math.round(gfhi*100)})`, COLOR_BASE_TEXT)); rows.push(spanDim(`(ppO₂ shown at stop depth)\n`, COLOR_DIM_TEXT)); rows.push(""); // riga vuota // RIEPILOGO GAS DI FONDO: END & Densità rows.push(spanBase(`Bottom gas: ${gasDisplay(gases[0]).label}`, COLOR_BASE_TEXT)); rows.push(spanDim(`\nEND @ ${D} m: ${infoBottom.ENDm.toFixed(0)} m | Gas density @ ${D} m: ${infoBottom.density_gL.toFixed(2)} g/L\n\n\n`, COLOR_DIM_TEXT)); rows.push(""); // riga vuota // Riga "leave bottom" + runtime fino alla 1ª tappa if (stops.length) { rows.push( spanDim(`Leave bottom (${Math.round(rtLeaveBottom)})`, COLOR_DIM_TEXT) + spanBase(`\n → ascend to first stop @ `, COLOR_BASE_TEXT) + spanHilite(rcol(`${stops[0].depth} m`, DEP_W)) + spanBase(` `, COLOR_BASE_TEXT) + spanDim(`(${Math.round(firstStopRt)})`, COLOR_DIM_TEXT) ); } // Soste for (const s of stops) { const depthStr = `${s.depth} m`; const timeStr = `${s.time} min`; const PambStop = 1 + s.depth / 10; const ppO2 = (s.gas.FO2 * PambStop).toFixed(2); const runStr = `(${Math.round(s.runtime)})`; const gdisp = gasDisplay(s.gas); const gasStr = gdisp.label; const line = spanBase("\nStop @ ", COLOR_BASE_TEXT) + spanHilite(rcol(depthStr, DEP_W)) + spanBase(" ", COLOR_BASE_TEXT) + spanHilite(rcol(timeStr, TIME_W)) + spanBase(" ", COLOR_BASE_TEXT) + spanDim(rcol(runStr, RT_W), COLOR_DIM_TEXT) + spanBase(" ", COLOR_BASE_TEXT) + spanBase("--> ", COLOR_BASE_TEXT) + spanDim(lcol(gasStr, GAS_W), COLOR_DIM_TEXT) + spanBase(" ", COLOR_BASE_TEXT) + spanDim(`ppO₂ ${rcol(ppO2, PPO2_W)}`, COLOR_DIM_TEXT); rows.push(line); } // Consumi rows.push('\n' + spanBase('\nGas usage (surface liters):\n', COLOR_BASE_TEXT)); let totalNL = 0; for (const it of usage) { totalNL += it.liters; const lbl = lcol(it.label, 18); const val = rcol(Math.round(it.liters).toString(), 8); rows.push(spanDim(`• ${lbl} ${val} NL\n`, COLOR_DIM_TEXT)); } rows.push(spanDim(`Total${' '.repeat(17)}${rcol(Math.round(totalNL).toString(), 8)} NL\n`, COLOR_DIM_TEXT)); // CNS rows.push(''); rows.push(spanBase(`\nCNS total: ${cnsTotal.toFixed(0)}%\n\n\n\n\n`, COLOR_BASE_TEXT)); const html = `
` + rows.join("") + ``; if ($w('#planText').html !== undefined) $w('#planText').html = html; else if ($w('#planText').value !== undefined) $w('#planText').value = html; else $w('#planText').text = rows.map(stripTags).join("\n"); // --- Snapshot tessuti immediatamente a superficie (Air 21/79) --- function renderSurfaceTissues(decopn, decope, lastStopDepth, ascRate, kN2, kHe, PH2O) { let pnS = decopn.slice(); let peS = decope.slice(); // Risalita ultima sosta -> superficie con aria (21/79) if (isFinite(lastStopDepth) && lastStopDepth > 0 && ascRate > 0) { const air = { FO2: 0.21, FHe: 0.00, FN2: 0.79 }; integrateTravelTissues(pnS, peS, kN2, kHe, air, lastStopDepth, 0, ascRate, PH2O); } // Snapshot NOW @ surface let lines = []; lines.push("\n\n\nTissue saturation @ surface (Air 21/79)\n"); lines.push("Idx | PN₂ (bar) | PHe (bar) | Ptot (bar)"); for (let i = 0; i < 16; i++) { const PN2 = pnS[i]; const PHe = peS[i]; const Ptot = PN2 + PHe; lines.push(`${pad2(i+1)} | ${r3(PN2)} | ${r3(PHe)} | ${r3(Ptot)}`); } if ($w('#text37').text !== undefined) { $w('#text37').text = lines.join("\n"); } } // --- Snapshot tessuti a superficie --- const lastStopDepth = (stops.length ? stops[stops.length - 1].depth : 0); renderSurfaceTissues(decopn, decope, lastStopDepth, AR, kN2, kHe, PH2O); return stops; } // ================= GAS (robusto a campi vuoti / EAN/O2) ============================ function getGases() { function _raw(id) { return String($w(id).value || '').trim(); } function _num(id) { const s = _raw(id).replace(',', '.'); const v = Number(s); return isFinite(v) ? v : NaN; } function _pct(id, def = 0) { const v = _num(id); return isFinite(v) ? v / 100 : def; } // usa #maxppo2 per TUTTE le miscele const maxppo2 = _num('#maxppo2'); let gases = [ { name: "Bottom", FO2: _pct('#o20', 0), FHe: _pct('#he0', 0), ppo2lim: maxppo2 }, { name: "Deco1", FO2: _pct('#o21', 0), FHe: _pct('#he1', 0), ppo2lim: maxppo2 }, { name: "Deco2", FO2: _pct('#o22', 0), FHe: _pct('#he2', 0), ppo2lim: maxppo2 }, { name: "Deco3", FO2: _pct('#o23', 0), FHe: _pct('#he3', 0), ppo2lim: maxppo2 }, { name: "Deco4", FO2: _pct('#o24', 0), FHe: _pct('#he4', 0), ppo2lim: maxppo2 } ]; gases.forEach(g => { g.FO2 = Math.min(1, Math.max(0, isFinite(g.FO2) ? g.FO2 : 0)); g.FHe = Math.min(1, Math.max(0, isFinite(g.FHe) ? g.FHe : 0)); let sum = g.FO2 + g.FHe; if (sum > 1) { g.FO2 = g.FO2 / sum; g.FHe = g.FHe / sum; } g.FN2 = 1 - g.FO2 - g.FHe; if (g.FO2 > 0 && isFinite(g.ppo2lim) && g.ppo2lim > 0) { const PambMax = g.ppo2lim / g.FO2; g.switchDepth = (PambMax - 1) * 10; } else { g.switchDepth = NaN; } }); return gases.filter(g => isFinite(g.FN2)); } // ===================== GAS USAGE (surface liters) ================================= function getDiveParams() { const depth_m = n('#depth'); const bottomMin = n('#bottomTime'); let descRate = n('#descRate'); let ascRate = n('#ascRate'); const rmv = n('#rmv'); if (!isFinite(descRate) || descRate <= 0) descRate = 20; if (!isFinite(ascRate) || ascRate <= 0) ascRate = 10; return { depth_m, bottomMin, descRate, ascRate, rmv }; } function gasAtDepth(gases, depthM) { const Pamb = 1 + depthM / 10; let usable = gases.filter(g => isFinite(g.switchDepth) && depthM <= g.switchDepth); usable = usable.filter(g => g.FO2 * Pamb <= g.ppo2lim + 1e-6); if (!usable.length) return gases[0]; usable.sort((a, b) => b.FO2 - a.FO2); return usable[0]; } function integrateTravel(gases, fromDepth, toDepth, rate_m_per_min, rmv, accum) { if (!isFinite(fromDepth) || !isFinite(toDepth) || rate_m_per_min <= 0) return; const delta = toDepth - fromDepth, dist = Math.abs(delta); if (dist < 1e-6) return; const steps = Math.max(1, Math.ceil(dist)); const stepM = delta / steps; const dtMin = Math.abs(stepM) / rate_m_per_min; for (let i = 0; i < steps; i++) { const midDepth = fromDepth + (i + 0.5) * stepM; const Pamb = 1 + Math.max(0, midDepth) / 10; const gas = gasAtDepth(gases, Math.max(0, midDepth)); const key = gasKey(gas); const consSL = rmv * Pamb * dtMin; accum[key] = (accum[key] || 0) + consSL; } } function integrateHold(gases, depthM, minutes, rmv, accum) { if (minutes <= 0) return; const Pamb = 1 + depthM / 10; const gas = gasAtDepth(gases, depthM); const key = gasKey(gas); const cons = rmv * Pamb * minutes; accum[key] = (accum[key] || 0) + cons; } // --- integrazione consumi con GAS FORZATO (discesa e fondo) --- function integrateTravelWithGas(gas, fromDepth, toDepth, rate_m_per_min, rmv, accum) { if (!isFinite(fromDepth) || !isFinite(toDepth) || rate_m_per_min <= 0) return; const delta = toDepth - fromDepth, dist = Math.abs(delta); if (dist < 1e-6) return; const steps = Math.max(1, Math.ceil(dist)); const stepM = delta / steps; const dtMin = Math.abs(stepM) / rate_m_per_min; const key = gasKey(gas); for (let i = 0; i < steps; i++) { const midDepth = fromDepth + (i + 0.5) * stepM; const Pamb = 1 + Math.max(0, midDepth) / 10; const consSL = rmv * Pamb * dtMin; accum[key] = (accum[key] || 0) + consSL; } } function integrateHoldWithGas(gas, depthM, minutes, rmv, accum) { if (minutes <= 0) return; const Pamb = 1 + depthM / 10; const key = gasKey(gas); const cons = rmv * Pamb * minutes; accum[key] = (accum[key] || 0) + cons; } function gasKey(g) { return `${g.name}|${Math.round(g.FO2*100)}/${Math.round(g.FHe*100)}`; } function gasLabelFromKey(k) { const [_, mix] = k.split('|'); const [o2s, hes] = mix.split('/').map(Number); if (o2s === 21 && hes === 0) return 'Air'; if (o2s === 100 && hes === 0) return 'Oxygen'; if (hes > 0) return `Trimix ${o2s}/${hes}`; if (o2s > 21) return `Nitrox ${o2s}`; return `Gas ${mix}`; } function computeGasUsage(stops, gases) { const { depth_m, bottomMin, descRate, ascRate, rmv } = getDiveParams(); const accum = {}; const bottomGas = gases[0]; // forza la miscela di fondo // PRIMA: discesa su bottom gas integrateTravelWithGas(bottomGas, 0, depth_m, descRate, rmv, accum); // POI: fondo su bottom gas integrateHoldWithGas(bottomGas, depth_m, bottomMin, rmv, accum); // Risalita fino alla prima tappa: dinamico (switch appena è lecito) const firstStopDepth = (stops.length ? stops[0].depth : 3); integrateTravel(gases, depth_m, firstStopDepth, ascRate, rmv, accum); // Soste + risalite tra tappe: dinamico for (let i = 0; i < stops.length; i++) { const s = stops[i]; integrateHold(gases, s.depth, s.time, rmv, accum); const nextDepth = (i < stops.length - 1) ? stops[i + 1].depth : 0; integrateTravel(gases, s.depth, nextDepth, ascRate, rmv, accum); } const items = Object.entries(accum).map(([k, liters]) => ({ key: k, label: gasLabelFromKey(k), liters })); items.sort((a, b) => a.label.localeCompare(b.label)); return items; } // ===================== END & GAS DENSITY (gas di fondo) =========================== // END (m) assumendo solo N2 come narcotico (O2 non narcotico): END = ((FN2/0.79)*(D+10))-10 function computeENDmeters(bottomGas, depth_m) { const FN2 = bottomGas.FN2; const Pamb = 1 + depth_m / 10; const END_ambient = (FN2 / 0.79) * Pamb; // equivalente air pressure return Math.max(0, (END_ambient - 1) * 10); } // densità mix @ 1 bar (kg/m^3) a ~20°C: He 0.1786, N2 1.2506, O2 1.331 function gasDensityAtDepth(bottomGas, depth_m) { const rhoHe = 0.1786, rhoN2 = 1.2506, rhoO2 = 1.3310; const rho1bar = bottomGas.FHe * rhoHe + bottomGas.FN2 * rhoN2 + bottomGas.FO2 * rhoO2; // kg/m3 const Pamb = 1 + depth_m / 10; const rhoDepth = rho1bar * Pamb; // kg/m3 return rhoDepth * 1.0; // kg/m3 -> kg/m3 (convertiamo dopo in g/L) } function bottomGasInfo(bottomGas, depth_m) { const ENDm = computeENDmeters(bottomGas, depth_m); const rho_kgm3 = gasDensityAtDepth(bottomGas, depth_m); const density_gL = rho_kgm3; // 1 kg/m3 = 1 g/L return { ENDm, density_gL }; } // ===================== CNS NOAA =================================================== // Tabella NOAA (minuti per 100% CNS) a vari ppO2 (bar). Linear interp. Sotto 0.5 bar = 0. const NOAA_TABLE = [ { pp: 1.6, min: 45 }, { pp: 1.5, min: 120 }, { pp: 1.4, min: 150 }, { pp: 1.3, min: 180 }, { pp: 1.2, min: 210 }, { pp: 1.1, min: 240 }, { pp: 1.0, min: 300 }, { pp: 0.9, min: 360 }, { pp: 0.8, min: 450 }, { pp: 0.7, min: 570 }, { pp: 0.6, min: 720 }, { pp: 0.5, min: 1440 } ]; function minutesFor100CNS(pp) { if (pp < 0.5) return Infinity; // clamp in range if (pp >= NOAA_TABLE[0].pp) return NOAA_TABLE[0].min; if (pp <= NOAA_TABLE[NOAA_TABLE.length - 1].pp) return NOAA_TABLE[NOAA_TABLE.length - 1].min; // linear interp between surrounding nodes for (let i = 0; i < NOAA_TABLE.length - 1; i++) { const a = NOAA_TABLE[i], b = NOAA_TABLE[i + 1]; if (pp <= a.pp && pp >= b.pp) { const t = (pp - b.pp) / (a.pp - b.pp); return b.min + t * (a.min - b.min); } } return Infinity; } function cnsIncrement(pp, minutes) { const M = minutesFor100CNS(pp); if (!isFinite(M) || M === Infinity) return 0; return (minutes / M) * 100; } // calcolo CNS% integrando a step di 1 m durante discese/risalite + soste + fondo function computeCNSPercent(stops, gases) { const { depth_m, bottomMin, descRate, ascRate } = getDiveParams(); let cns = 0; // helper: CNS su travel (1 m per step) function cnsTravel(fromDepth, toDepth, rate_m_per_min) { if (!isFinite(fromDepth) || !isFinite(toDepth) || rate_m_per_min <= 0) return; const delta = toDepth - fromDepth, dist = Math.abs(delta); if (dist < 1e-6) return; const steps = Math.max(1, Math.ceil(dist)); const stepM = delta / steps; const dtMin = Math.abs(stepM) / rate_m_per_min; for (let i = 0; i < steps; i++) { const midDepth = fromDepth + (i + 0.5) * stepM; const gas = gasAtDepth(gases, Math.max(0, midDepth)); const ppO2 = gas.FO2 * (1 + Math.max(0, midDepth) / 10); cns += cnsIncrement(ppO2, dtMin); } } // helper: CNS su hold function cnsHold(depthM, minutes) { if (minutes <= 0) return; const gas = gasAtDepth(gases, depthM); const ppO2 = gas.FO2 * (1 + depthM / 10); cns += cnsIncrement(ppO2, minutes); } // 1) discesa 0->fondo (gas fondo) cnsTravel(0, depth_m, descRate); // 2) fondo cnsHold(depth_m, bottomMin); // 3) risalita al primo stop const firstStopDepth = (stops.length ? stops[0].depth : 3); cnsTravel(depth_m, firstStopDepth, ascRate); // 4) soste + risalite tra tappe for (let i = 0; i < stops.length; i++) { const s = stops[i]; cnsHold(s.depth, s.time); const nextDepth = (i < stops.length - 1) ? stops[i + 1].depth : 0; cnsTravel(s.depth, nextDepth, ascRate); } return Math.max(0, cns); } // ============================ HELPERS ============================================= function n(id) { return Number(String($w(id).value || '').replace(',', '.')); } function pct(id) { const v = n(id); return isFinite(v) ? v / 100 : NaN; } function pad2(x) { return String(x).padStart(2, '0'); } function r3(x) { return (Math.round(x * 1000) / 1000).toFixed(3); } function rcol(s, w) { s = String(s); return (' '.repeat(w) + s).slice(-w); } function lcol(s, w) { s = String(s); return (s + ' '.repeat(w)).slice(0, w); } function spanBase(txt, color) { return `${escapeHtml(txt)}`; } function spanDim(txt, color) { return `${escapeHtml(txt)}`; } function spanHilite(txt) { return `${escapeHtml(txt)}`; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } [m])); } function stripTags(s) { return String(s).replace(/<[^>]+>/g, ''); } function gasDisplay(g) { const o2 = Math.round(g.FO2 * 100), he = Math.round(g.FHe * 100); let type, label; if (o2 === 21 && he === 0) { type = 'air'; label = 'Air'; } else if (o2 === 100 && he === 0) { type = 'oxygen'; label = 'Oxygen'; } else if (he > 0) { type = 'trimix'; label = `Trimix ${o2}/${he}`; } else if (o2 > 21 && he === 0) { type = 'nitrox'; label = `Nitrox ${o2}`; } else { type = 'air'; label = `Gas ${o2}/${he}`; } return { type, label, o2, he }; } // --- Snapshot tessuti immediatamente a superficie (Air 21/79) --- function renderSurfaceTissues(decopn, decope, lastStopDepth, ascRate, kN2, kHe, PH2O) { let pnS = decopn.slice(); let peS = decope.slice(); // Risalita ultima sosta -> superficie con aria (21/79) if (isFinite(lastStopDepth) && lastStopDepth > 0 && ascRate > 0) { const air = { FO2: 0.21, FHe: 0.00, FN2: 0.79 }; integrateTravelTissues(pnS, peS, kN2, kHe, air, lastStopDepth, 0, ascRate, PH2O); } // Snapshot NOW @ surface let lines = []; lines.push("Tissue saturation @ surface (Air 21/79)"); lines.push("Idx | PN₂ (bar) | PHe (bar) | Ptot (bar)"); for (let i = 0; i < 16; i++) { const PN2 = pnS[i]; const PHe = peS[i]; const Ptot = PN2 + PHe; lines.push(`${pad2(i+1)} | ${r3(PN2)} | ${r3(PHe)} | ${r3(Ptot)}`); } if ($w('#text37').text !== undefined) { $w('#text37').text = lines.join("\n"); } }// ================== PLANNER ZHL-16B/C + GF (dt=1s, PH2O=0.0627) ================== $w.onReady(() => { $w('#planBtn').onClick(() => { const { pn, pe } = runTissuesWithDescent(); // Step 1: discesa + fondo showCeilingInfo(pn, pe); // : ceiling iniziale + prima tappa runDecoPlan(pn, pe); // Step 3: risalita a prima tappa + piano deco + consumi + END/Density + CNS }); }); // === Lettura modello B/C dal RadioGroup === function currentModel() { try { const el = $w('#radioGroup1'); if (el && typeof el.value === 'string') { const v = el.value.trim().toUpperCase(); return (v === 'C') ? 'C' : 'B'; } } catch (e) {} return 'B'; } // --- Parametri numerici / fine tuning (arrotondamenti così sono più restrittivo in profondità e più perimissivo in superficie) --- const STEP_SEC = 1; const SHALLOW_THRESHOLD = 15; const CEIL_EPS_DEEP = -0.012; const CEIL_EPS_SHALLOW = +0.001; const ROUNDING = "floor"; const FIRST_STOP_EPS = 0.03; // --- Stile output --- const USE_HTML_COLORS = true; const COLOR_BASE_TEXT = "#ffffff"; const COLOR_DIM_TEXT = "#9aa0a6"; const HILITE_BG = "#64ffda"; const HILITE_FG = "#00332b"; // ==================== Coefficienti ZHL-16B / ZHL-16C ============================== function getCoefficients() { const model = currentModel(); if (model === "B") { return { halftimen2: [4.0, 8.0, 12.5, 18.5, 27.0, 38.3, 54.3, 77.0, 109.0, 146.0, 187.0, 239.0, 305.0, 390.0, 498.0, 635.0], halftimehe: [1.5, 3.0, 4.7, 7.0, 10.2, 14.5, 20.5, 29.1, 41.1, 55.1, 70.6, 90.2, 115.1, 147.2, 187.9, 239.6], n2a: [1.2599, 1.0, 0.8618, 0.7562, 0.6667, 0.5933, 0.5282, 0.4701, 0.4187, 0.3798, 0.3497, 0.3223, 0.2971, 0.2737, 0.2523, 0.2327], n2b: [0.505, 0.6514, 0.7222, 0.7725, 0.8125, 0.8434, 0.8693, 0.891, 0.9092, 0.9222, 0.9319, 0.9403, 0.9477, 0.9544, 0.9602, 0.9653], hea: [1.7435, 1.3838, 1.1925, 1.0465, 0.9226, 0.8211, 0.7309, 0.6506, 0.5794, 0.5256, 0.484, 0.446, 0.4112, 0.3788, 0.3492, 0.322], heb: [0.1911, 0.4295, 0.5446, 0.6265, 0.6917, 0.742, 0.7841, 0.8195, 0.8491, 0.8703, 0.886, 0.8997, 0.9118, 0.9226, 0.9321, 0.9404] }; } else { return { halftimen2: [4.0, 8.0, 12.5, 18.5, 27.0, 38.3, 54.3, 77.0, 109.0, 146.0, 187.0, 239.0, 305.0, 390.0, 498.0, 635.0], halftimehe: [1.51, 3.02, 4.72, 6.99, 10.21, 14.48, 20.53, 29.11, 41.20, 55.19, 70.69, 90.34, 115.29, 147.42, 188.24, 240.03], n2a: [1.1696, 1.0, 0.8618, 0.7562, 0.6667, 0.5933, 0.5282, 0.4701, 0.4187, 0.3798, 0.3497, 0.3223, 0.2971, 0.2737, 0.2523, 0.2327], n2b: [0.5578, 0.6514, 0.7222, 0.7825, 0.8126, 0.8434, 0.8693, 0.8910, 0.9092, 0.9222, 0.9319, 0.9403, 0.9477, 0.9544, 0.9602, 0.9653], hea: [1.6189, 1.3830, 1.1919, 1.0458, 0.9220, 0.8205, 0.7305, 0.6502, 0.5950, 0.5545, 0.5333, 0.5189, 0.5181, 0.5176, 0.5172, 0.5119], heb: [0.4770, 0.5747, 0.6527, 0.7223, 0.7582, 0.7957, 0.8279, 0.8553, 0.8757, 0.8903, 0.8997, 0.9073, 0.9122, 0.9171, 0.9217, 0.9267] }; } } // ===================== ceilingGF ===================== function ceilingGF(pnArr, peArr, GF) { const { n2a, n2b, hea, heb } = getCoefficients(); let cmax = 0; for (let i = 0; i < 16; i++) { const PN2 = pnArr[i], PHe = peArr[i], Pt = PN2 + PHe; if (Pt <= 0) continue; const A = ((n2a[i] * PN2) + (hea[i] * PHe)) / Pt; const B = ((n2b[i] * PN2) + (heb[i] * PHe)) / Pt; const denom = GF - (GF * B) + B; // B + GF*(1-B) const Pmin = (Pt - A * GF) * (B / Math.max(denom, 1e-9)); if (Pmin > cmax) cmax = Pmin; } return cmax; } // ===================== SATURAZIONE: DISCESA + FONDO =============================== function runTissuesWithDescent() { const { halftimen2, halftimehe } = getCoefficients(); const { depth_m, bottomMin, descRate } = getDiveParams(); const gases = getGases(); const PH2O = 0.0627; // iniziale (aria a 1 bar) const PN2_0 = 0.7902 * (1.0 - PH2O); let pn = Array(16).fill(PN2_0); let pe = Array(16).fill(0); const kN2 = halftimen2.map(ht => Math.log(2) / (ht * 60)); const kHe = halftimehe.map(ht => Math.log(2) / (ht * 60)); // DISCESA su gas di fondo integrateTravelTissues(pn, pe, kN2, kHe, gases[0], 0, depth_m, descRate, PH2O); // FONDO su gas di fondo const Pamb = 1 + depth_m / 10; const PiN2 = gases[0].FN2 * (Pamb - PH2O); const PiHe = gases[0].FHe * (Pamb - PH2O); const totalSec = Math.round(bottomMin * 60); for (let t = 0; t < totalSec; t += STEP_SEC) { for (let i = 0; i < 16; i++) { pn[i] += (PiN2 - pn[i]) * (1 - Math.exp(-kN2[i] * STEP_SEC)); pe[i] += (PiHe - pe[i]) * (1 - Math.exp(-kHe[i] * STEP_SEC)); } } // (debug ) let lines = []; lines.push(`\n\n Nerd things:\nTissue saturation after descent + ${bottomMin} min @ ${depth_m} m\n`); lines.push("Idx | PN₂ (bar) | PHe (bar) | Ptot (bar)"); for (let i = 0; i < 16; i++) { lines.push(`${pad2(i+1)} | ${r3(pn[i])} | ${r3(pe[i])} | ${r3(pn[i]+pe[i])}`); } $w('#text27').text = lines.join("\n"); return { pn, pe }; } // integrazione tessuti durante spostamento verticale a gas fisso function integrateTravelTissues(pn, pe, kN2, kHe, gas, fromDepth, toDepth, rate_m_per_min, PH2O) { if (!isFinite(fromDepth) || !isFinite(toDepth) || rate_m_per_min <= 0) return; const delta = toDepth - fromDepth; const dist = Math.abs(delta); if (dist < 1e-6) return; const steps = Math.max(1, Math.ceil(dist)); const stepM = delta / steps; const dtMin = Math.abs(stepM) / rate_m_per_min; const dtSec = dtMin * 60; for (let iStep = 0; iStep < steps; iStep++) { const midDepth = fromDepth + (iStep + 0.5) * stepM; const Pamb = 1 + Math.max(0, midDepth) / 10; const PiN2 = gas.FN2 * (Pamb - PH2O); const PiHe = gas.FHe * (Pamb - PH2O); for (let i = 0; i < 16; i++) { pn[i] += (PiN2 - pn[i]) * (1 - Math.exp(-kN2[i] * dtSec)); pe[i] += (PiHe - pe[i]) * (1 - Math.exp(-kHe[i] * dtSec)); } } } // =============== STEP 2 — Ceiling iniziale + prima tappa =========================== function showCeilingInfo(pn, pe) { const depth_m = n('#depth'); const pressione = 1 + depth_m / 10; const gflow = n('#gfLow') / 100; const gfhi = n('#gfHigh') / 100; // GF info let GFm = (gfhi - gflow) / ((pressione - 1) * 10); let GF = gfhi - GFm * ((pressione - 1) * 10); let gffinal = Math.min(Math.max(GF, gflow), gfhi); // Prima tappa (semper arrotondata a SALIRE al multiplo di 3 m) const ceiling0_raw = ceilingGF(pn, pe, gflow); const ceiling0 = ceiling0_raw + FIRST_STOP_EPS; let primatappamax = Math.ceil(((ceiling0 - 1) * 10) / 3) * 3; if (primatappamax < 3) primatappamax = 3; let lines = []; lines.push(`Model: ZHL-16${currentModel()} + GF ${Math.round(gflow*100)}/${Math.round(gfhi*100)}`); lines.push(`GF (at current Pamb): ${gffinal.toFixed(2)}`); lines.push(`Ceiling: ${(((ceiling0_raw-1)*10)).toFixed(1)} m`); lines.push(`First stop depth: ${primatappamax} m`); $w('#text29').text = lines.join("\n"); } // =============== PIANO DECO + RISALITA ALLA PRIMA TAPPA =========================== function runDecoPlan(pn, pe) { const gflow = n('#gfLow') / 100, gfhi = n('#gfHigh') / 100; const gases = getGases(); const { halftimen2, halftimehe } = getCoefficients(); const PH2O = 0.0627; const { depth_m, ascRate } = getDiveParams(); const kN2 = halftimen2.map(ht => Math.log(2) / (ht * 60)); const kHe = halftimehe.map(ht => Math.log(2) / (ht * 60)); function pickBestGas(depthM) { const Pamb = 1 + depthM / 10; let usable = gases.filter(g => isFinite(g.switchDepth) && depthM <= g.switchDepth); usable = usable.filter(g => g.FO2 * Pamb <= g.ppo2lim + 1e-6); if (!usable.length) return gases[0]; usable.sort((a, b) => b.FO2 - a.FO2); return usable[0]; } // copie tessuti let decopn = pn.slice(), decope = pe.slice(); // prima tappa da ceiling con GF_low const ceiling0_raw = ceilingGF(decopn, decope, gflow); const ceiling0 = ceiling0_raw + FIRST_STOP_EPS; let primatappamax = Math.ceil(((ceiling0 - 1) * 10) / 3) * 3; if (primatappamax < 3) primatappamax = 3; // integra RISALITA fondo -> prima tappa con gas dinamico (coerente col planner) { const from = depth_m, to = primatappamax; if (ascRate > 0 && from > to) { const delta = from - to; const steps = Math.max(1, Math.ceil(delta)); for (let i = 0; i < steps; i++) { const d1 = from - i; const d2 = from - (i + 1); const mid = (d1 + d2) / 2; const gas = pickBestGas(Math.max(0, mid)); integrateTravelTissues(decopn, decope, kN2, kHe, gas, d1, d2, ascRate, PH2O); } } } // retta GF const GFmdeco = (gfhi - gflow) / primatappamax; let counterstep = primatappamax; let stops = []; while (counterstep > 0) { let GF = gfhi - GFmdeco * counterstep; if (GF < gflow) GF = gflow; if (GF > gfhi) GF = gfhi; const gasNow = pickBestGas(counterstep); const PambStop = 1 + counterstep / 10; const PiN2 = gasNow.FN2 * (PambStop - PH2O); const PiHe = gasNow.FHe * (PambStop - PH2O); let holdSec = 0; let decoceiling = ceilingGF(decopn, decope, GF); const nextPamb = PambStop - 0.3; const eps = (counterstep <= SHALLOW_THRESHOLD) ? CEIL_EPS_SHALLOW : CEIL_EPS_DEEP; while (decoceiling > nextPamb + eps) { for (let i = 0; i < 16; i++) { decopn[i] += (PiN2 - decopn[i]) * (1 - Math.exp(-kN2[i] * STEP_SEC)); decope[i] += (PiHe - decope[i]) * (1 - Math.exp(-kHe[i] * STEP_SEC)); } holdSec += STEP_SEC; decoceiling = ceilingGF(decopn, decope, GF); } const holdMin = (ROUNDING === "floor") ? Math.floor(holdSec / 60) : Math.round(holdSec / 60); if (holdMin > 0) { stops.push({ depth: counterstep, time: holdMin, GF, ceiling: decoceiling, gas: gasNow }); } counterstep -= 3; } // ---- Consumi ---- const usage = computeGasUsage(stops, gases); // ---- Runtime cumulativo (min) ---- const { depth_m: D, bottomMin: BT, descRate, ascRate: AR } = getDiveParams(); let runtimeMin = 0; if (isFinite(D) && descRate > 0) runtimeMin += D / descRate; // discesa if (isFinite(BT) && BT > 0) runtimeMin += BT; // fondo let rtLeaveBottom = runtimeMin; // runtime quando si lascia il fondo if (stops.length > 0 && AR > 0) runtimeMin += (D - stops[0].depth) / AR; // alla 1ª tappa const firstStopRt = runtimeMin; for (let i = 0; i < stops.length; i++) { const s = stops[i]; runtimeMin += s.time; s.runtime = runtimeMin; if (i < stops.length - 1 && AR > 0) { const nextDepth = stops[i + 1].depth; runtimeMin += (s.depth - nextDepth) / AR; } } // ---- END & Gas Density (solo gas di fondo) ---- const infoBottom = bottomGasInfo(gases[0], D); // ---- CNS% totale (NOAA) su discesa, fondo, risalite, soste) ---- const cnsTotal = computeCNSPercent(stops, gases); // ===== Output piano (allineato, depth/time evidenziati) ===== const DEP_W = 5, TIME_W = 6, RT_W = 6, GAS_W = 18, PPO2_W = 5; let rows = []; rows.push(spanBase(`PLAN — Single OC (ZHL-16${currentModel()} + GF ${Math.round(gflow*100)}/${Math.round(gfhi*100)})`, COLOR_BASE_TEXT)); rows.push(spanDim(`(ppO₂ shown at stop depth)\n`, COLOR_DIM_TEXT)); rows.push(""); // riga vuota // RIEPILOGO GAS DI FONDO: END & Densità rows.push(spanBase(`Bottom gas: ${gasDisplay(gases[0]).label}`, COLOR_BASE_TEXT)); rows.push(spanDim(`\nEND @ ${D} m: ${infoBottom.ENDm.toFixed(0)} m | Gas density @ ${D} m: ${infoBottom.density_gL.toFixed(2)} g/L\n\n\n`, COLOR_DIM_TEXT)); rows.push(""); // riga vuota // Riga "leave bottom" + runtime fino alla 1ª tappa if (stops.length) { rows.push( spanDim(`Leave bottom (${Math.round(rtLeaveBottom)})`, COLOR_DIM_TEXT) + spanBase(`\n → ascend to first stop @ `, COLOR_BASE_TEXT) + spanHilite(rcol(`${stops[0].depth} m`, DEP_W)) + spanBase(` `, COLOR_BASE_TEXT) + spanDim(`(${Math.round(firstStopRt)})`, COLOR_DIM_TEXT) ); } // Soste for (const s of stops) { const depthStr = `${s.depth} m`; const timeStr = `${s.time} min`; const PambStop = 1 + s.depth / 10; const ppO2 = (s.gas.FO2 * PambStop).toFixed(2); const runStr = `(${Math.round(s.runtime)})`; const gdisp = gasDisplay(s.gas); const gasStr = gdisp.label; const line = spanBase("\nStop @ ", COLOR_BASE_TEXT) + spanHilite(rcol(depthStr, DEP_W)) + spanBase(" ", COLOR_BASE_TEXT) + spanHilite(rcol(timeStr, TIME_W)) + spanBase(" ", COLOR_BASE_TEXT) + spanDim(rcol(runStr, RT_W), COLOR_DIM_TEXT) + spanBase(" ", COLOR_BASE_TEXT) + spanBase("--> ", COLOR_BASE_TEXT) + spanDim(lcol(gasStr, GAS_W), COLOR_DIM_TEXT) + spanBase(" ", COLOR_BASE_TEXT) + spanDim(`ppO₂ ${rcol(ppO2, PPO2_W)}`, COLOR_DIM_TEXT); rows.push(line); } // Consumi rows.push('\n' + spanBase('\nGas usage (surface liters):\n', COLOR_BASE_TEXT)); let totalNL = 0; for (const it of usage) { totalNL += it.liters; const lbl = lcol(it.label, 18); const val = rcol(Math.round(it.liters).toString(), 8); rows.push(spanDim(`• ${lbl} ${val} NL\n`, COLOR_DIM_TEXT)); } rows.push(spanDim(`Total${' '.repeat(17)}${rcol(Math.round(totalNL).toString(), 8)} NL\n`, COLOR_DIM_TEXT)); // CNS rows.push(''); rows.push(spanBase(`\nCNS total: ${cnsTotal.toFixed(0)}%\n\n\n\n\n`, COLOR_BASE_TEXT)); const html = `
` + rows.join("") + ``; if ($w('#planText').html !== undefined) $w('#planText').html = html; else if ($w('#planText').value !== undefined) $w('#planText').value = html; else $w('#planText').text = rows.map(stripTags).join("\n"); // --- Snapshot tessuti immediatamente a superficie (Air 21/79) --- function renderSurfaceTissues(decopn, decope, lastStopDepth, ascRate, kN2, kHe, PH2O) { let pnS = decopn.slice(); let peS = decope.slice(); // Risalita ultima sosta -> superficie con aria (21/79) if (isFinite(lastStopDepth) && lastStopDepth > 0 && ascRate > 0) { const air = { FO2: 0.21, FHe: 0.00, FN2: 0.79 }; integrateTravelTissues(pnS, peS, kN2, kHe, air, lastStopDepth, 0, ascRate, PH2O); } // Snapshot NOW @ surface let lines = []; lines.push("\n\n\nTissue saturation @ surface (Air 21/79)\n"); lines.push("Idx | PN₂ (bar) | PHe (bar) | Ptot (bar)"); for (let i = 0; i < 16; i++) { const PN2 = pnS[i]; const PHe = peS[i]; const Ptot = PN2 + PHe; lines.push(`${pad2(i+1)} | ${r3(PN2)} | ${r3(PHe)} | ${r3(Ptot)}`); } if ($w('#text37').text !== undefined) { $w('#text37').text = lines.join("\n"); } } // --- Snapshot tessuti a superficie --- const lastStopDepth = (stops.length ? stops[stops.length - 1].depth : 0); renderSurfaceTissues(decopn, decope, lastStopDepth, AR, kN2, kHe, PH2O); return stops; } // ================= GAS (robusto a campi vuoti / EAN/O2) ============================ function getGases() { function _raw(id) { return String($w(id).value || '').trim(); } function _num(id) { const s = _raw(id).replace(',', '.'); const v = Number(s); return isFinite(v) ? v : NaN; } function _pct(id, def = 0) { const v = _num(id); return isFinite(v) ? v / 100 : def; } // usa #maxppo2 per TUTTE le miscele const maxppo2 = _num('#maxppo2'); let gases = [ { name: "Bottom", FO2: _pct('#o20', 0), FHe: _pct('#he0', 0), ppo2lim: maxppo2 }, { name: "Deco1", FO2: _pct('#o21', 0), FHe: _pct('#he1', 0), ppo2lim: maxppo2 }, { name: "Deco2", FO2: _pct('#o22', 0), FHe: _pct('#he2', 0), ppo2lim: maxppo2 }, { name: "Deco3", FO2: _pct('#o23', 0), FHe: _pct('#he3', 0), ppo2lim: maxppo2 }, { name: "Deco4", FO2: _pct('#o24', 0), FHe: _pct('#he4', 0), ppo2lim: maxppo2 } ]; gases.forEach(g => { g.FO2 = Math.min(1, Math.max(0, isFinite(g.FO2) ? g.FO2 : 0)); g.FHe = Math.min(1, Math.max(0, isFinite(g.FHe) ? g.FHe : 0)); let sum = g.FO2 + g.FHe; if (sum > 1) { g.FO2 = g.FO2 / sum; g.FHe = g.FHe / sum; } g.FN2 = 1 - g.FO2 - g.FHe; if (g.FO2 > 0 && isFinite(g.ppo2lim) && g.ppo2lim > 0) { const PambMax = g.ppo2lim / g.FO2; g.switchDepth = (PambMax - 1) * 10; } else { g.switchDepth = NaN; } }); return gases.filter(g => isFinite(g.FN2)); } // ===================== GAS USAGE (surface liters) ================================= function getDiveParams() { const depth_m = n('#depth'); const bottomMin = n('#bottomTime'); let descRate = n('#descRate'); let ascRate = n('#ascRate'); const rmv = n('#rmv'); if (!isFinite(descRate) || descRate <= 0) descRate = 20; if (!isFinite(ascRate) || ascRate <= 0) ascRate = 10; return { depth_m, bottomMin, descRate, ascRate, rmv }; } function gasAtDepth(gases, depthM) { const Pamb = 1 + depthM / 10; let usable = gases.filter(g => isFinite(g.switchDepth) && depthM <= g.switchDepth); usable = usable.filter(g => g.FO2 * Pamb <= g.ppo2lim + 1e-6); if (!usable.length) return gases[0]; usable.sort((a, b) => b.FO2 - a.FO2); return usable[0]; } function integrateTravel(gases, fromDepth, toDepth, rate_m_per_min, rmv, accum) { if (!isFinite(fromDepth) || !isFinite(toDepth) || rate_m_per_min <= 0) return; const delta = toDepth - fromDepth, dist = Math.abs(delta); if (dist < 1e-6) return; const steps = Math.max(1, Math.ceil(dist)); const stepM = delta / steps; const dtMin = Math.abs(stepM) / rate_m_per_min; for (let i = 0; i < steps; i++) { const midDepth = fromDepth + (i + 0.5) * stepM; const Pamb = 1 + Math.max(0, midDepth) / 10; const gas = gasAtDepth(gases, Math.max(0, midDepth)); const key = gasKey(gas); const consSL = rmv * Pamb * dtMin; accum[key] = (accum[key] || 0) + consSL; } } function integrateHold(gases, depthM, minutes, rmv, accum) { if (minutes <= 0) return; const Pamb = 1 + depthM / 10; const gas = gasAtDepth(gases, depthM); const key = gasKey(gas); const cons = rmv * Pamb * minutes; accum[key] = (accum[key] || 0) + cons; } // --- integrazione consumi con GAS FORZATO (discesa e fondo) --- function integrateTravelWithGas(gas, fromDepth, toDepth, rate_m_per_min, rmv, accum) { if (!isFinite(fromDepth) || !isFinite(toDepth) || rate_m_per_min <= 0) return; const delta = toDepth - fromDepth, dist = Math.abs(delta); if (dist < 1e-6) return; const steps = Math.max(1, Math.ceil(dist)); const stepM = delta / steps; const dtMin = Math.abs(stepM) / rate_m_per_min; const key = gasKey(gas); for (let i = 0; i < steps; i++) { const midDepth = fromDepth + (i + 0.5) * stepM; const Pamb = 1 + Math.max(0, midDepth) / 10; const consSL = rmv * Pamb * dtMin; accum[key] = (accum[key] || 0) + consSL; } } function integrateHoldWithGas(gas, depthM, minutes, rmv, accum) { if (minutes <= 0) return; const Pamb = 1 + depthM / 10; const key = gasKey(gas); const cons = rmv * Pamb * minutes; accum[key] = (accum[key] || 0) + cons; } function gasKey(g) { return `${g.name}|${Math.round(g.FO2*100)}/${Math.round(g.FHe*100)}`; } function gasLabelFromKey(k) { const [_, mix] = k.split('|'); const [o2s, hes] = mix.split('/').map(Number); if (o2s === 21 && hes === 0) return 'Air'; if (o2s === 100 && hes === 0) return 'Oxygen'; if (hes > 0) return `Trimix ${o2s}/${hes}`; if (o2s > 21) return `Nitrox ${o2s}`; return `Gas ${mix}`; } function computeGasUsage(stops, gases) { const { depth_m, bottomMin, descRate, ascRate, rmv } = getDiveParams(); const accum = {}; const bottomGas = gases[0]; // forza la miscela di fondo // PRIMA: discesa su bottom gas integrateTravelWithGas(bottomGas, 0, depth_m, descRate, rmv, accum); // POI: fondo su bottom gas integrateHoldWithGas(bottomGas, depth_m, bottomMin, rmv, accum); // Risalita fino alla prima tappa: dinamico (switch appena è lecito) const firstStopDepth = (stops.length ? stops[0].depth : 3); integrateTravel(gases, depth_m, firstStopDepth, ascRate, rmv, accum); // Soste + risalite tra tappe: dinamico for (let i = 0; i < stops.length; i++) { const s = stops[i]; integrateHold(gases, s.depth, s.time, rmv, accum); const nextDepth = (i < stops.length - 1) ? stops[i + 1].depth : 0; integrateTravel(gases, s.depth, nextDepth, ascRate, rmv, accum); } const items = Object.entries(accum).map(([k, liters]) => ({ key: k, label: gasLabelFromKey(k), liters })); items.sort((a, b) => a.label.localeCompare(b.label)); return items; } // ===================== END & GAS DENSITY (gas di fondo) =========================== // END (m) assumendo solo N2 come narcotico (O2 non narcotico): END = ((FN2/0.79)*(D+10))-10 function computeENDmeters(bottomGas, depth_m) { const FN2 = bottomGas.FN2; const Pamb = 1 + depth_m / 10; const END_ambient = (FN2 / 0.79) * Pamb; // equivalente air pressure return Math.max(0, (END_ambient - 1) * 10); } // densità mix @ 1 bar (kg/m^3) a ~20°C: He 0.1786, N2 1.2506, O2 1.331 function gasDensityAtDepth(bottomGas, depth_m) { const rhoHe = 0.1786, rhoN2 = 1.2506, rhoO2 = 1.3310; const rho1bar = bottomGas.FHe * rhoHe + bottomGas.FN2 * rhoN2 + bottomGas.FO2 * rhoO2; // kg/m3 const Pamb = 1 + depth_m / 10; const rhoDepth = rho1bar * Pamb; // kg/m3 return rhoDepth * 1.0; // kg/m3 -> kg/m3 (convertiamo dopo in g/L) } function bottomGasInfo(bottomGas, depth_m) { const ENDm = computeENDmeters(bottomGas, depth_m); const rho_kgm3 = gasDensityAtDepth(bottomGas, depth_m); const density_gL = rho_kgm3; // 1 kg/m3 = 1 g/L return { ENDm, density_gL }; } // ===================== CNS NOAA =================================================== // Tabella NOAA (minuti per 100% CNS) a vari ppO2 (bar). Linear interp. Sotto 0.5 bar = 0. const NOAA_TABLE = [ { pp: 1.6, min: 45 }, { pp: 1.5, min: 120 }, { pp: 1.4, min: 150 }, { pp: 1.3, min: 180 }, { pp: 1.2, min: 210 }, { pp: 1.1, min: 240 }, { pp: 1.0, min: 300 }, { pp: 0.9, min: 360 }, { pp: 0.8, min: 450 }, { pp: 0.7, min: 570 }, { pp: 0.6, min: 720 }, { pp: 0.5, min: 1440 } ]; function minutesFor100CNS(pp) { if (pp < 0.5) return Infinity; // clamp in range if (pp >= NOAA_TABLE[0].pp) return NOAA_TABLE[0].min; if (pp <= NOAA_TABLE[NOAA_TABLE.length - 1].pp) return NOAA_TABLE[NOAA_TABLE.length - 1].min; // linear interp between surrounding nodes for (let i = 0; i < NOAA_TABLE.length - 1; i++) { const a = NOAA_TABLE[i], b = NOAA_TABLE[i + 1]; if (pp <= a.pp && pp >= b.pp) { const t = (pp - b.pp) / (a.pp - b.pp); return b.min + t * (a.min - b.min); } } return Infinity; } function cnsIncrement(pp, minutes) { const M = minutesFor100CNS(pp); if (!isFinite(M) || M === Infinity) return 0; return (minutes / M) * 100; } // calcolo CNS% integrando a step di 1 m durante discese/risalite + soste + fondo function computeCNSPercent(stops, gases) { const { depth_m, bottomMin, descRate, ascRate } = getDiveParams(); let cns = 0; // helper: CNS su travel (1 m per step) function cnsTravel(fromDepth, toDepth, rate_m_per_min) { if (!isFinite(fromDepth) || !isFinite(toDepth) || rate_m_per_min <= 0) return; const delta = toDepth - fromDepth, dist = Math.abs(delta); if (dist < 1e-6) return; const steps = Math.max(1, Math.ceil(dist)); const stepM = delta / steps; const dtMin = Math.abs(stepM) / rate_m_per_min; for (let i = 0; i < steps; i++) { const midDepth = fromDepth + (i + 0.5) * stepM; const gas = gasAtDepth(gases, Math.max(0, midDepth)); const ppO2 = gas.FO2 * (1 + Math.max(0, midDepth) / 10); cns += cnsIncrement(ppO2, dtMin); } } // helper: CNS su hold function cnsHold(depthM, minutes) { if (minutes <= 0) return; const gas = gasAtDepth(gases, depthM); const ppO2 = gas.FO2 * (1 + depthM / 10); cns += cnsIncrement(ppO2, minutes); } // 1) discesa 0->fondo (gas fondo) cnsTravel(0, depth_m, descRate); // 2) fondo cnsHold(depth_m, bottomMin); // 3) risalita al primo stop const firstStopDepth = (stops.length ? stops[0].depth : 3); cnsTravel(depth_m, firstStopDepth, ascRate); // 4) soste + risalite tra tappe for (let i = 0; i < stops.length; i++) { const s = stops[i]; cnsHold(s.depth, s.time); const nextDepth = (i < stops.length - 1) ? stops[i + 1].depth : 0; cnsTravel(s.depth, nextDepth, ascRate); } return Math.max(0, cns); } // ============================ HELPERS ============================================= function n(id) { return Number(String($w(id).value || '').replace(',', '.')); } function pct(id) { const v = n(id); return isFinite(v) ? v / 100 : NaN; } function pad2(x) { return String(x).padStart(2, '0'); } function r3(x) { return (Math.round(x * 1000) / 1000).toFixed(3); } function rcol(s, w) { s = String(s); return (' '.repeat(w) + s).slice(-w); } function lcol(s, w) { s = String(s); return (s + ' '.repeat(w)).slice(0, w); } function spanBase(txt, color) { return `${escapeHtml(txt)}`; } function spanDim(txt, color) { return `${escapeHtml(txt)}`; } function spanHilite(txt) { return `${escapeHtml(txt)}`; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } [m])); } function stripTags(s) { return String(s).replace(/<[^>]+>/g, ''); } function gasDisplay(g) { const o2 = Math.round(g.FO2 * 100), he = Math.round(g.FHe * 100); let type, label; if (o2 === 21 && he === 0) { type = 'air'; label = 'Air'; } else if (o2 === 100 && he === 0) { type = 'oxygen'; label = 'Oxygen'; } else if (he > 0) { type = 'trimix'; label = `Trimix ${o2}/${he}`; } else if (o2 > 21 && he === 0) { type = 'nitrox'; label = `Nitrox ${o2}`; } else { type = 'air'; label = `Gas ${o2}/${he}`; } return { type, label, o2, he }; } // --- Snapshot tessuti immediatamente a superficie (Air 21/79) --- function renderSurfaceTissues(decopn, decope, lastStopDepth, ascRate, kN2, kHe, PH2O) { let pnS = decopn.slice(); let peS = decope.slice(); // Risalita ultima sosta -> superficie con aria (21/79) if (isFinite(lastStopDepth) && lastStopDepth > 0 && ascRate > 0) { const air = { FO2: 0.21, FHe: 0.00, FN2: 0.79 }; integrateTravelTissues(pnS, peS, kN2, kHe, air, lastStopDepth, 0, ascRate, PH2O); } // Snapshot NOW @ surface let lines = []; lines.push("Tissue saturation @ surface (Air 21/79)"); lines.push("Idx | PN₂ (bar) | PHe (bar) | Ptot (bar)"); for (let i = 0; i < 16; i++) { const PN2 = pnS[i]; const PHe = peS[i]; const Ptot = PN2 + PHe; lines.push(`${pad2(i+1)} | ${r3(PN2)} | ${r3(PHe)} | ${r3(Ptot)}`); } if ($w('#text37').text !== undefined) { $w('#text37').text = lines.join("\n"); } }