From 6d80f7511de2df17b7e35a9352eb2d0fc22846b7 Mon Sep 17 00:00:00 2001 From: George Kargiotakis Date: Wed, 3 Jun 2026 00:34:02 +0300 Subject: [PATCH 1/2] Integrate octo-deco for internal deco calculations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable internal Bühlmann ZH-L16 decompression simulations using the `octo-deco` library. This allows Divemap to generate safety ceilings for dives where the computer telemetry is missing (e.g., Suunto FIT exports). Key changes: - Create `deco_service.py` to calculate time-series ceilings. - Automate deco backfilling during import and profile retrieval. - Parse Gradient Factors (GF) from dive descriptions as a fallback. - Robustify Garmin FIT parser to handle missing fields gracefully. - Refactor `Dockerfile` to multi-stage build for efficient compilation of Cython extensions without bloating the runtime image. - Remove `--only-binary=all` to support source package installation. - Display "Calculated" indicators in the frontend profile chart. - Add unit tests for deco logic and Suunto-style imports. --- backend/Dockerfile | 53 +++++++---- backend/app/routers/dives/dives_profiles.py | 28 ++++++ backend/app/routers/dives/imports/garmin.py | 29 ++++++ backend/app/services/deco_service.py | 49 ++++++++++ backend/app/utils.py | 41 ++++++++- backend/docker-test-github-actions.sh | 10 ++- backend/requirements.txt | 3 + backend/tests/test_deco_service.py | 38 ++++++++ backend/tests/test_garmin_suunto_import.py | 90 +++++++++++++++++++ .../components/AdvancedDiveProfileChart.jsx | 13 ++- 10 files changed, 333 insertions(+), 21 deletions(-) create mode 100644 backend/app/services/deco_service.py create mode 100644 backend/tests/test_deco_service.py create mode 100644 backend/tests/test_garmin_suunto_import.py diff --git a/backend/Dockerfile b/backend/Dockerfile index 6bef2943..c30bd564 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,42 +1,61 @@ -FROM python:3.11-slim +# Stage 1: Builder +# This stage installs build tools and compiles dependencies +FROM python:3.11-slim AS builder -# Set working directory WORKDIR /app -# Install system dependencies including netcat-openbsd for IPv6 support -# Optimized for faster installation +# Install build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ pkg-config \ + gcc \ + python3-dev \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install dependencies into a wheels directory or directly +# Using a virtualenv is the cleanest way to transfer to the next stage +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +RUN pip install --no-cache-dir --disable-pip-version-check -r requirements.txt + + +# Stage 2: Runtime +# This stage is the final small image containing only the app and its runtime deps +FROM python:3.11-slim AS runtime + +WORKDIR /app + +# Install runtime-only system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ netcat-openbsd \ libmagic1 \ && rm -rf /var/lib/apt/lists/* \ && apt-get clean -# Copy requirements first for better caching -COPY requirements.txt . +# Copy the pre-compiled virtual environment from the builder stage +COPY --from=builder /opt/venv /opt/venv -# Install Python dependencies using only pre-compiled wheels -# Optimized pip installation for faster startup -RUN pip install --no-cache-dir --only-binary=all --disable-pip-version-check -r requirements.txt +# Ensure the app uses the virtualenv +ENV PATH="/opt/venv/bin:$PATH" +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 # Copy application code COPY . . -# Create uploads directory and make scripts executable in one layer +# Create uploads directory and set permissions RUN mkdir -p uploads && \ chmod +x /app/run_migrations.py && \ chmod +x /app/run_migrations_docker.sh && \ chmod +x /app/test_netcat_ipv6.sh && \ chmod +x /app/startup.sh -# Set environment variables for faster startup -ENV PYTHONUNBUFFERED=1 -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PIP_NO_CACHE_DIR=1 -ENV PIP_DISABLE_PIP_VERSION_CHECK=1 - # Expose port EXPOSE 8000 # Run the startup script -CMD ["/app/startup.sh"] \ No newline at end of file +CMD ["/app/startup.sh"] diff --git a/backend/app/routers/dives/dives_profiles.py b/backend/app/routers/dives/dives_profiles.py index c56c3631..820b83e6 100644 --- a/backend/app/routers/dives/dives_profiles.py +++ b/backend/app/routers/dives/dives_profiles.py @@ -22,6 +22,8 @@ import os import tempfile import uuid +from app.utils import parse_gf_from_text, has_deco_data +from app.services.deco_service import calculate_deco_ceiling from .dives_shared import router, get_db, get_current_user, get_current_active_user, get_current_admin_user, get_current_user_optional, User, Dive, DiveMedia, DiveTag, AvailableTag, r2_storage from app.schemas import DiveCreate, DiveUpdate, DiveResponse, DiveMediaCreate, DiveMediaResponse, DiveTagResponse @@ -174,6 +176,32 @@ def get_dive_profile( # Clean up temporary file if os.path.exists(temp_path): os.remove(temp_path) + + # Backfill decompression data if missing + if profile_data and not has_deco_data(profile_data): + # Try to parse GF from dive description + gf_low, gf_high = parse_gf_from_text(dive.dive_information) + + # If GFs found, calculate ceiling + if gf_low is not None and gf_high is not None: + try: + samples = profile_data.get('samples', []) + if samples: + calculated_ceilings = calculate_deco_ceiling( + samples, + gf_low=gf_low, + gf_high=gf_high + ) + + # Inject calculated ceilings into samples + for i, sample in enumerate(samples): + if i < len(calculated_ceilings): + sample['stopdepth'] = calculated_ceilings[i] + sample['in_deco'] = calculated_ceilings[i] > 0 + sample['calculated_deco'] = True + except Exception as e: + import logging + logging.warning(f"Failed to backfill deco ceiling for dive {dive_id}: {e}") return profile_data except HTTPException: diff --git a/backend/app/routers/dives/imports/garmin.py b/backend/app/routers/dives/imports/garmin.py index 3e17c77e..56608052 100644 --- a/backend/app/routers/dives/imports/garmin.py +++ b/backend/app/routers/dives/imports/garmin.py @@ -28,6 +28,8 @@ def get_fit_value(frame, field_name, default=None): pass return default +from app.services.deco_service import calculate_deco_ceiling + def parse_garmin_fit_file(content: bytes, db: Session, current_user_id: int, user_dives=None, all_sites=None): """ @@ -289,6 +291,33 @@ def parse_garmin_fit_file(content: bytes, db: Session, current_user_id: int, use dive_data["profile_data"]["samples"].append(sample) + # 4.1 Calculate Missing Ceiling (Bühlmann ZH-L16) + # If the file lacks deco data (e.g. Suunto), calculate it internally + has_deco_in_profile = any(s.get('stopdepth') is not None for s in dive_data["profile_data"]["samples"]) + if not has_deco_in_profile and dive_data["profile_data"]["samples"]: + try: + gfl = get_fit_value(session_settings, 'gf_low') if session_settings else 30 + if gfl is None: gfl = 30 + gfh = get_fit_value(session_settings, 'gf_high') if session_settings else 70 + if gfh is None: gfh = 70 + + calculated_ceilings = calculate_deco_ceiling( + dive_data["profile_data"]["samples"], + gf_low=gfl, + gf_high=gfh + ) + + # Apply calculated ceilings to samples + for i, sample in enumerate(dive_data["profile_data"]["samples"]): + if i < len(calculated_ceilings): + sample['stopdepth'] = calculated_ceilings[i] + sample['in_deco'] = calculated_ceilings[i] > 0 + # Label as calculated for frontend awareness if needed + sample['calculated_deco'] = True + except Exception as e: + import logging + logging.warning(f"Failed to calculate internal deco ceiling: {e}") + # Duplicate detection existing = find_existing_dive( db, current_user_id, diff --git a/backend/app/services/deco_service.py b/backend/app/services/deco_service.py new file mode 100644 index 00000000..adb7ba1c --- /dev/null +++ b/backend/app/services/deco_service.py @@ -0,0 +1,49 @@ +from octodeco.deco.DiveProfile import DiveProfile +from octodeco.deco.Gas import Air, Gas +from typing import List, Dict, Any, Optional + +def calculate_deco_ceiling(samples: List[Dict[str, Any]], gf_low: int = 30, gf_high: int = 70) -> List[float]: + """ + Calculate the decompression ceiling for a series of dive samples. + Uses octo-deco's internal Bühlmann ZH-L16 implementation. + + Args: + samples: List of dicts with 'time_minutes' and 'depth' (meters) + gf_low: Gradient Factor Low (e.g., 30) + gf_high: Gradient Factor High (e.g., 70) + + Returns: + List of calculated ceiling depths in meters. + """ + if not samples: + return [] + + # Initialize octodeco profile with provided GFs + # DiveProfile expects GFs in the 0-100 range (default 35/70) + profile = DiveProfile(gf_low=gf_low, gf_high=gf_high) + air = Air() + profile.add_gas(air) + + # Feed samples into profile + for i, s in enumerate(samples): + if i == 0: + # Overwrite the default initial point at time 0 + profile._points[0].time = s['time_minutes'] + profile._points[0].depth = s['depth'] + profile._points[0].gas = air + else: + profile._append_point_abstime(s['time_minutes'], s['depth'], air) + + # Run the full deco simulation + # This calculates gas uptake/release and the resulting ceiling line + profile.update_deco_info() + + # Extract the ceiling from the library's internal dataframe + # The 'Ceil' column correctly implements the Gradient Factor line (gf_low to gf_high) + df = profile.dataframe() + + # Ensure we return a list of floats matching the input size + ceilings = df['Ceil'].tolist() + + # Round for storage and UI + return [round(float(c), 2) for c in ceilings] diff --git a/backend/app/utils.py b/backend/app/utils.py index ff623b7c..2359c318 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -1,5 +1,5 @@ from fastapi import Request -from typing import Optional +from typing import Optional, List, Dict, Any, Tuple from sqlalchemy.orm import Session from datetime import datetime, timezone import orjson @@ -689,3 +689,42 @@ def populate_center_media_urls(media_obj, response_dict: dict) -> dict: return response_dict + +def parse_gf_from_text(text: Optional[str]) -> Tuple[Optional[int], Optional[int]]: + """ + Parse Gradient Factors (GF Low/High) from a text string. + Looks for patterns like 'GF 30/70', 'GF: 30/70', 'Gradient Factors: 30/70', etc. + + Returns: + A tuple of (gf_low, gf_high) or (None, None) if not found. + """ + if not text: + return None, None + + # Pattern to match GF Low/High: (GF|Gradient Factor)[s]?[:]? [0-9]+/[0-9]+ + # Supports optional brackets: (GF 30/70) + # Supports optional spaces: GF 30 / 70 + pattern = r'(?:GF|Gradient Factor)[s]?[:]?\s*\(?(\d+)\s*/\s*(\d+)\)?' + match = re.search(pattern, text, re.IGNORECASE) + + if match: + try: + gf_low = int(match.group(1)) + gf_high = int(match.group(2)) + return gf_low, gf_high + except (ValueError, IndexError): + pass + + return None, None + + +def has_deco_data(profile_data: dict) -> bool: + """ + Check if the dive profile data contains decompression information. + """ + samples = profile_data.get('samples', []) + if not samples: + return False + + # Check for 'stopdepth' or 'ndl_minutes' in samples + return any(s.get('stopdepth') is not None or s.get('ndl_minutes') is not None for s in samples) diff --git a/backend/docker-test-github-actions.sh b/backend/docker-test-github-actions.sh index de785cf7..4fcd7ec5 100755 --- a/backend/docker-test-github-actions.sh +++ b/backend/docker-test-github-actions.sh @@ -103,7 +103,15 @@ BASE_IMAGE_EXISTS=$(docker images -q divemap-test-base 2> /dev/null) cat > Dockerfile.test << 'EOF' # Stage 1: Base with OS packages and dependencies pre-installed in venv FROM python:3.11-slim AS base -RUN apt-get update && apt-get install -y pkg-config netcat-openbsd default-mysql-client libmagic1 && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y \ + pkg-config \ + netcat-openbsd \ + default-mysql-client \ + libmagic1 \ + build-essential \ + gcc \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . # Create venv and install dependencies here so it's cached in the base image diff --git a/backend/requirements.txt b/backend/requirements.txt index 49728c68..759d2b64 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -49,3 +49,6 @@ nh3==0.2.20 fitdecode==0.11.0 garmin-fit-sdk==21.158.0 tiktoken>=0.7.0 +octo-deco==2.0.3 +pandas==3.0.3 +pytz diff --git a/backend/tests/test_deco_service.py b/backend/tests/test_deco_service.py new file mode 100644 index 00000000..0b0389e5 --- /dev/null +++ b/backend/tests/test_deco_service.py @@ -0,0 +1,38 @@ +import pytest +from app.services.deco_service import calculate_deco_ceiling + +def test_calculate_deco_ceiling_empty(): + assert calculate_deco_ceiling([]) == [] + +def test_calculate_deco_ceiling_basic(): + # A simple dive: 10 mins at 30m + samples = [] + for i in range(11): + samples.append({ + 'time_minutes': float(i), + 'depth': 30.0 if i > 0 else 0.0 + }) + + ceilings = calculate_deco_ceiling(samples, gf_low=30, gf_high=70) + assert len(ceilings) == len(samples) + # At 30m for 10 mins, we might not have mandatory deco but we should have tissue loading + # The ceiling should be 0 or very shallow for an air dive at 30m for 10 min + assert all(isinstance(c, (int, float)) for c in ceilings) + +def test_calculate_deco_ceiling_with_deco(): + # A deeper/longer dive to trigger mandatory deco + # 40m for 20 mins on Air + samples = [] + # Descent + samples.append({'time_minutes': 0.0, 'depth': 0.0}) + samples.append({'time_minutes': 2.0, 'depth': 40.0}) + # Bottom + for i in range(3, 23): + samples.append({'time_minutes': float(i), 'depth': 40.0}) + + ceilings = calculate_deco_ceiling(samples, gf_low=30, gf_high=70) + + # Check that ceiling increases over time + assert max(ceilings) > 0 + # Final ceiling should be significant + assert ceilings[-1] > 3.0 diff --git a/backend/tests/test_garmin_suunto_import.py b/backend/tests/test_garmin_suunto_import.py new file mode 100644 index 00000000..2e69ff64 --- /dev/null +++ b/backend/tests/test_garmin_suunto_import.py @@ -0,0 +1,90 @@ +import pytest +from unittest.mock import MagicMock, patch +from datetime import datetime, timedelta +from app.routers.dives.imports.garmin import parse_garmin_fit_file + +def test_parse_suunto_style_fit_with_calculated_deco(): + # Mock content + mock_content = b"fake suunto fit content" + + with patch('fitdecode.FitReader') as mock_reader_class: + mock_reader = MagicMock() + mock_reader_class.return_value.__enter__.return_value = mock_reader + + start_time = datetime(2026, 5, 31, 9, 26, 35) + + # Mock Session (Suunto style: minimal) + mock_session_frame = MagicMock() + mock_session_frame.frame_type = 'data' + mock_session_frame.name = 'session' + session_values = { + 'start_time': start_time, + 'total_elapsed_time': 3600, # 60 min + 'max_depth': 40.0, + 'avg_depth': 20.0 + } + mock_session_frame.has_field.side_effect = lambda key: key in session_values + mock_session_frame.get_value.side_effect = lambda key: session_values.get(key) + + # Mock Records (Just a few deep samples to trigger deco) + records = [] + for i in range(0, 61, 10): # Every 10 mins + mock_r = MagicMock() + mock_r.frame_type = 'data' + mock_r.name = 'record' + # 40m depth from 10m to 50m + d = 40.0 if 10 <= i <= 50 else 0.0 + r_values = { + 'timestamp': start_time + timedelta(minutes=i), + 'depth': d + } + mock_r.has_field.side_effect = lambda key, v=r_values: key in v + mock_r.get_value.side_effect = lambda key, v=r_values: v.get(key) + records.append(mock_r) + + # Mock Dive Settings (GFs) + mock_settings_frame = MagicMock() + mock_settings_frame.frame_type = 'data' + mock_settings_frame.name = 'dive_settings' + settings_values = { + 'gf_low': 30, + 'gf_high': 70 + } + mock_settings_frame.has_field.side_effect = lambda key: key in settings_values + mock_settings_frame.get_value.side_effect = lambda key: settings_values.get(key) + + # Mock Dive Gas + mock_gas_frame = MagicMock() + mock_gas_frame.frame_type = 'data' + mock_gas_frame.name = 'dive_gas' + gas_values = { + 'oxygen_content': 21, + 'helium_content': 0, + 'status': 'enabled' + } + mock_gas_frame.has_field.side_effect = lambda key: key in gas_values + mock_gas_frame.get_value.side_effect = lambda key: gas_values.get(key) + + mock_reader.__iter__.return_value = [mock_session_frame, mock_settings_frame, mock_gas_frame] + records + + with patch('fitdecode.FIT_FRAME_DATA', 'data'), \ + patch('app.routers.dives.imports.garmin.find_sites_by_coords') as mock_find_sites, \ + patch('app.routers.dives.imports.garmin.find_existing_dive') as mock_find_existing: + + mock_find_sites.return_value = [] + mock_find_existing.return_value = None + + mock_db = MagicMock() + dives = parse_garmin_fit_file(mock_content, mock_db, 1) + + assert len(dives) == 1 + dive = dives[0] + samples = dive['profile_data']['samples'] + + # Verify that some samples have 'stopdepth' set (calculated) + deco_samples = [s for s in samples if s.get('stopdepth', 0) > 0] + assert len(deco_samples) > 0 + assert all(s.get('calculated_deco') is True for s in deco_samples) + + # Verify GF inclusion in dive info + assert "Deco Model: Bühlmann ZH-L16 (GF 30/70)" in dive["dive_information"] diff --git a/frontend/src/components/AdvancedDiveProfileChart.jsx b/frontend/src/components/AdvancedDiveProfileChart.jsx index de5db102..f6b135dd 100644 --- a/frontend/src/components/AdvancedDiveProfileChart.jsx +++ b/frontend/src/components/AdvancedDiveProfileChart.jsx @@ -93,7 +93,9 @@ const CustomTooltip = ({ {data.stopdepth > 0 && showCeiling && (
Ceiling: - {data.stopdepth?.toFixed(1)}m + + {data.stopdepth?.toFixed(1)}m{data.calculated_deco && ' (Calculated)'} +
)} {data.stoptime > 0 && data.in_deco && showStoptime && ( @@ -269,6 +271,12 @@ const AdvancedDiveProfileChart = ({ ); }, [profileData]); + // Check if deco is calculated internally + const isDecoCalculated = useMemo(() => { + if (!profileData?.samples) return false; + return profileData.samples.some(sample => sample.calculated_deco === true); + }, [profileData]); + // Process gas change events const gasChangeEvents = useMemo(() => { if (!profileData?.events) return []; @@ -350,6 +358,7 @@ const AdvancedDiveProfileChart = ({ in_deco: lastKnownInDeco, stopdepth: lastKnownStopdepth, stoptime: lastKnownStoptime, + calculated_deco: sample.calculated_deco, }; }); }, [profileData, showAllSamples]); @@ -1007,7 +1016,7 @@ const AdvancedDiveProfileChart = ({ className='w-4 h-0.5 border-dashed border-t-2' style={{ borderColor: '#56B4E9' }} > - Ceiling + Ceiling{isDecoCalculated ? ' (Calculated)' : ''} )} From fdebe4b44b038621354692a7a3918c2cff2434f9 Mon Sep 17 00:00:00 2001 From: George Kargiotakis Date: Wed, 3 Jun 2026 09:17:03 +0300 Subject: [PATCH 2/2] Refine tissue graphs and UI layout alignment - Adjust TissueHeatmap height and color scale for better visibility of ongassing activity across all compartments. - Align tissue graphs horizontally with the main dive profile chart by accounting for the temperature axis margin. - Reduce vertical spacing and borders in DiveDetail to bring simulated safety data closer to the depth profile. - Apply minor formatting fixes to App.jsx routing. --- backend/app/routers/dives/dives_profiles.py | 39 ++++- backend/app/routers/dives/imports/garmin.py | 24 ++- backend/app/services/deco_service.py | 54 +++++-- backend/tests/test_deco_service.py | 34 +++- backend/tests/test_garmin_suunto_import.py | 8 + frontend/src/App.jsx | 7 +- .../components/AdvancedDiveProfileChart.jsx | 12 +- frontend/src/components/TissueHeatmap.jsx | 129 +++++++++++++++ .../src/components/TissueSaturationChart.jsx | 153 ++++++++++++++++++ frontend/src/pages/DiveDetail.jsx | 21 ++- frontend/src/utils/textHelpers.jsx | 21 +++ 11 files changed, 466 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/TissueHeatmap.jsx create mode 100644 frontend/src/components/TissueSaturationChart.jsx diff --git a/backend/app/routers/dives/dives_profiles.py b/backend/app/routers/dives/dives_profiles.py index 820b83e6..933f0b6a 100644 --- a/backend/app/routers/dives/dives_profiles.py +++ b/backend/app/routers/dives/dives_profiles.py @@ -23,6 +23,9 @@ import tempfile import uuid from app.utils import parse_gf_from_text, has_deco_data + +# Maximum file size for dive profile uploads (15MB) +MAX_PROFILE_FILE_SIZE = 15 * 1024 * 1024 from app.services.deco_service import calculate_deco_ceiling from .dives_shared import router, get_db, get_current_user, get_current_active_user, get_current_admin_user, get_current_user_optional, User, Dive, DiveMedia, DiveTag, AvailableTag, r2_storage @@ -177,8 +180,8 @@ def get_dive_profile( if os.path.exists(temp_path): os.remove(temp_path) - # Backfill decompression data if missing - if profile_data and not has_deco_data(profile_data): + # Backfill decompression data if missing (either samples or heatmap) + if profile_data and (not has_deco_data(profile_data) or 'tissue_heatmap' not in profile_data): # Try to parse GF from dive description gf_low, gf_high = parse_gf_from_text(dive.dive_information) @@ -187,18 +190,26 @@ def get_dive_profile( try: samples = profile_data.get('samples', []) if samples: - calculated_ceilings = calculate_deco_ceiling( + calculated_ceilings, final_saturation, heatmap_data = calculate_deco_ceiling( samples, gf_low=gf_low, gf_high=gf_high ) - # Inject calculated ceilings into samples + # Inject calculated ceilings into samples ONLY if computer deco is missing + has_computer_deco = has_deco_data(profile_data) for i, sample in enumerate(samples): if i < len(calculated_ceilings): - sample['stopdepth'] = calculated_ceilings[i] - sample['in_deco'] = calculated_ceilings[i] > 0 - sample['calculated_deco'] = True + if not has_computer_deco or sample.get('calculated_deco'): + sample['stopdepth'] = calculated_ceilings[i] + sample['in_deco'] = calculated_ceilings[i] > 0 + sample['calculated_deco'] = True + + # Add tissue loading to profile data + if final_saturation: + profile_data['tissue_saturation'] = final_saturation + if heatmap_data: + profile_data['tissue_heatmap'] = heatmap_data except Exception as e: import logging logging.warning(f"Failed to backfill deco ceiling for dive {dive_id}: {e}") @@ -234,10 +245,24 @@ async def upload_dive_profile( if not file.filename.lower().endswith('.xml'): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only XML files are allowed") + # Check file size before reading + if file.size and file.size > MAX_PROFILE_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File too large. Maximum size allowed is {MAX_PROFILE_FILE_SIZE // (1024 * 1024)}MB" + ) + try: # Read file content content = await file.read() + # Double check content size if file.size was not available + if len(content) > MAX_PROFILE_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File content too large. Maximum size allowed is {MAX_PROFILE_FILE_SIZE // (1024 * 1024)}MB" + ) + # Generate unique filename timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"dive_{dive_id}_profile_{timestamp}.xml" diff --git a/backend/app/routers/dives/imports/garmin.py b/backend/app/routers/dives/imports/garmin.py index 56608052..3dde6e2a 100644 --- a/backend/app/routers/dives/imports/garmin.py +++ b/backend/app/routers/dives/imports/garmin.py @@ -28,6 +28,8 @@ def get_fit_value(frame, field_name, default=None): pass return default +# Maximum file size for FIT file uploads (15MB) +MAX_FIT_FILE_SIZE = 15 * 1024 * 1024 from app.services.deco_service import calculate_deco_ceiling @@ -301,7 +303,7 @@ def parse_garmin_fit_file(content: bytes, db: Session, current_user_id: int, use gfh = get_fit_value(session_settings, 'gf_high') if session_settings else 70 if gfh is None: gfh = 70 - calculated_ceilings = calculate_deco_ceiling( + calculated_ceilings, final_saturation, heatmap_data = calculate_deco_ceiling( dive_data["profile_data"]["samples"], gf_low=gfl, gf_high=gfh @@ -314,6 +316,12 @@ def parse_garmin_fit_file(content: bytes, db: Session, current_user_id: int, use sample['in_deco'] = calculated_ceilings[i] > 0 # Label as calculated for frontend awareness if needed sample['calculated_deco'] = True + + # Store tissue loading metadata + if final_saturation: + dive_data["profile_data"]["tissue_saturation"] = final_saturation + if heatmap_data: + dive_data["profile_data"]["tissue_heatmap"] = heatmap_data except Exception as e: import logging logging.warning(f"Failed to calculate internal deco ceiling: {e}") @@ -351,9 +359,23 @@ async def import_garmin_fit( detail="File must be a .fit file" ) + # Check file size before reading + if file.size and file.size > MAX_FIT_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File too large. Maximum size allowed is {MAX_FIT_FILE_SIZE // (1024 * 1024)}MB" + ) + try: content = await file.read() + # Double check content size if file.size was not available + if len(content) > MAX_FIT_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File content too large. Maximum size allowed is {MAX_FIT_FILE_SIZE // (1024 * 1024)}MB" + ) + all_centers = db.query(DivingCenter).all() user_dives = db.query(Dive).filter(Dive.user_id == current_user.id).all() all_sites = db.query(DiveSite.id, DiveSite.name, DiveSite.country, DiveSite.region).all() diff --git a/backend/app/services/deco_service.py b/backend/app/services/deco_service.py index adb7ba1c..43a2c334 100644 --- a/backend/app/services/deco_service.py +++ b/backend/app/services/deco_service.py @@ -1,10 +1,10 @@ from octodeco.deco.DiveProfile import DiveProfile from octodeco.deco.Gas import Air, Gas -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Tuple -def calculate_deco_ceiling(samples: List[Dict[str, Any]], gf_low: int = 30, gf_high: int = 70) -> List[float]: +def calculate_deco_ceiling(samples: List[Dict[str, Any]], gf_low: int = 30, gf_high: int = 70) -> Tuple[List[float], Optional[List[float]], Optional[List[List[float]]]]: """ - Calculate the decompression ceiling for a series of dive samples. + Calculate the decompression ceiling, final tissue saturation, and heatmap data. Uses octo-deco's internal Bühlmann ZH-L16 implementation. Args: @@ -13,29 +13,36 @@ def calculate_deco_ceiling(samples: List[Dict[str, Any]], gf_low: int = 30, gf_h gf_high: Gradient Factor High (e.g., 70) Returns: - List of calculated ceiling depths in meters. + Tuple containing: + 1. List of calculated ceiling depths in meters. + 2. List of 16 GF99 percentages for final tissue saturation (or None). + 3. 2D List (Time x 16 Compartments) of surface-relative GF99 saturation for heatmap. """ if not samples: - return [] - - # Initialize octodeco profile with provided GFs - # DiveProfile expects GFs in the 0-100 range (default 35/70) + return [], None, None + + # ... rest of setup ... profile = DiveProfile(gf_low=gf_low, gf_high=gf_high) air = Air() profile.add_gas(air) # Feed samples into profile + # octo-deco requires the first point to be at time 0.0 + first_sample_time = samples[0].get('time_minutes', 0) + for i, s in enumerate(samples): + # Shift all samples so the first one starts at 0.0 to satisfy octo-deco's requirement + # and ensure we have a valid baseline for tissue loading. + normalized_time = max(0.0, s.get('time_minutes', 0) - first_sample_time) + if i == 0: # Overwrite the default initial point at time 0 - profile._points[0].time = s['time_minutes'] + profile._points[0].time = 0.0 profile._points[0].depth = s['depth'] profile._points[0].gas = air else: - profile._append_point_abstime(s['time_minutes'], s['depth'], air) - - # Run the full deco simulation - # This calculates gas uptake/release and the resulting ceiling line + profile._append_point_abstime(normalized_time, s['depth'], air) + profile.update_deco_info() # Extract the ceiling from the library's internal dataframe @@ -46,4 +53,23 @@ def calculate_deco_ceiling(samples: List[Dict[str, Any]], gf_low: int = 30, gf_h ceilings = df['Ceil'].tolist() # Round for storage and UI - return [round(float(c), 2) for c in ceilings] + ceiling_list = [round(float(c), 2) for c in ceilings] + + # Extract final tissue status and full heatmap data + final_saturation = None + heatmap_data = [] + all_points = profile.points() + + for p in all_points: + # Calculate GF99 relative to SURFACE (1.0 bar) for the heatmap + # This shows how "at risk" the tissues are if the diver ascended instantly + if hasattr(p, 'tissue_state'): + # GF99s(1.0) returns a list of 16 GF99 percentages relative to surface + row = [round(float(gf), 1) for gf in p.tissue_state.GF99s(1.0)] + heatmap_data.append(row) + + if all_points: + # For the final saturation bar chart, use the last point's heatmap row + final_saturation = heatmap_data[-1] + + return ceiling_list, final_saturation, heatmap_data diff --git a/backend/tests/test_deco_service.py b/backend/tests/test_deco_service.py index 0b0389e5..f4318262 100644 --- a/backend/tests/test_deco_service.py +++ b/backend/tests/test_deco_service.py @@ -2,7 +2,10 @@ from app.services.deco_service import calculate_deco_ceiling def test_calculate_deco_ceiling_empty(): - assert calculate_deco_ceiling([]) == [] + ceilings, tissues, heatmap = calculate_deco_ceiling([]) + assert ceilings == [] + assert tissues is None + assert heatmap is None def test_calculate_deco_ceiling_basic(): # A simple dive: 10 mins at 30m @@ -13,11 +16,18 @@ def test_calculate_deco_ceiling_basic(): 'depth': 30.0 if i > 0 else 0.0 }) - ceilings = calculate_deco_ceiling(samples, gf_low=30, gf_high=70) + ceilings, tissues, heatmap = calculate_deco_ceiling(samples, gf_low=30, gf_high=70) assert len(ceilings) == len(samples) - # At 30m for 10 mins, we might not have mandatory deco but we should have tissue loading - # The ceiling should be 0 or very shallow for an air dive at 30m for 10 min assert all(isinstance(c, (int, float)) for c in ceilings) + + # Tissue data should be present + assert tissues is not None + assert len(tissues) == 16 + assert all(isinstance(val, (int, float)) for val in tissues) + + # Heatmap data should be present + assert heatmap is not None + assert len(heatmap) == len(samples) def test_calculate_deco_ceiling_with_deco(): # A deeper/longer dive to trigger mandatory deco @@ -30,9 +40,21 @@ def test_calculate_deco_ceiling_with_deco(): for i in range(3, 23): samples.append({'time_minutes': float(i), 'depth': 40.0}) - ceilings = calculate_deco_ceiling(samples, gf_low=30, gf_high=70) + # Ascent to surface + samples.append({'time_minutes': 27.0, 'depth': 0.0}) + + ceilings, tissues, heatmap = calculate_deco_ceiling(samples, gf_low=30, gf_high=70) # Check that ceiling increases over time assert max(ceilings) > 0 # Final ceiling should be significant - assert ceilings[-1] > 3.0 + assert ceilings[-1] > 3.0 + + # Check tissues (should be high for fast compartments) + assert tissues is not None + # Fast compartments (index 0-2) should be heavily loaded + assert any(val > 100 for val in tissues[:5]) + + # Check heatmap + assert heatmap is not None + assert len(heatmap) == len(samples) diff --git a/backend/tests/test_garmin_suunto_import.py b/backend/tests/test_garmin_suunto_import.py index 2e69ff64..1d643a45 100644 --- a/backend/tests/test_garmin_suunto_import.py +++ b/backend/tests/test_garmin_suunto_import.py @@ -88,3 +88,11 @@ def test_parse_suunto_style_fit_with_calculated_deco(): # Verify GF inclusion in dive info assert "Deco Model: Bühlmann ZH-L16 (GF 30/70)" in dive["dive_information"] + + # Verify tissue saturation data + assert "tissue_saturation" in dive["profile_data"] + assert len(dive["profile_data"]["tissue_saturation"]) == 16 + + # Verify tissue heatmap data + assert "tissue_heatmap" in dive["profile_data"] + assert len(dive["profile_data"]["tissue_heatmap"]) == len(samples) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a5ecc05c..46bbb45a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -305,7 +305,12 @@ function AppContent() { } /> } /> } /> - } /> + + } + /> } />
-
+
-
+
Depth
Avg Depth
Temp @@ -1013,7 +1013,7 @@ const AdvancedDiveProfileChart = ({ {hasDeco && hasStopdepth && (
Ceiling{isDecoCalculated ? ' (Calculated)' : ''} diff --git a/frontend/src/components/TissueHeatmap.jsx b/frontend/src/components/TissueHeatmap.jsx new file mode 100644 index 00000000..b4ee1f71 --- /dev/null +++ b/frontend/src/components/TissueHeatmap.jsx @@ -0,0 +1,129 @@ +import PropTypes from 'prop-types'; +import { useMemo } from 'react'; + +const COMPARTMENTS = 16; +const HALFTIMES = [5, 8, 12.5, 18.5, 27, 38.3, 54.3, 77, 109, 146, 187, 239, 305, 390, 498, 635]; + +/** + * Renders a tissue saturation heatmap similar to octo-deco.nl. + * Shows time on X-axis and 16 Bühlmann compartments on Y-axis. + */ +const TissueHeatmap = ({ heatmapData, samples }) => { + // We need to downsample heatmapData if it's too large (e.g. > 1000 points) + // to maintain browser performance while keeping visual accuracy. + const processedData = useMemo(() => { + if (!heatmapData || heatmapData.length === 0 || !samples || samples.length === 0) return null; + + // Ensure lengths match + const dataLen = Math.min(heatmapData.length, samples.length); + const targetPoints = 200; // Resolution of the heatmap + const step = Math.max(1, Math.floor(dataLen / targetPoints)); + + const rows = []; + for (let i = 0; i < dataLen; i += step) { + rows.push({ + time: samples[i].time_minutes, + values: heatmapData[i], + }); + } + return rows; + }, [heatmapData, samples]); + + if (!processedData) return null; + + // Color mapping: Blue (Ongassing) -> Green (Safe Offgassing) -> Yellow (Caution) -> Red (Deco/Violation) + const getColor = val => { + if (val < -100) return '#1e3a8a'; // Deep Blue (Fast ongassing) + if (val < -50) return '#3b82f6'; // Blue (Moderate ongassing) + if (val < 0) return '#93c5fd'; // Light Blue (Slow ongassing) + if (val === 0) return '#f3f4f6'; // Equilibrium (Gray) + if (val < 50) return '#bbf7d0'; // Safe Offgassing (Light Green) + if (val < 80) return '#22c55e'; // Safe Offgassing (Green) + if (val < 99) return '#eab308'; // Caution (Yellow) + return '#ef4444'; // Deco/M-Value (Red) + }; + + return ( +
+
+

+ Tissue Loading (ZH-L16) +

+

GF99% Evolution

+
+ +
+ {/* The Heatmap Grid */} +
+ {/* Y-Axis Labels (Compartments) */} +
+ {HALFTIMES.slice() + .reverse() + .map((ht, i) => ( + {ht}m + ))} +
+ + {/* Heatmap Columns */} +
+ {processedData.map((col, colIdx) => ( +
+ {col.values.map((val, rowIdx) => ( +
+ {/* Simple Tooltip on hover */} +
+ Time: {col.time.toFixed(1)}m
+ Comp: {HALFTIMES[rowIdx]}m
+ Load: {val}% +
+
+ ))} +
+ ))} +
+
+ + {/* X-Axis Legend */} +
+ 0 min + {processedData[processedData.length - 1].time.toFixed(0)} min +
+
+ + {/* Legend */} +
+
+
+ Ongassing +
+
+
+ Near Equilibrium +
+
+
+ Safe Offgassing +
+
+
+ Caution (80-99%) +
+
+
+ >100% (Deco) +
+
+
+ ); +}; + +TissueHeatmap.propTypes = { + heatmapData: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)), + samples: PropTypes.arrayOf(PropTypes.object), +}; + +export default TissueHeatmap; diff --git a/frontend/src/components/TissueSaturationChart.jsx b/frontend/src/components/TissueSaturationChart.jsx new file mode 100644 index 00000000..c38de940 --- /dev/null +++ b/frontend/src/components/TissueSaturationChart.jsx @@ -0,0 +1,153 @@ +import PropTypes from 'prop-types'; +import { useMemo } from 'react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, + ReferenceLine, +} from 'recharts'; + +const HALFTIMES = [5, 8, 12.5, 18.5, 27, 38.3, 54.3, 77, 109, 146, 187, 239, 305, 390, 498, 635]; + +/** + * Visualizes tissue saturation for all 16 Bühlmann compartments at the end of a dive. + */ +const TissueSaturationChart = ({ saturationData, gfHigh }) => { + const data = useMemo(() => { + if (!saturationData || saturationData.length !== 16) return []; + return saturationData.map((gf99, index) => ({ + name: `C${index + 1}`, + halftime: HALFTIMES[index], + gf99: gf99, + display_name: `${HALFTIMES[index]}m`, + })); + }, [saturationData]); + + if (data.length === 0) return null; + + return ( +
+
+
+

Final Tissue Status

+

+ Relative saturation (GF99) of all 16 Bühlmann ZH-L16 compartments after surfacing. +

+
+ {gfHigh && ( +
+ GF High: {gfHigh} +
+ )} +
+ +
+ + + + + Math.max(110, dataMax)]} + unit='%' + /> + { + if (active && payload && payload.length) { + const d = payload[0].payload; + return ( +
+
Compartment {d.name}
+
Halftime: {d.halftime} min
+
+ Saturation: {d.gf99}% +
+
+ ); + } + return null; + }} + /> + + {gfHigh && ( + + )} + + {data.map((entry, index) => { + let color = '#22c55e'; // Green (< 80%) + if (entry.gf99 > 100) + color = '#ef4444'; // Red (> M-Value) + else if (entry.gf99 > 80) + color = '#eab308'; // Yellow (Near M-Value) + else if (gfHigh && entry.gf99 > gfHigh) color = '#3b82f6'; // Blue (Above GF High but below M-Value) + + return ; + })} + +
+
+
+ +
+
+
+ Safe (<80%) +
+
+
+ Caution (80-100%) +
+
+
+ Over M-Value (>100%) +
+ {gfHigh && ( +
+
+ Above GF High +
+ )} +
+
+ ); +}; + +TissueSaturationChart.propTypes = { + saturationData: PropTypes.arrayOf(PropTypes.number), + gfHigh: PropTypes.number, +}; + +export default TissueSaturationChart; diff --git a/frontend/src/pages/DiveDetail.jsx b/frontend/src/pages/DiveDetail.jsx index ca7e550e..663c75a5 100644 --- a/frontend/src/pages/DiveDetail.jsx +++ b/frontend/src/pages/DiveDetail.jsx @@ -78,13 +78,15 @@ import { handleRateLimitError } from '../utils/rateLimitHandler'; import { calculateRouteBearings, formatBearing } from '../utils/routeUtils'; import { slugify } from '../utils/slugify'; import { getTagColor } from '../utils/tagHelpers'; -import { renderTextWithLinks } from '../utils/textHelpers'; +import { renderTextWithLinks, parseGradientFactors } from '../utils/textHelpers'; import { isYouTubeUrl, isVimeoUrl } from '../utils/youtubeHelpers'; import NotFound from './NotFound'; import UnprocessableEntity from './UnprocessableEntity'; const AdvancedDiveProfileChart = lazy(() => import('../components/AdvancedDiveProfileChart')); +const TissueSaturationChart = lazy(() => import('../components/TissueSaturationChart')); +const TissueHeatmap = lazy(() => import('../components/TissueHeatmap')); const DiveDetail = () => { const { id, slug } = useParams(); @@ -978,6 +980,23 @@ const DiveDetail = () => { } />
+ {profileData?.tissue_heatmap && ( +
+ +
+ )} + + {profileData?.tissue_saturation && ( +
+ +
+ )} { }); }; +/** + * Parses Gradient Factors (GF Low/High) from a text string. + * @param {string} text - The text to parse + * @returns {Object|null} Object with low and high properties or null + */ +export const parseGradientFactors = text => { + if (!text || typeof text !== 'string') return null; + + // Matches patterns like 'GF 30/70', 'GF: 30/70', '(GF 30/70)', 'GF 30 / 70' + const pattern = /(?:GF|Gradient Factor)[s]?[:]?\s*\(?(\d+)\s*\/\s*(\d+)\)?/i; + const match = text.match(pattern); + + if (match) { + return { + low: parseInt(match[1], 10), + high: parseInt(match[2], 10), + }; + } + return null; +}; + /** * Formats gas strings for display, e.g., "Nitrox 32%" to "EAN32" * @param {string} gasStr - The gas string to format