diff --git a/backend/app/physics.py b/backend/app/physics.py
new file mode 100644
index 0000000..92a0768
--- /dev/null
+++ b/backend/app/physics.py
@@ -0,0 +1,220 @@
+"""
+Core physics module for dive calculations.
+Based on algorithms from Subsurface (https://github.com/subsurface/subsurface).
+
+This module implements:
+1. Real Gas Law (Compressibility/Z-Factor) using Virial expansion.
+2. Gas Laws (Dalton's Law, partial pressures).
+3. Isobaric Counterdiffusion (ICD) checks.
+4. Unit conversions and standard constants.
+"""
+
+from typing import Tuple, Optional, Dict, List
+from pydantic import BaseModel
+
+# -----------------------------------------------------------------------------
+# Constants & Enums
+# -----------------------------------------------------------------------------
+
+# Subsurface uses 1.01325 bar as standard surface pressure for volume conversions
+SURFACE_PRESSURE_BAR = 1.01325
+
+class GasMix(BaseModel):
+ """
+ Represents a breathing gas mixture.
+ Percentages should be 0-100 (e.g., 21 for Air).
+ """
+ o2: float
+ he: float = 0.0
+
+ @property
+ def n2(self) -> float:
+ return 100.0 - self.o2 - self.he
+
+ @property
+ def is_air(self) -> bool:
+ return abs(self.o2 - 21.0) < 0.1 and self.he < 0.1
+
+ @property
+ def is_trimix(self) -> bool:
+ return self.he > 0.0
+
+# -----------------------------------------------------------------------------
+# Compressibility (Z-Factor) - Real Gas Law
+# -----------------------------------------------------------------------------
+
+def calculate_z_factor(pressure_bar: float, gas: GasMix) -> float:
+ """
+ Calculates the gas compressibility factor (Z) using the Virial equation.
+ Based on Subsurface implementation (core/compressibility.c / core/gas.c).
+
+ Z = PV / nRT
+ Z = 1 + B/V + C/V^2 ... approximated via pressure series for diving ranges.
+
+ Args:
+ pressure_bar: Pressure in bar.
+ gas: GasMix object.
+
+ Returns:
+ float: The compressibility factor Z.
+ """
+ # Clamp pressure to 0-500 bar range as per Subsurface to avoid polynomial explosion
+ p = max(0.0, min(pressure_bar, 500.0))
+
+ # Coefficients from Subsurface (3rd order virial expansion)
+ # These are empirical coefficients for O2, N2, He
+ O2_COEFFS = [-7.18092073703e-4, 2.81852572808e-6, -1.50290620492e-9]
+ N2_COEFFS = [-2.19260353292e-4, 2.92844845532e-6, -2.07613482075e-9]
+ HE_COEFFS = [4.87320026468e-4, -8.83632921053e-8, 5.33304543646e-11]
+
+ def virial(coeffs: List[float], x: float) -> float:
+ return x * coeffs[0] + (x ** 2) * coeffs[1] + (x ** 3) * coeffs[2]
+
+ # Subsurface calculation uses weighted averages of the virial terms
+ # Note: Subsurface code uses permille (0-1000), we convert to that scale for the formula match
+ o2_permille = gas.o2 * 10.0
+ he_permille = gas.he * 10.0
+ n2_permille = gas.n2 * 10.0
+
+ # The formula essentially calculates Z-1 (z_minus_1) scaled by 1000
+ z_m1 = (virial(O2_COEFFS, p) * o2_permille +
+ virial(HE_COEFFS, p) * he_permille +
+ virial(N2_COEFFS, p) * n2_permille)
+
+ # Convert back: Z = 1 + (Weighted_Virial_Sum / 1000)
+ return z_m1 * 0.001 + 1.0
+
+def calculate_real_volume(tank_water_volume_liters: float, pressure_bar: float, gas: GasMix) -> float:
+ """
+ Calculates the actual volume of gas (at surface pressure) in a tank, accounting for compressibility.
+
+ Real Volume = (Tank_Water_Vol * Pressure) / Z
+ (Note: Adjusted for surface pressure reference)
+
+ Args:
+ tank_water_volume_liters: Wet volume of the tank (e.g., 12L).
+ pressure_bar: Current pressure in the tank.
+ gas: The gas mixture.
+
+ Returns:
+ float: Equivalent surface volume in liters.
+ """
+ if pressure_bar <= 0:
+ return 0.0
+
+ z = calculate_z_factor(pressure_bar, gas)
+
+ # Standard formula: V_surface = (P_tank * V_tank) / (P_surface * Z)
+ return (pressure_bar * tank_water_volume_liters) / (SURFACE_PRESSURE_BAR * z)
+
+def calculate_pressure_from_volume(surface_volume_liters: float, tank_water_volume_liters: float, gas: GasMix) -> float:
+ """
+ Inverse of calculate_real_volume. Iteratively solves for pressure given a gas volume.
+ Useful for "How much pressure do I need for X liters of gas?"
+ """
+ # Initial guess using Ideal Gas Law: P = (V_surf * P_surf) / V_tank
+ p_guess = (surface_volume_liters * SURFACE_PRESSURE_BAR) / tank_water_volume_liters
+
+ # Newton-Raphson or simple iteration to converge
+ for _ in range(5):
+ z = calculate_z_factor(p_guess, gas)
+ p_new = (surface_volume_liters * SURFACE_PRESSURE_BAR * z) / tank_water_volume_liters
+ if abs(p_new - p_guess) < 0.1:
+ return p_new
+ p_guess = p_new
+
+ return p_guess
+
+# -----------------------------------------------------------------------------
+# Partial Pressures & Depths
+# -----------------------------------------------------------------------------
+
+def depth_to_bar(depth_meters: float, surface_pressure: float = 1.013) -> float:
+ """Calculates absolute ambient pressure at depth (ATA/bar)."""
+ # Simple approx: depth/10 + surface.
+ # For higher precision, density of water (salt/fresh) could be a parameter.
+ return (depth_meters / 10.0) + surface_pressure
+
+def calculate_mod(gas: GasMix, pp_o2_max: float) -> float:
+ """
+ Calculates Maximum Operating Depth (MOD) in meters.
+ MOD = (ppO2_max / fO2 - surface_pressure) * 10
+ """
+ if gas.o2 <= 0:
+ return 0.0
+
+ f_o2 = gas.o2 / 100.0
+ max_ata = pp_o2_max / f_o2
+ return max(0.0, (max_ata - 1.0) * 10.0)
+
+def calculate_end(depth_meters: float, gas: GasMix) -> float:
+ """
+ Calculates Equivalent Narcotic Depth (END).
+ Assumes O2 is narcotic (standard recreational/tech view).
+ Formula: END = (Depth + 10) * (1 - fHe) - 10
+ """
+ f_he = gas.he / 100.0
+ return (depth_meters + 10.0) * (1.0 - f_he) - 10.0
+
+def calculate_ead(depth_meters: float, gas: GasMix) -> float:
+ """
+ Calculates Equivalent Air Depth (EAD) for Nitrox.
+ EAD = (Depth + 10) * (fN2 / 0.79) - 10
+ """
+ f_n2 = gas.n2 / 100.0
+ return (depth_meters + 10.0) * (f_n2 / 0.79) - 10.0
+
+# -----------------------------------------------------------------------------
+# Safety Checks
+# -----------------------------------------------------------------------------
+
+class ICDResult(BaseModel):
+ warning: bool
+ delta_n2: float
+ delta_he: float
+ message: Optional[str] = None
+
+def check_isobaric_counterdiffusion(current_gas: GasMix, next_gas: GasMix) -> ICDResult:
+ """
+ Checks for Isobaric Counterdiffusion (ICD) risks when switching gases.
+ Implements the 'Rule of Fifths': Nitrogen increase should not exceed 1/5th of Helium decrease.
+
+ Based on Subsurface: core/gas.cpp -> isobaric_counterdiffusion()
+
+ Args:
+ current_gas: The gas being breathed currently.
+ next_gas: The gas being switched to.
+
+ Returns:
+ ICDResult: Warning flag and delta values.
+ """
+ # Delta N2 (increase is positive)
+ d_n2 = next_gas.n2 - current_gas.n2
+
+ # Delta He (decrease is negative)
+ d_he = next_gas.he - current_gas.he
+
+ # Logic:
+ # 1. Current gas must have Helium (>0)
+ # 2. Switching results in N2 increase (>0)
+ # 3. Switching results in He decrease (<0)
+ # 4. Rule check: 5 * delta_N2 > -delta_He (Magnitude of N2 rise is large relative to He drop)
+
+ warning = False
+ message = None
+
+ if current_gas.he > 0 and d_n2 > 0 and d_he < 0:
+ # Subsurface logic: 5 * results->dN2 > -results->dHe
+ if 5 * d_n2 > -d_he:
+ warning = True
+ message = (
+ f"ICD Warning: Isobaric Counterdiffusion risk detected. "
+ f"Nitrogen increase ({d_n2:.1f}%) is more than 1/5th of Helium decrease ({abs(d_he):.1f}%)."
+ )
+
+ return ICDResult(
+ warning=warning,
+ delta_n2=d_n2,
+ delta_he=d_he,
+ message=message
+ )
diff --git a/backend/app/routers/dives/dives_import.py b/backend/app/routers/dives/dives_import.py
index 41635c7..1fa8fa3 100644
--- a/backend/app/routers/dives/dives_import.py
+++ b/backend/app/routers/dives/dives_import.py
@@ -33,6 +33,7 @@
from .dives_validation import raise_validation_error
from .dives_logging import log_dive_operation, log_error
from .dives_utils import has_deco_profile, generate_dive_name, get_or_create_deco_tag, find_dive_site_by_import_id
+from app.physics import GasMix, calculate_real_volume
# Helper function to convert old difficulty labels to new codes
@@ -643,6 +644,7 @@ def parse_cylinder(cylinder_elem):
cylinder_data['workpressure'] = cylinder_elem.get('workpressure')
cylinder_data['description'] = cylinder_elem.get('description')
cylinder_data['o2'] = cylinder_elem.get('o2')
+ cylinder_data['he'] = cylinder_elem.get('he')
cylinder_data['start'] = cylinder_elem.get('start')
cylinder_data['end'] = cylinder_elem.get('end')
cylinder_data['depth'] = cylinder_elem.get('depth')
@@ -820,6 +822,51 @@ def convert_to_divemap_format(dive_number, rating, visibility, sac, otu, cns, ta
if sac:
dive_info_parts.append(f"SAC: {sac}")
+ # Calculate Real SAC (Z-Factor) using Physics Engine
+ if parsed_duration and parsed_duration > 0 and cylinders:
+ # Use first cylinder with valid pressure drop
+ for cylinder in cylinders:
+ try:
+ # Extract volume (e.g. "15.0 l")
+ size_str = cylinder.get('size', '').replace(' l', '').strip()
+ vol = float(size_str) if size_str else 0
+
+ # Extract pressures (e.g. "200.0 bar")
+ start_str = cylinder.get('start', '').replace(' bar', '').strip()
+ end_str = cylinder.get('end', '').replace(' bar', '').strip()
+ start_p = float(start_str) if start_str else 0
+ end_p = float(end_str) if end_str else 0
+
+ # Extract gas mix
+ o2_str = cylinder.get('o2', '21%').replace('%', '').strip()
+ he_str = cylinder.get('he', '0%').replace('%', '').strip()
+ o2 = float(o2_str) if o2_str else 21.0
+ he = float(he_str) if he_str else 0.0
+
+ if vol > 0 and start_p > end_p:
+ # Get average depth from computer data if available
+ avg_depth = 0
+ if computer_data and computer_data.get('mean_depth'):
+ avg_depth = float(computer_data['mean_depth'].replace(' m', ''))
+
+ if avg_depth > 0:
+ gas_mix = GasMix(o2=o2, he=he)
+
+ # Calculate Real Volume used (Surface Equivalent)
+ vol_start = calculate_real_volume(vol, start_p, gas_mix)
+ vol_end = calculate_real_volume(vol, end_p, gas_mix)
+ gas_used_liters = vol_start - vol_end
+
+ # Calculate SAC (L/min/atm)
+ # ATA = Depth/10 + 1 (Approx for SAC)
+ ata = (avg_depth / 10.0) + 1.0
+ real_sac = gas_used_liters / parsed_duration / ata
+
+ dive_info_parts.append(f"Real SAC (Z-Factor): {real_sac:.1f} L/min")
+ break # Only calculate for primary cylinder
+ except (ValueError, AttributeError):
+ continue
+
if otu:
dive_info_parts.append(f"OTU: {otu}")
diff --git a/backend/tests/test_physics.py b/backend/tests/test_physics.py
new file mode 100644
index 0000000..05c3723
--- /dev/null
+++ b/backend/tests/test_physics.py
@@ -0,0 +1,66 @@
+
+from app.physics import (
+ GasMix,
+ calculate_z_factor,
+ calculate_real_volume,
+ calculate_pressure_from_volume,
+ check_isobaric_counterdiffusion
+)
+import pytest
+
+def test_z_factor_air_surface():
+ # Air at 1 bar should be close to ideal (Z=1)
+ air = GasMix(o2=21, he=0)
+ z = calculate_z_factor(1.0, air)
+ assert abs(z - 1.0) < 0.01
+
+def test_z_factor_air_high_pressure():
+ # Air at 200 bar compresses LESS than ideal (Z > 1)
+ air = GasMix(o2=21, he=0)
+ z = calculate_z_factor(200.0, air)
+ assert z > 1.0
+ # Expected approx range 1.05 - 1.10 for 200 bar air
+ assert 1.0 < z < 1.15
+
+def test_real_volume_air_200bar():
+ # 12L tank at 200 bar
+ # Ideal: 12 * 200 = 2400 L
+ # Real: Less than 2400 because Z > 1
+ air = GasMix(o2=21, he=0)
+ vol = calculate_real_volume(12, 200, air)
+
+ # Calculate expected roughly
+ # z ~ 1.07
+ # P_surf = 1.01325
+ # V = (200 * 12) / (1.01325 * 1.07) ~ 2213 L
+ assert vol < 2400
+ assert 2100 < vol < 2300
+
+def test_icd_check_trimix_to_air():
+ # Switching from Trimix 21/35 (35% He) to Air (0% He, 79% N2)
+ # Current: 21% O2, 35% He, 44% N2
+ # Next: 21% O2, 0% He, 79% N2
+ # dHe = -35
+ # dN2 = +35
+ # Rule of Fifths: 5 * dN2 > -dHe ?
+ # 5 * 35 = 175. 175 > 35. Yes -> Warning.
+
+ tx = GasMix(o2=21, he=35)
+ air = GasMix(o2=21, he=0)
+
+ result = check_isobaric_counterdiffusion(tx, air)
+ assert result.warning is True
+ assert "Isobaric Counterdiffusion risk" in result.message
+
+def test_icd_check_safe_switch():
+ # Switching from Tx 18/45 to Tx 50/25 (Deco gas)
+ # Current: 18% O2, 45% He, 37% N2
+ # Next: 50% O2, 25% He, 25% N2
+ # dHe = -20
+ # dN2 = -12 (N2 actually decreases too, so no ICD risk from N2 loading)
+
+ deep_gas = GasMix(o2=18, he=45)
+ deco_gas = GasMix(o2=50, he=25)
+
+ result = check_isobaric_counterdiffusion(deep_gas, deco_gas)
+ assert result.warning is False
diff --git a/frontend/src/components/calculators/BestMixCalculator.js b/frontend/src/components/calculators/BestMixCalculator.js
index 665ea87..2c11935 100644
--- a/frontend/src/components/calculators/BestMixCalculator.js
+++ b/frontend/src/components/calculators/BestMixCalculator.js
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { bestMixSchema } from '../../utils/calculatorSchemas';
+import { SURFACE_PRESSURE_BAR } from '../../utils/physics';
const BestMixCalculator = () => {
const [bestMixResult, setBestMixResult] = useState({
@@ -33,10 +34,10 @@ const BestMixCalculator = () => {
const values = watch();
// Calculate max allowed depth based on pO2 and Air (21% O2) if not using Trimix
- // Formula: (pO2 / 0.21 - 1) * 10
+ // Formula: (pO2 / 0.21 - Surface) * 10
const maxDepthAllowed = values.isTrimix
? 100
- : Math.floor(((parseFloat(values.pO2) || 1.4) / 0.21 - 1) * 10);
+ : Math.floor(((parseFloat(values.pO2) || 1.4) / 0.21 - SURFACE_PRESSURE_BAR) * 10);
useEffect(() => {
// Clamp depth if it exceeds the new maximum when switching modes
@@ -51,7 +52,7 @@ const BestMixCalculator = () => {
const depth = Math.max(0, parseFloat(values.depth) || 0);
const pO2Limit = parseFloat(values.pO2) || 1.4;
// Effective depth for ATA calc is actual depth
- const ata = depth / 10 + 1;
+ const ata = depth / 10 + SURFACE_PRESSURE_BAR;
// 1. Maximize O2 based on pO2 limit
let fO2 = pO2Limit / ata;
@@ -68,7 +69,7 @@ const BestMixCalculator = () => {
// 2. If Trimix, add Helium to limit Narcosis (END/EAD)
if (values.isTrimix) {
const targetEAD = parseFloat(values.targetEAD) || 30;
- ataEAD = targetEAD / 10 + 1;
+ ataEAD = targetEAD / 10 + SURFACE_PRESSURE_BAR;
// Max allowed N2 partial pressure to simulate Air at target EAD
// Air is 79% N2. pN2 at EAD = 0.79 * ataEAD
@@ -252,15 +253,18 @@ const BestMixCalculator = () => {
Formula:
- (Depth / 10) + 1
+ (Depth / 10) + {SURFACE_PRESSURE_BAR}
Calculation:
- ({bestMixResult.details.depth} / 10) + 1 = {bestMixResult.details.ata.toFixed(2)}{' '}
- ATA
+ ({bestMixResult.details.depth} / 10) + {SURFACE_PRESSURE_BAR} ={' '}
+ {bestMixResult.details.ata.toFixed(2)} ATA
+
+ * {SURFACE_PRESSURE_BAR} bar is Standard Surface Pressure (1 atm).
+
2. Oxygen Fraction
@@ -295,14 +299,18 @@ const BestMixCalculator = () => {
{bestMixResult.details.targetEAD}m
- Allowed pN2 (at EAD):
+
+ Allowed pN2 (at EAD):
+
0.79 * {bestMixResult.details.ataEAD.toFixed(2)} ATA ={' '}
{bestMixResult.details.maxPPN2.toFixed(2)} bar
-
Max N2 % (at Depth):
+
+ Max N2 % (at Depth):
+
{bestMixResult.details.maxPPN2.toFixed(2)} bar /{' '}
{bestMixResult.details.ata.toFixed(2)} ATA ={' '}
diff --git a/frontend/src/components/calculators/GasPlanningCalculator.js b/frontend/src/components/calculators/GasPlanningCalculator.js
index ad256f8..95fd5ea 100644
--- a/frontend/src/components/calculators/GasPlanningCalculator.js
+++ b/frontend/src/components/calculators/GasPlanningCalculator.js
@@ -5,15 +5,21 @@ import { useForm } from 'react-hook-form';
import { gasPlanningSchema } from '../../utils/calculatorSchemas';
import { TANK_SIZES } from '../../utils/diveConstants';
+import { calculatePressureFromVolume, SURFACE_PRESSURE_BAR } from '../../utils/physics';
const GasPlanningCalculator = () => {
const [planGasResult, setPlanGasResult] = useState({
diveGasLiters: 0,
reserveGasLiters: 0,
totalGasLiters: 0,
- totalPressure: 0,
+ idealPressure: 0,
+ realPressure: 0,
+ realDiveGasPressure: 0,
+ realReserveGasPressure: 0,
+ realBarPerMin: 0,
isSafe: true,
- remainingPressure: 0,
+ remainingPressureIdeal: 0,
+ remainingPressureReal: 0,
});
const [showDetails, setShowDetails] = useState(false);
@@ -62,17 +68,40 @@ const GasPlanningCalculator = () => {
reserveGasLiters = 0;
}
- const totalPressure = totalGasLiters / tankSize;
- const isSafe = totalPressure <= pressure;
- const remainingPressure = pressure - totalPressure;
+ // Ideal Gas Law (PV = const)
+ const idealPressure = totalGasLiters / tankSize;
+
+ // Real Gas Law (Virial Z-factor)
+ // Assuming Air (21/0) as this simple calculator doesn't specify gas mix
+ const realPressure = calculatePressureFromVolume(totalGasLiters, tankSize, { o2: 21, he: 0 });
+ const realDiveGasPressure = calculatePressureFromVolume(diveGasLiters, tankSize, {
+ o2: 21,
+ he: 0,
+ });
+ const realReserveGasPressure = calculatePressureFromVolume(reserveGasLiters, tankSize, {
+ o2: 21,
+ he: 0,
+ });
+
+ const realBarPerMin = time > 0 ? realDiveGasPressure / time : 0;
+
+ // Safety checks
+ const isSafe = realPressure <= pressure; // Use Real pressure for safety as it's conservative (higher required P)
+ const remainingPressureIdeal = pressure - idealPressure;
+ const remainingPressureReal = pressure - realPressure;
setPlanGasResult({
diveGasLiters,
reserveGasLiters,
totalGasLiters,
- totalPressure,
+ idealPressure,
+ realPressure,
+ realDiveGasPressure,
+ realReserveGasPressure,
+ realBarPerMin,
isSafe,
- remainingPressure,
+ remainingPressureIdeal,
+ remainingPressureReal,
});
}, [depth, time, sac, tankSize, pressure, values.isAdvanced]);
@@ -233,30 +262,41 @@ const GasPlanningCalculator = () => {
Ambient Pressure:
- ({depth}m / 10) + 1 = {(depth / 10 + 1).toFixed(2)} ATA
+ ({depth}m / 10) + {SURFACE_PRESSURE_BAR} ={' '}
+ {(depth / 10 + SURFACE_PRESSURE_BAR).toFixed(2)} ATA
+
+ * {SURFACE_PRESSURE_BAR} bar is Standard Surface Pressure (1 atm).
+
Consumption at Depth:
- {sac.toFixed(1)} L/min * {(depth / 10 + 1).toFixed(2)} ATA ={' '}
- {(sac * (depth / 10 + 1)).toFixed(1)} L/min
+ {sac.toFixed(1)} L/min * {(depth / 10 + SURFACE_PRESSURE_BAR).toFixed(2)} ATA ={' '}
+ {(sac * (depth / 10 + SURFACE_PRESSURE_BAR)).toFixed(1)} L/min
Total Volume:
{(sac * (depth / 10 + 1)).toFixed(1)} L/min * {time} min ={' '}
- {planGasResult.diveGasLiters.toFixed(0)} L
+ {planGasResult.totalGasLiters.toFixed(0)} L
-
-
Pressure Required:
+
+ Ideal (Linear) Pressure:
{planGasResult.totalGasLiters.toFixed(0)} L / {tankSize}L ={' '}
- {planGasResult.totalPressure.toFixed(0)} bar
+ {planGasResult.idealPressure.toFixed(0)} bar
+
+ Required Pressure (Real):
+ {planGasResult.realPressure.toFixed(0)} bar
+
+
+ Real Pressure accounts for gas becoming less compressible at high pressures.
+
{values.isAdvanced && (
Rule of Thirds (x1.5):
@@ -279,7 +319,7 @@ const GasPlanningCalculator = () => {
planGasResult.isSafe ? 'text-emerald-800' : 'text-red-800'
}`}
>
- Total Gas Consumed
+ Total Gas Consumed (Real)
{
planGasResult.isSafe ? 'text-emerald-600' : 'text-red-600'
}`}
>
- {planGasResult.totalPressure.toFixed(0)}
+ {planGasResult.realPressure.toFixed(0)}
{
)}
- {planGasResult.isSafe && !values.isAdvanced && planGasResult.remainingPressure < 50 && (
-
-
- WARNING: Low reserve
-
- )}
+ {planGasResult.isSafe &&
+ !values.isAdvanced &&
+ planGasResult.remainingPressureReal < 50 && (
+
+
+ WARNING: Low reserve
+
+ )}
- {planGasResult.isSafe && (values.isAdvanced || planGasResult.remainingPressure >= 50) && (
-
- )}
+ {planGasResult.isSafe &&
+ (values.isAdvanced || planGasResult.remainingPressureReal >= 50) && (
+
+ )}
Dive Gas
{planGasResult.diveGasLiters.toFixed(0)} L
-
- ({(planGasResult.diveGasLiters / tankSize).toFixed(0)} bar)
-
+
+ ~{planGasResult.realDiveGasPressure.toFixed(0)} bar
+
+
+ {planGasResult.realBarPerMin.toFixed(1)} bar/min
+
{values.isAdvanced && (
@@ -341,8 +387,8 @@ const GasPlanningCalculator = () => {
Reserve (1/3)
{planGasResult.reserveGasLiters.toFixed(0)} L
-
- ({(planGasResult.reserveGasLiters / tankSize).toFixed(0)} bar)
+
+ (~{planGasResult.realReserveGasPressure.toFixed(0)} bar)
@@ -355,8 +401,10 @@ const GasPlanningCalculator = () => {
{values.isAdvanced
- ? 'Total Gas = (SAC * ATA * Time) * 1.5. Uses Rule of Thirds to ensure you only use 2/3 of your gas for the planned dive profile.'
- : 'Total Gas = SAC * ATA * Time. Calculates estimated total gas usage for your planned bottom time.'}
+ ? 'Total Gas = (SAC * ATA * Time) * 1.5. Uses Rule of Thirds.'
+ : 'Total Gas = SAC * ATA * Time.'}{' '}
+ Uses Real Gas Law (Z-Factor) for pressure calculation, accounting for non-ideal gas
+ behavior.
diff --git a/frontend/src/components/calculators/ICDCalculator.js b/frontend/src/components/calculators/ICDCalculator.js
new file mode 100644
index 0000000..cdd9d44
--- /dev/null
+++ b/frontend/src/components/calculators/ICDCalculator.js
@@ -0,0 +1,180 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { RefreshCw, AlertTriangle, CheckCircle2, Info } from 'lucide-react';
+import { useState, useEffect } from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { z } from 'zod';
+
+import { checkIsobaricCounterdiffusion } from '../../utils/physics';
+import GasMixInput from '../forms/GasMixInput';
+
+const icdSchema = z.object({
+ currentGas: z.object({
+ o2: z.number().min(1).max(100),
+ he: z.number().min(0).max(99),
+ }),
+ nextGas: z.object({
+ o2: z.number().min(1).max(100),
+ he: z.number().min(0).max(99),
+ }),
+});
+
+const ICDCalculator = () => {
+ const [result, setResult] = useState(null);
+
+ const { control, watch } = useForm({
+ resolver: zodResolver(icdSchema),
+ defaultValues: {
+ currentGas: { o2: 18, he: 45 },
+ nextGas: { o2: 50, he: 0 },
+ },
+ mode: 'onChange',
+ });
+
+ const values = watch();
+
+ useEffect(() => {
+ const icdResult = checkIsobaricCounterdiffusion(values.currentGas, values.nextGas);
+ setResult(icdResult);
+ }, [values.currentGas, values.nextGas]);
+
+ return (
+
+
+
+
+
+
+
ICD Check (Gas Switch)
+
+
+ Check for Isobaric Counterdiffusion risk when switching from Trimix to another gas.
+
+
+
+
+
+ {/* Current Gas */}
+
+
+ 1. Current Gas (Breathed now)
+
+
+ (
+
+ )}
+ />
+
+
+
+ {/* Next Gas */}
+
+
+ 2. Next Gas (Switching to)
+
+
+ (
+
+ )}
+ />
+
+
+
+
+ {result && (
+
+ {result.warning ? (
+
+
+
ICD RISK DETECTED
+
{result.message}
+
+
+
+ N2 Increase
+
+
+ +{result.deltaN2.toFixed(1)}%
+
+
+
+
He Decrease
+
+ -{Math.abs(result.deltaHe).toFixed(1)}%
+
+
+
+
+ ) : (
+
+
+
+
+
Switch Appears Safe
+
+ Nitrogen increase is within safe limits (Rule of Fifths).
+
+ {!(parseFloat(values.currentGas.he) > 0) && (
+
+ * ICD is only a risk when switching away from a gas containing Helium.
+
+ )}
+
+ )}
+
+ )}
+
+
+
+
+ ICD & The Rule of Fifths
+
+
+
+ Isobaric Counterdiffusion (ICD) occurs when switching from a light
+ gas (Helium) to a heavy gas (Nitrogen). Helium diffuses out of tissues slower than
+ Nitrogen diffuses in, causing a transient supersaturation spike even at constant
+ depth.
+
+
+ This is particularly dangerous for the Inner Ear (Vestibular DCS) ,
+ potentially causing severe vertigo and nausea underwater.
+
+
+ GUE Approach: Global Underwater Explorers (GUE) addresses ICD by
+ using standardized gases (e.g., 21/35, 18/45) that naturally avoid these risky ratios,
+ effectively designing the risk out of the system without requiring per-dive
+ calculation.
+
+
+ Other Agency Guidelines (IANTD/TDI): Most agencies recommend the{' '}
+ Rule of Fifths : ensure any increase in Nitrogen percentage is no more
+ than 1/5th of the decrease in Helium percentage. Also, avoid sudden spikes in
+ Equivalent Narcotic Depth (END).
+
+
+
+ Warning if: 5 × (ΔN2 %) > |ΔHe %|
+
+
+
+
+
+
+
+ Based on Subsurface algorithms. This check helps identify risky gas switches in technical
+ dive planning.
+
+
+
+ );
+};
+
+export default ICDCalculator;
diff --git a/frontend/src/components/calculators/MinGasCalculator.js b/frontend/src/components/calculators/MinGasCalculator.js
index a6816ab..3d6c361 100644
--- a/frontend/src/components/calculators/MinGasCalculator.js
+++ b/frontend/src/components/calculators/MinGasCalculator.js
@@ -5,9 +5,10 @@ import { useForm } from 'react-hook-form';
import { minGasSchema } from '../../utils/calculatorSchemas';
import { TANK_SIZES } from '../../utils/diveConstants';
+import { calculatePressureFromVolume, SURFACE_PRESSURE_BAR } from '../../utils/physics';
const MinGasCalculator = () => {
- const [minGasResult, setMinGasResult] = useState({ liters: 0, bar: 0 });
+ const [minGasResult, setMinGasResult] = useState({ liters: 0, barIdeal: 0, barReal: 0 });
const [showDetails, setShowDetails] = useState(false);
const [breakdown, setBreakdown] = useState({});
@@ -42,7 +43,7 @@ const MinGasCalculator = () => {
useEffect(() => {
// 1. Problem Solving at Depth (1 min typically)
- const depthATA = depth / 10 + 1;
+ const depthATA = depth / 10 + SURFACE_PRESSURE_BAR;
const volSolve = depthATA * solveTime * sac;
let volAscent = 0;
@@ -55,7 +56,7 @@ const MinGasCalculator = () => {
if (depth > targetDepth) {
const ascentTime = (depth - targetDepth) / ascentRate;
const avgAscentDepth = (depth + targetDepth) / 2;
- const avgAscentATA = avgAscentDepth / 10 + 1;
+ const avgAscentATA = avgAscentDepth / 10 + SURFACE_PRESSURE_BAR;
volAscent = avgAscentATA * ascentTime * sac;
}
// No safety stop or surface ascent calculated for this gas in tech mode (gas switch assumed)
@@ -65,26 +66,31 @@ const MinGasCalculator = () => {
if (depth > 5) {
const ascentTime = (depth - 5) / ascentRate;
const avgAscentDepth = (depth + 5) / 2;
- const avgAscentATA = avgAscentDepth / 10 + 1;
+ const avgAscentATA = avgAscentDepth / 10 + SURFACE_PRESSURE_BAR;
volAscent = avgAscentATA * ascentTime * sac;
}
// 3. Safety Stop at 5m
- const safetyStopATA = 5 / 10 + 1; // 1.5 ATA
+ const safetyStopATA = 5 / 10 + SURFACE_PRESSURE_BAR;
volSafety = safetyStopATA * safetyStopDuration * sac;
// 4. Ascent from 5m to Surface
const shallowestDepth = Math.min(depth, 5);
const surfaceAscentTime = shallowestDepth / ascentRate;
const avgSurfaceDepth = shallowestDepth / 2;
- const avgSurfaceATA = avgSurfaceDepth / 10 + 1;
+ const avgSurfaceATA = avgSurfaceDepth / 10 + SURFACE_PRESSURE_BAR;
volSurface = avgSurfaceATA * surfaceAscentTime * sac;
}
const totalLiters = volSolve + volAscent + volSafety + volSurface;
- const totalBar = totalLiters / tankSize;
- setMinGasResult({ liters: totalLiters, bar: totalBar });
+ // Ideal Gas Law
+ const barIdeal = totalLiters / tankSize;
+
+ // Real Gas Law (Assuming Air for simple calculation)
+ const barReal = calculatePressureFromVolume(totalLiters, tankSize, { o2: 21, he: 0 });
+
+ setMinGasResult({ liters: totalLiters, barIdeal, barReal });
setBreakdown({
volSolve,
volAscent,
@@ -287,9 +293,13 @@ const MinGasCalculator = () => {
Solve Problem:
- {depth / 10 + 1} ATA * {solveTime} min * {sac}L = {breakdown.volSolve?.toFixed(0)} L
+ {(depth / 10 + SURFACE_PRESSURE_BAR).toFixed(2)} ATA * {solveTime} min * {sac}L ={' '}
+ {breakdown.volSolve?.toFixed(0)} L
+
+ * {SURFACE_PRESSURE_BAR} bar is Standard Surface Pressure (1 atm).
+
Ascent Gas:
{breakdown.volAscent?.toFixed(0)} L
@@ -299,8 +309,8 @@ const MinGasCalculator = () => {
Safety Stop:
- 1.5 ATA * {safetyStopDuration} min * {sac}L = {breakdown.volSafety?.toFixed(0)}{' '}
- L
+ {(5 / 10 + SURFACE_PRESSURE_BAR).toFixed(2)} ATA * {safetyStopDuration} min *{' '}
+ {sac}L = {breakdown.volSafety?.toFixed(0)} L
@@ -314,35 +324,42 @@ const MinGasCalculator = () => {
{minGasResult.liters.toFixed(0)} L
- In Bar:
+ Ideal Pressure:
- {minGasResult.liters.toFixed(0)} L / {tankSize}L = {minGasResult.bar.toFixed(1)} bar
+ {minGasResult.liters.toFixed(0)} L / {tankSize}L ={' '}
+ {minGasResult.barIdeal.toFixed(1)} bar
+
+ Real Pressure (Z-Factor):
+ {minGasResult.barReal.toFixed(1)} bar
+
+
+ Real Pressure accounts for gas becoming less compressible at high pressures.
+
)}
- Minimum Gas
+ Minimum Gas (Real)
- {Math.ceil(minGasResult.bar)}
+ {Math.ceil(minGasResult.barReal)}
bar
- Reserve required for safe ascent in emergency.
+ Reserve required (accounting for compressibility).
- This calculates the 'Rock Bottom' or 'Minimum Gas' reserve. This is the amount of gas
- required to handle a stress situation at the deepest point and perform a safe ascent for
- two divers.
+ This calculates the 'Rock Bottom' reserve using the Real Gas Law (Z-Factor), ensuring
+ you have enough actual gas volume for an emergency ascent.
diff --git a/frontend/src/components/calculators/ModCalculator.js b/frontend/src/components/calculators/ModCalculator.js
index d4ba720..6c96917 100644
--- a/frontend/src/components/calculators/ModCalculator.js
+++ b/frontend/src/components/calculators/ModCalculator.js
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { modSchema } from '../../utils/calculatorSchemas';
+import { calculateMOD, calculateEND, SURFACE_PRESSURE_BAR } from '../../utils/physics';
import GasMixInput from '../forms/GasMixInput';
const ModCalculator = () => {
@@ -36,19 +37,18 @@ const ModCalculator = () => {
if (fO2 <= 0) return { mod: 0, end: 0, ata: 0, fO2, fHe };
- // 1. Calculate Max Ambient Pressure (ATA)
- const maxAta = parseFloat(pO2) / fO2;
+ // 1. Calculate MOD (Depth) using shared physics
+ const mod = calculateMOD(gas, parseFloat(pO2));
- // 2. Calculate MOD (Depth)
- const mod = (maxAta - 1) * 10;
+ // 2. Calculate Max Ambient Pressure (ATA) - derived for display
+ const maxAta = parseFloat(pO2) / fO2;
- // 3. Calculate END
- const endAta = maxAta * (1 - fHe);
- const end = (endAta - 1) * 10;
+ // 3. Calculate END using shared physics
+ const end = calculateEND(mod, gas);
return {
- mod: Math.max(0, mod),
- end: Math.max(0, end),
+ mod,
+ end,
ata: maxAta,
fO2,
fHe,
@@ -215,7 +215,11 @@ const ModCalculator = () => {
Formula:
- (ATA - 1) * 10
+ (ATA - {SURFACE_PRESSURE_BAR}) * 10
+
+
+ * {SURFACE_PRESSURE_BAR} bar is Standard Surface Pressure (1 atm), used for precise
+ depth calculation.
Result:
@@ -275,7 +279,7 @@ const ModCalculator = () => {
- Formula: MOD = (pO2 _max / fO2 - 1) * 10.
+ Formula: MOD = (pO2 _max / fO2 - {SURFACE_PRESSURE_BAR}) * 10.
{result.fHe > 0 ? (
{' '}
diff --git a/frontend/src/components/calculators/SacRateCalculator.js b/frontend/src/components/calculators/SacRateCalculator.js
index fceea0e..98ba8f3 100644
--- a/frontend/src/components/calculators/SacRateCalculator.js
+++ b/frontend/src/components/calculators/SacRateCalculator.js
@@ -5,40 +5,9 @@ import { useForm, Controller } from 'react-hook-form';
import { sacRateSchema } from '../../utils/calculatorSchemas';
import { TANK_SIZES } from '../../utils/diveConstants';
+import { calculateRealVolume, SURFACE_PRESSURE_BAR } from '../../utils/physics';
import GasMixInput from '../forms/GasMixInput';
-// Gas compressibility factor (Z) using Subsurface's virial model
-const getZFactor = (bar, gas) => {
- // Clamp pressure as Subsurface does
- const p = Math.max(0, Math.min(bar, 500));
-
- // Coefficients from Subsurface (3rd order virial)
- const O2_COEFFS = [-7.18092073703e-4, 2.81852572808e-6, -1.50290620492e-9];
- const N2_COEFFS = [-2.19260353292e-4, 2.92844845532e-6, -2.07613482075e-9];
- const HE_COEFFS = [4.87320026468e-4, -8.83632921053e-8, 5.33304543646e-11];
-
- const virial = (coeffs, x) => {
- return x * coeffs[0] + x * x * coeffs[1] + x * x * x * coeffs[2];
- };
-
- // Subsurface uses permille (0-1000)
- const o2 = (gas?.o2 || 21) * 10;
- const he = (gas?.he || 0) * 10;
- const n2 = 1000 - o2 - he;
-
- const z_m1 = virial(O2_COEFFS, p) * o2 + virial(HE_COEFFS, p) * he + virial(N2_COEFFS, p) * n2;
-
- return z_m1 * 0.001 + 1.0;
-};
-
-// Calculate Real Gas Volume in Liters (at 1 atm)
-const getRealVolume = (bar, tankSize, gas) => {
- if (!bar || !tankSize) return 0;
- const z = getZFactor(bar, gas);
- // Subsurface uses 1 atm (1.01325 bar) as the standard pressure for volume
- return (tankSize * (bar / 1.01325)) / z;
-};
-
const SacRateCalculator = () => {
const [sacResults, setSacResults] = useState({ ideal: 0, real: 0 });
const [showDetails, setShowDetails] = useState(false);
@@ -72,7 +41,7 @@ const SacRateCalculator = () => {
const endPressure = parseFloat(values.endPressure) || 0;
const gas = values.gas || { o2: 21, he: 0 };
- const ata = depth / 10 + 1;
+ const ata = depth / 10 + SURFACE_PRESSURE_BAR;
// Ideal SAC Calculation
const idealGasUsedBar = startPressure - endPressure;
@@ -83,8 +52,8 @@ const SacRateCalculator = () => {
}
// Real SAC Calculation
- const realVolStart = getRealVolume(startPressure, tankSize, gas);
- const realVolEnd = getRealVolume(endPressure, tankSize, gas);
+ const realVolStart = calculateRealVolume(startPressure, tankSize, gas);
+ const realVolEnd = calculateRealVolume(endPressure, tankSize, gas);
const realGasUsedLiters = Math.max(0, realVolStart - realVolEnd);
let realSac = 0;
if (time > 0 && ata > 0) {
@@ -274,11 +243,17 @@ const SacRateCalculator = () => {
Ambient Pressure:
- {((parseFloat(values.depth) || 0) / 10 + 1).toFixed(2)} ATA
+
+ ({parseFloat(values.depth) || 0}m / 10) + {SURFACE_PRESSURE_BAR} ={' '}
+ {((parseFloat(values.depth) || 0) / 10 + SURFACE_PRESSURE_BAR).toFixed(2)} ATA
+
+
+
+ * {SURFACE_PRESSURE_BAR} bar is Standard Surface Pressure (1 atm).
- Real SAC (Van der Waals)
+ Real SAC (Virial Equation)
Takes gas compressibility (Z-factor) into account.
diff --git a/frontend/src/pages/Tools.js b/frontend/src/pages/Tools.js
index 793ee34..078d62d 100644
--- a/frontend/src/pages/Tools.js
+++ b/frontend/src/pages/Tools.js
@@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import BestMixCalculator from '../components/calculators/BestMixCalculator';
import GasFillPriceCalculator from '../components/calculators/GasFillPriceCalculator';
import GasPlanningCalculator from '../components/calculators/GasPlanningCalculator';
+import ICDCalculator from '../components/calculators/ICDCalculator';
import MinGasCalculator from '../components/calculators/MinGasCalculator';
import ModCalculator from '../components/calculators/ModCalculator';
import SacRateCalculator from '../components/calculators/SacRateCalculator';
@@ -20,6 +21,7 @@ const TOOL_TABS = [
{ value: 'sac', label: 'SAC Rate' },
{ value: 'gas-planning', label: 'Gas Consumption' },
{ value: 'min-gas', label: 'Min Gas' },
+ { value: 'icd', label: 'ICD Check' },
{ value: 'gas-fill', label: 'Fill Price' },
{ value: 'buoyancy', label: 'Tank Buoyancy' },
{ value: 'weight', label: 'Weight' },
@@ -100,6 +102,10 @@ const Tools = () => {
+
+
+
+
diff --git a/frontend/src/utils/diveConstants.js b/frontend/src/utils/diveConstants.js
index a87a33e..a678590 100644
--- a/frontend/src/utils/diveConstants.js
+++ b/frontend/src/utils/diveConstants.js
@@ -1,5 +1,13 @@
// Tank Definitions
export const TANK_SIZES = [
+ {
+ id: '3',
+ name: '3 Liters (Pony)',
+ size: 3,
+ defaultPressure: 232,
+ material: 'steel',
+ emptyWeight: 4,
+ },
{
id: 'al40',
name: 'AL40 (5.7L)',
@@ -102,6 +110,24 @@ export const TANK_SIZES = [
emptyWeight: 28,
isDoubles: true,
},
+ {
+ id: '30',
+ name: 'Double 15s (30L)',
+ size: 30,
+ defaultPressure: 232,
+ material: 'steel',
+ emptyWeight: 33, // 2x16.5
+ isDoubles: true,
+ },
+ {
+ id: '36',
+ name: 'Double 18s (36L)',
+ size: 36,
+ defaultPressure: 232,
+ material: 'steel',
+ emptyWeight: 40, // 2x20
+ isDoubles: true,
+ },
];
// Common Gas Mixes
diff --git a/frontend/src/utils/physics.js b/frontend/src/utils/physics.js
new file mode 100644
index 0000000..17216f2
--- /dev/null
+++ b/frontend/src/utils/physics.js
@@ -0,0 +1,171 @@
+/**
+ * Core physics module for dive calculations.
+ * Aligned with backend/app/physics.py
+ * Based on algorithms from Subsurface (https://github.com/subsurface/subsurface).
+ */
+
+// Subsurface uses 1.01325 bar as standard surface pressure for volume conversions
+export const SURFACE_PRESSURE_BAR = 1.01325;
+
+/**
+ * Calculates the gas compressibility factor (Z) using the Virial equation.
+ * Based on Subsurface implementation (core/compressibility.c / core/gas.c).
+ *
+ * @param {number} bar Pressure in bar
+ * @param {Object} gas Gas mix object { o2: 21, he: 0 }
+ * @returns {number} The compressibility factor Z
+ */
+export const calculateZFactor = (bar, gas) => {
+ // Clamp pressure to 0-500 bar range as per Subsurface
+ const p = Math.max(0, Math.min(bar, 500));
+
+ // Coefficients from Subsurface (3rd order virial expansion)
+ const O2_COEFFS = [-7.18092073703e-4, 2.81852572808e-6, -1.50290620492e-9];
+ const N2_COEFFS = [-2.19260353292e-4, 2.92844845532e-6, -2.07613482075e-9];
+ const HE_COEFFS = [4.87320026468e-4, -8.83632921053e-8, 5.33304543646e-11];
+
+ const virial = (coeffs, x) => {
+ return x * coeffs[0] + x * x * coeffs[1] + x * x * x * coeffs[2];
+ };
+
+ const o2 = (gas?.o2 || 21) * 10;
+ const he = (gas?.he || 0) * 10;
+ // Subsurface uses permille (0-1000)
+ const n2 = 1000 - o2 - he;
+
+ const z_m1 = virial(O2_COEFFS, p) * o2 + virial(HE_COEFFS, p) * he + virial(N2_COEFFS, p) * n2;
+
+ // Convert back: Z = 1 + (Weighted_Virial_Sum / 1000)
+ return z_m1 * 0.001 + 1.0;
+};
+
+/**
+ * Calculates the actual volume of gas (at surface pressure) in a tank.
+ * Real Volume = (Tank_Water_Vol * Pressure) / Z
+ *
+ * @param {number} bar Current pressure in bar
+ * @param {number} tankSizeLitres Wet volume of tank (e.g. 12)
+ * @param {Object} gas Gas mix { o2: 21, he: 0 }
+ * @returns {number} Equivalent surface volume in liters
+ */
+export const calculateRealVolume = (bar, tankSizeLitres, gas) => {
+ if (!bar || !tankSizeLitres) return 0;
+ const z = calculateZFactor(bar, gas);
+ // Adjusted for surface pressure reference
+ return (tankSizeLitres * (bar / SURFACE_PRESSURE_BAR)) / z;
+};
+
+/**
+ * Inverse of calculateRealVolume. Solves for pressure given a gas volume.
+ * Used for "How much pressure do I need for X liters of gas?"
+ *
+ * @param {number} volumeLitres Required gas volume at surface
+ * @param {number} tankSizeLitres Tank wet volume
+ * @param {Object} gas Gas mix
+ * @returns {number} Required pressure in bar
+ */
+export const calculatePressureFromVolume = (volumeLitres, tankSizeLitres, gas) => {
+ if (!tankSizeLitres || tankSizeLitres === 0) return 0;
+
+ // Initial guess using Ideal Gas Law
+ let p_guess = (volumeLitres * SURFACE_PRESSURE_BAR) / tankSizeLitres;
+
+ // Simple iteration to converge
+ for (let i = 0; i < 5; i++) {
+ const z = calculateZFactor(p_guess, gas);
+ const p_new = (volumeLitres * SURFACE_PRESSURE_BAR * z) / tankSizeLitres;
+ if (Math.abs(p_new - p_guess) < 0.1) return p_new;
+ p_guess = p_new;
+ }
+
+ return p_guess;
+};
+
+/**
+ * Calculates Maximum Operating Depth (MOD) in meters.
+ * MOD = (ppO2_max / fO2 - surface_pressure) * 10
+ *
+ * @param {Object} gas Gas mix { o2: 21, he: 0 }
+ * @param {number} ppO2Max Maximum partial pressure of Oxygen (e.g. 1.4)
+ * @returns {number} MOD in meters
+ */
+export const calculateMOD = (gas, ppO2Max) => {
+ const o2 = parseFloat(gas?.o2) || 21;
+ if (o2 <= 0) return 0;
+
+ const fO2 = o2 / 100;
+ const maxAta = ppO2Max / fO2;
+ // (ATA - Surface) * 10m/bar
+ return Math.max(0, (maxAta - SURFACE_PRESSURE_BAR) * 10); // Using exact surface pressure
+};
+
+/**
+ * Calculates Equivalent Narcotic Depth (END).
+ * Assumes O2 is narcotic (standard recreational/tech view).
+ * Formula: END = (Depth + 10) * (1 - fHe) - 10
+ *
+ * @param {number} depth Depth in meters
+ * @param {Object} gas Gas mix { o2: 21, he: 0 }
+ * @returns {number} END in meters
+ */
+export const calculateEND = (depth, gas) => {
+ const he = parseFloat(gas?.he) || 0;
+ const fHe = he / 100;
+ return (depth + 10) * (1 - fHe) - 10;
+};
+
+/**
+ * Calculates Equivalent Air Depth (EAD) for Nitrox.
+ * EAD = (Depth + 10) * (fN2 / 0.79) - 10
+ *
+ * @param {number} depth Depth in meters
+ * @param {Object} gas Gas mix { o2: 21, he: 0 }
+ * @returns {number} EAD in meters
+ */
+export const calculateEAD = (depth, gas) => {
+ const o2 = parseFloat(gas?.o2) || 21;
+ const he = parseFloat(gas?.he) || 0;
+ const fN2 = (100 - o2 - he) / 100;
+ return (depth + 10) * (fN2 / 0.79) - 10;
+};
+
+/**
+ * Checks for Isobaric Counterdiffusion (ICD) risks when switching gases.
+ * Implements the 'Rule of Fifths': Nitrogen increase should not exceed 1/5th of Helium decrease.
+ *
+ * @param {Object} currentGas The gas being breathed currently { o2, he }
+ * @param {Object} nextGas The gas being switched to { o2, he }
+ * @returns {Object} { warning: boolean, deltaN2: number, deltaHe: number, message: string | null }
+ */
+export const checkIsobaricCounterdiffusion = (currentGas, nextGas) => {
+ const curO2 = parseFloat(currentGas?.o2) || 21;
+ const curHe = parseFloat(currentGas?.he) || 0;
+ const curN2 = 100 - curO2 - curHe;
+
+ const nextO2 = parseFloat(nextGas?.o2) || 21;
+ const nextHe = parseFloat(nextGas?.he) || 0;
+ const nextN2 = 100 - nextO2 - nextHe;
+
+ // Delta N2 (increase is positive)
+ const dN2 = nextN2 - curN2;
+
+ // Delta He (decrease is negative)
+ const dHe = nextHe - curHe;
+
+ let warning = false;
+ let message = null;
+
+ // Logic:
+ // 1. Current gas must have Helium (>0)
+ // 2. Switching results in N2 increase (>0)
+ // 3. Switching results in He decrease (<0)
+ // 4. Rule check: 5 * delta_N2 > -delta_He
+ if (curHe > 0 && dN2 > 0 && dHe < 0) {
+ if (5 * dN2 > -dHe) {
+ warning = true;
+ message = `ICD Warning: N2 increase (${dN2.toFixed(1)}%) > 1/5th of He drop (${Math.abs(dHe).toFixed(1)}%). Risk of inner-ear DCS.`;
+ }
+ }
+
+ return { warning, deltaN2: dN2, deltaHe: dHe, message };
+};