Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 36 additions & 17 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
CMD ["/app/startup.sh"]
53 changes: 53 additions & 0 deletions backend/app/routers/dives/dives_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
import os
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
from app.schemas import DiveCreate, DiveUpdate, DiveResponse, DiveMediaCreate, DiveMediaResponse, DiveTagResponse
Expand Down Expand Up @@ -174,6 +179,40 @@ def get_dive_profile(
# Clean up temporary file
if os.path.exists(temp_path):
os.remove(temp_path)

# 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)

# 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, final_saturation, heatmap_data = calculate_deco_ceiling(
samples,
gf_low=gf_low,
gf_high=gf_high
)

# 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):
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}")

return profile_data
except HTTPException:
Expand Down Expand Up @@ -206,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"
Expand Down
51 changes: 51 additions & 0 deletions backend/app/routers/dives/imports/garmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ 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


def parse_garmin_fit_file(content: bytes, db: Session, current_user_id: int, user_dives=None, all_sites=None):
"""
Expand Down Expand Up @@ -289,6 +293,39 @@ 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, final_saturation, heatmap_data = 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

# 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}")

# Duplicate detection
existing = find_existing_dive(
db, current_user_id,
Expand Down Expand Up @@ -322,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()
Expand Down
75 changes: 75 additions & 0 deletions backend/app/services/deco_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from octodeco.deco.DiveProfile import DiveProfile
from octodeco.deco.Gas import Air, Gas
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) -> Tuple[List[float], Optional[List[float]], Optional[List[List[float]]]]:
"""
Calculate the decompression ceiling, final tissue saturation, and heatmap data.
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:
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 [], 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 = 0.0
profile._points[0].depth = s['depth']
profile._points[0].gas = air
else:
profile._append_point_abstime(normalized_time, s['depth'], air)

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
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
41 changes: 40 additions & 1 deletion backend/app/utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
10 changes: 9 additions & 1 deletion backend/docker-test-github-actions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading