import pandas as pd
import plotly.graph_objects as go
from dash import Dash, dcc, html, Input, Output, State, dash_table
import dash_bootstrap_components as dbc
import numpy as np
from scipy.interpolate import griddata
import plotly.express as px
# --- 1. Data Loading and Preparation ---
try:
df = pd.read_csv("model-grid-subsample.csv")
# Filter data to eliminate points above ground
df = df[df.dem_m > df.zkm * 1e3]
# Round depths for discrete slider points
df['zm_depth_rounded'] = (df['zm_depth'] / 5).round() * 5
# Get ranges for sliders
min_lat, max_lat = df['Latitude'].min(), df['Latitude'].max()
min_lon, max_lon = df['Longitude'].min(), df['Longitude'].max()
min_depth, max_depth = df['zm_depth'].min(), df['zm_depth'].max()
# Determine unique depths for the slider
available_depths = sorted(df['zm_depth_rounded'].unique())
if len(available_depths) > 100:
available_depths = np.linspace(min_depth, max_depth, 100).round(0).astype(int)
available_depths = sorted(list(set(available_depths)))
# Enhanced parameter information with risk levels and interpretations
param_info = {
'mean_tds': {
'name': 'Total Dissolved Solids (TDS)',
'short_name': 'Salinity',
'unit': 'mg/L',
'desc': 'Measures the total amount of dissolved substances in water. Higher values indicate saltier water.',
'interpretation': 'High TDS can indicate saltwater intrusion, contamination, or natural mineral dissolution.',
'thresholds': {'excellent': 300, 'good': 600, 'poor': 1000, 'very_poor': 2000},
'color_scale': 'Reds',
'icon': 'fas fa-tint'
},
'mean_temp': {
'name': 'Temperature',
'short_name': 'Temperature',
'unit': '°C',
'desc': 'Water temperature affects chemical reactions and biological processes underground.',
'interpretation': 'Temperature anomalies can indicate geothermal activity or surface water infiltration.',
'thresholds': {'cold': 10, 'cool': 15, 'normal': 20, 'warm': 25},
'color_scale': 'RdYlBu_r',
'icon': 'fas fa-thermometer-half'
},
'mean_res': {
'name': 'Electrical Resistivity',
'short_name': 'Resistivity',
'unit': 'Ohm-m',
'desc': 'Measures how well the material resists electrical current. Lower values indicate higher salinity.',
'interpretation': 'Low resistivity suggests high salt content or contamination.',
'thresholds': {'very_low': 1, 'low': 10, 'moderate': 100, 'high': 1000},
'color_scale': 'Viridis',
'icon': 'fas fa-bolt'
},
'mean_por': {
'name': 'Porosity',
'short_name': 'Porosity',
'unit': '%',
'desc': 'Percentage of empty space in rock or sediment that can hold water.',
'interpretation': 'Higher porosity means more water storage capacity.',
'thresholds': {'very_low': 5, 'low': 15, 'moderate': 25, 'high': 35},
'color_scale': 'Blues',
'icon': 'fas fa-circle-notch'
},
'mean_bicarb': {
'name': 'Bicarbonate (HCO₃⁻)',
'short_name': 'Bicarbonate',
'unit': 'mg/L',
'desc': 'Common ion that affects water pH and hardness. Part of natural buffering system.',
'interpretation': 'Moderate levels are normal. Very high levels may indicate specific geological conditions.',
'thresholds': {'low': 100, 'moderate': 300, 'high': 500, 'very_high': 800},
'color_scale': 'Greens',
'icon': 'fas fa-atom'
}
}
# Calculate statistics for each parameter
param_stats = {}
for param in param_info.keys():
param_stats[param] = {
'min': df[param].min(),
'max': df[param].max(),
'mean': df[param].mean(),
'std': df[param].std(),
'median': df[param].median()
}
except FileNotFoundError:
print("Error: 'model-grid-subsample.csv' not found. Please ensure the file is in the correct path.")
exit()
# --- 2. Helper Functions ---
def get_quality_category(value, thresholds):
"""Categorize parameter values based on thresholds"""
if 'excellent' in thresholds:
if value <= thresholds['excellent']:
return 'Excellent'
elif value <= thresholds['good']:
return 'Good'
elif value <= thresholds['poor']:
return 'Poor'
else:
return 'Very Poor'
else:
# For other parameters, use descriptive categories
thresh_keys = list(thresholds.keys())
for i, key in enumerate(thresh_keys):
if value <= thresholds[key]:
return key.title()
return thresh_keys[-1].title()
def create_summary_stats_table(param, depth):
"""Create a summary statistics table for the selected parameter and depth"""
filtered_df = df[df['zm_depth_rounded'] == depth]
if filtered_df.empty:
return dbc.Alert("No data available for this depth", color="warning")
stats = filtered_df[param].describe()
return dash_table.DataTable(
data=[
{'Statistic': 'Count', 'Value': f"{stats['count']:.0f}"},
{'Statistic': 'Mean', 'Value': f"{stats['mean']:.2f}"},
{'Statistic': 'Median', 'Value': f"{stats['50%']:.2f}"},
{'Statistic': 'Std Dev', 'Value': f"{stats['std']:.2f}"},
{'Statistic': 'Min', 'Value': f"{stats['min']:.2f}"},
{'Statistic': 'Max', 'Value': f"{stats['max']:.2f}"},
],
columns=[{'name': 'Statistic', 'id': 'Statistic'}, {'name': 'Value', 'id': 'Value'}],
style_cell={'textAlign': 'left', 'fontSize': '12px', 'padding': '8px'},
style_header={'backgroundColor': '#3498DB', 'color': 'white', 'fontWeight': 'bold'},
style_data={'backgroundColor': '#F8F9FA'},
style_table={'height': '200px', 'overflowY': 'auto'}
)
# --- 3. Dash App Initialization ---
app = Dash(__name__, external_stylesheets=[
dbc.themes.MINTY,
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
])
app.title='Groundwater Salinity Dashboard'
# --- 4. Enhanced Layout with Bootstrap Cards ---
app.layout = dbc.Container([
# Header Section
dbc.Row([
dbc.Col([
html.H1([
html.I(className="fas fa-water me-3", style={'color': '#3498DB'}),
"Groundwater Quality Explorer"
], className="text-center mb-3", style={'color': '#2C3E50'}),
html.P("Interactive visualization of groundwater properties across different depths and locations",
className="text-center text-muted mb-4", style={'fontSize': '18px'}),
])
], className="mb-4"),
# Quick Stats Cards Row
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardBody([
html.H4([
html.I(className="fas fa-chart-bar me-2", style={'color': '#3498DB'}),
"Quick Stats"
], className="card-title"),
html.P(f"Total Data Points: {len(df):,}", className="mb-1"),
html.P(f"Depth Range: {min_depth:.0f}m - {max_depth:.0f}m", className="mb-1"),
html.P(f"Area Coverage: {(max_lat-min_lat):.3f}° × {(max_lon-min_lon):.3f}°", className="mb-0"),
])
], color="light", outline=True, className="h-100")
], width=4),
dbc.Col([
dbc.Card([
dbc.CardBody([
html.H4([
html.I(className="fas fa-compass me-2", style={'color': '#E74C3C'}),
"How to Use"
], className="card-title"),
html.P("1. Select a parameter to visualize", className="mb-1"),
html.P("2. Choose depth for horizontal view", className="mb-1"),
html.P("3. Set cut line for vertical cross-section", className="mb-0"),
])
], color="light", outline=True, className="h-100")
], width=4),
dbc.Col([
dbc.Card([
dbc.CardBody([
html.H4([
html.I(className="fas fa-exclamation-triangle me-2", style={'color': '#F39C12'}),
"Data Quality"
], className="card-title"),
html.P("This is model-predicted data", className="mb-1"),
html.P("Use for exploratory analysis", className="mb-1"),
html.P("Verify with field measurements", className="mb-0"),
])
], color="light", outline=True, className="h-100")
], width=4),
], className="mb-4"),
# Parameter Selection Card
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardHeader([
html.H3([
html.I(className="fas fa-cog me-2", style={'color': '#2C3E50'}),
"Parameter Selection"
], className="mb-0")
]),
dbc.CardBody([
dbc.Row([
dbc.Col([
dbc.Label("Select Parameter:", className="fw-bold mb-2"),
dcc.Dropdown(
id='main-param-dropdown',
options=[{'label': f"{info['name']} ({info['unit']})", 'value': key}
for key, info in param_info.items()],
value='mean_tds',
clearable=False,
className="mb-2"
),
], width=6),
dbc.Col([
dbc.Label("Parameter Information:", className="fw-bold mb-2"),
dbc.Card([
dbc.CardBody(id='param-info-display', className="p-3")
], color="info", outline=True)
], width=6),
])
])
])
])
], className="mb-4"),
# Horizontal View Card
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardHeader([
html.H3([
html.I(className="fas fa-map me-2", style={'color': '#2C3E50'}),
"Horizontal View (Map at Fixed Depth)"
], className="mb-0")
]),
dbc.CardBody([
dbc.Row([
dbc.Col([
dbc.Label("Select Depth:", className="fw-bold mb-2"),
dcc.Slider(
id='depth-slider',
min=min(available_depths),
max=max(available_depths),
step=5,
value=min(available_depths) if available_depths else 0,
marks={str(int(d)): {'label': f'{int(d)}m', 'style': {'fontSize': '12px'}}
for d in available_depths[::max(1, len(available_depths)//8)]},
tooltip={"placement": "bottom", "always_visible": True},
className="mb-3"
),
], width=8),
dbc.Col([
dbc.Label("Statistics at This Depth:", className="fw-bold mb-2"),
html.Div(id='depth-stats-table')
], width=4),
], className="mb-3"),
dcc.Graph(
id='horizontal-slice-map',
config={'displayModeBar': True, 'scrollZoom': True},
style={'height': '600px'}
),
dbc.Alert(id='horizontal-slice-info', color="info", className="mt-3")
])
])
])
], className="mb-4"),
# Vertical Cross-Section Card
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardHeader([
html.H3([
html.I(className="fas fa-layer-group me-2", style={'color': '#2C3E50'}),
"Vertical Cross-Section (Depth Profile)"
], className="mb-0")
]),
dbc.CardBody([
dbc.Row([
dbc.Col([
dbc.Label("Cross-Section Type:", className="fw-bold mb-2"),
dbc.RadioItems(
id='cut-type-radio',
options=[
{'label': [html.I(className="fas fa-arrows-alt-h me-2"), 'East-West Cut (Fixed Latitude)'], 'value': 'lat'},
{'label': [html.I(className="fas fa-arrows-alt-v me-2"), 'North-South Cut (Fixed Longitude)'], 'value': 'lon'}
],
value='lat',
className="mb-3"
)
], width=4),
dbc.Col([
dbc.Label("Cut Position:", className="fw-bold mb-2"),
dcc.Slider(
id='cut-value-slider',
min=min_lat,
max=max_lat,
step=0.01,
value=df['Latitude'].mean(),
marks={round(v, 2): {'label': str(round(v, 2)), 'style': {'fontSize': '12px'}}
for v in np.linspace(min_lat, max_lat, 5).round(2)},
tooltip={"placement": "bottom", "always_visible": True},
className="mb-3"
),
], width=8),
]),
dcc.Graph(
id='vertical-slice-plot',
config={'displayModeBar': True},
style={'height': '600px'}
),
dbc.Alert(id='vertical-slice-info', color="info", className="mt-3")
])
])
])
], className="mb-4"),
# Parameter Reference Cards Row
dbc.Row([
dbc.Col([
html.H3([
html.I(className="fas fa-book me-2", style={'color': '#2C3E50'}),
"Parameter Reference Guide"
], className="text-center mb-4")
])
]),
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardHeader([
html.H5([
html.I(className=info['icon'] + " me-2", style={'color': '#3498DB'}),
info['name']
], className="mb-0")
]),
dbc.CardBody([
dbc.Badge(f"Units: {info['unit']}", color="primary", className="mb-2"),
html.P(info['desc'], className="mb-2", style={'fontSize': '14px'}),
html.P([
html.I(className="fas fa-lightbulb me-1", style={'color': '#F39C12'}),
info['interpretation']
], className="mb-2", style={'fontSize': '13px', 'fontStyle': 'italic'}),
dbc.Card([
dbc.CardBody([
html.P("Quality Thresholds:", className="fw-bold mb-2", style={'fontSize': '12px'}),
html.Ul([
html.Li(f"{k.title()}: {v} {info['unit']}", style={'fontSize': '11px'})
for k, v in info['thresholds'].items()
], className="mb-0")
])
], color="light", className="mt-2")
])
], className="h-100")
], width=12//5) for key, info in param_info.items()
], className="mb-4"),
# Footer
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardBody([
html.P([
html.I(className="fas fa-flask me-2", style={'color': '#7F8C8D'}),
"Dasbooard Developed usin Plotly/Dash | ",
html.I(className="fas fa-target me-2", style={'color': '#7F8C8D'}),
"Thank you to USGS for the data Purpose: Exploratory analysis"
], className="text-center mb-0", style={'color': '#7F8C8D'})
])
], color="dark", className="text-white")
])
])
], fluid=True, className="py-3")
# --- 5. Enhanced Callbacks ---
@app.callback(
Output('param-info-display', 'children'),
Input('main-param-dropdown', 'value')
)
def update_param_info(selected_param):
if selected_param:
info = param_info[selected_param]
return html.Div([
html.P([
html.I(className="fas fa-info-circle me-2", style={'color': '#3498DB'}),
info['desc']
], className="mb-2"),
html.P([
html.I(className="fas fa-lightbulb me-2", style={'color': '#F39C12'}),
info['interpretation']
], className="mb-0", style={'fontStyle': 'italic'})
])
return "Select a parameter to see details"
@app.callback(
Output('depth-stats-table', 'children'),
Input('main-param-dropdown', 'value'),
Input('depth-slider', 'value')
)
def update_depth_stats(selected_param, selected_depth):
if selected_param and selected_depth:
return create_summary_stats_table(selected_param, selected_depth)
return dbc.Alert("Select parameters to see statistics", color="secondary")
@app.callback(
Output('cut-value-slider', 'min'),
Output('cut-value-slider', 'max'),
Output('cut-value-slider', 'step'),
Output('cut-value-slider', 'value'),
Output('cut-value-slider', 'marks'),
Input('cut-type-radio', 'value')
)
def update_cut_slider_ranges(cut_type):
if cut_type == 'lat':
min_val, max_val = df['Latitude'].min(), df['Latitude'].max()
step = 0.005
marks = {round(v, 3): {'label': str(round(v, 3)), 'style': {'fontSize': '12px'}}
for v in np.linspace(min_val, max_val, 7).round(3)}
value = df['Latitude'].mean()
else:
min_val, max_val = df['Longitude'].min(), df['Longitude'].max()
step = 0.005
marks = {round(v, 3): {'label': str(round(v, 3)), 'style': {'fontSize': '12px'}}
for v in np.linspace(min_val, max_val, 7).round(3)}
value = df['Longitude'].mean()
return min_val, max_val, step, value, marks
@app.callback(
Output('horizontal-slice-map', 'figure'),
Output('horizontal-slice-info', 'children'),
Input('depth-slider', 'value'),
Input('main-param-dropdown', 'value')
)
def update_horizontal_slice(selected_depth, selected_prop):
filtered_df = df[df['zm_depth_rounded'] == selected_depth].copy()
fig = go.Figure()
if not filtered_df.empty:
info = param_info[selected_prop]
# Create interpolated surface
grid_lat = np.linspace(filtered_df['Latitude'].min(), filtered_df['Latitude'].max(), 80)
grid_lon = np.linspace(filtered_df['Longitude'].min(), filtered_df['Longitude'].max(), 80)
points = filtered_df[['Latitude', 'Longitude']].values
values = filtered_df[selected_prop].values
grid_data = griddata(points, values, (grid_lat[None,:], grid_lon[:,None]), method='cubic')
fig.add_trace(go.Heatmap(
x=grid_lon,
y=grid_lat,
z=grid_data,
colorscale=info['color_scale'],
colorbar=dict(
title=f"{info['short_name']}<br>({info['unit']})",
# titleside='right',
thickness=15,
len=0.7
),
hovertemplate='<b>Latitude:</b> %{y:.4f}<br><b>Longitude:</b> %{x:.4f}<br><b>' +
info['short_name'] + ':</b> %{z:.2f} ' + info['unit'] + '<extra></extra>'
))
# Add sample points
fig.add_trace(go.Scatter(
x=filtered_df['Longitude'],
y=filtered_df['Latitude'],
mode='markers',
marker=dict(size=4, color='white', opacity=0.8, line=dict(width=1, color='black')),
name='Sample Points',
hovertemplate='<b>Sample Point</b><br>Lat: %{y:.4f}<br>Lon: %{x:.4f}<br>' +
info['short_name'] + ': %{text}<extra></extra>',
text=[f"{val:.2f} {info['unit']}" for val in filtered_df[selected_prop]]
))
fig.update_layout(
title=f"{info['name']} at {selected_depth}m Depth",
xaxis_title="Longitude",
yaxis_title="Latitude",
margin={"r":60,"t":60,"l":60,"b":60},
hovermode='closest',
showlegend=True,
height=500,
plot_bgcolor='white'
)
# Statistics info
mean_val = filtered_df[selected_prop].mean()
std_val = filtered_df[selected_prop].std()
count = len(filtered_df)
info_text = [
html.I(className="fas fa-chart-bar me-2"),
f"{count} data points | Mean: {mean_val:.2f} {info['unit']} | Std Dev: {std_val:.2f} {info['unit']}"
]
else:
fig.add_annotation(
text="No data available for this depth<br>Try selecting a different depth",
xref="paper", yref="paper", x=0.5, y=0.5,
showarrow=False, font=dict(size=16, color='gray')
)
fig.update_layout(
title="No Data Available",
xaxis_title="Longitude",
yaxis_title="Latitude",
margin={"r":60,"t":60,"l":60,"b":60},
plot_bgcolor='white'
)
info_text = [html.I(className="fas fa-exclamation-triangle me-2"), "No data available for the selected depth"]
return fig, info_text
@app.callback(
Output('vertical-slice-plot', 'figure'),
Output('vertical-slice-info', 'children'),
Input('main-param-dropdown', 'value'),
Input('cut-type-radio', 'value'),
Input('cut-value-slider', 'value')
)
def update_vertical_slice(selected_prop, cut_type, cut_value):
fig = go.Figure()
tolerance = 0.01
if cut_type == 'lat':
filtered_df = df[(df['Latitude'] >= cut_value - tolerance) &
(df['Latitude'] <= cut_value + tolerance)].copy()
x_axis_col = 'Longitude'
xaxis_title = 'Longitude'
title_extra = f"Latitude = {cut_value:.3f}°"
cut_direction = "East-West"
else:
filtered_df = df[(df['Longitude'] >= cut_value - tolerance) &
(df['Longitude'] <= cut_value + tolerance)].copy()
x_axis_col = 'Latitude'
xaxis_title = 'Latitude'
title_extra = f"Longitude = {cut_value:.3f}°"
cut_direction = "North-South"
if not filtered_df.empty:
info = param_info[selected_prop]
# Create interpolated surface
grid_x = np.linspace(filtered_df[x_axis_col].min(), filtered_df[x_axis_col].max(), 60)
grid_y_depth = np.linspace(filtered_df['zm_depth'].min(), filtered_df['zm_depth'].max(), 60)
points = filtered_df[[x_axis_col, 'zm_depth']].values
values = filtered_df[selected_prop].values
grid_data = griddata(points, values, (grid_x[None,:], grid_y_depth[:,None]), method='cubic')
fig.add_trace(go.Heatmap(
x=grid_x,
y=grid_y_depth,
z=grid_data,
colorscale=info['color_scale'],
colorbar=dict(
title=f"{info['short_name']}<br>({info['unit']})",
# titleside='right',
thickness=15,
len=0.7
),
hovertemplate=f'<b>{xaxis_title}:</b> %{{x:.4f}}<br><b>Depth:</b> %{{y:.1f}}m<br><b>' +
info['short_name'] + ':</b> %{z:.2f} ' + info['unit'] + '<extra></extra>'
))
# Add sample points
fig.add_trace(go.Scatter(
x=filtered_df[x_axis_col],
y=filtered_df['zm_depth'],
mode='markers',
marker=dict(size=4, color='white', opacity=0.8, line=dict(width=1, color='black')),
name='Sample Points',
hovertemplate=f'<b>Sample Point</b><br>{xaxis_title}: %{{x:.4f}}<br>Depth: %{{y:.1f}}m<br>' +
info['short_name'] + ': %{text}<extra></extra>',
text=[f"{val:.2f} {info['unit']}" for val in filtered_df[selected_prop]]
))
fig.update_layout(
title=f"{info['name']} - {cut_direction} Cross-Section ({title_extra})",
xaxis_title=xaxis_title,
yaxis_title='Depth (m)',
yaxis=dict(autorange='reversed'),
margin={"r":60,"t":60,"l":60,"b":60},
hovermode='closest',
showlegend=True,
height=500,
plot_bgcolor='white'
)
# Statistics info
mean_val = filtered_df[selected_prop].mean()
count = len(filtered_df)
depth_range = filtered_df['zm_depth'].max() - filtered_df['zm_depth'].min()
info_text = [
html.I(className="fas fa-chart-bar me-2"),
f"{count} data points | Mean: {mean_val:.2f} {info['unit']} | Depth Range: {depth_range:.1f}m"
]
else:
fig.add_annotation(
text="No data available for this cross-section<br>Try adjusting the cut position",
xref="paper", yref="paper", x=0.5, y=0.5,
showarrow=False, font=dict(size=16, color='gray')
)
fig.update_layout(
title="No Data Available",
xaxis_title=xaxis_title,
yaxis_title='Depth (m)',
yaxis=dict(autorange='reversed'),
margin={"r":60,"t":60,"l":60,"b":60},
plot_bgcolor='white'
)
info_text = [html.I(className="fas fa-exclamation-triangle me-2"), "No data available for the selected cross-section"]
return fig, info_text