"""
Indian ARGO CTD / BGC Float Dashboard
======================================
Streamlit re-implementation per INCOIS PRD.
Data Source: Argo GDAC (IFREMER)
Components
----------
1. Geospatial float-position map (colour-coded by institution/region)
2. Annual float-count bar chart (1999–present)
3. BGC profile KPI tiles (DOXY, Chla, Nitrate, pH)
4. Active floats/profiles last-7-days treemap
5. Float-age donut chart
6. DAC/Institution summary table
"""
# ==================== IMPORTS ====================
import streamlit as st
import os
import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from datetime import datetime, timedelta
from pathlib import Path
import warnings
import xarray as xr
warnings.filterwarnings("ignore")
# ==================== PATHS & CONSTANTS ====================
BASE_DIR = Path(__file__).parent
CACHE_DIR = BASE_DIR / "cache"
PROF_FILE = BASE_DIR / "ar_index_global_prof.txt"
BIO_FILE = BASE_DIR / "argo_bio-profile_index.txt"
META_FILE = BASE_DIR / "ar_index_global_meta.txt"
# Institution → colour (PRD §7.1.2 / Table 6)
REGION_COLORS = {
"IN": "#8BC34A", # Indian Ocean – olive green
"AO": "#00BCD4", # Arabian / Atlantic Ocean – cyan
"BO": "#FF5722", # Bay of Bengal – deep orange
"CS": "#FFC107", # Coral Sea – amber
"HZ": "#9E9E9E", # Marginal seas – grey
"IF": "#4CAF50", # Intermediate / Far seas – green
"JA": "#2196F3", # JMA – blue
"KO": "#E91E63", # KIOST – pink
"KM": "#9C27B0", # KMA – purple
"ME": "#795548", # MEDS – brown
"NM": "#607D8B", # NMDIS – blue-grey
}
# KPI tile colours (PRD §7.3.2 / Table 7)
KPI_COLORS = {
"DOXY": "#00BCD4",
"Chla": "#8BC34A",
"Nitrate": "#FF8F00",
"pH": "#4CAF50",
}
# Age-group colours (PRD §7.5.2)
AGE_COLORS = {
"00-02": "#673AB7",
"03-05": "#2196F3",
"06-08": "#FF9800",
"09-11": "#F44336",
"12+": "#795548",
}
# Profiler Type (WMO R08 Table) → Human-readable instrument name
PROFILER_TYPE_NAMES = {
831: "P-ALACE", 834: "Provor-II", 835: "Provor-III", 836: "Provor-MT",
837: "Arvor-C", 838: "Arvor-D", 839: "Provor-IV", 840: "Provor (no CT)",
841: "Provor-SBE", 842: "Arvor-CM", 843: "Provor-V", 844: "Arvor",
845: "Webb-PALACE", 846: "APEX", 847: "APEX-EM", 848: "APEX-EM-SBE",
849: "APEX-Deep", 850: "SOLO (no CT)", 851: "SOLO-SBE", 852: "SOLO-FSI",
853: "SOLO2", 854: "S2A", 855: "Ninja (no CT)", 856: "Ninja-D",
857: "Ninja-BGC", 858: "Ninja-Deep", 859: "Ninja-SBE", 860: "Ninja",
861: "ALTO", 862: "Navis-EBR", 863: "Navis-A", 864: "Navis-Deep",
865: "Nova", 869: "Deep ARVOR", 870: "APEX-APF11", 871: "APEX-Deep-APF11",
872: "APEX-BGC", 873: "Arvor-Deep", 874: "APEX-Deep-SBE",
875: "Provor-BGC", 876: "Deep SOLO", 877: "Deep SOLO-MRV",
878: "Deep NINJA", 879: "HM2000", 880: "HM4000", 881: "Deep Arvor-O",
882: "Deep S2A", 883: "Provor-BGC-II", 884: "Arvor-I", 885: "TWR",
886: "SOLO-BGC", 887: "Arvor-RBR", 888: "ALTO-RBR", 889: "Arvor-Deep-RBR",
890: "APEX-RBR", 891: "Navis-RBR",
}
# Colors for top profiler type families
PROFILER_COLORS = {
"APEX": "#4FC3F7", "Arvor": "#FF7043", "SOLO-SBE": "#26A69A",
"SOLO2": "#BA68C8", "Deep ARVOR": "#FFB74D", "Provor-SBE": "#00BCD4",
"S2A": "#F06292", "Navis-A": "#9CCC65", "Provor-MT": "#9575CD",
"SOLO-FSI": "#FFD54F", "Nova": "#90A4AE", "Ninja": "#EF5350",
"Arvor-D": "#42A5F5", "Provor-II": "#66BB6A", "Navis-EBR": "#AB47BC",
"Arvor-CM": "#FFA726", "APEX-Deep-SBE": "#78909C", "APEX-APF11": "#29B6F6",
"Deep NINJA": "#EC407A", "Deep SOLO-MRV": "#5C6BC0",
"Other": "#607D8B",
}
# ==================== PAGE CONFIG ====================
st.set_page_config(
page_title="Indian ARGO CTD_BGC Dashboard",
page_icon="🌊",
layout="wide",
initial_sidebar_state="expanded",
menu_items={
"About": "Indian ARGO CTD/BGC Float Dashboard · INCOIS · Data: IFREMER GDAC"
},
)
# ==================== CUSTOM CSS ====================
st.markdown(
"""
""",
unsafe_allow_html=True,
)
# ==================== HELPER: dark plotly layout ====================
def _dark_layout(**overrides):
"""Return a dark-themed plotly layout dict."""
base = dict(
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font=dict(family="Inter, sans-serif", color="#c8d6e5", size=12),
margin=dict(l=40, r=20, t=40, b=40),
)
base.update(overrides)
return base
# ==================== DATA LOADING ====================
# Global constants for classification
DEEP_PROFILER_TYPES = {862, 864, 876, 882, 869, 863, 873, 874, 886, 877, 875, 884, 872, 879, 865, 860, 878, 861, 871, 870, 881, 853}
@st.cache_data(show_spinner="Loading core-profile index …")
def load_profile_data():
"""Load ar_index_global_prof.txt with Parquet cache (24-h TTL)."""
CACHE_DIR.mkdir(exist_ok=True)
cache_path = CACHE_DIR / "profiles.parquet"
if cache_path.exists():
use_cache = not PROF_FILE.exists()
if not use_cache:
age_h = (datetime.now().timestamp() - cache_path.stat().st_mtime) / 3600
use_cache = age_h < 24
if use_cache:
df = pd.read_parquet(cache_path)
# Ensure is_deep exists (handles stale caches from before this column was added)
if "is_deep" not in df.columns:
df["is_deep"] = df["profiler_type"].isin(DEEP_PROFILER_TYPES) if "profiler_type" in df.columns else False
if "dac" not in df.columns:
df["dac"] = df["file"].str.extract(r"^([^/]+)/")
return df
df = pd.read_csv(PROF_FILE, comment="#")
# Strip whitespace from column names (GDAC files sometimes have spaces)
df.columns = df.columns.str.strip()
# --- Land-mask filtering removed: caused discrepancies ---
df = df.dropna(subset=["latitude", "longitude"])
# Ensure coordinates are within valid ranges [-90, 90] and [-180, 180]
df = df[
(df["latitude"] >= -90) & (df["latitude"] <= 90) &
(df["longitude"] >= -180) & (df["longitude"] <= 180)
]
df["date"] = pd.to_datetime(df["date"], format="%Y%m%d%H%M%S", errors="coerce")
if "date_update" in df.columns:
df["date_update"] = pd.to_datetime(
df["date_update"], format="%Y%m%d%H%M%S", errors="coerce"
)
df["wmo_id"] = df["file"].str.extract(r"/(\d+)/")
df["dac"] = df["file"].str.extract(r"^([^/]+)/")
df["year"] = df["date"].dt.year
df["is_deep"] = df["profiler_type"].isin(DEEP_PROFILER_TYPES)
df.to_parquet(cache_path, index=False)
return df
@st.cache_data(show_spinner="Loading BGC-profile index …")
def load_bio_data():
"""Load argo_bio-profile_index.txt with Parquet cache (24-h TTL)."""
CACHE_DIR.mkdir(exist_ok=True)
cache_path = CACHE_DIR / "bgc_profiles.parquet"
if cache_path.exists():
use_cache = not BIO_FILE.exists()
if not use_cache:
age_h = (datetime.now().timestamp() - cache_path.stat().st_mtime) / 3600
use_cache = age_h < 24
if use_cache:
return pd.read_parquet(cache_path)
df = pd.read_csv(BIO_FILE, comment="#")
df.columns = df.columns.str.strip()
df["date"] = pd.to_datetime(df["date"], format="%Y%m%d%H%M%S", errors="coerce")
df["wmo_id"] = df["file"].str.extract(r"/(\d+)/")
df["year"] = df["date"].dt.year
params_upper = df["parameters"].fillna("").str.upper()
df["has_doxy"] = params_upper.str.contains("DOXY")
df["has_chla"] = params_upper.str.contains("CHLA")
df["has_nitrate"] = params_upper.str.contains("NITRATE")
df["has_ph"] = params_upper.str.contains("PH_IN_SITU")
df.to_parquet(cache_path, index=False)
return df
@st.cache_data(show_spinner="Loading float metadata index …")
def load_meta_data():
"""Load ar_index_global_meta.txt with Parquet cache (24-h TTL).
Provides one row per float (WMO) with profiler_type, institution,
dac, and a human-readable profiler_name from WMO R08.
"""
CACHE_DIR.mkdir(exist_ok=True)
cache_path = CACHE_DIR / "meta.parquet"
if cache_path.exists():
use_cache = not META_FILE.exists()
if not use_cache:
age_h = (datetime.now().timestamp() - cache_path.stat().st_mtime) / 3600
use_cache = age_h < 24
if use_cache:
return pd.read_parquet(cache_path)
df = pd.read_csv(META_FILE, comment="#")
df.columns = df.columns.str.strip()
df["wmo_id"] = df["file"].str.extract(r"/(\d+)/")
df["dac"] = df["file"].str.extract(r"^([^/]+)/")
df["date_update"] = pd.to_datetime(
df["date_update"], format="%Y%m%d%H%M%S", errors="coerce"
)
# Map numeric profiler_type code to human-readable name
df["profiler_name"] = (
df["profiler_type"]
.map(PROFILER_TYPE_NAMES)
.fillna("Unknown")
)
df.to_parquet(cache_path, index=False)
return df
@st.cache_data
def _bgc_wmo_set(_df_bio):
"""Set of WMO IDs that have at least one BGC profile."""
return set(_df_bio["wmo_id"].dropna().unique())
# ==================== LAUNCH DATE HELPERS ====================
def _read_launch_date_from_nc(meta_path):
"""Read LAUNCH_DATE from a single float meta NetCDF. Returns 14-char string or None."""
try:
ds = xr.open_dataset(meta_path)
if "LAUNCH_DATE" not in ds:
ds.close()
return None
raw = ds.LAUNCH_DATE.values
ds.close()
if hasattr(raw, "item"):
try:
raw = raw.item()
except Exception:
pass
if isinstance(raw, bytes):
return raw.decode("utf-8", errors="ignore").strip()
return str(raw).strip()
except Exception:
return None
def _load_launch_date_csv(launch_csv):
"""Load incois_launch_dates.csv, returning empty DataFrame on error."""
if not launch_csv.exists():
return pd.DataFrame(columns=["wmo_id", "launch_date"])
try:
df = pd.read_csv(launch_csv, dtype=str)
df["wmo_id"] = df["wmo_id"].str.strip()
return df
except Exception:
return pd.DataFrame(columns=["wmo_id", "launch_date"])
def _scan_existing_nc_for_launch_dates(incois_wmo_set, launch_csv):
"""
Scan already-downloaded more_components/{wmo}_meta.nc or inactive_floats/{wmo}_meta.nc files
and extract LAUNCH_DATE for any INCOIS float not yet in the CSV.
Returns count of NEW entries added.
"""
existing = _load_launch_date_csv(launch_csv)
already_have = set(existing["wmo_id"].tolist())
new_rows = []
for wmo in incois_wmo_set:
if wmo in already_have:
continue
meta_path = BASE_DIR / f"more_components/{wmo}_meta.nc"
if not meta_path.exists():
meta_path = BASE_DIR / f"inactive_floats/{wmo}_meta.nc"
if not meta_path.exists():
continue
ld = _read_launch_date_from_nc(meta_path)
if ld and len(ld) >= 8:
new_rows.append({"wmo_id": wmo, "launch_date": ld})
if new_rows:
CACHE_DIR.mkdir(exist_ok=True)
updated = pd.concat([existing, pd.DataFrame(new_rows)], ignore_index=True)
updated = updated.drop_duplicates("wmo_id")
updated.to_csv(launch_csv, index=False)
return len(new_rows)
# ==================== LOAD DATA ====================
with st.spinner("🌊 Initialising ARGO Dashboard …"):
df_prof = load_profile_data()
df_bio = load_bio_data()
df_meta = load_meta_data()
bgc_wmos = _bgc_wmo_set(df_bio)
# Derived column: is this float a BGC float?
df_prof["is_bgc"] = df_prof["wmo_id"].isin(bgc_wmos)
wmos_with_doxy = set(df_bio[df_bio["has_doxy"]]["wmo_id"].dropna().unique()) if "has_doxy" in df_bio.columns else set()
wmos_with_chla = set(df_bio[df_bio["has_chla"]]["wmo_id"].dropna().unique()) if "has_chla" in df_bio.columns else set()
wmos_with_nitrate = set(df_bio[df_bio["has_nitrate"]]["wmo_id"].dropna().unique()) if "has_nitrate" in df_bio.columns else set()
wmos_with_ph = set(df_bio[df_bio["has_ph"]]["wmo_id"].dropna().unique()) if "has_ph" in df_bio.columns else set()
# Enrich profiles with profiler_name from meta (authoritative per-float source)
_meta_pname = df_meta.set_index("wmo_id")["profiler_name"]
df_prof["profiler_name"] = df_prof["wmo_id"].map(_meta_pname).fillna("Unknown")
@st.dialog("Float Information", width="large")
def show_float_details(wmo):
# Determine if active/inactive to set target folder
float_profiles = df_prof[df_prof["wmo_id"] == str(wmo)]
is_active = False
if not float_profiles.empty:
latest_prof_date = float_profiles["date"].max()
if latest_prof_date is not pd.NaT and latest_prof_date.year >= 2026:
is_active = True
folder_name = "more_components" if is_active else "inactive_floats"
meta_path = BASE_DIR / f"{folder_name}/{wmo}_meta.nc"
prof_path = BASE_DIR / f"{folder_name}/{wmo}_prof.nc"
# Auto-download from IFREMER GDAC if files do not exist
if not meta_path.exists() or not prof_path.exists():
import urllib.request
dac_row = df_meta[df_meta["wmo_id"] == wmo]
if len(dac_row) > 0:
dac = dac_row.iloc[0]["dac"]
else:
dac = "incois" # fallback
meta_url = f"https://data-argo.ifremer.fr/dac/{dac}/{wmo}/{wmo}_meta.nc"
prof_url = f"https://data-argo.ifremer.fr/dac/{dac}/{wmo}/{wmo}_prof.nc"
target_dir = BASE_DIR / folder_name
target_dir.mkdir(exist_ok=True)
with st.spinner(f"Downloading GDAC NetCDF files for {wmo} ({dac}) to local {folder_name} folder..."):
try:
if not meta_path.exists():
urllib.request.urlretrieve(meta_url, meta_path)
if not prof_path.exists():
urllib.request.urlretrieve(prof_url, prof_path)
except Exception as e:
st.error(f"Failed to download files from {meta_url}. Error: {e}")
return
try:
ds_meta = xr.open_dataset(meta_path)
ds_prof = xr.open_dataset(prof_path)
def d(val):
if hasattr(val, "item") and callable(val.item):
try:
val = val.item()
except:
pass
if isinstance(val, bytes):
return val.decode('utf-8', errors='ignore').strip()
elif isinstance(val, np.ndarray) and val.dtype.kind == 'S':
return ", ".join([v.decode('utf-8', errors='ignore').strip() for v in val.flat if v.decode('utf-8', errors='ignore').strip()])
elif isinstance(val, (list, np.ndarray)):
return ", ".join([d(v) for v in val])
return str(val).strip()
maker = d(ds_meta.PLATFORM_MAKER.values) if 'PLATFORM_MAKER' in ds_meta else 'N/A'
serial = d(ds_meta.FLOAT_SERIAL_NO.values) if 'FLOAT_SERIAL_NO' in ds_meta else 'N/A'
ptype = d(ds_meta.PLATFORM_TYPE.values) if 'PLATFORM_TYPE' in ds_meta else 'N/A'
trans = d(ds_meta.TRANS_SYSTEM.values) if 'TRANS_SYSTEM' in ds_meta else 'N/A'
owner = d(ds_meta.FLOAT_OWNER.values) if 'FLOAT_OWNER' in ds_meta else 'N/A'
dc_map = {
"AO": "AOML", "BO": "BODC", "CO": "Coriolis", "CS": "CSIRO",
"IN": "INCOIS", "JA": "JMA", "KM": "KMA", "ME": "MEDS",
"RU": "RU", "HZ": "CSIO", "NM": "NMDIS"
}
if 'DATA_CENTRE' in ds_meta:
dc_code = d(ds_meta.DATA_CENTRE.values).upper()
dc = dc_map.get(dc_code, dc_code)
elif 'OPERATING_INSTITUTION' in ds_meta:
dc = d(ds_meta.OPERATING_INSTITUTION.values)
else:
dc = 'N/A'
sensors = d(ds_meta.SENSOR.values) if 'SENSOR' in ds_meta else 'N/A'
ptt = d(ds_meta.PTT.values) if 'PTT' in ds_meta else 'N/A'
launch_date = d(ds_meta.LAUNCH_DATE.values) if 'LAUNCH_DATE' in ds_meta else 'N/A'
if launch_date != 'N/A' and len(launch_date) == 14:
try:
dt = datetime.strptime(launch_date, '%Y%m%d%H%M%S')
launch_date_fmt = dt.strftime('%d/%m/%Y %H:%M:%S')
age = f"{(datetime.now() - dt).days / 365.25:.2f} years ago"
except:
launch_date_fmt = launch_date
age = "N/A"
else:
launch_date_fmt = launch_date
age = "N/A"
launch_lat = float(ds_meta.LAUNCH_LATITUDE.values) if 'LAUNCH_LATITUDE' in ds_meta else 'N/A'
launch_lon = float(ds_meta.LAUNCH_LONGITUDE.values) if 'LAUNCH_LONGITUDE' in ds_meta else 'N/A'
project = d(ds_meta.PROJECT_NAME.values) if 'PROJECT_NAME' in ds_meta else 'N/A'
pi = d(ds_meta.PI_NAME.values) if 'PI_NAME' in ds_meta else 'N/A'
if 'CYCLE_NUMBER' in ds_prof and len(ds_prof.CYCLE_NUMBER) > 0:
cycle = int(np.nanmax(ds_prof.CYCLE_NUMBER.values))
juld = ds_prof.JULD.values
last_date_np = juld[~np.isnat(juld)]
if len(last_date_np) > 0:
dt_last = pd.to_datetime(last_date_np[-1])
last_date = dt_last.strftime('%d/%m/%Y %H:%M:%S')
if launch_date != 'N/A' and len(launch_date) == 14:
try:
dt_launch = datetime.strptime(launch_date, '%Y%m%d%H%M%S')
cycle_age_years = (dt_last - dt_launch).days / 365.25
cycle_age = f"{cycle_age_years:.2f} years old"
except:
cycle_age = "N/A"
else:
cycle_age = "N/A"
else:
last_date = "N/A"
cycle_age = "N/A"
try:
def get_ds_var(name):
adj_name = f"{name}_ADJUSTED"
if adj_name in ds_prof:
val = ds_prof[adj_name].values
if not np.isnan(val).all():
return val
if name in ds_prof:
return ds_prof[name].values
return None
pres_data = get_ds_var('PRES')
if pres_data is not None:
valid_cycles = np.where(~np.isnan(pres_data).all(axis=1))[0]
if len(valid_cycles) > 0:
last_valid_idx = valid_cycles[-1]
last_pres = pres_data[last_valid_idx]
temp_data = get_ds_var('TEMP')
last_temp = temp_data[last_valid_idx] if temp_data is not None else np.full_like(last_pres, np.nan)
psal_data = get_ds_var('PSAL')
last_psal = psal_data[last_valid_idx] if psal_data is not None else np.full_like(last_pres, np.nan)
valid_idx = ~np.isnan(last_pres)
pres_v = last_pres[valid_idx]
temp_v = last_temp[valid_idx]
psal_v = last_psal[valid_idx]
if len(pres_v) > 0:
surface_idx = np.argmin(pres_v)
bottom_idx = np.argmax(pres_v)
surf_data = f"{pres_v[surface_idx]:.2f} dbar {temp_v[surface_idx]:.3f}°C {psal_v[surface_idx]:.3f} PSU"
bott_data = f"{pres_v[bottom_idx]:.2f} dbar {temp_v[bottom_idx]:.3f}°C {psal_v[bottom_idx]:.3f} PSU"
else:
surf_data = "N/A"
bott_data = "N/A"
else:
surf_data = "N/A"
bott_data = "N/A"
else:
surf_data = "N/A"
bott_data = "N/A"
except:
surf_data = "N/A"
bott_data = "N/A"
else:
cycle = "N/A"
last_date = "N/A"
cycle_age = "N/A"
surf_data = "N/A"
bott_data = "N/A"
status = "Inactive"
if last_date != "N/A":
try:
dt_last = datetime.strptime(last_date, '%d/%m/%Y %H:%M:%S')
if (datetime.now() - dt_last).days <= 90:
status = "Active"
except:
pass
status_color = "#EF5350" if status == "Inactive" else "#66BB6A"
st.markdown("### Main Information")
st.markdown(f"""
About Float
WMO {wmo} |
Platform maker {maker} |
Float serial number {serial} |
Platform type {ptype} |
Transmission system {trans} |
PTT {ptt} |
Owner {owner} |
Data Centre {dc} |
Sensors {sensors} |
Deployment
Launched {age} {launch_date_fmt} |
Deployment Latitude {launch_lat} |
Deployment Longitude {launch_lon} |
Ship frv sagar sampada |
Cruise
|
Project {project} |
Principal Investigator {pi} |
Cycle activity
Status {status} |
Age {cycle_age} |
Last profile date {last_date} |
Cycle {cycle} |
Last Surface Data {surf_data} |
Last Bottom Data {bott_data} |
""", unsafe_allow_html=True)
st.markdown("---")
st.markdown("#### Argo parameters section charts and overlaid profiles")
try:
import plot_utils
cycles, dates, pres, temp, psal, rho = plot_utils.get_valid_data(ds_prof)
if len(pres) > 0:
if len(dates) > 0:
min_date = pd.to_datetime(np.nanmin(dates)).strftime('%d/%m/%Y')
max_date = pd.to_datetime(np.nanmax(dates)).strftime('%d/%m/%Y')
date_suffix = f"Argo float {wmo} between {min_date} and {max_date}"
else:
date_suffix = f"Argo float {wmo}"
c1, c2, c3 = st.columns(3)
with c1:
fig = plot_utils.create_ts_diagram(cycles, temp, psal, wmo, title=f"T/S Diagram
{date_suffix}")
st.plotly_chart(fig, use_container_width=True)
with c2:
fig = plot_utils.create_section_chart(dates, pres, temp, "Temperature (°C)", f"Section chart TEMP
{date_suffix}", wmo)
st.plotly_chart(fig, use_container_width=True)
with c3:
fig = plot_utils.create_section_chart(dates, pres, psal, "Salinity (PSU)", f"Section chart PSAL
{date_suffix}", wmo)
st.plotly_chart(fig, use_container_width=True)
c4, c5, c6 = st.columns(3)
with c4:
fig = plot_utils.create_section_chart(dates, pres, rho, "Potential Density (kg/m³)", f"Section chart RHO
{date_suffix}", wmo)
st.plotly_chart(fig, use_container_width=True)
with c5:
fig = plot_utils.create_overlaid_profiles(temp, pres, cycles, "Temperature (°C)", f"Overlaid profiles TEMP
{date_suffix}", wmo)
st.plotly_chart(fig, use_container_width=True)
with c6:
fig = plot_utils.create_overlaid_profiles(psal, pres, cycles, "Salinity (PSU)", f"Overlaid profiles PSAL
{date_suffix}", wmo)
st.plotly_chart(fig, use_container_width=True)
c7, c8, c9 = st.columns(3)
with c7:
fig = plot_utils.create_overlaid_profiles(rho, pres, cycles, "Potential Density (kg/m³)", f"Overlaid profiles RHO
{date_suffix}", wmo)
st.plotly_chart(fig, use_container_width=True)
else:
st.info("No valid profile data available for technical plots.")
except Exception as e:
st.error(f"Error rendering technical plots: {e}")
except Exception as e:
st.error(f"Error loading float details: {e}")
# ==================== HEADER ====================
_cache_path = CACHE_DIR / "profiles.parquet"
_last_refresh = (
datetime.fromtimestamp(_cache_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
if _cache_path.exists() else "N/A"
)
st.markdown(
f"""
""",
unsafe_allow_html=True,
)
# ==================== SIDEBAR FILTERS (PRD §6) ====================
# Read URL query params for shareable filter state
qp = st.query_params
# ── Reset Trigger for Map Home Button ──
st.markdown(
"""
""",
unsafe_allow_html=True
)
reset_trigger = st.text_input("Reset Trigger", placeholder="reset_trigger_placeholder", key="reset_trigger", label_visibility="collapsed")
if reset_trigger == "true":
if "main_map" in st.session_state:
st.session_state.main_map = None
if "bar_chart" in st.session_state:
st.session_state.bar_chart = None
if "last_viewed_wmo" in st.session_state:
st.session_state.last_viewed_wmo = None
if "search_wmo_input" in st.session_state:
st.session_state.search_wmo_input = ""
st.query_params.update({"wmo": ""})
st.session_state.reset_trigger = ""
st.rerun()
st.components.v1.html(
"""
""",
height=1,
width=1
)
with st.sidebar:
# ── circular logo ──
try:
import base64
logo_path = BASE_DIR / "incois_logo.jpg"
if logo_path.exists():
with open(logo_path, "rb") as f:
logo_data = base64.b64encode(f.read()).decode("utf-8")
st.markdown(
f"""
""",
unsafe_allow_html=True
)
except Exception as e:
pass
st.markdown("## 🔍 Filters")
# ── Refresh ──
if st.button("🔄 Refresh Data", use_container_width=True, type="primary"):
if PROF_FILE.exists():
for f in CACHE_DIR.glob("*.parquet"):
try:
f.unlink()
except:
pass
st.cache_data.clear()
st.rerun()
st.markdown("---")
# ── WMO search ──
search_wmo = st.text_input(
"🔎 Search WMO Float ID",
value=qp.get("wmo", ""),
placeholder="e.g. 2902115, 2902116",
help="Comma-separated WMO numbers",
key="search_wmo_input",
)
# ── Parameter Filter ──
st.markdown("### Parameters")
_param_options = ["Pressure", "Temperature", "Salinity", "Oxygen (DOXY)", "Chlorophyll (Chla)", "Nitrate", "pH"]
selected_params = st.multiselect(
"🔎 Search by Parameters",
options=_param_options,
default=[],
placeholder="Select parameters",
help="Filter floats that have these parameters"
)
# ── QC Mode ──
_qc_options = ["All", "Delayed", "Real time"]
_qc_default = _qc_options.index(qp.get("qc", "All")) if qp.get("qc", "All") in _qc_options else 0
qc_mode = st.selectbox(
"QC Mode",
_qc_options,
index=_qc_default,
help="All = all data; Delayed = quality-checked; Real time = latest",
)
# ── Community ──
st.markdown("### Community")
comm_all = st.checkbox("ALL", value=qp.get("comm_all", "1") == "1", key="comm_all")
comm_null = st.checkbox("NULL", value=qp.get("comm_null", "0") == "1", key="comm_null")
comm_argos = st.checkbox("ARGOS", value=qp.get("comm_argos", "0") == "1", key="comm_argos")
comm_beidou = st.checkbox("BEIDOU", value=qp.get("comm_beidou", "0") == "1", key="comm_beidou")
comm_iridium = st.checkbox("IRIDIUM", value=qp.get("comm_iridium", "0") == "1", key="comm_iridium")
# ── Network ──
st.markdown("### Network")
net_all = st.checkbox("All (Inclusive)", value=qp.get("net_all", "1") == "1", key="net_all")
net_bgc = st.checkbox("BGC (Bio-Argo)", value=qp.get("net_bgc", "0") == "1", key="net_bgc")
net_ctd = st.checkbox("CTD (Core Argo)", value=qp.get("net_ctd", "0") == "1", key="net_ctd")
net_dep = st.checkbox("DEP (Deep Argo)", value=qp.get("net_dep", "0") == "1", key="net_dep")
# ── Float Model / Profiler Type ──
st.markdown("### Float Model")
_available_models = sorted(df_meta["profiler_name"].dropna().unique().tolist())
selected_profiler_types = st.multiselect(
"Select Float Model(s)",
options=_available_models,
default=[],
placeholder="All models (no filter)",
help="Filter by instrument model from metadata registry (WMO R08)",
)
# ── Map Options ──
st.markdown("### Map Options")
show_live_only = st.toggle("Live Floats Only (90d)", value=qp.get("live_only", "0") == "1", help="Hide historical dead floats to reduce map clutter")
# ── Date range ──
st.markdown("### Date Range")
d_col1, d_col2 = st.columns(2)
with d_col1:
_min_d = datetime(1960, 1, 1)
_max_d = datetime.now()
# Determine default start date (earliest profile or 1960)
_default_start = _min_d
if "df_prof" in locals() and len(df_prof) > 0 and pd.notna(df_prof["date"].min()):
_default_start = df_prof["date"].min().to_pydatetime()
_sd = datetime.strptime(qp.get("sd", ""), "%Y-%m-%d") if "sd" in qp and qp.get("sd", "") else _default_start
start_date = st.date_input("Start", value=_sd, min_value=_min_d, max_value=_max_d)
with d_col2:
_ed = datetime.strptime(qp.get("ed", ""), "%Y-%m-%d") if "ed" in qp and qp.get("ed", "") else _max_d
end_date = st.date_input("End", value=_ed, min_value=_min_d, max_value=_max_d)
# ── Longitude ──
st.markdown("### Longitude")
_lon_lo = float(qp.get("lon_lo", "20.0"))
_lon_hi = float(qp.get("lon_hi", "145.0"))
lon_range = st.slider(
"Longitude range",
min_value=-180.0,
max_value=180.0,
value=(_lon_lo, _lon_hi),
step=0.5,
label_visibility="collapsed",
)
# ── Latitude ──
st.markdown("### Latitude")
_lat_lo = float(qp.get("lat_lo", "-70.1"))
_lat_hi = float(qp.get("lat_hi", "30.0"))
lat_range = st.slider(
"Latitude range",
min_value=-90.0,
max_value=90.0,
value=(_lat_lo, _lat_hi),
step=0.5,
label_visibility="collapsed",
)
# ── Sync current filter state to URL query params ──
st.query_params.update({
"wmo": search_wmo,
"qc": qc_mode,
"comm_all": "1" if comm_all else "0",
"comm_null": "1" if comm_null else "0",
"comm_argos": "1" if comm_argos else "0",
"comm_beidou": "1" if comm_beidou else "0",
"comm_iridium": "1" if comm_iridium else "0",
"net_all": "1" if net_all else "0",
"net_bgc": "1" if net_bgc else "0",
"net_ctd": "1" if net_ctd else "0",
"net_dep": "1" if net_dep else "0",
"sd": str(start_date),
"ed": str(end_date),
"lon_lo": str(lon_range[0]),
"lon_hi": str(lon_range[1]),
"lat_lo": str(lat_range[0]),
"lat_hi": str(lat_range[1]),
"live_only": "1" if show_live_only else "0",
})
# ==================== FILTER LOGIC (PRD §6.1) ====================
def apply_filters(df, *, is_bio=False):
"""Apply every sidebar filter to *df* and return the filtered copy."""
out = df.copy()
# Date
if "date" in out.columns:
out = out[
(out["date"] >= pd.Timestamp(start_date))
& (out["date"] <= pd.Timestamp(end_date))
]
# Lon / Lat
if "longitude" in out.columns:
out = out[
(out["longitude"] >= lon_range[0]) & (out["longitude"] <= lon_range[1])
]
if "latitude" in out.columns:
out = out[
(out["latitude"] >= lat_range[0]) & (out["latitude"] <= lat_range[1])
]
# Network Logic — only apply to core profiles (bio df lacks is_bgc/is_deep)
if not net_all and not is_bio and "is_bgc" in out.columns and "is_deep" in out.columns:
masks = []
if net_bgc:
masks.append(out["is_bgc"])
if net_ctd:
# Core = NOT BGC and NOT Deep
masks.append(~out["is_bgc"] & ~out["is_deep"])
if net_dep:
masks.append(out["is_deep"])
if masks:
combined_mask = masks[0]
for m in masks[1:]:
combined_mask |= m
out = out[combined_mask]
elif not (net_bgc or net_ctd or net_dep):
# If nothing selected and All is off, show nothing
out = out.iloc[0:0]
# WMO search
if search_wmo.strip():
wmo_list = [w.strip() for w in search_wmo.split(",") if w.strip()]
out = out[out["wmo_id"].isin(wmo_list)]
# Community Logic
if not comm_all and "positioning_system" in out.columns:
masks = []
if comm_null:
masks.append(out["positioning_system"].isna() | (out["positioning_system"] == ""))
if comm_argos:
masks.append(out["positioning_system"].fillna("").str.upper().str.contains("ARGOS"))
if comm_beidou:
masks.append(out["positioning_system"].fillna("").str.upper().str.contains("BEIDOU"))
if comm_iridium:
masks.append(out["positioning_system"].fillna("").str.upper().str.contains("IRIDIUM"))
if masks:
combined_mask = masks[0]
for m in masks[1:]:
combined_mask |= m
out = out[combined_mask]
elif not (comm_null or comm_argos or comm_beidou or comm_iridium):
out = out.iloc[0:0]
# QC mode (bio only)
if is_bio and "parameter_data_mode" in out.columns:
if qc_mode == "Delayed":
out = out[out["parameter_data_mode"].fillna("").str.contains("D")]
elif qc_mode == "Real time":
out = out[~out["parameter_data_mode"].fillna("").str.contains("D")]
# Profiler Type / Float Model filter (from meta enrichment)
if selected_profiler_types and "profiler_name" in out.columns:
out = out[out["profiler_name"].isin(selected_profiler_types)]
# Parameter filtering
if selected_params:
wmo_mask = pd.Series(True, index=out.index)
if "Oxygen (DOXY)" in selected_params:
wmo_mask &= out["wmo_id"].isin(wmos_with_doxy)
if "Chlorophyll (Chla)" in selected_params:
wmo_mask &= out["wmo_id"].isin(wmos_with_chla)
if "Nitrate" in selected_params:
wmo_mask &= out["wmo_id"].isin(wmos_with_nitrate)
if "pH" in selected_params:
wmo_mask &= out["wmo_id"].isin(wmos_with_ph)
out = out[wmo_mask]
return out
filt_prof = apply_filters(df_prof)
filt_bio = apply_filters(df_bio, is_bio=True)
# ================================================================
# FLEET OVERVIEW KPI ROW (from meta registry)
# ================================================================
_total_registered = len(df_meta)
_total_profiled = df_meta["wmo_id"].isin(df_prof["wmo_id"].unique()).sum()
_never_profiled = _total_registered - _total_profiled
_unique_models = df_meta["profiler_name"].nunique()
st.markdown("### 🛰️ Fleet Overview (from Metadata Registry)")
fo1, fo2, fo3, fo4 = st.columns(4)
for col, label, value, color, icon in [
(fo1, "Registered Floats", _total_registered, "#00BCD4", "📋"),
(fo2, "Profiled Floats", _total_profiled, "#8BC34A", "✅"),
(fo3, "Never Profiled", _never_profiled, "#FF5722", "⚠️"),
(fo4, "Float Models", _unique_models, "#9C27B0", "🔧"),
]:
with col:
st.markdown(
f"""
{icon} {label}
{value:,}
META REGISTRY
""",
unsafe_allow_html=True,
)
# ================================================================
# ROW 1 — MAP (left ~55 %) + BAR CHART & KPIs (right ~45 %)
# ================================================================
col_left, col_right = st.columns([55, 45], gap="medium")
with col_left:
# ── Component 1: Geospatial Float Position Map (PRD §7.1) ──
st.markdown('', unsafe_allow_html=True)
st.markdown("### 📍 Geographic Float Positions")
if len(filt_prof) > 0:
# --- Check map selection from session state ---
selected_wmo_from_map = None
if "main_map" in st.session_state:
sel = st.session_state.main_map
if sel and "selection" in sel and "points" in sel["selection"] and len(sel["selection"]["points"]) > 0:
pt = sel["selection"]["points"][0]
if "customdata" in pt and len(pt["customdata"]) > 0:
selected_wmo_from_map = str(pt["customdata"][0])
is_sidebar_search = bool(search_wmo.strip())
is_wmo_searched = is_sidebar_search or bool(selected_wmo_from_map)
selected_dac_from_bar = None
if "bar_chart" in st.session_state:
sel = st.session_state.bar_chart
if sel and "selection" in sel and "points" in sel["selection"] and len(sel["selection"]["points"]) > 0:
pt = sel["selection"]["points"][0]
if "customdata" in pt and len(pt["customdata"]) > 0:
selected_dac_from_bar = str(pt["customdata"][0])
# Apply Live-Only filter if toggled and not searching specific WMOs
map_source = filt_prof.copy()
# If user clicked a float on the map, filter source to just that float
if selected_wmo_from_map:
map_source = map_source[map_source["wmo_id"] == selected_wmo_from_map]
if selected_dac_from_bar:
map_source = map_source[map_source["dac"] == selected_dac_from_bar]
if show_live_only and not is_wmo_searched:
latest_d = map_source["date"].max()
ninety_days_ago = latest_d - timedelta(days=90)
# Find WMOs that have a profile in the last 90 days
live_wmos = map_source[map_source["date"] >= ninety_days_ago]["wmo_id"].unique()
map_source = map_source[map_source["wmo_id"].isin(live_wmos)]
if is_wmo_searched:
# Check if this float is newly selected from map to show dialog
if selected_wmo_from_map:
if st.session_state.get("last_viewed_wmo") != selected_wmo_from_map:
st.session_state["last_viewed_wmo"] = selected_wmo_from_map
show_float_details(selected_wmo_from_map)
# Show full trajectory for specific floats
map_df = (
map_source.dropna(subset=["latitude", "longitude"])
.sort_values(["wmo_id", "date"])
.copy()
)
# Add a profile sequence number for each float
map_df["profile_seq"] = map_df.groupby("wmo_id").cumcount() + 1
fig_map = go.Figure()
for wmo, group in map_df.groupby("wmo_id"):
inst = group["institution"].iloc[0]
color = REGION_COLORS.get(inst, "#ff0000")
fig_map.add_trace(go.Scattermapbox(
lat=group["latitude"].tolist(),
lon=group["longitude"].tolist(),
mode="lines+markers+text",
text=group["profile_seq"].astype(str).tolist(),
textposition="top right",
textfont=dict(size=11, color="white"),
marker=dict(size=7, color=color, opacity=0.9),
line=dict(width=2, color=color),
name=str(wmo),
hoverinfo="text",
hovertext=group.apply(lambda r: f"WMO: {wmo}
Date: {r['date']}
Lat: {r['latitude']:.2f}, Lon: {r['longitude']:.2f}
Profile: {r['profile_seq']}", axis=1).tolist()
))
center_lat = float(map_df["latitude"].mean()) if len(map_df) > 0 else 0.0
center_lon = float(map_df["longitude"].mean()) if len(map_df) > 0 else 0.0
fig_map.update_layout(
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
margin=dict(l=0, r=0, t=0, b=0),
mapbox=dict(
style="white-bg",
layers=[
{
"below": 'traces',
"sourcetype": "raster",
"sourceattribution": "Esri",
"source": [
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
]
}
],
center=dict(lat=center_lat, lon=center_lon),
zoom=4
),
legend=dict(
title="WMO ID",
bgcolor="rgba(10,14,39,0.85)",
bordercolor="rgba(0,188,212,0.18)",
borderwidth=1,
font=dict(size=11, color="#c8d6e5"),
yanchor="bottom",
y=0.01,
xanchor="left",
x=0.01,
)
)
else:
# Latest position per float (one marker per WMO)
map_df = (
map_source.dropna(subset=["latitude", "longitude"])
.sort_values("date")
.groupby("wmo_id")
.tail(1)
.copy()
)
# Cap at 12 000 for browser performance
if len(map_df) > 12_000:
map_df = map_df.sample(12_000, random_state=42)
center_lat = float(map_df["latitude"].mean()) if len(map_df) > 0 else -10.0
center_lon = float(map_df["longitude"].mean()) if len(map_df) > 0 else 80.0
fig_map = px.scatter_mapbox(
map_df,
lat="latitude",
lon="longitude",
color="institution",
color_discrete_map=REGION_COLORS,
hover_name="wmo_id",
custom_data=["wmo_id"],
hover_data={
"institution": True,
"date": True,
"latitude": ":.2f",
"longitude": ":.2f",
},
zoom=2,
center={"lat": center_lat, "lon": center_lon},
category_orders={"institution": list(REGION_COLORS.keys())},
)
fig_map.update_traces(marker=dict(size=8, opacity=0.9))
fig_map.update_layout(
mapbox_style="white-bg",
mapbox_layers=[
{
"below": 'traces',
"sourcetype": "raster",
"sourceattribution": "Esri",
"source": [
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
]
}
],
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
margin=dict(l=0, r=0, t=0, b=0),
legend=dict(
title="Region",
bgcolor="rgba(10,14,39,0.85)",
bordercolor="rgba(0,188,212,0.18)",
borderwidth=1,
font=dict(size=11, color="#c8d6e5"),
yanchor="bottom",
y=0.01,
xanchor="left",
x=0.01,
orientation="h",
),
)
fig_map.update_layout(height=620)
st.plotly_chart(fig_map, use_container_width=True, key="main_map", on_select="rerun", config={"toImageButtonOptions": {"format": "png", "scale": 2, "filename": "argo_float_map"}})
if selected_wmo_from_map:
if st.button(f"📄 View Info for Float {selected_wmo_from_map}"):
show_float_details(selected_wmo_from_map)
elif is_sidebar_search and len([w for w in search_wmo.split(",") if w.strip()]) == 1:
searched_id = search_wmo.strip()
if st.button(f"📄 View Info for Float {searched_id}"):
show_float_details(searched_id)
if is_wmo_searched:
st.caption(f"📌 {len(map_df['wmo_id'].unique()):,} floats displayed with full trajectory ({len(map_df):,} total profiles)")
else:
st.caption(f"📌 {len(map_df):,} unique floats displayed")
else:
st.info("No float data for current filters.")
st.markdown('
', unsafe_allow_html=True)
# ── Component 2 + 3: Bar chart + KPI tiles ──
with col_right:
st.markdown('', unsafe_allow_html=True)
# ── Bar chart (PRD §7.2) ──
st.markdown("### 📈 Number of Floats per DAC")
if len(filt_prof) > 0 and "dac" in filt_prof.columns:
# Active floats in the last 90 days of each year
latest_ds_date = filt_prof["date"].max()
res = []
years = sorted(filt_prof["year"].dropna().unique())
for y in years:
if y == latest_ds_date.year:
end_of_year = latest_ds_date
else:
end_of_year = pd.Timestamp(f"{int(y)}-12-31")
start_period = end_of_year - pd.Timedelta(days=90)
active_df = filt_prof[(filt_prof["date"] >= start_period) & (filt_prof["date"] <= end_of_year)]
active_floats = active_df.drop_duplicates(subset=["wmo_id"])
for dac, count in active_floats["dac"].value_counts().items():
res.append({"Year": int(y), "DAC": dac, "Count": count})
yearly = pd.DataFrame(res)
if len(yearly) > 0:
yearly["Year"] = yearly["Year"].astype(int)
yearly = yearly.sort_values(["Year", "Count"], ascending=[True, False])
totals = yearly.groupby("Year")["Count"].sum().reset_index()
# Professional DAC Color Mapping
DAC_COLORS = {
"aoml": "#4FC3F7", "coriolis": "#FF7043", "kiost": "#26A69A",
"meds": "#BA68C8", "csiro": "#FFB74D", "jma": "#00BCD4",
"incois": "#F06292", "csio": "#9CCC65", "bodc": "#9575CD",
"kma": "#FFD54F", "nmdis": "#90A4AE",
}
fig_bar = px.bar(
yearly,
x="Year",
y="Count",
color="DAC",
custom_data=["DAC"],
color_discrete_map=DAC_COLORS,
category_orders={"Year": sorted(yearly["Year"].unique())}
)
fig_bar.update_traces(
marker_line_width=0,
hovertemplate="
%{x}DAC: %{fullData.name}
Floats: %{y:,}
"
)
fig_bar.add_trace(go.Scatter(
x=totals["Year"],
y=totals["Count"],
mode="text",
text=totals["Count"],
textposition="top center",
textfont=dict(size=10, color="#ffffff", family="Outfit"),
showlegend=False,
hoverinfo="skip"
))
fig_bar.update_layout(
**_dark_layout(
height=420,
barmode="stack",
xaxis=dict(title="", type="category", tickangle=-45, gridcolor="rgba(255,255,255,0.03)"),
yaxis=dict(title="Active Floats", gridcolor="rgba(255,255,255,0.05)", zeroline=False),
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1, title=None, font=dict(size=10)),
bargap=0.3,
margin=dict(l=50, r=20, t=80, b=40),
)
)
st.plotly_chart(fig_bar, use_container_width=True, key="bar_chart", on_select="rerun", config={"toImageButtonOptions": {"format": "png", "scale": 2, "filename": "argo_annual_floats"}})
else:
st.info("No active float data for bar chart.")
else:
st.info("No data for bar chart.")
# ── KPI tiles (PRD §7.3) ──
st.markdown("### 🧪 BGC Profile Counts")
doxy_n = int(filt_bio["has_doxy"].sum()) if len(filt_bio) > 0 else 0
chla_n = int(filt_bio["has_chla"].sum()) if len(filt_bio) > 0 else 0
nit_n = int(filt_bio["has_nitrate"].sum()) if len(filt_bio) > 0 else 0
ph_n = int(filt_bio["has_ph"].sum()) if len(filt_bio) > 0 else 0
k1, k2, k3, k4 = st.columns(4)
for col, label, value, color in [
(k1, "DOXY", doxy_n, KPI_COLORS["DOXY"]),
(k2, "Chla", chla_n, KPI_COLORS["Chla"]),
(k3, "Nitrate", nit_n, KPI_COLORS["Nitrate"]),
(k4, "pH", ph_n, KPI_COLORS["pH"]),
]:
with col:
st.markdown(
f"""
{label}
{value:,}
PROFILES
""",
unsafe_allow_html=True,
)
st.markdown('
', unsafe_allow_html=True)
# ================================================================
# ROW 2 — TREEMAP (left) + DONUT (right)
# ================================================================
st.markdown("---")
col_tree, col_donut = st.columns(2, gap="medium")
# ── Component 4: Active Floats & Profiles last 1 day ──
with col_tree:
st.markdown('', unsafe_allow_html=True)
st.markdown("### 📊 Active Floats & Profiles — Last 1 Day")
if len(filt_prof) > 0:
latest_date = filt_prof["date"].max()
one_day_ago = pd.Timestamp(latest_date - timedelta(days=1))
last1 = filt_prof[filt_prof["date"] >= one_day_ago].copy()
if len(last1) > 0:
tree_data = (
last1.groupby("institution")
.agg(floats=("wmo_id", "nunique"), profiles=("file", "count"))
.reset_index()
)
total_f1 = int(tree_data["floats"].sum())
total_p1 = int(tree_data["profiles"].sum())
# Summary card
st.markdown(
f"""
All Communities
{total_f1:,}
Active Floats
""",
unsafe_allow_html=True,
)
# Treemap
fig_tree = px.treemap(
tree_data,
path=["institution"],
values="profiles",
color="profiles",
color_continuous_scale=[
[0, "#1a2744"],
[0.5, "#1e3a5f"],
[1.0, "#2C5F8A"],
],
hover_data=["floats", "profiles"],
height=340,
)
fig_tree.update_traces(
textinfo="label+value",
textfont=dict(size=14, color="white"),
marker=dict(line=dict(width=2, color="#0a0e27"), cornerradius=5),
hovertemplate=(
"
%{label}"
"Profiles: %{value:,}
"
"Floats: %{customdata[0]:,}
"
),
)
fig_tree.update_layout(
**_dark_layout(margin=dict(l=0, r=0, t=10, b=0)),
coloraxis_showscale=False,
)
st.plotly_chart(fig_tree, use_container_width=True, key="treemap", config={"toImageButtonOptions": {"format": "png", "scale": 2, "filename": "argo_last1day_treemap"}})
else:
st.info("No active floats in the last 1 day for current filters.")
else:
st.info("No data available.")
st.markdown('
', unsafe_allow_html=True)
# ── Component 5: Float Age Donut (PRD §7.5) ──
with col_donut:
st.markdown('', unsafe_allow_html=True)
st.markdown("### 🕐 Float Age Distribution")
if len(filt_prof) > 0:
# Only calculate age for active floats (reported in the last 90 days)
latest_ds_date = filt_prof["date"].max()
ninety_days_ago = pd.Timestamp(latest_ds_date - timedelta(days=90))
float_last = filt_prof.dropna(subset=["date"]).groupby("wmo_id")["date"].max().reset_index()
active_wmos = float_last[float_last["date"] >= ninety_days_ago]["wmo_id"]
active_prof = filt_prof[filt_prof["wmo_id"].isin(active_wmos)]
# Use earliest profile date per active float as proxy for launch date
float_first = (
active_prof.dropna(subset=["date"])
.groupby("wmo_id")["date"]
.min()
.reset_index()
)
float_first["age_years"] = (
(pd.Timestamp.now() - float_first["date"]).dt.days / 365.25
)
bins = [0, 3, 6, 9, 12, 999]
labels = ["00-02", "03-05", "06-08", "09-11", "12+"]
float_first["age_group"] = pd.cut(
float_first["age_years"], bins=bins, labels=labels, right=False
)
age_counts = float_first["age_group"].value_counts().reset_index()
age_counts.columns = ["Age Group", "Count"]
age_counts["Age Group"] = pd.Categorical(
age_counts["Age Group"], categories=labels, ordered=True
)
age_counts = age_counts.sort_values("Age Group")
age_counts = age_counts[age_counts["Count"] > 0]
if len(age_counts) > 0:
fig_donut = px.pie(
age_counts,
values="Count",
names="Age Group",
hole=0.45,
color="Age Group",
color_discrete_map=AGE_COLORS,
height=420,
)
fig_donut.update_traces(
textinfo="label+percent",
textposition="outside",
textfont=dict(size=12, color="#c8d6e5"),
pull=[0.02] * len(age_counts),
hovertemplate=(
"%{label}
"
"Count: %{value:,}
"
"Percent: %{percent}"
),
marker=dict(line=dict(color="#0a0e27", width=2)),
)
fig_donut.update_layout(
**_dark_layout(margin=dict(l=20, r=80, t=10, b=20)),
legend=dict(
title="Age Group",
orientation="v",
yanchor="middle",
y=0.5,
xanchor="left",
x=1.05,
font=dict(size=12, color="#c8d6e5"),
bgcolor="rgba(0,0,0,0)",
),
)
st.plotly_chart(fig_donut, use_container_width=True, key="donut", config={"toImageButtonOptions": {"format": "png", "scale": 2, "filename": "argo_age_distribution"}})
else:
st.info("No age data available.")
else:
st.info("No data available.")
st.markdown('
', unsafe_allow_html=True)
# ================================================================
# ROW 2.5 — PROFILER TYPE DONUT (left) + FLEET COMPOSITION (right)
# Data source: ar_index_global_meta.txt
# ================================================================
st.markdown("---")
col_profiler, col_fleet = st.columns(2, gap="medium")
# ── Profiler Type / Instrument Breakdown Donut ──
with col_profiler:
st.markdown('', unsafe_allow_html=True)
st.markdown("### 🔧 Float Instrument Types (Meta Registry)")
if len(df_meta) > 0:
ptype_counts = df_meta["profiler_name"].value_counts().reset_index()
ptype_counts.columns = ["Model", "Count"]
# Group small categories into "Other" for readability
top_n = 10
if len(ptype_counts) > top_n:
top = ptype_counts.head(top_n)
other_count = ptype_counts.iloc[top_n:]["Count"].sum()
other_row = pd.DataFrame([{"Model": "Other", "Count": other_count}])
ptype_counts = pd.concat([top, other_row], ignore_index=True)
fig_ptype = px.pie(
ptype_counts,
values="Count",
names="Model",
hole=0.45,
color="Model",
color_discrete_map=PROFILER_COLORS,
height=420,
)
fig_ptype.update_traces(
textinfo="label+percent",
textposition="outside",
textfont=dict(size=11, color="#c8d6e5"),
pull=[0.02] * len(ptype_counts),
hovertemplate=(
"%{label}
"
"Floats: %{value:,}
"
"Share: %{percent}"
),
marker=dict(line=dict(color="#0a0e27", width=2)),
)
fig_ptype.update_layout(
**_dark_layout(margin=dict(l=20, r=80, t=10, b=20)),
legend=dict(
title="Instrument",
orientation="v",
yanchor="middle",
y=0.5,
xanchor="left",
x=1.05,
font=dict(size=11, color="#c8d6e5"),
bgcolor="rgba(0,0,0,0)",
),
)
st.plotly_chart(fig_ptype, use_container_width=True, key="profiler_donut", config={"toImageButtonOptions": {"format": "png", "scale": 2, "filename": "argo_profiler_types"}})
st.caption(f"📋 {len(df_meta):,} floats across {df_meta['profiler_name'].nunique()} instrument models (source: ar_index_global_meta.txt)")
else:
st.info("No metadata available.")
st.markdown('
', unsafe_allow_html=True)
# ── Fleet Composition Stacked Area Chart ──
with col_fleet:
st.markdown('', unsafe_allow_html=True)
st.markdown("### 📊 Fleet Composition Over Time")
if len(filt_prof) > 0 and "profiler_name" in filt_prof.columns:
# Get the deployment year per float (earliest profile date)
float_deploy = (
filt_prof.dropna(subset=["date"])
.groupby("wmo_id")
.agg(deploy_year=("year", "min"), profiler_name=("profiler_name", "first"))
.reset_index()
)
if len(float_deploy) > 0:
# Count deployments by year and profiler type
comp = float_deploy.groupby(["deploy_year", "profiler_name"]).size().reset_index(name="Count")
# Keep only top N models, group rest as "Other"
top_models = float_deploy["profiler_name"].value_counts().head(8).index.tolist()
comp["Model"] = comp["profiler_name"].where(comp["profiler_name"].isin(top_models), "Other")
comp = comp.groupby(["deploy_year", "Model"])["Count"].sum().reset_index()
comp = comp.sort_values("deploy_year")
fig_fleet = px.area(
comp,
x="deploy_year",
y="Count",
color="Model",
color_discrete_map=PROFILER_COLORS,
height=420,
)
fig_fleet.update_traces(
line=dict(width=0.5),
hovertemplate="%{fullData.name}
Year: %{x}
Floats: %{y:,}",
)
fig_fleet.update_layout(
**_dark_layout(
xaxis=dict(title="Deployment Year", gridcolor="rgba(255,255,255,0.03)"),
yaxis=dict(title="Floats Deployed", gridcolor="rgba(255,255,255,0.05)", zeroline=False),
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1, title=None, font=dict(size=10)),
margin=dict(l=50, r=20, t=60, b=40),
),
)
st.plotly_chart(fig_fleet, use_container_width=True, key="fleet_composition", config={"toImageButtonOptions": {"format": "png", "scale": 2, "filename": "argo_fleet_composition"}})
st.caption("Shows how the fleet instrument mix has evolved per deployment year")
else:
st.info("No deployment data available.")
else:
st.info("No data available.")
st.markdown('
', unsafe_allow_html=True)
# ================================================================
# ROW 3 — DAC / Institution Summary Tables (PRD §7.6)
# ================================================================
st.markdown("---")
col_dac1, col_dac2 = st.columns(2, gap="medium")
if len(df_prof) > 0:
dac_profs = (
df_prof.groupby("institution")
.agg(Profiles=("file", "count"))
.reset_index()
)
dac_floats = df_meta.groupby("institution").agg(Floats=("wmo_id", "nunique")).reset_index()
dac = pd.merge(dac_floats, dac_profs, on="institution", how="left").fillna(0)
dac = dac.sort_values("Profiles", ascending=False)
dacs = dac["institution"].tolist()
latest_date = df_prof["date"].max()
ninety_days_ago = pd.Timestamp(latest_date - timedelta(days=90))
float_latest = df_prof.dropna(subset=["date"]).groupby(["institution", "wmo_id"])["date"].max().reset_index()
float_latest["is_live"] = float_latest["date"] >= ninety_days_ago
live_df = float_latest.groupby("institution").agg(
live_floats=("is_live", "sum")
).reset_index()
status_df = pd.merge(dac_floats.rename(columns={"Floats": "total_count"}), live_df, on="institution", how="left").fillna(0)
status_df["dead_floats"] = status_df["total_count"] - status_df["live_floats"]
status_df = status_df.set_index("institution").reindex(dacs).reset_index().fillna(0)
# Global metrics for the graph
global_total_floats = status_df["total_count"].sum()
global_total_profiles = dac["Profiles"].sum()
global_live = status_df["live_floats"].sum()
global_dead = status_df["dead_floats"].sum()
header = "".join(f"{d} | " for d in dacs)
floats_cells = "".join(f"{int(r):,} | " for r in dac["Floats"])
profs_cells = "".join(f"{int(r):,} | " for r in dac["Profiles"])
with col_dac1:
st.markdown("### 🏢 DAC / Institution Summary")
st.markdown('', unsafe_allow_html=True)
st.markdown(
f"""
| Metric | {header}
| Floats | {floats_cells}
| Profiles | {profs_cells}
""",
unsafe_allow_html=True,
)
st.markdown('
', unsafe_allow_html=True)
st.markdown("
", unsafe_allow_html=True)
st.markdown("### 🌐 Global Network Status")
metrics_df = pd.DataFrame({
"Metric": ["Active Floats", "Dead Floats", "Total Floats", "Total Profiles"],
"Count": [global_live, global_dead, global_total_floats, global_total_profiles],
"Color": ["#4CAF50", "#F44336", "#9C27B0", "#2196F3"]
})
fig_global = px.bar(
metrics_df,
x="Count",
y="Metric",
orientation="h",
text="Count",
log_x=True,
)
fig_global.update_traces(
marker_color=metrics_df["Color"],
texttemplate='%{text:,}',
textposition='auto',
textfont=dict(color='white'),
hovertemplate="%{y}: %{x:,}"
)
fig_global.update_layout(
**_dark_layout(
xaxis=dict(title="", showticklabels=False, showgrid=False, zeroline=False),
yaxis=dict(title="", showgrid=False, tickfont=dict(size=12, color="#c8d6e5")),
margin=dict(l=0, r=20, t=10, b=0),
height=160,
)
)
st.plotly_chart(fig_global, use_container_width=True, key="global_status_bar", config={"displayModeBar": False})
with col_dac2:
st.markdown("### 📡 Float Status Summary")
# Dominant instrument per institution from meta registry
_inst_top_model = (
df_meta.groupby("institution")["profiler_name"]
.agg(lambda x: x.value_counts().index[0] if len(x) > 0 else "—")
)
header2 = "".join(f"{d} | " for d in status_df["institution"])
total_cells = "".join(f"{int(r):,} | " for r in status_df["total_count"])
live_cells = "".join(f"{int(r):,} | " for r in status_df["live_floats"])
dead_cells = "".join(f"{int(r):,} | " for r in status_df["dead_floats"])
model_cells = "".join(
f"{_inst_top_model.get(d, '—')} | "
for d in status_df["institution"]
)
st.markdown('', unsafe_allow_html=True)
st.markdown(
f"""
| Status | {header2}
| Total Count | {total_cells}
| Live Floats | {live_cells}
| Dead Floats | {dead_cells}
| Top Model | {model_cells}
""",
unsafe_allow_html=True,
)
st.markdown('
', unsafe_allow_html=True)
else:
st.info("No data available for summary tables.")
# ================================================================
# ROW 4 — INCOIS Deployment Matrix (Year vs Month)
# ================================================================
st.markdown("---")
st.markdown("### 🗓️ INCOIS Float Deployments (Year vs Month)")
if len(df_meta) > 0:
# --- All INCOIS floats from the authoritative metadata registry ---
# FIX 1: Use both DAC and institution to catch all INCOIS floats
meta_in = df_meta[
(df_meta["dac"].str.lower().str.strip() == "incois") |
(df_meta["institution"].str.upper().str.strip() == "IN")
].drop_duplicates(subset=["wmo_id"]).copy()
meta_in["wmo_id"] = meta_in["wmo_id"].astype(str).str.strip()
if len(meta_in) > 0:
launch_csv = CACHE_DIR / "incois_launch_dates.csv"
incois_wmos = set(meta_in["wmo_id"].tolist())
# ------------------------------------------------------------------
# STEP 1 — Silently absorb any already-downloaded meta NC files.
# ------------------------------------------------------------------
_scan_existing_nc_for_launch_dates(incois_wmos, launch_csv)
# ------------------------------------------------------------------
# STEP 2 — Load the CSV cache
# FIX 2: Drop duplicates to prevent overcounting in pivot table
# ------------------------------------------------------------------
ld_raw = _load_launch_date_csv(launch_csv).drop_duplicates(subset=["wmo_id"], keep="first")
ld_raw["launch_date_parsed"] = pd.to_datetime(
ld_raw["launch_date"], format="%Y%m%d%H%M%S", errors="coerce"
)
launch_dates = ld_raw[["wmo_id", "launch_date_parsed"]]
cached_wmos = set(launch_dates["wmo_id"].tolist())
missing_wmos = sorted(incois_wmos - cached_wmos)
# ------------------------------------------------------------------
# STEP 3 — Optional fetch button for floats whose NC files have
# never been downloaded.
# ------------------------------------------------------------------
if missing_wmos:
_dac_lookup = (
df_meta[df_meta["wmo_id"].isin(missing_wmos)]
.set_index("wmo_id")["dac"]
.to_dict()
)
with st.expander(
f"⚠️ Launch dates missing for **{len(missing_wmos)}** floats — click to fetch from GDAC",
expanded=False,
):
st.caption(
"This fetches each float's `_meta.nc` from IFREMER GDAC over HTTPS and "
"caches the `LAUNCH_DATE` field locally. Run once; results are saved to "
f"`{launch_csv.name}` and reused on every subsequent load."
)
if st.button("🌐 Fetch Missing Launch Dates from GDAC", key="fetch_launch_dates"):
import urllib.request as _urlreq
existing_csv = _load_launch_date_csv(launch_csv)
new_rows = []
failed = []
prog = st.progress(0.0)
status_ph = st.empty()
total = len(missing_wmos)
for idx, wmo in enumerate(missing_wmos, 1):
status_ph.markdown(f"Fetching **{wmo}** ({idx}/{total})…")
prog.progress(idx / total)
# Determine if active/inactive to set target folder
float_profiles = df_prof[df_prof["wmo_id"] == str(wmo)]
is_active = False
if not float_profiles.empty:
latest_prof_date = float_profiles["date"].max()
if latest_prof_date is not pd.NaT and latest_prof_date.year >= 2026:
is_active = True
folder_name = "more_components" if is_active else "inactive_floats"
target_dir = BASE_DIR / folder_name
target_dir.mkdir(exist_ok=True)
meta_path = target_dir / f"{wmo}_meta.nc"
dac = _dac_lookup.get(wmo, "incois")
if not meta_path.exists():
url = f"https://data-argo.ifremer.fr/dac/{dac}/{wmo}/{wmo}_meta.nc"
try:
_urlreq.urlretrieve(url, meta_path)
except Exception as e:
failed.append((wmo, str(e)))
continue
ld = _read_launch_date_from_nc(meta_path)
if ld and len(ld) >= 8:
new_rows.append({"wmo_id": wmo, "launch_date": ld})
else:
failed.append((wmo, "LAUNCH_DATE not found in NetCDF"))
prog.empty()
status_ph.empty()
if new_rows:
CACHE_DIR.mkdir(exist_ok=True)
updated = pd.concat(
[existing_csv, pd.DataFrame(new_rows)], ignore_index=True
).drop_duplicates("wmo_id")
updated.to_csv(launch_csv, index=False)
st.success(f"✅ Cached launch dates for {len(new_rows)} floats. {len(failed)} could not be fetched.")
st.rerun()
else:
st.error(f"Could not fetch any new launch dates. {len(failed)} failures.")
# ------------------------------------------------------------------
# STEP 4 — Determine deployment date for every INCOIS float.
# ------------------------------------------------------------------
earliest_profile = (
df_prof[df_prof["wmo_id"].isin(incois_wmos)]
.groupby("wmo_id")["date"]
.min()
.reset_index()
.rename(columns={"date": "earliest_profile_date"})
)
earliest_profile["wmo_id"] = earliest_profile["wmo_id"].astype(str).str.strip()
merged = meta_in[["wmo_id"]].copy()
merged = pd.merge(merged, launch_dates, on="wmo_id", how="left")
merged = pd.merge(merged, earliest_profile, on="wmo_id", how="left")
merged["deploy_date"] = merged["launch_date_parsed"].fillna(merged["earliest_profile_date"])
n_true = int(merged["launch_date_parsed"].notna().sum())
n_proxy = int((merged["launch_date_parsed"].isna() & merged["earliest_profile_date"].notna()).sum())
n_unknown = int(merged["deploy_date"].isna().sum())
merged = merged.dropna(subset=["deploy_date"])
merged["Year"] = merged["deploy_date"].dt.year.astype(int)
merged["Month"] = merged["deploy_date"].dt.month.astype(int)
# ------------------------------------------------------------------
# STEP 5 — Pivot: one row per year, one column per month.
# ------------------------------------------------------------------
pivot = merged.pivot_table(
index="Year", columns="Month", values="wmo_id",
aggfunc="count", fill_value=0,
)
pivot = pivot.reindex(columns=range(1, 13), fill_value=0)
MONTH_NAMES = {
1:"JAN", 2:"FEB", 3:"MAR", 4:"APR", 5:"MAY", 6:"JUN",
7:"JUL", 8:"AUG", 9:"SEP", 10:"OCT", 11:"NOV", 12:"DEC",
}
pivot.columns = [MONTH_NAMES[m] for m in pivot.columns]
pivot["Total"] = pivot.sum(axis=1)
pivot.loc["Total"] = pivot.sum(axis=0)
total_floats = int(pivot.loc["Total", "Total"])
# ------------------------------------------------------------------
# STEP 6 — Render
# ------------------------------------------------------------------
badge_parts = [
f"✓ {n_true} true launch dates",
f"~ {n_proxy} profile-date proxy",
]
if n_unknown:
badge_parts.append(f"✗ {n_unknown} unknown (excluded)")
proxy_pct = round(100 * n_proxy / max(n_true + n_proxy, 1))
if proxy_pct > 20 and n_unknown > 0:
st.warning(f"⚠️ {n_unknown} floats have no date source. Click the **Fetch Missing Launch Dates** expander above to fix this.")
st.markdown(
f""
f"Total Deployed INCOIS Floats: "
f"{total_floats:,}"
f" · "
+ " | ".join(badge_parts)
+ "
",
unsafe_allow_html=True,
)
st.markdown('', unsafe_allow_html=True)
header_html = (
"
Year | "
+ "".join(f"
{m} | " for m in pivot.columns)
)
body_html = ""
# Calculate max value for heatmap scaling (excluding Totals)
try:
heatmap_max = pivot.drop("Total", axis=0).drop("Total", axis=1).max().max()
except:
heatmap_max = 1
if heatmap_max <= 0: heatmap_max = 1
for year in pivot.index:
is_total_row = year == "Total"
row_bg = "background:rgba(0,188,212,0.06);" if is_total_row else ""
yr_lbl = "Total" if is_total_row else int(year)
yr_bg = "#0c1427" if is_total_row else "#060b19"
row_html = (
f"
{yr_lbl} | "
)
for col in pivot.columns:
val = pivot.loc[year, col]
is_tot = is_total_row or col == "Total"
if is_tot:
style = "font-weight:bold;color:#FFB74D;"
cell_bg = ""
else:
style = ""
if val > 0:
intensity = min(val / heatmap_max, 1.0)
# Cyan color (#00BCD4) with dynamic opacity based on value
cell_bg = f"background:rgba(0, 188, 212, {max(0.1, intensity * 0.9)});"
else:
cell_bg = ""
cell = (
f"{int(val):,}" if val > 0
else "
-"
)
row_html += f"
{cell} | "
body_html += f"
{row_html}
"
st.markdown(
f"""
{header_html}
{body_html}
""",
unsafe_allow_html=True,
)
st.markdown('
', unsafe_allow_html=True)
else:
st.info("No INCOIS deployment data found.")
else:
st.info("No data available for deployment matrix.")
# ================================================================
# RAW DATA VIEWER (bonus — not in PRD but useful for ops)
# ================================================================
st.markdown("---")
with st.expander("📋 View Raw Data", expanded=False):
tab1, tab2, tab3 = st.tabs(["Core Profiles", "BGC Profiles", "Float Metadata"])
with tab1:
st.dataframe(
filt_prof.head(200), use_container_width=True, hide_index=True
)
st.caption(
f"Showing {min(200, len(filt_prof)):,} of {len(filt_prof):,} records"
)
st.download_button(
"⬇️ Download Filtered Core Profiles (CSV)",
data=filt_prof.to_csv(index=False),
file_name="argo_core_profiles_filtered.csv",
mime="text/csv",
key="dl_core",
)
with tab2:
st.dataframe(
filt_bio.head(200), use_container_width=True, hide_index=True
)
st.caption(
f"Showing {min(200, len(filt_bio)):,} of {len(filt_bio):,} records"
)
st.download_button(
"⬇️ Download Filtered BGC Profiles (CSV)",
data=filt_bio.to_csv(index=False),
file_name="argo_bgc_profiles_filtered.csv",
mime="text/csv",
key="dl_bgc",
)
with tab3:
st.dataframe(
df_meta.head(500), use_container_width=True, hide_index=True
)
st.caption(
f"Showing {min(500, len(df_meta)):,} of {len(df_meta):,} float metadata records (source: ar_index_global_meta.txt)"
)
st.download_button(
"⬇️ Download Float Metadata (CSV)",
data=df_meta.to_csv(index=False),
file_name="argo_float_metadata.csv",
mime="text/csv",
key="dl_meta",
)
# ==================== FOOTER ====================
st.markdown(
f"""
""",
unsafe_allow_html=True,
)