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) && ( -
-
- Within safe limits -
- )} + {planGasResult.isSafe && + (values.isAdvanced || planGasResult.remainingPressureReal >= 50) && ( +
+
+ Within safe limits +
+ )}
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 */} +
+ +
+ ( + + )} + /> +
+
+ + {/* Next Gas */} +
+ +
+ ( + + )} + /> +
+
+
+ + {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 }; +};