OCx25 / co2_plot_blueprint.py
mshuaibi's picture
init ocx25
5ae7694
Raw
History Blame Contribute Delete
169 kB
import pandas as pd
import matplotlib.pyplot as plt
from flask import Blueprint, render_template_string, request, jsonify, Response
import os
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
import numpy as np
from scipy.stats import linregress
from scipy.optimize import curve_fit
import json
import glob
# Create Blueprint
co2_plot_bp = Blueprint('co2_plot', __name__, url_prefix='/co2')
# Atomic weights for conversion between atomic and weight fractions
ATOMIC_WEIGHTS = {
'Ag': 107.8682, 'Au': 196.966569, 'Cd': 112.411, 'Cu': 63.546, 'Ga': 69.723,
'Hg': 200.59, 'In': 114.818, 'Mn': 54.938044, 'Mo': 95.96, 'Nb': 92.90637,
'Ni': 58.6934, 'Pd': 106.42, 'Pt': 195.084, 'Rh': 102.90550, 'Sn': 118.710,
'Tl': 204.38, 'W': 183.84, 'Zn': 65.38
}
# Voltage conversion functions
def lin_fxn(x, a, b):
return a*x+b
def fit_lin(X, Y):
params, covariance = curve_fit(lin_fxn, X, Y)
a_fit, b_fit = params
return (a_fit, b_fit)
def est_x(x, X, Y):
fit = fit_lin(X, Y)
x = fit[0]*x+fit[1]
return x
def load_calibration_data_for_voltage_conversion(custom_params=None):
"""Load calibration data for voltage conversion from full cell to half cell."""
# Default experiment conditions: Neutral CO2RR in 4cm2 cell, Sputtered Copper Catalyst, 0.1M Bicarbonate - ref electrode (3M kcl) 230mV vs SHE
default_params = {
'ref_pot': 0.23, # V Ag/AgCl electrode
'cathode_pH': 12.5,
'anode_pH': 3,
'geo_area': 4, # cm2
'membrane_loss': 0.1, # V
# Note: anode_measured_potential_vs_ref is now interpolated from calibration data
}
# Use custom parameters if provided, otherwise use defaults
if custom_params:
params = {**default_params, **custom_params}
else:
params = default_params
ref_pot = params['ref_pot']
cathode_pH = params['cathode_pH']
anode_pH = params['anode_pH']
Nern_pH_loss = (cathode_pH-anode_pH)*0.059
geo_area = params['geo_area']
membrane_loss = params['membrane_loss']
# Measurements from calibration work
j = np.array([50,100,200])
cathode_pot = np.array([-1.62,-2.0,-2.3])
cathode_R = np.array([0.48,0.34,0.3])
anode_pot = np.array([1.3,1.35,1.4])
anode_R = np.array([0,0,0]) #almost negligible
fullcell_pot = np.array([3,3.4,3.7])
fullcell_R = np.array([0.47,0.35,0.3])
n = len(cathode_pot)
cathode_pot_corr = np.zeros(n)
anode_pot_corr = np.zeros(n)
cathode_overpot = np.zeros(n)
anode_overpot = np.zeros(n)
fullcell_pot_corr = np.zeros(n)
for i in range(0, n):
cathode_pot_corr[i] = correct_potential(cathode_pot[i], cathode_R[i], cathode_pH, j[i], geo_area, ref_pot)
anode_pot_corr[i] = correct_potential(anode_pot[i], anode_R[i], anode_pH, j[i], geo_area, ref_pot)
cathode_overpot[i] = get_overpotential(cathode_pot_corr[i],0.08)
anode_overpot[i] = get_overpotential(anode_pot_corr[i], 1.23)
fullcell_pot_corr[i] = fullcell_pot[i]-fullcell_R[i]*j[i]/1000*geo_area
conditions_dict = {
'ref pot': ref_pot,
'cathode pH': cathode_pH,
'anode pH': anode_pH,
'Nern pH loss': Nern_pH_loss,
'geo area': geo_area,
'membrane loss': membrane_loss,
}
measurements_dict = {
'j': j, 'cathode pot': cathode_pot, 'cathode R': cathode_R, 'anode pot': anode_pot, 'anode R': anode_R,
'fullcell pot': fullcell_pot, 'fullcell R': fullcell_R,
}
data_dict = {
'cathode pot corr': cathode_pot_corr, 'anode pot corr': anode_pot_corr,
'cathode overpot': cathode_overpot, 'anode overpot': anode_overpot, 'fullcell pot corr': fullcell_pot_corr
}
return {'measurements': measurements_dict, 'conditions': conditions_dict, 'extracted params': data_dict}
def she2rhe(ushe, pH, ref_pot):
ushe = ushe+ref_pot+(0.059*pH)
return ushe
def rhe2she(urhe, pH, ref_pot):
urhe = urhe - (0.059 * pH)
return urhe
def correct_potential(pot, R, pH, j, area, ref_pot):
if pot<0:
corrected_pot = she2rhe(pot+j/1000*area*R,pH, ref_pot)
else:
corrected_pot = she2rhe(pot-j/1000*area*R,pH, ref_pot)
return corrected_pot
def interpolate_cathode_R(current_density):
"""
Interpolate cathode resistance R from log(j) vs R calibration data.
Calibration data:
j = [50, 100, 200] mA/cm²
R = [0.48, 0.34, 0.3] ohm
Fits log(j) vs R and interpolates R for given current density.
"""
# Calibration data
j_array = np.array([50, 100, 200]) # mA/cm²
R_array = np.array([0.48, 0.34, 0.3]) # ohm
# Convert to log scale for j
log_j = np.log10(j_array)
# Fit linear relationship: R = a * log10(j) + b
fit_params = np.polyfit(log_j, R_array, 1)
a, b = fit_params
# Interpolate R for given current density (convert mA/cm² to mA/cm², already in correct units)
if current_density <= 0:
# Use minimum R if current density is too small
return R_array[-1] # Use the smallest R (at highest j)
log_j_input = np.log10(current_density)
R_interpolated = a * log_j_input + b
# Clamp to reasonable bounds (between min and max R values)
R_interpolated = np.clip(R_interpolated, R_array.min(), R_array.max())
return R_interpolated
def interpolate_anode_potential_vs_ref(current_density):
"""
Interpolate anode measured potential vs reference from log(j) vs anode_pot calibration data.
Calibration data:
j = [50, 100, 200] mA/cm²
anode_pot = [1.3, 1.35, 1.4] V
Fits log(j) vs anode_pot and interpolates anode_pot for given current density.
"""
# Calibration data
j_array = np.array([50, 100, 200]) # mA/cm²
anode_pot_array = np.array([1.3, 1.35, 1.4]) # V
# Convert to log scale for j
log_j = np.log10(j_array)
# Fit linear relationship: anode_pot = a * log10(j) + b
fit_params = np.polyfit(log_j, anode_pot_array, 1)
a, b = fit_params
# Interpolate anode_pot for given current density
if current_density <= 0:
# Use minimum anode_pot if current density is too small
return anode_pot_array[0] # Use the smallest anode_pot (at lowest j)
log_j_input = np.log10(current_density)
anode_pot_interpolated = a * log_j_input + b
# Clamp to reasonable bounds (between min and max anode_pot values)
anode_pot_interpolated = np.clip(anode_pot_interpolated, anode_pot_array.min(), anode_pot_array.max())
return anode_pot_interpolated
def cell2rhe(vcell, ref_pot, anode_pH,
membrane_loss, Nern_pH_loss, current_density, geo_area,
custom_anode_potential_vs_ref=None, custom_R_cathode=None):
"""
Convert full cell voltage to cathode potential vs RHE.
Steps (matching notebook example):
1. Interpolate anode measured potential vs reference from calibration data (or use custom value)
2. Convert anode measured potential (vs reference) to RHE:
V_anode_RHE = anode_measured_potential_vs_ref + ref_pot + 0.059 * anode_pH
3. Calculate cathode RHE (before IR correction):
V_cathode_RHE = (V_anode_RHE + membrane_loss + Nern_pH_loss) - full_cell_V
4. Interpolate cathode resistance from calibration data (or use custom value)
5. Apply IR correction:
V_cathode_RHE = V_cathode_RHE - (i/1000 * R * A)
where i is current density in A/cm², R is interpolated resistance, A is geometric area
Parameters:
-----------
custom_anode_potential_vs_ref : float, optional
Custom anode measured potential vs reference (V). If provided, overrides interpolation.
custom_R_cathode : float, optional
Custom cathode resistance (Ω). If provided, overrides interpolation.
"""
# Step 1: Interpolate anode measured potential vs reference (or use custom value)
if custom_anode_potential_vs_ref is not None:
anode_measured_potential_vs_ref = custom_anode_potential_vs_ref
else:
anode_measured_potential_vs_ref = interpolate_anode_potential_vs_ref(current_density)
# Step 2: Convert anode measured potential to RHE
v_anode_rhe = anode_measured_potential_vs_ref + ref_pot + 0.059 * anode_pH
# Step 3: Calculate cathode RHE with membrane and Nernst pH losses (before IR correction)
v_cathode_rhe = (v_anode_rhe + membrane_loss + Nern_pH_loss) - vcell
# Step 4: Interpolate cathode resistance from calibration data (or use custom value)
if custom_R_cathode is not None:
R = custom_R_cathode
else:
R = interpolate_cathode_R(current_density) # current_density in mA/cm², R in ohm
# Step 5: Apply IR correction
# Convert current density from mA/cm² to A/cm² and apply IR correction
# i/1000 converts mA/cm² to A/cm²
IR_drop = (current_density / 1000.0) * R * geo_area
v_cathode_rhe = v_cathode_rhe - IR_drop
return v_cathode_rhe
def get_overpotential(pot, pot_theory):
overpot = abs(pot-pot_theory)
return overpot
def fullcell2halfcell(vcell, current_density, custom_params=None):
'''
Main function to convert a voltage value from full cell to half cell vs she or rhe
Parameters:
-----------
vcell : float
Full cell voltage (V)
current_density : float
Current density (mA/cm²)
custom_params : dict, optional
Custom parameters for voltage conversion
'''
cali_dict = load_calibration_data_for_voltage_conversion(custom_params)
# Extract custom values if provided
custom_anode_pot = custom_params.get('anode_measured_potential_vs_ref') if custom_params else None
custom_R = custom_params.get('R_cathode') if custom_params else None
urhe = cell2rhe(vcell,
cali_dict['conditions']['ref pot'],
cali_dict['conditions']['anode pH'],
cali_dict['conditions']['membrane loss'],
cali_dict['conditions']['Nern pH loss'],
current_density,
cali_dict['conditions']['geo area'],
custom_anode_potential_vs_ref=custom_anode_pot,
custom_R_cathode=custom_R)
ushe = rhe2she(urhe, cali_dict['conditions']['cathode pH'], cali_dict['conditions']['ref pot'])
return ushe, urhe
def convert_atomic_to_weight_fraction(df, element_columns):
"""
Convert atomic fraction to weight fraction for elemental compositions.
"""
df_converted = df.copy()
for col in element_columns:
if col in df_converted.columns and col in ATOMIC_WEIGHTS:
df_converted[col] = df_converted[col] * ATOMIC_WEIGHTS[col]
# Normalize to get weight fractions (0-1 scale)
for idx, row in df_converted.iterrows():
total_weight = sum(row[col] for col in element_columns if col in df_converted.columns and col in ATOMIC_WEIGHTS)
if total_weight > 0:
for col in element_columns:
if col in df_converted.columns and col in ATOMIC_WEIGHTS:
df_converted.at[idx, col] = row[col] / total_weight
return df_converted
def filter_df_by_current_density(df, target_current_density, tolerance=10):
"""
Filter dataframe to get data close to a specific current density value.
"""
# Filter data within tolerance of target current density
filtered_df = df[abs(df['current density'] - target_current_density) <= tolerance].copy()
return filtered_df
def generate_df_at_voltage(df, voltage_col='voltage_mean', cd_col='current density', fe_prefix='fe_', group_cols=None, target_voltage=3.0):
"""
Generate a dataframe interpolated at a specific voltage value.
"""
if group_cols is None:
# Default: group by 'source' and all columns containing 'xrf' in their name
xrf_cols = [col for col in df.columns if 'xrf' in col]
group_cols = ['source'] + xrf_cols
# Handle both _mean/_std suffix format and simple format
fe_mean_cols = [col for col in df.columns if col.startswith(fe_prefix) and col.endswith('_mean')]
fe_std_cols = [col for col in df.columns if col.startswith(fe_prefix) and col.endswith('_std')]
# If no _mean columns found, look for simple fe_ columns (without _mean suffix)
if not fe_mean_cols:
fe_mean_cols = [col for col in df.columns if col.startswith(fe_prefix) and not col.endswith('_std')]
# Get elemental composition columns (Ag, Au, Cu, etc.)
# Handle both voltage_mean and voltage column names
voltage_cols_to_exclude = ['voltage_mean', 'voltage_std', 'voltage']
composition_col = 'xrf composition' if 'xrf composition' in df.columns else 'target composition'
element_cols = [col for col in df.columns if col not in ['source', 'current density', composition_col, 'rep'] + voltage_cols_to_exclude and not col.startswith('fe_') and not col.endswith('std')]
results = []
for group_keys, group_df in df.groupby(group_cols):
if not isinstance(group_keys, tuple):
group_keys = (group_keys,)
# Current density fit
log_cds = np.log(group_df[cd_col].replace(0, np.nan).dropna().values)
valid_idx = group_df[cd_col].replace(0, np.nan).dropna().index
voltages_for_fit = group_df.loc[valid_idx, voltage_col].values
if len(voltages_for_fit) >= 2:
slope, intercept, _, _, _ = linregress(voltages_for_fit, log_cds)
pred_log_cd = slope * target_voltage + intercept
pred_cd = np.exp(pred_log_cd)
else:
pred_cd = np.nan
fe_pred_dict = {}
# MEAN
for fe_col in fe_mean_cols:
fe_vals = group_df[fe_col].values
mask = ~np.isnan(fe_vals)
if np.sum(mask) >= 2:
slope_fe, intercept_fe, _, _, _ = linregress(group_df[voltage_col].values[mask], fe_vals[mask])
pred_fe = slope_fe * target_voltage + intercept_fe
else:
pred_fe = np.nan
fe_pred_dict[fe_col] = pred_fe
# STD
for fe_col in fe_std_cols:
fe_vals = group_df[fe_col].values
mask = ~np.isnan(fe_vals)
if np.sum(mask) >= 2:
slope_fe, intercept_fe, _, _, _ = linregress(group_df[voltage_col].values[mask], fe_vals[mask])
pred_fe = slope_fe * target_voltage + intercept_fe
else:
pred_fe = np.nan
fe_pred_dict[fe_col] = pred_fe
# Get elemental composition values (these don't change with voltage, so take the first value)
element_dict = {}
for element_col in element_cols:
element_vals = group_df[element_col].dropna()
if not element_vals.empty:
element_dict[element_col] = element_vals.iloc[0]
else:
element_dict[element_col] = np.nan
row = dict(zip(group_cols, group_keys))
row['current density'] = pred_cd
row.update(fe_pred_dict)
row.update(element_dict) # Add elemental composition columns
results.append(row)
return pd.DataFrame(results)
def load_xrd_data(sample_id, data_type="raw"):
"""
Load XRD data for a specific sample ID from Data/XRD or Data/CustomXRD directory.
Args:
sample_id: The sample ID to load
data_type: Either "raw" (.xy files) or "normalized" (.csv files)
Returns the XRD data as a list of [x, y] pairs or None if not found.
"""
try:
# First check for custom XRD data, then fall back to original
custom_xrd_base = "Data/CustomXRD"
original_xrd_base = "Data/XRD"
# Construct potential file paths
if data_type == "raw":
custom_path = f"{custom_xrd_base}/raw/{sample_id}.xy"
original_path = f"{original_xrd_base}/raw/{sample_id}.xy"
elif data_type == "normalized":
custom_path = f"{custom_xrd_base}/normalized/{sample_id}.csv"
original_path = f"{original_xrd_base}/normalized/{sample_id}.csv"
else:
print(f"Invalid data type: {data_type}")
return None
# Check custom XRD first, then original
xrd_file_path = None
if os.path.exists(custom_path):
xrd_file_path = custom_path
print(f"DEBUG: Using custom XRD file: {custom_path}")
elif os.path.exists(original_path):
xrd_file_path = original_path
print(f"DEBUG: Using original XRD file: {original_path}")
else:
print(f"XRD file not found in custom or original locations for sample {sample_id} ({data_type})")
return None
# Read the file
data = []
with open(xrd_file_path, 'r') as f:
lines = f.readlines()
# Skip the first line (header)
for line_num, line in enumerate(lines[1:], 2): # Start from line 2
line = line.strip()
if line and not line.startswith('#'): # Skip empty lines and comments
try:
# Handle different separators (space, tab, comma)
parts = line.replace(',', ' ').split()
if len(parts) >= 2:
x_val = float(parts[0])
y_val = float(parts[1])
data.append([x_val, y_val])
except ValueError:
# Skip lines that can't be parsed as numbers
if line_num <= 10: # Only log first few errors to avoid spam
print(f"Warning: Could not parse line {line_num} in {xrd_file_path}: {line}")
continue
if not data:
print(f"No valid data found in XRD file: {xrd_file_path}")
return None
print(f"Loaded XRD data for sample {sample_id} ({data_type}): {len(data)} data points")
return data
except Exception as e:
print(f"Error loading XRD data: {e}")
return None
def load_original_data():
"""Load the original data from CSV file or current data from dashboard"""
try:
# First try to load current data from dashboard
current_data_file = "Data/current_data_co2.json"
if os.path.exists(current_data_file):
with open(current_data_file, 'r') as f:
saved_data = json.load(f)
if isinstance(saved_data, dict) and 'data' in saved_data and 'columns' in saved_data:
current_data = saved_data['data']
column_order = saved_data['columns']
df = pd.DataFrame(current_data, columns=column_order)
elif isinstance(saved_data, list):
df = pd.DataFrame(saved_data)
else:
df = pd.DataFrame(saved_data)
# Filter for CO2R reaction if reaction column exists
if 'reaction' in df.columns:
df = df[df['reaction'] == 'CO2R'].copy()
df = df.drop('reaction', axis=1)
print(f"DEBUG: Available columns after loading CO2R data: {list(df.columns)}")
print(f"DEBUG: Data shape: {df.shape}")
print(f"DEBUG: Voltage columns present: {[col for col in df.columns if 'voltage' in col.lower()]}")
return df
except Exception as e:
print(f"Could not load current data: {e}")
# Fallback to original CSV data
try:
df = pd.read_csv("Data/DashboardData.csv")
if 'reaction' in df.columns:
df = df[df['reaction'] == 'CO2R'].copy()
df = df.drop('reaction', axis=1)
return df
except Exception as e:
print(f"Could not load CSV data: {e}")
return pd.DataFrame()
def calculate_pca_components(df):
"""Calculate PCA components from elemental composition data."""
if df.empty or len(df) < 2:
df['PCA1'] = np.nan
df['PCA2'] = np.nan
return df
# Get only elemental composition columns
voltage_cols_to_exclude = ['voltage_mean', 'voltage_std', 'voltage']
composition_col = 'xrf composition' if 'xrf composition' in df.columns else 'target composition'
element_cols = [col for col in df.columns if col not in ['sample id', 'source', 'batch number', 'batch date', 'current density', composition_col, 'target composition', 'xrf composition', 'rep'] + voltage_cols_to_exclude and not col.startswith('fe_') and not col.endswith('std')]
# Filter out non-numeric columns
numeric_element_cols = []
for col in element_cols:
try:
if pd.to_numeric(df[col], errors='coerce').notna().sum() >= 2:
numeric_element_cols.append(col)
except:
continue
if len(numeric_element_cols) < 2:
df['PCA1'] = np.nan
df['PCA2'] = np.nan
return df
# Prepare data for PCA
pca_data = df[numeric_element_cols].copy()
for col in pca_data.columns:
pca_data[col] = pd.to_numeric(pca_data[col], errors='coerce')
pca_data = pca_data.fillna(0)
if pca_data.sum().sum() == 0:
df['PCA1'] = 0
df['PCA2'] = 0
return df
try:
# Standardize and apply PCA
scaler = StandardScaler()
pca_data_scaled = scaler.fit_transform(pca_data)
pca = PCA(n_components=2)
pca_components = pca.fit_transform(pca_data_scaled)
df['PCA1'] = pca_components[:, 0]
df['PCA2'] = pca_components[:, 1]
except Exception as e:
print(f"PCA calculation failed: {e}")
df['PCA1'] = np.nan
df['PCA2'] = np.nan
return df
def format_column_name(column_name):
"""Format column names to be more readable."""
if column_name in ['voltage_mean', 'voltage']:
return 'Full Cell Voltage (V)'
elif column_name == 'voltage_she':
return 'Est. Half-cell potential vs SHE (V)'
elif column_name == 'voltage_rhe':
return 'Est. Half-cell potential vs RHE (V)'
elif column_name == 'cost_per_gram':
return 'Cost per kg'
elif column_name.startswith('fe_'):
base_name = column_name.replace('fe_', '').replace('_mean', '')
if base_name == 'h2':
return 'Faradaic Efficiency H₂'
elif base_name == 'co':
return 'Faradaic Efficiency CO'
elif base_name == 'ch4':
return 'Faradaic Efficiency CH₄'
elif base_name == 'c2h4':
return 'Faradaic Efficiency C₂H₄'
elif base_name == 'gas_total':
return 'Faradaic Efficiency Gas Total'
elif base_name == 'liquid':
return 'Faradaic Efficiency Liquid'
else:
return 'Faradaic Efficiency ' + base_name.upper()
elif column_name.startswith('max_partial_current_'):
base_name = column_name.replace('max_partial_current_', '').replace('_mean', '').replace('_std', '')
species_map = {
'h2': 'H₂',
'co': 'CO',
'ch4': 'CH₄',
'c2h4': 'C₂H₄',
'gas_total': 'Gas Total',
'liquid': 'Liquid'
}
species_label = species_map.get(base_name, base_name.upper())
return f'Max Partial Current {species_label}'
elif column_name.startswith('partial_current_'):
base_name = column_name.replace('partial_current_', '').replace('_mean', '').replace('_std', '')
species_map = {
'h2': 'H₂',
'co': 'CO',
'ch4': 'CH₄',
'c2h4': 'C₂H₄',
'gas_total': 'Gas Total',
'liquid': 'Liquid'
}
species_label = species_map.get(base_name, base_name.upper())
return f'Partial Current {species_label}'
elif column_name in ['PCA1', 'PCA2']:
return column_name
elif column_name in ['Ag', 'Au', 'Cd', 'Cu', 'Ga', 'Hg', 'In', 'Ni', 'Pd', 'Pt', 'Rh', 'Sn', 'Tl', 'Zn']:
return column_name
else:
return column_name
@co2_plot_bp.route('/')
def co2_plot_main():
"""Main CO2R plot page"""
# Load and process data
current_df = load_original_data()
if current_df.empty:
return "<h2>Error: No CO2R data available</h2><p>Please ensure CO2R data is available in the main dashboard.</p>"
# Calculate PCA components
df_with_pca = calculate_pca_components(current_df)
# Identify element columns
voltage_cols_to_exclude = ['voltage_mean', 'voltage_std', 'voltage']
composition_col = 'xrf composition' if 'xrf composition' in df_with_pca.columns else 'target composition'
element_cols = [col for col in df_with_pca.columns if col not in ['sample id', 'source', 'batch number', 'batch date', 'current density', composition_col, 'target composition', 'xrf composition', 'PCA1', 'PCA2', 'rep'] + voltage_cols_to_exclude and not col.startswith('fe_') and not col.endswith('std')]
# Move partial current columns to the end for dropdown ordering
element_pc_cols = [c for c in element_cols if c.startswith('partial_current_') or c.startswith('max_partial_current_')]
element_non_pc_cols = [c for c in element_cols if c not in element_pc_cols]
element_cols = element_non_pc_cols + element_pc_cols
# Add PCA1 as first option if available
if 'PCA1' in df_with_pca.columns:
element_cols.insert(0, 'PCA1')
if 'Cu' in df_with_pca.columns and 'Cu' not in element_cols:
element_cols.insert(0, 'Cu')
# Identify FE columns for y-axis options
fe_cols = [col for col in df_with_pca.columns if col.startswith('fe_') and col.endswith('_mean')]
if not fe_cols:
fe_cols = [col for col in df_with_pca.columns if col.startswith('fe_') and not col.endswith('_std')]
# Default y-axis to CO FE if available
default_y_col = 'fe_co_mean' if 'fe_co_mean' in fe_cols else (fe_cols[0] if fe_cols else 'voltage_mean')
# Generate dropdown options
element_options = ''.join([f'<option value="{col}">{format_column_name(col)}</option>' for col in element_cols])
fe_options = ''.join([f'<option value="{col}" {"selected" if col == default_y_col else ""}>{format_column_name(col)}</option>' for col in fe_cols])
# Add voltage options to y-axis
if 'voltage_mean' in df_with_pca.columns:
fe_options += f'<option value="voltage_mean">{format_column_name("voltage_mean")}</option>'
if 'voltage' in df_with_pca.columns:
fe_options += f'<option value="voltage">{format_column_name("voltage")}</option>'
# Create comprehensive x-axis and z-axis options from original
comprehensive_x_axis_options = element_cols.copy()
if 'PCA2' in df_with_pca.columns:
comprehensive_x_axis_options.append('PCA2')
if 'voltage_mean' in df_with_pca.columns:
comprehensive_x_axis_options.append('voltage_mean')
elif 'voltage' in df_with_pca.columns:
comprehensive_x_axis_options.append('voltage')
comprehensive_x_axis_options.extend(fe_cols)
# Reorder to move partial current options to the bottom
comp_pc_cols = [c for c in comprehensive_x_axis_options if c.startswith('partial_current_') or c.startswith('max_partial_current_')]
comp_non_pc_cols = [c for c in comprehensive_x_axis_options if c not in comp_pc_cols]
comprehensive_x_axis_options = comp_non_pc_cols + comp_pc_cols
# Y-axis options: same as x-axis options
y_axis_options = comprehensive_x_axis_options.copy()
# Ensure partial current options remain at the bottom for y-axis as well
y_pc_cols = [c for c in y_axis_options if c.startswith('partial_current_') or c.startswith('max_partial_current_')]
y_non_pc_cols = [c for c in y_axis_options if c not in y_pc_cols]
y_axis_options = y_non_pc_cols + y_pc_cols
# Create z-axis options (for color control) - same as y-axis options with "Default" as first option
z_axis_options = ['default_colors'] # Default option for current blue/red/black coloring
z_axis_options.extend(y_axis_options) # Add all y-axis options (already ordered)
# Generate all dropdown options
x_axis_options_html = ''.join([f'<option value="{col}">{format_column_name(col)}</option>' for col in comprehensive_x_axis_options])
y_axis_options_html = ''.join([f'<option value="{col}" {"selected" if col == default_y_col else ""}>{format_column_name(col)}</option>' for col in y_axis_options])
z_axis_options_html = ''.join([f'<option value="{col}" {"selected" if col == "default_colors" else ""}>{"Default" if col == "default_colors" else format_column_name(col)}</option>' for col in z_axis_options])
# Current density options
current_density_options = [50, 100, 150, 200, 300]
default_current_density = 100
# Create the comprehensive HTML template from original interactive plot
html_template = f'''
<!DOCTYPE html>
<html>
<head>
<title>OCx25 Dataset: CO₂RR Performance Interactive Plot</title>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<style>
body {{
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
margin: 0;
padding: 0;
background: #fafafa;
min-height: 100vh;
color: #202124;
overflow-x: hidden;
line-height: 1.6;
}}
.back-link {{
position: fixed;
top: 20px;
left: 20px;
z-index: 1000;
background: #4285f4;
color: white;
padding: 12px 20px;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
font-size: 14px;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(66, 133, 244, 0.3);
}}
.back-link:hover {{
background: #3367d6;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(66, 133, 244, 0.4);
}}
.container {{
max-width: 100%;
margin: 0 auto;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
overflow: hidden;
margin: 12px;
border: 1px solid #e8eaed;
}}
h1 {{
color: #202124;
text-align: center;
font-size: 2.4em;
font-weight: 400;
letter-spacing: -0.5px;
margin: 0;
padding: 32px 24px 16px 24px;
background: #ffffff;
border-bottom: 1px solid #e8eaed;
}}
h3 {{
color: #5f6368;
text-align: center;
margin: 0;
padding: 0 24px 20px 24px;
background: #ffffff;
font-size: 1.1em;
font-weight: 400;
letter-spacing: 0.2px;
}}
.controls {{
background: #f8f9fa;
padding: 24px;
border-bottom: 1px solid #e8eaed;
display: flex;
gap: 24px;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}}
.control-group {{
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}}
label {{
font-weight: 500;
color: #5f6368;
font-size: 0.875em;
text-transform: none;
letter-spacing: 0.2px;
}}
select {{
padding: 12px 16px;
border: 1px solid #dadce0;
border-radius: 8px;
background: #ffffff;
font-size: 14px;
font-weight: 400;
color: #202124;
cursor: pointer;
transition: all 0.2s ease;
min-width: 160px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}}
select:hover {{
border-color: #4285f4;
box-shadow: 0 2px 8px rgba(66, 133, 244, 0.15);
}}
select:focus {{
outline: none;
border-color: #4285f4;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
}}
.slider-container {{
display: flex;
flex-direction: column;
gap: 12px;
min-width: 200px;
align-items: center;
}}
.slider-value {{
font-size: 1.2em;
font-weight: 500;
color: #4285f4;
background: #e8f0fe;
padding: 12px 20px;
border-radius: 8px;
border: 1px solid #d2e3fc;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}}
input[type="range"] {{
width: 200px;
height: 6px;
border-radius: 3px;
background: #e8eaed;
outline: none;
opacity: 1;
transition: all 0.2s ease;
cursor: pointer;
-webkit-appearance: none;
}}
input[type="range"]::-webkit-slider-thumb {{
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #4285f4;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
border: 2px solid #ffffff;
transition: all 0.2s ease;
}}
input[type="range"]::-webkit-slider-thumb:hover {{
transform: scale(1.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}}
input[type="range"]::-moz-range-thumb {{
width: 20px;
height: 20px;
border-radius: 50%;
background: #4285f4;
cursor: pointer;
border: 2px solid #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}}
.checkbox-container {{
display: flex;
align-items: center;
gap: 12px;
background: #ffffff;
padding: 16px 20px;
border-radius: 8px;
border: 1px solid #e8eaed;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}}
input[type="checkbox"] {{
width: 18px;
height: 18px;
accent-color: #4285f4;
cursor: pointer;
}}
.checkbox-container label {{
margin: 0;
color: #5f6368;
font-weight: 400;
}}
.export-btn {{
background: #ffffff;
color: #34a853;
border: 1px solid #34a853;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
text-transform: none;
letter-spacing: 0.2px;
min-width: 160px;
}}
.export-btn:hover {{
background: #34a853;
color: #ffffff;
box-shadow: 0 2px 8px rgba(52, 168, 83, 0.15);
transform: translateY(-1px);
}}
.export-btn:active {{
transform: translateY(0);
}}
.download-notebook-btn {{
display: inline-block;
background: #ffffff;
color: #1a73e8;
border: 1px solid #1a73e8;
padding: 10px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-transform: none;
letter-spacing: 0.2px;
margin-top: 8px;
text-decoration: none;
}}
.download-notebook-btn:hover {{
background: #1a73e8;
color: #ffffff;
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.15);
transform: translateY(-1px);
}}
.download-notebook-btn:active {{
transform: translateY(0);
}}
.voltage-config-btn {{
background: #ffffff;
color: #9c27b0;
border: 1px solid #9c27b0;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
text-transform: none;
letter-spacing: 0.2px;
min-width: 200px;
}}
.voltage-config-btn:hover {{
background: #9c27b0;
color: #ffffff;
box-shadow: 0 2px 8px rgba(156, 39, 176, 0.15);
transform: translateY(-1px);
}}
/* Context Menu Styles */
.context-menu {{
position: absolute;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 150px;
padding: 4px 0;
}}
.context-menu-item {{
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
color: #333;
transition: background-color 0.2s ease;
}}
.context-menu-item:hover {{
background-color: #f5f5f5;
}}
/* XRD Analysis Button Overlay */
.xrd-analysis-btn {{
position: absolute;
top: 20px;
right: 20px;
z-index: 1000;
}}
.analysis-btn {{
background: linear-gradient(135deg, #9c27b0 0%, #673ab7 100%);
color: white;
border: none;
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(156, 39, 176, 0.3);
text-transform: none;
letter-spacing: 0.3px;
}}
.analysis-btn:hover {{
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(156, 39, 176, 0.4);
}}
.analysis-btn:active {{
transform: translateY(0);
}}
/* XRD Analysis Tooltip */
.xrd-tooltip {{
position: absolute;
background: white;
border: 2px solid #9c27b0;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(156, 39, 176, 0.3);
z-index: 1000;
padding: 0;
max-width: 150px;
pointer-events: auto;
}}
.tooltip-content {{
padding: 8px;
}}
.tooltip-link {{
background: linear-gradient(135deg, #9c27b0 0%, #673ab7 100%);
color: white;
padding: 6px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
text-align: center;
transition: all 0.2s ease;
white-space: nowrap;
}}
.tooltip-link:hover {{
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(156, 39, 176, 0.4);
}}
.voltage-config-btn:active {{
transform: translateY(0);
}}
.plots-container {{
display: flex;
flex-direction: column;
gap: 20px;
padding: 32px;
background: #fafafa;
min-height: 1200px;
}}
.plots-row {{
display: flex;
gap: 20px;
min-height: 600px;
}}
.plot-section {{
flex: 1;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
overflow: hidden;
transition: all 0.2s ease;
border: 1px solid #e8eaed;
}}
.plot-section:hover {{
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}}
.plot-section h3 {{
margin: 0;
padding: 20px 20px 16px 20px;
background: #f8f9fa;
color: #202124;
font-size: 1.1em;
font-weight: 500;
letter-spacing: 0.2px;
border-bottom: 1px solid #e8eaed;
}}
.plot-section .header-row {{
background: #f8f9fa;
padding: 20px 20px 16px 20px;
border-bottom: 1px solid #e8eaed;
display: flex;
justify-content: space-between;
align-items: center;
}}
.plot-section .header-row h3 {{
background: none;
padding: 0;
margin: 0;
flex: 1;
border-bottom: none;
color: #202124;
}}
.header-controls {{
display: flex;
align-items: center;
gap: 20px;
}}
.toggle-group {{
display: flex;
align-items: center;
gap: 10px;
}}
.toggle-label {{
font-size: 14px;
color: #5f6368;
font-weight: 500;
}}
.toggle-switch {{
display: flex;
background: #e8eaed;
border-radius: 20px;
padding: 2px;
position: relative;
}}
.toggle-switch input[type="radio"] {{
display: none;
}}
.toggle-switch label {{
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
color: #5f6368;
cursor: pointer;
border-radius: 18px;
transition: all 0.2s ease;
position: relative;
z-index: 1;
}}
.toggle-switch input[type="radio"]:checked + label {{
background: #4285f4;
color: white;
box-shadow: 0 2px 4px rgba(66, 133, 244, 0.3);
}}
.plot-content {{
padding: 24px;
min-height: 600px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
}}
/* Responsive plot containers */
#plot {{
width: 100% !important;
height: 600px !important;
min-width: 800px !important;
min-height: 400px !important;
max-width: 100% !important;
max-height: 800px !important;
}}
#pointPlotContent {{
width: 100% !important;
height: 500px !important;
min-width: 600px !important;
min-height: 300px !important;
max-width: 100% !important;
max-height: 700px !important;
}}
#xrdPlotContent {{
width: 100% !important;
height: 500px !important;
min-width: 800px !important;
min-height: 300px !important;
max-width: 100% !important;
max-height: 700px !important;
}}
.reset-btn {{
background: #ffffff;
color: #ea4335;
border: 1px solid #ea4335;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
text-transform: none;
letter-spacing: 0.2px;
min-width: 120px;
}}
.reset-btn:hover {{
background: #ea4335;
color: #ffffff;
box-shadow: 0 2px 8px rgba(234, 67, 53, 0.15);
transform: translateY(-1px);
}}
.reset-btn:active {{
transform: translateY(0);
}}
.info-panel {{
margin: 16px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
font-size: 14px;
color: #5f6368;
border: 1px solid #e8eaed;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}}
.info-panel strong {{
color: #202124;
font-weight: 500;
}}
.info-panel em {{
color: #5f6368;
font-style: italic;
}}
.loading {{
text-align: center;
color: #5f6368;
font-style: normal;
margin: 24px 0;
font-size: 14px;
}}
/* Custom scrollbar */
::-webkit-scrollbar {{
width: 6px;
}}
::-webkit-scrollbar-track {{
background: #f1f3f4;
border-radius: 3px;
}}
::-webkit-scrollbar-thumb {{
background: #dadce0;
border-radius: 3px;
}}
::-webkit-scrollbar-thumb:hover {{
background: #bdc1c6;
}}
/* Modal styles */
.modal {{
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}}
.modal-content {{
background-color: #ffffff;
margin: 5% auto;
padding: 0;
border-radius: 12px;
width: 80%;
max-width: 600px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
animation: modalSlideIn 0.3s ease;
}}
@keyframes modalSlideIn {{
from {{ transform: translateY(-50px); opacity: 0; }}
to {{ transform: translateY(0); opacity: 1; }}
}}
.modal-header {{
background: #f8f9fa;
padding: 20px 24px;
border-bottom: 1px solid #e8eaed;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}}
.modal-header h2 {{
margin: 0;
color: #202124;
font-size: 1.5em;
font-weight: 500;
}}
.close {{
color: #5f6368;
font-size: 28px;
font-weight: bold;
cursor: pointer;
transition: color 0.2s ease;
}}
.close:hover {{
color: #202124;
}}
.modal-body {{
padding: 24px;
}}
.form-group {{
margin-bottom: 20px;
}}
.form-group label {{
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #202124;
font-size: 14px;
}}
.form-group input {{
width: 100%;
padding: 12px 16px;
border: 1px solid #dadce0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s ease;
box-sizing: border-box;
}}
.form-group input:focus {{
outline: none;
border-color: #4285f4;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
}}
.form-row {{
display: flex;
gap: 16px;
}}
.form-row .form-group {{
flex: 1;
}}
.modal-footer {{
padding: 20px 24px;
border-top: 1px solid #e8eaed;
display: flex;
justify-content: flex-end;
gap: 12px;
}}
.btn {{
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}}
.btn-primary {{
background: #4285f4;
color: white;
}}
.btn-primary:hover {{
background: #3367d6;
transform: translateY(-1px);
}}
.btn-secondary {{
background: #f8f9fa;
color: #5f6368;
border: 1px solid #dadce0;
}}
.btn-secondary:hover {{
background: #e8eaed;
}}
@media (max-width: 1200px) {{
.plots-row {{
flex-direction: column;
}}
.controls {{
flex-direction: column;
gap: 24px;
}}
.control-group {{
min-width: 200px;
}}
h1 {{
font-size: 2em;
}}
}}
@media (max-width: 768px) {{
.container {{
margin: 16px;
border-radius: 8px;
}}
h1 {{
padding: 32px 24px 20px 24px;
font-size: 1.8em;
}}
h3 {{
padding: 0 24px 24px 24px;
}}
.controls {{
padding: 24px;
}}
.plots-container {{
padding: 24px;
}}
}}
</style>
</head>
<body>
<a href="/" class="back-link">← Back to Dashboard</a>
<div class="container">
<h1><strong>OCx25 Dataset:</strong> CO₂RR Performance Data Visualization</h1>
<div class="controls">
<div class="control-group">
<label for="xAxis">X-Axis</label>
<select id="xAxis" onchange="updatePlot()">
{x_axis_options_html}
</select>
</div>
<div class="control-group">
<label for="yAxis">Y-Axis</label>
<select id="yAxis" onchange="updatePlot()">
{y_axis_options_html}
</select>
</div>
<div class="control-group">
<label for="zAxis">Z-Axis (Color)</label>
<select id="zAxis" onchange="updatePlot()">
{z_axis_options_html}
</select>
</div>
<div class="slider-container" id="currentDensitySliderContainer">
<label for="currentDensitySlider">Current Density</label>
<div class="slider-value" id="currentDensityValue">{default_current_density} mA/cm²</div>
<input type="range" id="currentDensitySlider"
min="0" max="{len(current_density_options)-1}"
step="1" value="{current_density_options.index(default_current_density)}"
oninput="updateCurrentDensity(this.value)"
onchange="updatePlot()">
</div>
<div class="control-group">
<label for="voltageType">Voltage Type</label>
<select id="voltageType" onchange="updatePlot()">
<option value="fullcell">Full Cell Voltage</option>
<option value="she">Est. Half-cell potential vs SHE</option>
<option value="rhe">Est. Half-cell potential vs RHE</option>
</select>
</div>
<div class="checkbox-container">
<input type="checkbox" id="errorBars" checked onchange="updatePlot()">
<label for="errorBars">Show Error Bars</label>
</div>
<div class="checkbox-container">
<input type="checkbox" id="disableXrdErrors">
<label for="disableXrdErrors">Disable pop-up error messages</label>
</div>
<div class="control-group">
<button id="voltageConfigBtn" class="voltage-config-btn" onclick="openVoltageConfig()">
⚙️ Configure Voltage Conversion
</button>
</div>
<div class="control-group">
<button id="exportBtn" class="export-btn" onclick="exportData()">
⊞ Export Data (CSV)
</button>
</div>
</div>
<div class="plots-container">
<div class="plots-row">
<div class="plot-section">
<div class="header-row">
<h3>Main Plot</h3>
<div class="header-controls">
<div class="toggle-group">
<label class="toggle-label">Units:</label>
<div class="toggle-switch">
<input type="radio" id="unitAtomic" name="unitType" value="atomic" checked>
<label for="unitAtomic">At. fraction</label>
<input type="radio" id="unitWeight" name="unitType" value="weight">
<label for="unitWeight">Wt. fraction</label>
</div>
</div>
</div>
</div>
<div class="plot-content">
<div id="plot"></div>
</div>
</div>
<div class="plot-section">
<div class="header-row">
<h3>Point Analysis</h3>
<button id="resetBtn" class="reset-btn" onclick="resetPointPlot()">
⟳ Reset
</button>
</div>
<div class="plot-content">
<div id="pointPlotContent"></div>
</div>
</div>
</div>
<div class="plot-section">
<div class="header-row">
<h3>XRD Analysis</h3>
<div class="header-controls">
<div class="toggle-group">
<label class="toggle-label">Data Type:</label>
<div class="toggle-switch">
<input type="radio" id="xrdRaw" name="xrdDataType" value="raw">
<label for="xrdRaw">Raw</label>
<input type="radio" id="xrdNormalized" name="xrdDataType" value="normalized" checked>
<label for="xrdNormalized">Normalized</label>
</div>
</div>
<button id="resetXrdBtn" class="reset-btn" onclick="resetXrdPlot()">
⟳ Reset
</button>
</div>
</div>
<div class="plot-content">
<div id="xrdPlotContent"></div>
<!-- XRD Analysis Tooltip -->
<div id="xrdTooltip" class="xrd-tooltip" style="display: none;"
onmouseenter="cancelTooltipHide()"
onmouseleave="hideXrdTooltip()">
<div class="tooltip-content">
<div class="tooltip-link" onclick="openXrdAnalysisFromTooltip()">
View XRD Analysis
</div>
</div>
</div>
</div>
</div>
</div>
<div id="loading" class="loading" style="display: none;">Updating plot...</div>
<div class="info-panel">
<strong>Symbol Coding:</strong><br>
<span style="font-weight: bold;">Circles</span>: Samples synthesized by Chemical Reduction (UofT)<br>
<span style="font-weight: bold;">Diamonds</span>: Samples synthesized by Spark Ablation (VSP)<br>
<br><strong>Default Color Coding:</strong><br>
• <span style="color: #ef4444;">Red points</span>: Performance above Cu (UofT) threshold<br>
• <span style="color: #3b82f6;">Blue points</span>: Performance above Cu (VSP) threshold<br>
• <span style="color: #6b7280;">Black points</span>: Performance below both thresholds<br>
<em>Note: The specific threshold values depend on the selected y-axis metric and are calculated as the mean performance for each source.</em>
<br><br><strong>Analysis Modes:</strong><br>
• <strong>Current Density:</strong> Filter data at specific current density values (50-300 mA/cm²)<br>
<br><br><strong>Note on Error Bars:</strong><br>
Error bars are shown only when averaging across identical compositions in this analysis.<br>
• <strong>UofT (Chemical Reduction):</strong> Samples were first made as powders, XRF-measured once, then used to prepare 3 GDEs (Gas Diffusion Electrodes) for electrochemical testing. Since all GDEs came from the same powder vial (same composition), they were grouped together to calculate mean and standard deviation.<br>
• <strong>VSP (Spark Ablation):</strong> Samples were deposited directly as 3 separate GDEs. Each had slightly different XRF compositions, so they could not be grouped. Their results are shown individually, without averaged error bars.
<br><br><strong>Voltage Conversion Methodology:</strong><br>
The conversion from full cell voltage to half-cell potentials (vs SHE and vs RHE) is performed using calibration data from electrochemical measurements in a three-electrode configuration. The conversion accounts for:<br>
• Membrane overpotential and ionic resistance<br>
• Nernstian pH gradient effects<br>
• Reference electrode potential corrections<br>
• Current density-dependent ohmic losses<br>
The methodology follows established protocols for accurate half-cell potential determination in CO₂ reduction electrolyzers.<br>
<a href="https://www.nature.com/articles/s41893-025-01643-4" target="_blank">Arabyarmohammadi, F. et al. Voltage distribution within carbon dioxide reduction electrolysers. <em>Nature Sustainability</em> (2025)</a>
<br><br>
<a href="https://huggingface.co/spaces/facebook/OCx25/blob/main/voltage_conversion_example.ipynb"
target="_blank" rel="noopener" class="download-notebook-btn">
📓 View Voltage-Conversion Notebook ↗
</a>
<br><br><strong>XRD Analysis:</strong><br>
Click on any point in the main plot to view the corresponding XRD pattern in the third window.<br>
• XRD data is loaded from <code>/Data/XRD/raw/</code> or <code>/Data/CustomXRD/raw/</code> directories<br>
• Custom XRD data can be uploaded via the dashboard's "Load Your Own XRD Data" section<br>
• Files should be named using the sample ID (e.g., <code>sample_001.xy</code> for raw or <code>sample_001.csv</code> for normalized)<br>
• The plot shows 2θ (degrees) vs Intensity (counts)<br>
• If no XRD data is found for a sample, an error message will be displayed
</div>
</div>
<!-- Voltage Configuration Modal -->
<div id="voltageConfigModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Configure Voltage Conversion Parameters</h2>
<span class="close" onclick="closeVoltageConfig()">&times;</span>
</div>
<div class="modal-body">
<p style="margin-bottom: 20px; color: #5f6368; font-size: 14px;">
Adjust the parameters used for converting full cell voltages to half-cell potentials.
These values are based on experimental calibration data.
</p>
<div class="form-row">
<div class="form-group">
<label for="ref_pot">Reference Electrode Potential (V vs SHE)</label>
<input type="number" id="ref_pot" step="0.001" value="0.23">
</div>
<div class="form-group">
<label for="geo_area">Geometric Area (cm²)</label>
<input type="number" id="geo_area" step="0.1" value="4">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="cathode_pH">Cathode pH</label>
<input type="number" id="cathode_pH" step="0.1" value="12.5">
</div>
<div class="form-group">
<label for="anode_pH">Anode pH</label>
<input type="number" id="anode_pH" step="0.1" value="3">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="membrane_loss">Membrane Loss (V)</label>
<input type="number" id="membrane_loss" step="0.01" value="0.1">
</div>
</div>
<div class="form-group">
<label for="anode_measured_potential_vs_ref">Anode Measured Half-cell Potential vs Reference (V)</label>
<input type="number" id="anode_measured_potential_vs_ref" step="0.001" value="1.35">
</div>
<div class="form-group">
<label for="R_cathode">R Cathode (Ω)</label>
<input type="number" id="R_cathode" step="0.001" value="0.34">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="resetVoltageConfig()">Reset to Defaults</button>
<button class="btn btn-primary" onclick="applyVoltageConfig()">Apply Changes</button>
</div>
</div>
</div>
<script>
// Global variables
let currentData = null;
let originalData = null; // Store original atomic % data for calculations
let currentMode = 'current_density';
let currentDensityOptions = {current_density_options};
let voltageConversionParams = null; // Store custom voltage conversion parameters
let currentDensityIndex = {current_density_options.index(default_current_density)};
let currentVoltage = 3.0;
let clickedPointData = null; // Store the clicked point data globally
let accumulatedPoints = []; // Store multiple clicked points for comparison
let accumulatedXrdData = []; // Store multiple XRD datasets for comparison
let clickedPoints = new Set(); // Track clicked points by sample ID
// Initialize with data (filtered at default current density)
const initialData = {json.dumps(filter_df_by_current_density(df_with_pca, default_current_density).to_dict('records'))};
currentData = initialData;
originalData = initialData;
console.log('Loaded CO2R data:', currentData.length, 'rows');
console.log('Available columns:', Object.keys(currentData[0] || {{}}));
// Set default selections
document.getElementById('xAxis').value = 'PCA1';
document.getElementById('yAxis').value = 'PCA2';
document.getElementById('zAxis').value = 'default_colors';
// CSV export helper (generic: reads current div traces and downloads x,y pairs)
function exportDivCsv(divId, filename) {{
try {{
const gd = document.getElementById(divId);
if (!gd || !gd.data) return;
const rows = [];
rows.push(['x','y'].join(','));
(gd.data || []).forEach(tr => {{
const xs = tr.x || [];
const ys = tr.y || [];
const n = Math.min(xs.length, ys.length);
for (let i = 0; i < n; i++) {{
rows.push([xs[i], ys[i]].join(','));
}}
}});
const blob = new Blob([rows.join('\\n')], {{ type: 'text/csv' }});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = (filename || 'plot') + '.csv';
document.body.appendChild(a); a.click();
URL.revokeObjectURL(url); document.body.removeChild(a);
}} catch (e) {{ console.error('Export CSV failed:', e); }}
}}
// Add change event listeners to clear second plot when axes change
document.getElementById('xAxis').addEventListener('change', function() {{
if (clickedPointData) {{
console.log('X-axis changed, clearing second plot');
document.querySelector('.plot-section:nth-child(2) .header-row h3').textContent = 'Point Analysis';
document.getElementById('pointPlotContent').innerHTML = '';
clickedPointData = null;
}}
}});
document.getElementById('yAxis').addEventListener('change', function() {{
if (clickedPointData) {{
console.log('Y-axis changed, clearing second plot');
document.querySelector('.plot-section:nth-child(2) .header-row h3').textContent = 'Point Analysis';
document.getElementById('pointPlotContent').innerHTML = '';
clickedPointData = null;
}}
}});
document.getElementById('zAxis').addEventListener('change', function() {{
if (clickedPointData) {{
console.log('Z-axis changed, clearing second plot');
document.querySelector('.plot-section:nth-child(2) .header-row h3').textContent = 'Point Analysis';
document.getElementById('pointPlotContent').innerHTML = '';
clickedPointData = null;
}}
}});
function updateCurrentDensity(value) {{
currentDensityIndex = parseInt(value);
const currentDensity = currentDensityOptions[currentDensityIndex];
document.getElementById('currentDensityValue').textContent = currentDensity + ' mA/cm²';
// Always recalculate and refresh interpolated values based on current density
const interpolatedAnodePot = interpolateAnodePotentialVsRef(currentDensity);
const interpolatedR = interpolateCathodeR(currentDensity);
// Update the fields - always refresh when recalculated
const anodeField = document.getElementById('anode_measured_potential_vs_ref');
const rField = document.getElementById('R_cathode');
// Always update the values (user can still manually edit if needed)
anodeField.value = interpolatedAnodePot.toFixed(4);
rField.value = interpolatedR.toFixed(4);
}}
async function updatePlot() {{
const loadingDiv = document.getElementById('loading');
loadingDiv.style.display = 'block';
try {{
// Get current selections
const xCol = document.getElementById('xAxis').value;
const yCol = document.getElementById('yAxis').value;
const zCol = document.getElementById('zAxis').value;
// Get current unit type
const selectedUnit = document.querySelector('input[name="unitType"]:checked').value;
const voltageType = document.getElementById('voltageType').value;
// Prepare request data based on current mode
let requestData = {{
mode: currentMode,
xAxis: xCol,
yAxis: yCol,
zAxis: zCol,
unitType: selectedUnit,
voltageType: voltageType
}};
if (currentMode === 'current_density') {{
requestData.currentDensity = currentDensityOptions[currentDensityIndex];
}} else {{
requestData.voltage = currentVoltage;
}}
// Include custom voltage conversion parameters if set
if (voltageConversionParams) {{
requestData.voltageConversionParams = voltageConversionParams;
}}
// Fetch new data
const response = await fetch('/co2/update_data', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
}},
body: JSON.stringify(requestData)
}});
if (!response.ok) {{
throw new Error('Network response was not ok');
}}
const result = await response.json();
currentData = result.data;
originalData = result.originalData || result.data; // Use original data for calculations
// Update the plot with new data
createPlot(xCol, yCol, zCol, currentData, originalData);
// Update the second plot if points were clicked
await updatePointPlot();
}} catch (error) {{
console.error('Error updating plot:', error);
loadingDiv.textContent = 'Error updating plot. Please try again.';
}} finally {{
loadingDiv.style.display = 'none';
}}
}}
function createPlot(xCol, yCol, zCol, data, originalDataForCalc = null) {{
// Use original data for calculations if available, otherwise use display data
const calcData = originalDataForCalc || data;
console.log('Creating CO2R plot with:', xCol, 'vs', yCol, 'colored by', zCol);
console.log('Data points:', data.length);
if (!data || data.length === 0) {{
document.getElementById('plot').innerHTML = '<div style="text-align: center; padding: 50px;"><h3>No data available for plotting</h3></div>';
return;
}}
// Calculate Cu means for the selected y column using original data
let cuMeanUoft = null;
let cuMeanVsp = null;
// Find the y-axis value where Cu=1.0 for each source
for (let row of calcData) {{
if (Math.abs(row['Cu'] - 1.0) < 0.001 && row['source'] === 'uoft') {{
cuMeanUoft = row[yCol];
}}
if (Math.abs(row['Cu'] - 1.0) < 0.001 && row['source'] === 'vsp') {{
cuMeanVsp = row[yCol];
}}
}}
// Create separate traces for UOFT and VSP points using original data for color calculations
const uoftData = calcData.filter(row => row['source'] === 'uoft');
const vspData = calcData.filter(row => row['source'] === 'vsp');
const traces = [];
// Helper: build a clean XY array filtering invalid numbers
function buildXY(rows, source) {{
const x = [];
const y = [];
const text = [];
const errorArray = [];
const customdata = [];
rows.forEach(row => {{
const xv = Number(row[xCol]);
const yv = Number(row[yCol]);
if (Number.isFinite(xv) && Number.isFinite(yv)) {{
x.push(xv);
y.push(yv);
// For error bars, check if corresponding _std column exists
let errorValue = 0;
if (yCol.includes('_mean')) {{
const stdCol = yCol.replace('_mean', '_std');
if (row[stdCol] !== undefined) {{
errorValue = Number(row[stdCol]) || 0;
}}
}}
errorArray.push(errorValue);
// Add sample ID to customdata for visual feedback
customdata.push(row['sample id'] || 'Unknown');
text.push(
'Source: ' + row['source'] + '<br>Sample ID: ' + (row['sample id'] || 'N/A') + '<br>Batch: ' + (row['batch number'] || 'N/A') + ' (' + (row['batch date'] || 'N/A') + ')<br>Chemical Formula: ' + (row['xrf composition'] || row['target composition'] || 'N/A') + '<br>' + (currentMode === 'current_density' ? 'Current Density: ' + currentDensityOptions[currentDensityIndex] + ' mA/cm²' : 'Voltage: ' + currentVoltage.toFixed(2) + 'V') + '<br>' + (row['sample_count'] !== undefined ? 'Samples Aggregated: ' + row['sample_count'] + '<br>' : '') + 'X: ' + xv.toFixed(3) + '<br>Y: ' + yv.toFixed(3)
);
}}
}});
return {{ x, y, text, errors: errorArray, customdata }};
}}
// UOFT points (circles)
if (uoftData.length > 0) {{
if (zCol === 'default_colors') {{
// Group UOFT data by color for default coloring
const uoftByColor = {{}};
uoftData.forEach((row, index) => {{
const yValue = row[yCol];
let color = '#6b7280';
if (cuMeanUoft !== null && cuMeanVsp !== null) {{
if (cuMeanVsp > cuMeanUoft) {{
if (yValue >= cuMeanVsp) color = '#3b82f6';
else if (yValue >= cuMeanUoft) color = '#ef4444';
else color = '#6b7280';
}} else if (cuMeanUoft > cuMeanVsp) {{
if (yValue >= cuMeanUoft) color = '#ef4444';
else if (yValue >= cuMeanVsp) color = '#3b82f6';
else color = '#6b7280';
}} else {{
color = '#3b82f6';
}}
}}
if (!uoftByColor[color]) {{
uoftByColor[color] = [];
}}
uoftByColor[color].push(row);
}});
// Create separate trace for each color
Object.keys(uoftByColor).forEach(color => {{
const colorData = uoftByColor[color];
// Find corresponding display data for this color group
const displayColorData = data.filter(displayRow =>
colorData.some(origRow => origRow['sample id'] === displayRow['sample id'])
);
const d = buildXY(displayColorData);
const trace = {{
x: d.x,
y: d.y,
mode: 'markers',
type: 'scatter',
marker: {{
size: 12,
color: color,
line: {{
width: d.customdata.map(id => clickedPoints.has(id) ? 4 : 1.5),
color: d.customdata.map(id => clickedPoints.has(id) ? '#00FF00' : 'rgba(0,0,0,0.3)')
}},
symbol: 'circle',
opacity: 0.9
}},
text: d.text,
hoverinfo: 'text',
showlegend: true,
name: 'UofT (chemical reduction)',
customdata: d.customdata
}};
// Add error bars for any *_mean column using corresponding *_std (only if checkbox is checked)
if (yCol.includes('_mean') && document.getElementById('errorBars').checked && d.errors.some(e => e > 0)) {{
trace.error_y = {{
type: 'data',
array: d.errors,
visible: true,
color: color,
thickness: 1.5,
width: 2
}};
}}
traces.push(trace);
}});
}} else {{
// Single trace with coloraxis for custom z-axis
const d = buildXY(uoftData);
const trace = {{
x: d.x,
y: d.y,
mode: 'markers',
type: 'scatter',
marker: {{
size: 12,
color: uoftData.map(row => row[zCol]),
line: {{
width: d.customdata.map(id => clickedPoints.has(id) ? 4 : 1.5),
color: d.customdata.map(id => clickedPoints.has(id) ? 'red' : 'rgba(0,0,0,0.3)')
}},
symbol: 'circle',
opacity: 0.9,
coloraxis: 'coloraxis'
}},
text: d.text,
hoverinfo: 'text',
showlegend: false,
name: 'UofT (chemical reduction)',
customdata: d.customdata
}};
// Add error bars for any *_mean column using corresponding *_std (only if checkbox is checked)
if (yCol.includes('_mean') && document.getElementById('errorBars').checked && d.errors.some(e => e > 0)) {{
trace.error_y = {{
type: 'data',
array: d.errors,
visible: true,
thickness: 1.5,
width: 2
}};
}}
traces.push(trace);
}}
}}
// VSP points (diamonds) - similar logic as UOFT
if (vspData.length > 0) {{
if (zCol === 'default_colors') {{
// Group VSP data by color for default coloring
const vspByColor = {{}};
vspData.forEach((row, index) => {{
const yValue = row[yCol];
let color = '#6b7280';
if (cuMeanUoft !== null && cuMeanVsp !== null) {{
if (cuMeanVsp > cuMeanUoft) {{
if (yValue >= cuMeanVsp) color = '#3b82f6';
else if (yValue >= cuMeanUoft) color = '#ef4444';
else color = '#6b7280';
}} else if (cuMeanUoft > cuMeanVsp) {{
if (yValue >= cuMeanUoft) color = '#ef4444';
else if (yValue >= cuMeanVsp) color = '#3b82f6';
else color = '#6b7280';
}} else {{
color = '#3b82f6';
}}
}}
if (!vspByColor[color]) {{
vspByColor[color] = [];
}}
vspByColor[color].push(row);
}});
// Create separate trace for each color
Object.keys(vspByColor).forEach(color => {{
const colorData = vspByColor[color];
const displayColorData = data.filter(displayRow =>
colorData.some(origRow => origRow['sample id'] === displayRow['sample id'])
);
const d = buildXY(displayColorData);
const trace = {{
x: d.x,
y: d.y,
mode: 'markers',
type: 'scatter',
marker: {{
size: 12,
color: color,
line: {{
width: d.customdata.map(id => clickedPoints.has(id) ? 4 : 1.5),
color: d.customdata.map(id => clickedPoints.has(id) ? '#00FF00' : 'rgba(0,0,0,0.3)')
}},
symbol: 'diamond',
opacity: 0.9
}},
text: d.text,
hoverinfo: 'text',
showlegend: true,
name: 'VSP (spark ablation)',
customdata: d.customdata
}};
// Add error bars for any *_mean column using corresponding *_std (only if checkbox is checked)
if (yCol.includes('_mean') && document.getElementById('errorBars').checked && d.errors.some(e => e > 0)) {{
trace.error_y = {{
type: 'data',
array: d.errors,
visible: true,
color: color,
thickness: 1.5,
width: 2
}};
}}
traces.push(trace);
}});
}} else {{
// Single trace with coloraxis for custom z-axis
const d = buildXY(vspData);
const trace = {{
x: d.x,
y: d.y,
mode: 'markers',
type: 'scatter',
marker: {{
size: 12,
color: vspData.map(row => row[zCol]),
line: {{
width: d.customdata.map(id => clickedPoints.has(id) ? 4 : 1.5),
color: d.customdata.map(id => clickedPoints.has(id) ? 'red' : 'rgba(0,0,0,0.3)')
}},
symbol: 'diamond',
opacity: 0.9,
coloraxis: 'coloraxis'
}},
text: d.text,
hoverinfo: 'text',
showlegend: false,
name: 'VSP (spark ablation)',
customdata: d.customdata
}};
// Add error bars for any *_mean column using corresponding *_std (only if checkbox is checked)
if (yCol.includes('_mean') && document.getElementById('errorBars').checked && d.errors.some(e => e > 0)) {{
trace.error_y = {{
type: 'data',
array: d.errors,
visible: true,
thickness: 1.5,
width: 2
}};
}}
traces.push(trace);
}}
}}
// Get units for axis labels
const selectedUnit = document.querySelector('input[name="unitType"]:checked').value;
const xAxisUnit = getColumnUnits(xCol, selectedUnit);
const yAxisUnit = getColumnUnits(yCol, selectedUnit);
// Format column names for display
const xColFormatted = formatColumnName(xCol);
const yColFormatted = formatColumnName(yCol);
// Get units for z-axis label
const zAxisUnit = getColumnUnits(zCol, selectedUnit);
const zColFormatted = formatColumnName(zCol);
// Get voltage type for dynamic labeling
const voltageType = document.getElementById('voltageType').value;
let voltageLabel = 'Full Cell Voltage (V)';
if (voltageType === 'she') {{
voltageLabel = 'Est. Half-cell potential vs SHE (V)';
}} else if (voltageType === 'rhe') {{
voltageLabel = 'Est. Half-cell potential vs RHE (V)';
}}
const layout = {{
title: {{
text: xColFormatted + ' vs ' + ((yCol === 'voltage' || yCol === 'voltage_mean') ? voltageLabel.replace(' (V)', '') : yColFormatted) + ' ' + (currentMode === 'current_density' ? 'at ' + currentDensityOptions[currentDensityIndex] + ' mA/cm²' : 'at ' + currentVoltage.toFixed(2) + 'V'),
font: {{ size: 18, color: '#202124' }},
x: 0.5
}},
xaxis: {{
title: xColFormatted + xAxisUnit,
showgrid: true,
gridwidth: 1,
gridcolor: 'lightgray',
zerolinecolor: '#ccc',
color: '#333',
titlefont: {{ size: 14, color: '#666' }},
tickfont: {{ size: 12, color: '#666' }}
}},
yaxis: {{
title: (yCol === 'voltage' || yCol === 'voltage_mean') ? voltageLabel : (yColFormatted + yAxisUnit),
showgrid: true,
gridwidth: 1,
gridcolor: 'lightgray',
zerolinecolor: '#ccc',
color: '#333',
titlefont: {{ size: 14, color: '#666' }},
tickfont: {{ size: 12, color: '#666' }}
}},
hovermode: 'closest',
template: 'plotly_white',
width: null,
height: 600,
autosize: true,
showlegend: zCol === 'default_colors',
margin: {{ l: 60, r: 30, t: 60, b: 60 }},
legend: {{
x: 1.02,
y: 1,
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#ccc',
borderwidth: 1
}}
}};
// Add color bar if using custom z-axis
if (zCol !== 'default_colors') {{
// Calculate min and max values for color bar
const zValues = data.map(d => d[zCol]).filter(v => v !== null && v !== undefined);
const minZ = Math.min(...zValues);
const maxZ = Math.max(...zValues);
layout.coloraxis = {{
colorscale: [[0, '#3b82f6'], [0.5, '#f59e0b'], [1, '#ef4444']],
cmin: minZ,
cmax: maxZ,
colorbar: {{
title: {{
text: zColFormatted + getColumnUnits(zCol, selectedUnit),
font: {{ size: 14, color: '#666' }}
}},
tickfont: {{ size: 12, color: '#666' }},
len: 0.8,
y: 0.5,
yanchor: 'middle',
x: 1.02,
xanchor: 'left'
}}
}};
// Update margin to make room for color bar
layout.margin = {{ l: 60, r: 100, t: 60, b: 60 }};
}}
// Add reference lines with annotations if available
const shapes = [];
const annotations = [];
if (cuMeanUoft !== null) {{
// Calculate x-axis range more robustly
const xValues = data.map(row => row[xCol]).filter(val => val !== null && !isNaN(val));
const xMin = Math.min(...xValues);
const xMax = Math.max(...xValues);
const xRange = xMax - xMin;
shapes.push({{
type: 'line',
x0: xMin - xRange * 0.1,
x1: xMax + xRange * 0.1,
y0: cuMeanUoft,
y1: cuMeanUoft,
line: {{ color: '#ef4444', dash: 'dash', width: 3 }}
}});
// Add annotation for UOFT line
annotations.push({{
x: xMax + xRange * 0.1,
y: cuMeanUoft,
text: `Cu (UofT)`,
showarrow: false,
xanchor: 'left',
yanchor: 'middle',
bgcolor: 'rgba(255,255,255,0.9)',
bordercolor: '#ef4444',
borderwidth: 2,
font: {{ color: '#ef4444', size: 14 }}
}});
}}
if (cuMeanVsp !== null) {{
// Calculate x-axis range more robustly
const xValues = data.map(row => row[xCol]).filter(val => val !== null && !isNaN(val));
const xMin = Math.min(...xValues);
const xMax = Math.max(...xValues);
const xRange = xMax - xMin;
shapes.push({{
type: 'line',
x0: xMin - xRange * 0.1,
x1: xMax + xRange * 0.1,
y0: cuMeanVsp,
y1: cuMeanVsp,
line: {{ color: '#3b82f6', dash: 'dot', width: 3 }}
}});
// Add annotation for VSP line
annotations.push({{
x: xMax + xRange * 0.1,
y: cuMeanVsp,
text: `Cu (VSP)`,
showarrow: false,
xanchor: 'left',
yanchor: 'middle',
bgcolor: 'rgba(255,255,255,0.9)',
bordercolor: '#3b82f6',
borderwidth: 2,
font: {{ color: '#3b82f6', size: 14 }}
}});
}}
if (shapes.length > 0) {{
layout.shapes = shapes;
}}
if (annotations.length > 0) {{
layout.annotations = annotations;
}}
const exportCsvButton = {{
name: 'exportCsv',
title: 'Export CSV',
icon: {{width: 500, height: 500, path: 'M50 400 L450 400 L450 450 L50 450 Z M100 50 L400 50 L400 350 L100 350 Z'}},
click: function(gd) {{ try {{ exportDivCsv('plot', 'co2_plot'); }} catch(e) {{ console.error('Export CSV failed:', e); }} }}
}};
Plotly.newPlot('plot', traces, layout, {{
responsive: true,
modeBarButtonsToAdd: [exportCsvButton],
toImageButtonOptions: {{
format: 'png',
filename: 'co2_plot',
height: 800,
width: 1200,
scale: 3
}}
}});
// Add click event to the plot
document.getElementById('plot').on('plotly_click', function(data) {{
const point = data.points[0];
// Get the current axis selections
const xCol = document.getElementById('xAxis').value;
const yCol = document.getElementById('yAxis').value;
// Extract the clicked point information directly from the trace data
const clickedX = point.x;
const clickedY = point.y;
const clickedSource = point.data.name; // This will be 'UofT (chemical reduction)' or 'VSP (spark ablation)'
// Determine the source from the trace name
let source = 'uoft';
if (clickedSource.includes('VSP')) {{
source = 'vsp';
}}
// Use the hover text to get complete sample information
let xrfComposition = 'Unknown';
let sampleId = 'Unknown';
let batchNumber = 'Unknown';
let batchDate = 'Unknown';
try {{
const hoverText = point.data.text[point.pointIndex];
if (hoverText) {{
// Extract composition (XRF or target)
if (hoverText.includes('Chemical Formula:')) {{
xrfComposition = hoverText.split('Chemical Formula: ')[1].split('<br>')[0];
}}
// Extract Sample ID
if (hoverText.includes('Sample ID:')) {{
sampleId = hoverText.split('Sample ID: ')[1].split('<br>')[0];
}}
// Extract Batch information
if (hoverText.includes('Batch:')) {{
const batchInfo = hoverText.split('Batch: ')[1].split('<br>')[0];
// Parse "B001 (2024-01-01)" format
if (batchInfo.includes(' (')) {{
batchNumber = batchInfo.split(' (')[0];
batchDate = batchInfo.split(' (')[1].replace(')', '');
}} else {{
batchNumber = batchInfo;
}}
}}
}}
}} catch (e) {{
console.log('Could not parse hover text, using fallback method');
}}
// If hover text parsing failed, try to find the point in currentData as fallback
if (xrfComposition === 'Unknown') {{
console.log('Trying fallback method to find point data...');
for (let i = 0; i < currentData.length; i++) {{
const dataPoint = currentData[i];
if (Math.abs(dataPoint[xCol] - clickedX) < 0.001 &&
Math.abs(dataPoint[yCol] - clickedY) < 0.001 &&
dataPoint.source === source) {{
xrfComposition = dataPoint['xrf composition'] || dataPoint['target composition'];
sampleId = dataPoint['sample id'] || 'Unknown';
batchNumber = dataPoint['batch number'] || 'Unknown';
batchDate = dataPoint['batch date'] || 'Unknown';
console.log('Found complete sample data via fallback:', {{xrfComposition, sampleId, batchNumber, batchDate}});
break;
}}
}}
}}
// Store the clicked point data globally with complete sample information
clickedPointData = {{
source: source,
'xrf composition': xrfComposition,
'sample id': sampleId,
'batch number': batchNumber,
'batch date': batchDate,
x_col: xCol,
y_col: yCol,
clicked_x: clickedX,
clicked_y: clickedY
}};
console.log('Clicked point data stored:', clickedPointData);
console.log('Sample ID extracted:', sampleId);
console.log('XRF Composition:', xrfComposition);
// Check if this point is already selected
if (clickedPoints.has(sampleId)) {{
console.log('Point already selected, deselecting:', sampleId);
// Remove from clicked points set
clickedPoints.delete(sampleId);
// Update plot to remove green border
updateClickedPointVisual(sampleId);
// Remove from accumulated points
removePointFromAccumulation(sampleId);
// Remove XRD data for the clicked sample
removeXrdPlot(sampleId);
// Update accumulated points display
showAccumulatedPoints(xCol, yCol);
}} else {{
console.log('Point not selected, selecting:', sampleId);
// Add to clicked points set for visual feedback
clickedPoints.add(sampleId);
// Update plot to show clicked point with green border
updateClickedPointVisual(sampleId);
// Add to accumulated points if it's a new point
addPointToAccumulation(clickedPointData);
// Show accumulated points in the second plot
showAccumulatedPoints(xCol, yCol);
// Load XRD data for the clicked sample
console.log('About to load XRD for sample:', sampleId);
loadXrdPlot(sampleId);
}}
console.log('Point clicked successfully!');
}});
}}
// JavaScript function to get column units
function getColumnUnits(columnName, unitType = 'atomic') {{
if (columnName === 'voltage_mean' || columnName === 'voltage') {{
return ' (V)';
}} else if (columnName === 'current density') {{
return ' (mA/cm²)';
}} else if (columnName === 'cost_per_gram') {{
return ' ($/kg)';
}} else if (columnName.startsWith('partial_current_') || columnName.startsWith('max_partial_current_')) {{
return ' (mA/cm²)';
}} else if (columnName.startsWith('fe_')) {{
return ' (%)';
}} else if (columnName === 'PCA1' || columnName === 'PCA2') {{
return ''; // No units for dimensionless PCA components
}} else if (['Ag', 'Au', 'Cd', 'Cu', 'Ga', 'Hg', 'In', 'Ni', 'Pd', 'Pt', 'Rh', 'Sn', 'Tl', 'Zn'].includes(columnName)) {{
return unitType === 'weight' ? ' (wt. fraction)' : ' (at. fraction)';
}} else {{
return '';
}}
}}
// JavaScript function to format column names
function formatColumnName(columnName) {{
if (columnName === 'default_colors') {{
return 'Default';
}} else if (columnName === 'voltage_mean' || columnName === 'voltage') {{
return 'Full Cell Voltage (V)';
}} else if (columnName === 'voltage_she') {{
return 'Est. Half-cell potential vs SHE (V)';
}} else if (columnName === 'voltage_rhe') {{
return 'Est. Half-cell potential vs RHE (V)';
}} else if (columnName === 'current density') {{
return 'Current Density';
}} else if (columnName === 'cost_per_gram') {{
return 'Cost per kg';
}} else if (columnName.startsWith('max_partial_current_')) {{
const baseName = columnName.replace('max_partial_current_', '').replace('_mean', '').replace('_std', '');
const map = {{ h2: 'H₂', co: 'CO', ch4: 'CH₄', c2h4: 'C₂H₄', gas_total: 'Gas Total', liquid: 'Liquid' }};
const species = map[baseName] || baseName.toUpperCase();
return `Max Partial Current ${{species}}`;
}} else if (columnName.startsWith('partial_current_')) {{
const baseName = columnName.replace('partial_current_', '').replace('_mean', '').replace('_std', '');
const map = {{ h2: 'H₂', co: 'CO', ch4: 'CH₄', c2h4: 'C₂H₄', gas_total: 'Gas Total', liquid: 'Liquid' }};
const species = map[baseName] || baseName.toUpperCase();
return `Partial Current ${{species}}`;
}} else if (columnName.startsWith('fe_')) {{
// Convert fe_h2_mean to "Faradaic Efficiency H2"
const baseName = columnName.replace('fe_', '').replace('_mean', '');
if (baseName === 'h2') {{
return 'Faradaic Efficiency H₂';
}} else if (baseName === 'co') {{
return 'Faradaic Efficiency CO';
}} else if (baseName === 'ch4') {{
return 'Faradaic Efficiency CH₄';
}} else if (baseName === 'c2h4') {{
return 'Faradaic Efficiency C₂H₄';
}} else if (baseName === 'gas_total') {{
return 'Faradaic Efficiency Gas Total';
}} else if (baseName === 'liquid') {{
return 'Faradaic Efficiency Liquid';
}} else {{
return 'Faradaic Efficiency ' + baseName.toUpperCase();
}}
}} else if (columnName === 'PCA1' || columnName === 'PCA2') {{
return columnName;
}} else if (['Ag', 'Au', 'Cd', 'Cu', 'Ga', 'Hg', 'In', 'Ni', 'Pd', 'Pt', 'Rh', 'Sn', 'Tl', 'Zn'].includes(columnName)) {{
return columnName;
}} else {{
return columnName;
}}
}}
// Function to add a new point to accumulation
function addPointToAccumulation(pointData) {{
// Check if this point is already in accumulation
const isDuplicate = accumulatedPoints.some(point =>
point.source === pointData.source &&
point['xrf composition'] === pointData['xrf composition']
);
if (!isDuplicate) {{
accumulatedPoints.push(pointData);
console.log('Point added to accumulation. Total points:', accumulatedPoints.length);
}} else {{
console.log('Point already in accumulation, skipping duplicate');
}}
}}
// Function to remove a point from accumulation
function removePointFromAccumulation(sampleId) {{
console.log('Removing point from accumulation for sample:', sampleId);
// Find and remove the point by sample ID
const index = accumulatedPoints.findIndex(point => point['sample id'] === sampleId);
if (index !== -1) {{
accumulatedPoints.splice(index, 1);
console.log('Removed point from accumulation. Remaining points:', accumulatedPoints.length);
}} else {{
console.log('Point not found in accumulation:', sampleId);
}}
}}
// Function to remove XRD plot for a specific sample ID
function removeXrdPlot(sampleId) {{
console.log('Removing XRD plot for sample:', sampleId);
// Remove from accumulation
const index = accumulatedXrdData.findIndex(item => item.sampleId === sampleId);
if (index !== -1) {{
accumulatedXrdData.splice(index, 1);
console.log('Removed XRD data from accumulation. Remaining samples:', accumulatedXrdData.length);
// Update XRD plot display
showAccumulatedXrdPlots();
}} else {{
console.log('Sample not found in XRD accumulation:', sampleId);
}}
}}
// Function to show accumulated points in the second plot
async function showAccumulatedPoints(xCol, yCol) {{
if (accumulatedPoints.length === 0) {{
document.querySelector('.plot-section:nth-child(2) .header-row h3').textContent = 'Point Analysis';
document.getElementById('pointPlotContent').innerHTML = '';
return;
}}
// Update title to show multiple points
document.querySelector('.plot-section:nth-child(2) .header-row h3').textContent = 'Point Analysis';
// Fetch data for all accumulated points
const allPointData = [];
for (const point of accumulatedPoints) {{
try {{
const response = await fetch('/co2/get_point_data', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{
source: point.source,
xrf_composition: point['xrf composition'],
x_col: xCol,
y_col: yCol,
mode: currentMode
}})
}});
if (response.ok) {{
const result = await response.json();
if (result.success) {{
allPointData.push({{
point: point,
data: result.data
}});
}}
}}
}} catch (error) {{
console.error('Error fetching data for point:', point, error);
}}
}}
// Create the combined plot
createAccumulatedPointPlot(allPointData, xCol, yCol);
}}
// Function to create the accumulated points plot
function createAccumulatedPointPlot(allPointData, xCol, yCol) {{
if (allPointData.length === 0) {{
document.getElementById('pointPlotContent').innerHTML =
'<p style="text-align: center; color: #5f6368; margin-top: 50px; font-size: 1.1em;">No data available for accumulated points</p>';
return;
}}
const traces = [];
const colors = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
allPointData.forEach((pointData, index) => {{
const point = pointData.point;
const data = pointData.data;
// Filter out any null values
const validData = data.filter(d => d.x_value !== null && d.y_value !== null);
if (validData.length > 0) {{
const color = colors[index % colors.length];
// Use the same symbol logic as the main plot (circle for UOFT, diamond for VSP)
const symbol = point.source === 'uoft' ? 'circle' : 'diamond';
const trace = {{
x: validData.map(d => currentMode === 'current_density' ? d.current_density : d.voltage),
y: validData.map(d => d.y_value),
mode: 'markers+lines',
type: 'scatter',
marker: {{
size: 10,
color: color,
symbol: symbol,
line: {{ width: 1, color: 'rgba(0,0,0,0.5)' }}
}},
line: {{
color: color,
width: 2
}},
text: validData.map(d => 'Source: ' + point['source'] + '<br>Chemical Formula: ' + point['xrf composition'] + '<br>Sample ID: ' + point['sample id'] + '<br>Batch: ' + point['batch number'] + ' (' + point['batch date'] + ')<br>' + (currentMode === 'current_density' ? 'Current Density: ' + d.current_density + ' mA/cm²' : 'Voltage: ' + d.voltage + 'V') + '<br>Y: ' + (d.y_value?.toFixed(3) || 'N/A')),
hoverinfo: 'text',
name: point.source + ' - ' + point['xrf composition']
}};
traces.push(trace);
}}
}});
const layout = {{
title: {{
text: formatColumnName(yCol) + ' vs ' + (currentMode === 'current_density' ? 'Current Density' : 'Voltage') + ' - Multiple Points',
font: {{ size: 18, color: '#202124' }},
x: 0.5
}},
xaxis: {{
title: currentMode === 'current_density' ? 'Current Density (mA/cm²)' : 'Full Cell Voltage (V)',
showgrid: true,
gridwidth: 1,
gridcolor: 'lightgray',
zerolinecolor: '#ccc',
color: '#333',
titlefont: {{ size: 14, color: '#666' }},
tickfont: {{ size: 12, color: '#666' }}
}},
yaxis: {{
title: formatColumnName(yCol) + getColumnUnits(yCol),
showgrid: true,
gridwidth: 1,
gridcolor: 'lightgray',
zerolinecolor: '#ccc',
color: '#333',
titlefont: {{ size: 14, color: '#666' }},
tickfont: {{ size: 12, color: '#666' }}
}},
hovermode: 'closest',
template: 'plotly_white',
width: null,
height: 500,
autosize: true,
showlegend: true,
margin: {{ l: 60, r: 30, t: 60, b: 60 }},
legend: {{
x: 1.02,
y: 1,
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#ccc',
borderwidth: 1
}}
}};
const exportPointCsvButton = {{
name: 'exportCsv',
title: 'Export CSV',
icon: {{width: 500, height: 500, path: 'M50 400 L450 400 L450 450 L50 450 Z M100 50 L400 50 L400 350 L100 350 Z'}},
click: function(gd) {{ try {{ exportDivCsv('pointPlotContent', 'co2_point_plot'); }} catch(e) {{ console.error('Export CSV failed:', e); }} }}
}};
Plotly.newPlot('pointPlotContent', traces, layout, {{
responsive: true,
modeBarButtonsToAdd: [exportPointCsvButton],
toImageButtonOptions: {{
format: 'png',
filename: 'co2_point_plot',
height: 600,
width: 1200,
scale: 3
}}
}});
}}
// Function to reset the accumulated points plot
function resetPointPlot() {{
accumulatedPoints = [];
clickedPointData = null;
document.querySelector('.plot-section:nth-child(2) .header-row h3').textContent = 'Point Analysis';
document.getElementById('pointPlotContent').innerHTML = '';
// Also reset XRD data and clicked points
accumulatedXrdData = [];
clickedPoints.clear(); // Clear clicked points
document.getElementById('xrdPlotContent').innerHTML = '';
// Update plot to remove green borders
const plotDiv = document.getElementById('plot');
if (plotDiv && plotDiv.data) {{
plotDiv.data.forEach(trace => {{
if (trace.marker && trace.marker.line) {{
trace.marker.line.width = 1.5;
trace.marker.line.color = 'rgba(0,0,0,0.3)';
}}
}});
Plotly.redraw('plot');
}}
console.log('Point Analysis and XRD reset');
}}
// Function to update the second plot when mode or parameters change
async function updatePointPlot() {{
if (accumulatedPoints.length > 0) {{
const xCol = document.getElementById('xAxis').value;
const yCol = document.getElementById('yAxis').value;
// Only update if the axes haven't changed
const firstPoint = accumulatedPoints[0];
if (xCol === firstPoint.x_col && yCol === firstPoint.y_col) {{
console.log('Updating second plot for same axes');
await showAccumulatedPoints(xCol, yCol);
}} else {{
console.log('Axes changed, clearing second plot');
// Clear the second plot when axes change
document.querySelector('.plot-section:nth-child(2) .header-row h3').textContent = 'Point Analysis';
document.getElementById('pointPlotContent').innerHTML = '';
accumulatedPoints = []; // Reset accumulated points when axes change
clickedPointData = null;
}}
}}
}}
// Function to load XRD plot for a specific sample ID and add to accumulation
async function loadXrdPlot(sampleId) {{
console.log('DEBUG: Loading XRD plot for sample:', sampleId);
try {{
// Get selected data type from toggle
const dataType = document.querySelector('input[name="xrdDataType"]:checked').value;
console.log('DEBUG: Selected data type:', dataType);
// Fetch XRD data for the specific sample
const response = await fetch('/co2/get_xrd_data', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
}},
body: JSON.stringify({{
sample_id: sampleId,
data_type: dataType
}})
}});
console.log('DEBUG: Fetch response status:', response.status);
if (!response.ok) {{
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to fetch XRD data');
}}
const result = await response.json();
if (!result.success) {{
throw new Error(result.error || 'XRD data not found');
}}
// Add XRD data to accumulation
addXrdToAccumulation(result.data, result.sample_id, result.data_points);
}} catch (error) {{
console.error('Error loading XRD data:', error);
// Only show alert if error messages are not disabled
const disableErrors = document.getElementById('disableXrdErrors').checked;
if (!disableErrors) {{
alert('Error loading XRD data:\\n\\n' + error.message);
}}
}}
}}
// Function to add XRD data to accumulation
function addXrdToAccumulation(xrdData, sampleId, dataPoints) {{
// Check if this sample is already in accumulation
const existingIndex = accumulatedXrdData.findIndex(item => item.sampleId === sampleId);
if (existingIndex !== -1) {{
console.log('Sample already in XRD accumulation:', sampleId);
return; // Don't add duplicates
}}
// Get source and XRF composition from the clicked point data
const source = clickedPointData ? clickedPointData.source : 'Unknown';
const xrfComposition = clickedPointData ? clickedPointData['xrf composition'] : 'Unknown';
// Add to accumulation
accumulatedXrdData.push({{
data: xrdData,
sampleId: sampleId,
dataPoints: dataPoints,
source: source,
xrfComposition: xrfComposition
}});
console.log('Added XRD data to accumulation. Total samples:', accumulatedXrdData.length);
// Show accumulated XRD plots
showAccumulatedXrdPlots();
}}
// Function to show all accumulated XRD plots
function showAccumulatedXrdPlots() {{
console.log('*** showAccumulatedXrdPlots() called ***');
console.log('accumulatedXrdData length:', accumulatedXrdData.length);
if (accumulatedXrdData.length === 0) {{
console.log('No XRD data to plot, clearing plot content');
document.getElementById('xrdPlotContent').innerHTML = '';
return;
}}
// Create traces for all accumulated XRD data
const traces = [];
const colors = ['#4285f4', '#ea4335', '#34a853', '#fbbc04', '#ff6d01', '#9c27b0', '#00bcd4', '#795548'];
accumulatedXrdData.forEach((xrdItem, index) => {{
const color = colors[index % colors.length];
// Use stored source and XRF composition to match point analysis format
const source = xrdItem.source || 'Unknown';
const xrfComposition = xrdItem.xrfComposition || 'Unknown';
console.log('Creating trace ' + index + ': ' + source + ' - ' + xrfComposition);
console.log('DEBUG: XRD item data structure:', {{
sampleId: xrdItem.sampleId,
hasData: !!xrdItem.data,
dataKeys: xrdItem.data ? Object.keys(xrdItem.data) : null,
xLength: xrdItem.data && xrdItem.data.x ? xrdItem.data.x.length : null,
yLength: xrdItem.data && xrdItem.data.y ? xrdItem.data.y.length : null,
dataPoints: xrdItem.dataPoints
}});
const trace = {{
x: xrdItem.data.x,
y: xrdItem.data.y,
mode: 'lines',
type: 'scatter',
line: {{
color: color,
width: 2
}},
name: source + ' - ' + xrfComposition,
hovertemplate: '<br>2θ: %{{x:.2f}}°<br>Intensity: %{{y:.2f}}<extra></extra>'
}};
traces.push(trace);
}});
const layout = {{
xaxis: {{
title: '2θ (degrees)',
showgrid: true,
gridwidth: 1,
gridcolor: 'lightgray',
zerolinecolor: '#ccc',
color: '#333',
titlefont: {{ size: 14, color: '#666' }},
tickfont: {{ size: 12, color: '#666' }}
}},
yaxis: {{
title: 'Intensity (counts)',
showgrid: true,
gridwidth: 1,
gridcolor: 'lightgray',
zerolinecolor: '#ccc',
color: '#333',
titlefont: {{ size: 14, color: '#666' }},
tickfont: {{ size: 12, color: '#666' }}
}},
hovermode: 'closest',
template: 'plotly_white',
autosize: true,
showlegend: true,
legend: {{
x: 1.02,
y: 1,
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#ccc',
borderwidth: 1
}},
margin: {{ l: 60, r: 150, t: 20, b: 60 }},
width: null,
height: 500
}};
// Clear existing plot first
console.log('Clearing existing plot content...');
document.getElementById('xrdPlotContent').innerHTML = '';
console.log('Creating new Plotly plot with', traces.length, 'traces...');
const exportXrdCsvButton = {{
name: 'exportCsv',
title: 'Export CSV',
icon: {{width: 500, height: 500, path: 'M50 400 L450 400 L450 450 L50 450 Z M100 50 L400 50 L400 350 L100 350 Z'}},
click: function(gd) {{ exportDivCsv('xrdPlotContent', 'co2_xrd_plot'); }}
}};
Plotly.newPlot('xrdPlotContent', traces, layout, {{
responsive: true,
modeBarButtonsToAdd: [exportXrdCsvButton],
toImageButtonOptions: {{
format: 'png',
filename: 'co2_xrd_plot',
height: 600,
width: 1500,
scale: 3
}}
}});
// Add hover event listener for XRD plots
document.getElementById('xrdPlotContent').on('plotly_hover', function(data) {{
if (data && data.points && data.points.length > 0) {{
const point = data.points[0];
const traceIndex = point.curveNumber;
// Get sample ID from the trace
if (traceIndex < accumulatedXrdData.length) {{
const sampleId = accumulatedXrdData[traceIndex].sampleId;
showXrdTooltip(point.x, point.y, sampleId);
}}
}}
}});
// Hide tooltip when mouse leaves
document.getElementById('xrdPlotContent').on('plotly_unhover', function(data) {{
hideXrdTooltip();
}});
console.log('*** Plotly plot created successfully. Total traces:', traces.length, '***');
}}
// Function to reset XRD accumulation
function resetXrdPlot() {{
accumulatedXrdData = [];
clickedPoints.clear(); // Clear clicked points
document.getElementById('xrdPlotContent').innerHTML = '';
// Also reset point analysis
accumulatedPoints = [];
clickedPointData = null;
document.querySelector('.plot-section:nth-child(2) .header-row h3').textContent = 'Point Analysis';
document.getElementById('pointPlotContent').innerHTML = '';
// Update plot to remove green borders
const plotDiv = document.getElementById('plot');
if (plotDiv && plotDiv.data) {{
plotDiv.data.forEach(trace => {{
if (trace.marker && trace.marker.line) {{
trace.marker.line.width = 1.5;
trace.marker.line.color = 'rgba(0,0,0,0.3)';
}}
}});
Plotly.redraw('plot');
}}
console.log('XRD and Point Analysis reset');
}}
// Function to reload all accumulated XRD plots with current data type
async function reloadAccumulatedXrdPlots() {{
console.log('*** reloadAccumulatedXrdPlots() called ***');
console.log('DEBUG: accumulatedXrdData before reload:', accumulatedXrdData.map(item => ({{
sampleId: item.sampleId,
dataPoints: item.dataPoints,
hasData: !!item.data
}})));
if (accumulatedXrdData.length === 0) {{
console.log('No accumulated XRD data to reload');
return; // Nothing to reload
}}
console.log('Reloading accumulated XRD plots with new data type. Current samples:', accumulatedXrdData.length);
// Store current accumulated data with metadata
const currentSamples = [...accumulatedXrdData];
console.log('Stored samples for reload:', currentSamples.map(s => s.sampleId));
// Clear current accumulation
accumulatedXrdData = [];
console.log('Cleared accumulatedXrdData, length now:', accumulatedXrdData.length);
// Reload each sample with new data type while preserving metadata
for (const xrdItem of currentSamples) {{
console.log('Reloading sample:', xrdItem.sampleId);
await reloadSingleXrdPlot(xrdItem.sampleId, xrdItem.source, xrdItem.xrfComposition);
}}
console.log('Finished reloading all samples. Final accumulatedXrdData length:', accumulatedXrdData.length);
// Update the plot once after all samples are reloaded
console.log('Updating plot with all reloaded data...');
showAccumulatedXrdPlots();
}}
// Function to reload a single XRD plot while preserving metadata
async function reloadSingleXrdPlot(sampleId, source, xrfComposition) {{
try {{
// Get selected data type from toggle
const dataType = document.querySelector('input[name="xrdDataType"]:checked').value;
// Fetch XRD data for the specific sample
const response = await fetch('/co2/get_xrd_data', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
}},
body: JSON.stringify({{
sample_id: sampleId,
data_type: dataType
}})
}});
if (!response.ok) {{
throw new Error('Failed to fetch XRD data');
}}
const result = await response.json();
console.log('DEBUG: API response for sample', sampleId, ':', {{
success: result.success,
dataType: dataType,
hasData: !!result.data,
dataKeys: result.data ? Object.keys(result.data) : null,
dataPointsX: result.data && result.data.x ? result.data.x.length : null,
dataPointsY: result.data && result.data.y ? result.data.y.length : null
}});
if (result.success) {{
console.log('Successfully fetched XRD data for reload:', sampleId, 'data type:', dataType, 'points:', result.data_points);
// Add to accumulation with preserved metadata (skip duplicate check and plot update for reload)
addXrdToAccumulationWithMetadata(result.data, sampleId, result.data_points, source, xrfComposition, true, true);
console.log('Reloaded XRD data for sample:', sampleId, 'with data type:', dataType);
}} else {{
console.error('Failed to load XRD data:', result.error);
}}
}} catch (error) {{
console.error('Error reloading XRD plot:', error);
}}
}}
// Function to add XRD data to accumulation with explicit metadata
function addXrdToAccumulationWithMetadata(xrdData, sampleId, dataPoints, source, xrfComposition, skipDuplicateCheck = false, skipPlotUpdate = false) {{
// Check if this sample is already in accumulation (unless skipping for reload)
if (!skipDuplicateCheck) {{
const existingIndex = accumulatedXrdData.findIndex(item => item.sampleId === sampleId);
if (existingIndex !== -1) {{
console.log('Sample already in XRD accumulation:', sampleId);
return; // Don't add duplicates
}}
}}
// Add to accumulation with explicit metadata
accumulatedXrdData.push({{
data: xrdData,
sampleId: sampleId,
dataPoints: dataPoints,
source: source,
xrfComposition: xrfComposition
}});
console.log('Added XRD data to accumulation with metadata. Total samples:', accumulatedXrdData.length);
// Show accumulated XRD plots (unless skipping for batch updates)
if (!skipPlotUpdate) {{
showAccumulatedXrdPlots();
}}
}}
// Function to update visual appearance of clicked points
function updateClickedPointVisual(sampleId) {{
// Get current plot data
const plotDiv = document.getElementById('plot');
const plotData = plotDiv.data;
// Update marker borders for clicked points
plotData.forEach(trace => {{
if (trace.customdata) {{
trace.marker.line.width = trace.customdata.map(id =>
clickedPoints.has(id) ? 4 : 1.5
);
trace.marker.line.color = trace.customdata.map(id =>
clickedPoints.has(id) ? '#00FF00' : 'rgba(0,0,0,0.3)'
);
}}
}});
// Redraw the plot efficiently
Plotly.redraw('plot');
}}
// Function to export data as CSV
async function exportData() {{
try {{
const exportBtn = document.getElementById('exportBtn');
exportBtn.textContent = '⊞ Exporting...';
exportBtn.disabled = true;
// Send request to export CSV
const response = await fetch('/co2/export_csv', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
}},
body: JSON.stringify({{
mode: currentMode,
currentDensity: currentMode === 'current_density' ? currentDensityOptions[currentDensityIndex] : null,
voltage: currentMode === 'voltage' ? currentVoltage : null
}})
}});
if (!response.ok) {{
throw new Error('Export failed');
}}
// Get the CSV data
const csvData = await response.text();
// Create and download the file
const blob = new Blob([csvData], {{ type: 'text/csv' }});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const filename = currentMode === 'current_density' ?
`co2_data_${{currentDensityOptions[currentDensityIndex]}}mA_cm2.csv` :
`co2_data_${{currentVoltage.toFixed(1)}}V.csv`;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Reset button
exportBtn.textContent = '⊞ Export Data (CSV)';
exportBtn.disabled = false;
}} catch (error) {{
console.error('Export error:', error);
alert('Export failed. Please try again.');
// Reset button
const exportBtn = document.getElementById('exportBtn');
exportBtn.textContent = '⊞ Export Data (CSV)';
exportBtn.disabled = false;
}}
}}
// Voltage configuration functions
function openVoltageConfig() {{
// Always refresh interpolated values based on current slider value
const currentDensity = currentDensityOptions[currentDensityIndex] || 100; // Use current slider value or default to 100
// Recalculate and refresh interpolated values
const interpolatedAnodePot = interpolateAnodePotentialVsRef(currentDensity);
const interpolatedR = interpolateCathodeR(currentDensity);
// Always update the values when modal opens
document.getElementById('anode_measured_potential_vs_ref').value = interpolatedAnodePot.toFixed(4);
document.getElementById('R_cathode').value = interpolatedR.toFixed(4);
document.getElementById('voltageConfigModal').style.display = 'block';
}}
function closeVoltageConfig() {{
document.getElementById('voltageConfigModal').style.display = 'none';
}}
// Interpolation functions (matching Python implementation)
function interpolateAnodePotentialVsRef(currentDensity) {{
// Calibration data: j = [50, 100, 200] mA/cm², anode_pot = [1.3, 1.35, 1.4] V
const jArray = [50, 100, 200];
const anodePotArray = [1.3, 1.35, 1.4];
if (currentDensity <= 0) {{
return anodePotArray[0];
}}
// Fit linear relationship: anode_pot = a * log10(j) + b
const logJ = jArray.map(j => Math.log10(j));
const n = logJ.length;
const sumX = logJ.reduce((a, b) => a + b, 0);
const sumY = anodePotArray.reduce((a, b) => a + b, 0);
const sumXY = logJ.reduce((sum, x, i) => sum + x * anodePotArray[i], 0);
const sumX2 = logJ.reduce((sum, x) => sum + x * x, 0);
const a = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const b = (sumY - a * sumX) / n;
const logJInput = Math.log10(currentDensity);
let anodePotInterpolated = a * logJInput + b;
// Clamp to reasonable bounds
anodePotInterpolated = Math.max(anodePotArray[0], Math.min(anodePotArray[anodePotArray.length - 1], anodePotInterpolated));
return anodePotInterpolated;
}}
function interpolateCathodeR(currentDensity) {{
// Calibration data: j = [50, 100, 200] mA/cm², R = [0.48, 0.34, 0.3] Ω
const jArray = [50, 100, 200];
const RArray = [0.48, 0.34, 0.3];
if (currentDensity <= 0) {{
return RArray[RArray.length - 1];
}}
// Fit linear relationship: R = a * log10(j) + b
const logJ = jArray.map(j => Math.log10(j));
const n = logJ.length;
const sumX = logJ.reduce((a, b) => a + b, 0);
const sumY = RArray.reduce((a, b) => a + b, 0);
const sumXY = logJ.reduce((sum, x, i) => sum + x * RArray[i], 0);
const sumX2 = logJ.reduce((sum, x) => sum + x * x, 0);
const a = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const b = (sumY - a * sumX) / n;
const logJInput = Math.log10(currentDensity);
let RInterpolated = a * logJInput + b;
// Clamp to reasonable bounds
RInterpolated = Math.max(RArray[RArray.length - 1], Math.min(RArray[0], RInterpolated));
return RInterpolated;
}}
// Initialize interpolated values based on default current density (called after functions are defined)
function initializeInterpolatedValues() {{
const defaultDensity = currentDensityOptions[currentDensityIndex] || 100;
const interpolatedAnodePot = interpolateAnodePotentialVsRef(defaultDensity);
const interpolatedR = interpolateCathodeR(defaultDensity);
document.getElementById('anode_measured_potential_vs_ref').value = interpolatedAnodePot.toFixed(4);
document.getElementById('R_cathode').value = interpolatedR.toFixed(4);
}}
// Call initialization after DOM is ready
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', initializeInterpolatedValues);
}} else {{
initializeInterpolatedValues();
}}
function resetVoltageConfig() {{
// Use current slider value for interpolation
const currentDensity = currentDensityOptions[currentDensityIndex] || 100; // Use current slider value or default to 100
document.getElementById('ref_pot').value = '0.23';
document.getElementById('geo_area').value = '4';
document.getElementById('cathode_pH').value = '12.5';
document.getElementById('anode_pH').value = '3';
document.getElementById('membrane_loss').value = '0.1';
// Calculate and set interpolated values based on current slider value
const interpolatedAnodePot = interpolateAnodePotentialVsRef(currentDensity);
const interpolatedR = interpolateCathodeR(currentDensity);
document.getElementById('anode_measured_potential_vs_ref').value = interpolatedAnodePot.toFixed(4);
document.getElementById('R_cathode').value = interpolatedR.toFixed(4);
}}
function applyVoltageConfig() {{
// Get parameter values from form
const params = {{
ref_pot: parseFloat(document.getElementById('ref_pot').value),
geo_area: parseFloat(document.getElementById('geo_area').value),
cathode_pH: parseFloat(document.getElementById('cathode_pH').value),
anode_pH: parseFloat(document.getElementById('anode_pH').value),
membrane_loss: parseFloat(document.getElementById('membrane_loss').value),
anode_measured_potential_vs_ref: parseFloat(document.getElementById('anode_measured_potential_vs_ref').value),
R_cathode: parseFloat(document.getElementById('R_cathode').value)
}};
// Store parameters globally
voltageConversionParams = params;
// Close modal
closeVoltageConfig();
// Update plot with new parameters
updatePlot();
console.log('Applied voltage conversion parameters:', params);
}}
// Close modal when clicking outside of it
window.onclick = function(event) {{
const modal = document.getElementById('voltageConfigModal');
if (event.target == modal) {{
closeVoltageConfig();
}}
}}
// Initialize the plot
updatePlot();
// Initialize XRD plot (empty)
document.getElementById('xrdPlotContent').innerHTML = '';
// Add event listeners to XRD data type toggle
console.log('Setting up XRD toggle event listeners...');
const xrdToggles = document.querySelectorAll('input[name="xrdDataType"]');
console.log('Found XRD toggles:', xrdToggles.length);
xrdToggles.forEach((radio, index) => {{
console.log('Setting up listener for toggle ' + index + ':', radio.id, radio.value);
radio.addEventListener('change', function() {{
console.log('*** XRD TOGGLE EVENT FIRED ***');
console.log('XRD data type changed to:', this.value);
console.log('Current accumulatedXrdData length:', accumulatedXrdData.length);
// Reload all accumulated XRD plots with new data type
reloadAccumulatedXrdPlots();
}});
}});
console.log('XRD toggle event listeners setup complete');
// Add event listeners to unit type toggle
document.querySelectorAll('input[name="unitType"]').forEach(radio => {{
radio.addEventListener('change', function() {{
console.log('Unit type changed to:', this.value);
updatePlot();
}});
}});
// Global variable to store the selected sample ID
let selectedXrdSampleId = null;
let tooltipHideTimeout = null;
// Function to show XRD tooltip
function showXrdTooltip(x, y, sampleId) {{
selectedXrdSampleId = sampleId;
// Clear any existing hide timeout
if (tooltipHideTimeout) {{
clearTimeout(tooltipHideTimeout);
tooltipHideTimeout = null;
}}
const tooltip = document.getElementById('xrdTooltip');
if (tooltip) {{
// Position tooltip below Plotly's original hover tooltip
tooltip.style.display = 'block';
tooltip.style.left = (event.pageX + 20) + 'px';
tooltip.style.top = (event.pageY + 40) + 'px'; // Position below Plotly's tooltip
}}
}}
// Function to hide XRD tooltip with delay
function hideXrdTooltip() {{
// Add a small delay before hiding to allow mouse to move to tooltip
tooltipHideTimeout = setTimeout(() => {{
const tooltip = document.getElementById('xrdTooltip');
if (tooltip) {{
tooltip.style.display = 'none';
}}
}}, 200); // 200ms delay
}}
// Function to cancel hide when hovering over tooltip
function cancelTooltipHide() {{
if (tooltipHideTimeout) {{
clearTimeout(tooltipHideTimeout);
tooltipHideTimeout = null;
}}
}}
// Function to open XRD analysis from tooltip
function openXrdAnalysisFromTooltip() {{
if (selectedXrdSampleId) {{
// Parse sample ID to extract dataset and sample
// For sample like "uoft8_241025_Cd-0.875-Ni-0.125_pp0_rep1"
// Dataset should be "uoft8_241025" (first two parts)
const parts = selectedXrdSampleId.split('_');
const dataset = parts.slice(0, 2).join('_'); // First two parts
const sample = selectedXrdSampleId;
// Navigate to XRD dashboard with parameters
const url = `/xrd/?dataset=${{encodeURIComponent(dataset)}}&sample=${{encodeURIComponent(sample)}}`;
window.open(url, '_blank');
// Hide tooltip after clicking
hideXrdTooltip();
}}
}}
</script>
</body>
</html>
'''
return html_template
@co2_plot_bp.route('/export_csv', methods=['POST'])
def export_csv():
"""Export CO2R data as CSV"""
try:
# Get current data and calculate PCA
current_df = load_original_data()
df_with_pca = calculate_pca_components(current_df)
# Add voltage conversion columns if voltage data exists
if 'voltage' in df_with_pca.columns or 'voltage_mean' in df_with_pca.columns:
# Determine which voltage column to use
voltage_col = 'voltage_mean' if 'voltage_mean' in df_with_pca.columns else 'voltage'
# Get current density column
current_density_col = 'current density' if 'current density' in df_with_pca.columns else None
# Convert voltage values to SHE and RHE
voltage_values = df_with_pca[voltage_col].values
she_values = []
rhe_values = []
for idx, v in enumerate(voltage_values):
if pd.notna(v):
# Get current density for this row, default to 100 mA/cm² if not available
current_density = df_with_pca[current_density_col].iloc[idx] if current_density_col and pd.notna(df_with_pca[current_density_col].iloc[idx]) else 100.0
ushe, urhe = fullcell2halfcell(v, current_density)
she_values.append(ushe)
rhe_values.append(urhe)
else:
she_values.append(np.nan)
rhe_values.append(np.nan)
# Add the new columns
df_with_pca['V vs SHE'] = she_values
df_with_pca['V vs RHE'] = rhe_values
# Use the dataframe with PCA components and voltage conversions
csv_data = df_with_pca.to_csv(index=False)
# Create response with CSV data
response = Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=CO2R_data.csv'}
)
return response
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# Create a route to update data based on mode
@co2_plot_bp.route('/update_data', methods=['POST'])
def update_data():
try:
data = request.get_json()
mode = data.get('mode', 'current_density')
x_axis = data.get('xAxis', 'Cu')
y_axis = data.get('yAxis', 'PCA2')
z_axis = data.get('zAxis', 'default_colors')
unit_type = data.get('unitType', 'atomic') # Get the unit type
voltage_type = data.get('voltageType', 'fullcell') # Get the voltage type
# Get current data
current_df = load_original_data()
if mode == 'current_density':
current_density = data.get('currentDensity', 100)
# Filter dataframe at the specified current density
new_df = filter_df_by_current_density(current_df, current_density)
else: # voltage mode
voltage = data.get('voltage', 3.0)
# Generate new dataframe at the specified voltage
new_df = generate_df_at_voltage(current_df, target_voltage=voltage)
new_df = calculate_pca_components(new_df)
# Apply voltage conversion if needed
if voltage_type in ['she', 'rhe'] and ('voltage' in new_df.columns or 'voltage_mean' in new_df.columns):
# Get custom parameters if provided
custom_params = data.get('voltageConversionParams')
# Determine which voltage column to use
voltage_col = 'voltage_mean' if 'voltage_mean' in new_df.columns else 'voltage'
# Get current density column
current_density_col = 'current density' if 'current density' in new_df.columns else None
# Convert voltage values
voltage_values = new_df[voltage_col].values
converted_voltages = []
for idx, v in enumerate(voltage_values):
if pd.notna(v):
# Get current density for this row, default to 100 mA/cm² if not available
current_density = new_df[current_density_col].iloc[idx] if current_density_col and pd.notna(new_df[current_density_col].iloc[idx]) else 100.0
ushe, urhe = fullcell2halfcell(v, current_density, custom_params)
if voltage_type == 'she':
converted_voltages.append(ushe)
else: # rhe
converted_voltages.append(urhe)
else:
converted_voltages.append(np.nan)
# Create new column with converted voltage
if voltage_type == 'she':
new_df['voltage_she'] = converted_voltages
new_df[voltage_col] = new_df['voltage_she'] # Replace original voltage
else: # rhe
new_df['voltage_rhe'] = converted_voltages
new_df[voltage_col] = new_df['voltage_rhe'] # Replace original voltage
# Store original data for calculations (color mapping, reference lines)
original_df = new_df.copy()
# Apply unit conversion if needed (only for display)
if unit_type == 'weight':
# Convert atomic fraction to weight fraction
element_columns = [col for col in new_df.columns if col in ATOMIC_WEIGHTS]
new_df = convert_atomic_to_weight_fraction(new_df, element_columns)
# Convert to JSON-serializable format
df_dict = new_df.to_dict('records')
original_df_dict = original_df.to_dict('records')
return jsonify({
'success': True,
'data': df_dict,
'originalData': original_df_dict,
'unitType': unit_type
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# Create a route to get data for a specific point across all current densities or voltages
@co2_plot_bp.route('/get_point_data', methods=['POST'])
def get_point_data():
try:
data = request.get_json()
source = data.get('source')
xrf_composition = data.get('xrf_composition')
x_col = data.get('x_col')
y_col = data.get('y_col')
mode = data.get('mode', 'current_density')
print(f"Looking for point: source={source}, xrf={xrf_composition}, x_col={x_col}, y_col={y_col}, mode={mode}")
point_data = []
# Get current data
current_df = load_original_data()
if mode == 'current_density':
# Get data for this specific point across all current densities
current_density_options = [50, 100, 150, 200, 300]
for cd in current_density_options:
# Filter data at this current density
filtered_df = filter_df_by_current_density(current_df, cd)
filtered_df = calculate_pca_components(filtered_df)
# Find the specific point
composition_col = 'xrf composition' if 'xrf composition' in filtered_df.columns else 'target composition'
point_row = filtered_df[(filtered_df['source'] == source) &
(filtered_df[composition_col] == xrf_composition)]
if not point_row.empty:
x_val = point_row[x_col].iloc[0] if x_col in point_row.columns else None
y_val = point_row[y_col].iloc[0] if y_col in point_row.columns else None
point_data.append({
'current_density': cd,
'x_value': x_val,
'y_value': y_val
})
print(f"Found data at {cd} mA/cm²: x={x_val}, y={y_val}")
else:
print(f"No data found at {cd} mA/cm² for this point")
else: # voltage mode
# Get data for this specific point across all voltages
voltage_options = [2.0, 2.5, 3.0, 3.5, 4.0]
for voltage in voltage_options:
# Generate dataframe at this voltage
filtered_df = generate_df_at_voltage(current_df, target_voltage=voltage)
filtered_df = calculate_pca_components(filtered_df)
# Find the specific point
composition_col = 'xrf composition' if 'xrf composition' in filtered_df.columns else 'target composition'
point_row = filtered_df[(filtered_df['source'] == source) &
(filtered_df[composition_col] == xrf_composition)]
if not point_row.empty:
x_val = point_row[x_col].iloc[0] if x_col in point_row.columns else None
y_val = point_row[y_col].iloc[0] if y_col in point_row.columns else None
point_data.append({
'voltage': voltage,
'x_value': x_val,
'y_value': y_val
})
print(f"Found data at {voltage}V: x={x_val}, y={y_val}")
else:
print(f"No data found at {voltage}V for this point")
print(f"Total points found: {len(point_data)}")
return jsonify({'success': True, 'data': point_data})
except Exception as e:
print(f"Error in get_point_data: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# Create a route to get XRD data for a specific sample ID
@co2_plot_bp.route('/get_xrd_data', methods=['POST'])
def get_xrd_data():
try:
data = request.get_json()
sample_id = data.get('sample_id')
data_type = data.get('data_type', 'raw') # Default to raw
if not sample_id:
return jsonify({'success': False, 'error': 'Sample ID is required'}), 400
print(f"DEBUG: Requesting XRD data for sample: {sample_id}, type: {data_type}")
print(f"DEBUG: Flask working directory: {os.getcwd()}")
# Load XRD data with specified type
xrd_data = load_xrd_data(sample_id, data_type)
if xrd_data is None:
return jsonify({
'success': False,
'error': f'No XRD data found/collected for sample: {sample_id}',
'sample_id': sample_id,
'data_type': data_type
}), 404
# Convert to format suitable for Plotly
x_values = [point[0] for point in xrd_data]
y_values = [point[1] for point in xrd_data]
return jsonify({
'success': True,
'sample_id': sample_id,
'data_type': data_type,
'data': {
'x': x_values,
'y': y_values
},
'data_points': len(xrd_data)
})
except Exception as e:
print(f"Error in get_xrd_data: {e}")
return jsonify({'success': False, 'error': str(e)}), 500