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 "

Error: No CO2R data available

Please ensure CO2R data is available in the main dashboard.

" # 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'' for col in element_cols]) fe_options = ''.join([f'' for col in fe_cols]) # Add voltage options to y-axis if 'voltage_mean' in df_with_pca.columns: fe_options += f'' if 'voltage' in df_with_pca.columns: fe_options += f'' # 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'' for col in comprehensive_x_axis_options]) y_axis_options_html = ''.join([f'' for col in y_axis_options]) z_axis_options_html = ''.join([f'' 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''' OCx25 Dataset: CO₂RR Performance Interactive Plot ← Back to Dashboard

OCx25 Dataset: CO₂RR Performance Data Visualization

{default_current_density} mA/cm²

Main Plot

Point Analysis

XRD Analysis

Symbol Coding:
Circles: Samples synthesized by Chemical Reduction (UofT)
Diamonds: Samples synthesized by Spark Ablation (VSP)

Default Color Coding:
Red points: Performance above Cu (UofT) threshold
Blue points: Performance above Cu (VSP) threshold
Black points: Performance below both thresholds
Note: The specific threshold values depend on the selected y-axis metric and are calculated as the mean performance for each source.

Analysis Modes:
Current Density: Filter data at specific current density values (50-300 mA/cm²)


Note on Error Bars:
Error bars are shown only when averaging across identical compositions in this analysis.
UofT (Chemical Reduction): 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.
VSP (Spark Ablation): 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.

Voltage Conversion Methodology:
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:
• Membrane overpotential and ionic resistance
• Nernstian pH gradient effects
• Reference electrode potential corrections
• Current density-dependent ohmic losses
The methodology follows established protocols for accurate half-cell potential determination in CO₂ reduction electrolyzers.
Arabyarmohammadi, F. et al. Voltage distribution within carbon dioxide reduction electrolysers. Nature Sustainability (2025)

📓 View Voltage-Conversion Notebook ↗

XRD Analysis:
Click on any point in the main plot to view the corresponding XRD pattern in the third window.
• XRD data is loaded from /Data/XRD/raw/ or /Data/CustomXRD/raw/ directories
• Custom XRD data can be uploaded via the dashboard's "Load Your Own XRD Data" section
• Files should be named using the sample ID (e.g., sample_001.xy for raw or sample_001.csv for normalized)
• The plot shows 2θ (degrees) vs Intensity (counts)
• If no XRD data is found for a sample, an error message will be displayed
''' 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