Public script
ECSA_CO+Hupd_Final
Doctoral Researcher
Description
Calculate ECSA of Pt from Hupd region and CO stripping peak and print plottable outputs (first and third CV cycles).
This public script can be saved to your dashboard or opened in GetHug to run and modify. Its raw server storage path is not exposed.
Show Code Preview
# ============================================================
# 1) CONSTANTS
# ============================================================
import numpy as np
import pandas as pd
from pathlib import Path
# Expected specific charges
# CO monolayer oxidation on Pt: 420 microC cm^-2_Pt
# Hupd on polycrystalline Pt: 210 microC cm^-2_Pt
CO_STRIPPING_CHARGE_DENSITY_C_PER_CM2 = 420e-6
HUPD_CHARGE_DENSITY_C_PER_CM2 = 210e-6
# File parsing
DEFAULT_SKIP_INITIAL_ROWS = 3
POTENTIAL_COLUMN_CANDIDATES = ["Ewe/V", "Potential_V", "Potential", "E/V"]
CURRENT_COLUMN_CANDIDATES = ["<I>/mA", "Current_mA", "Current", "I/mA"]
# Cycle detection
LOW_POTENTIAL_TARGET_V = 0.05
LOW_POTENTIAL_TOLERANCE_V = 0.08
MIN_NEGATIVE_STEPS = 2
MIN_POSITIVE_STEPS = 2
MIN_CYCLE_SEPARATION_ROWS = 200
# CO stripping integration
INTERSECTION_SEARCH_MIN_V = 0.50
CO_RIGHT_LIMIT_TARGET_V = 1.22
# Hupd integration
HUPD_RIGHT_LIMIT_TARGET_V = 0.45
# Electrochemistry constants
SCAN_RATE_MV_S = 20.0
SCAN_RATE_V_S = SCAN_RATE_MV_S / 1000.0
# Output formatting
OUTPUT_FLOAT_FORMAT = "%.8f"
# ============================================================
# 2) INPUT VALUES
# ============================================================
file_path = input("Enter the full path to the raw CV text file: ").strip().strip('"').strip("'")
r_ohm = float(input("Enter the ohmic resistance in ohms for iR correction: ").strip())
pt_mass_ug = float(input("Enter the Pt mass on the electrode in micrograms (ug): ").strip())
pt_mass_g = pt_mass_ug * 1e-6
if pt_mass_g <= 0:
raise ValueError("The Pt mass must be positive.")
skip_initial_rows = DEFAULT_SKIP_INITIAL_ROWS
# ============================================================
# 3) TEXT FILES TO ANALYZE
# ============================================================
input_file = Path(file_path)
if not input_file.exists():
raise FileNotFoundError(f"File not found: {input_file}")
def find_existing_column(columns, candidates):
for cand in candidates:
if cand in columns:
return cand
raise KeyError(f"Could not find any of these columns: {candidates}. Found columns: {list(columns)}")
df_raw = pd.read_csv(input_file, sep=r"\s+|\t+", engine="python")
potential_col = find_existing_column(df_raw.columns, POTENTIAL_COLUMN_CANDIDATES)
current_col = find_existing_column(df_raw.columns, CURRENT_COLUMN_CANDIDATES)
df = df_raw[[potential_col, current_col]].copy()
df.columns = ["Potential_V_measured", "Current_mA"]
df = df.apply(pd.to_numeric, errors="coerce").dropna().reset_index(drop=True)
if len(df) <= skip_initial_rows + 20:
raise ValueError("Not enough valid rows after applying the initial row skip.")
df = df.iloc[skip_initial_rows:].reset_index(drop=True)
# ============================================================
# 4) CALCULATIONS
# ============================================================
def moving_average(arr, window=3):
if window <= 1:
return np.asarray(arr, dtype=float).copy()
return pd.Series(arr).rolling(window=window, center=True, min_periods=1).mean().to_numpy()
def detect_cycle_start_indices(potential_array):
"""
Detect cycle starts from the point where the potential reaches the low-potential
turning point and starts increasing again.
"""
p = moving_average(np.asarray(potential_array, dtype=float), window=3)
dp = np.diff(p)
starts = [0]
i = MIN_NEGATIVE_STEPS
while i < len(dp) - MIN_POSITIVE_STEPS:
neg_ok = np.all(dp[i - MIN_NEGATIVE_STEPS:i] < 0)
pos_ok = np.all(dp[i:i + MIN_POSITIVE_STEPS] > 0)
if neg_ok and pos_ok and p[i] <= LOW_POTENTIAL_TARGET_V + LOW_POTENTIAL_TOLERANCE_V:
new_start = i
if new_start - starts[-1] > MIN_CYCLE_SEPARATION_ROWS:
starts.append(new_start)
i += 20
else:
i += 1
return starts
cycle_starts = detect_cycle_start_indices(df["Potential_V_measured"].to_numpy())
if len(cycle_starts) < 3:
raise ValueError(
f"Only {len(cycle_starts)} cycle starts were detected. "
"Please check the file format or adjust the cycle detection settings."
)
cycle_starts = cycle_starts[:3]
cycle_bounds = []
for idx, start in enumerate(cycle_starts):
end = cycle_starts[idx + 1] if idx + 1 < len(cycle_starts) else len(df)
cycle_bounds.append((start, end))
cycles = []
for start, end in cycle_bounds:
cycle_df = df.iloc[start:end].reset_index(drop=True)
cycles.append(cycle_df)
if len(cycles) < 3:
raise ValueError("Three cycles were expected but could not be isolated.")
cycle_1 = cycles[0].copy() # contains CO stripping peak
cycle_2 = cycles[1].copy()
cycle_3 = cycles[2].copy() # CO background; also used for Hupd signal
def split_anodic_branch(cycle_df):
"""Keep only the forward scan from low potential up to the maximum potential."""
idx_max = int(cycle_df["Potential_V_measured"].idxmax())
return cycle_df.iloc[:idx_max + 1].reset_index(drop=True)
cycle_1_anodic = split_anodic_branch(cycle_1)
cycle_3_anodic = split_anodic_branch(cycle_3)
# iR correction: E_corrected = E_measured - R * I
# Current in the file is in mA, so convert to A before multiplying by ohms
for c in (cycle_1, cycle_2, cycle_3, cycle_1_anodic, cycle_3_anodic):
current_a = c["Current_mA"].to_numpy() / 1000.0
c["Potential_V_iR"] = c["Potential_V_measured"].to_numpy() - r_ohm * current_a
def choose_nearest_index_and_value(x, target):
idx = int(np.argmin(np.abs(np.asarray(x, dtype=float) - float(target))))
return idx, float(np.asarray(x, dtype=float)[idx])
def trapezoid_integral(x, y):
"""
Compatibility trapezoidal integral that does not rely on np.trapz.
Returns the area under y(x).
"""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
if len(x) != len(y):
raise ValueError("x and y must have the same length for integration.")
if len(x) < 2:
return 0.0
dx = np.diff(x)
avg_y = 0.5 * (y[:-1] + y[1:])
return float(np.sum(dx * avg_y))
def integrate_between_limits_on_xy(x_data, y_data, left_v, right_v):
"""
Integrate y vs x between left_v and right_v using linear interpolation at boundaries.
"""
x = np.asarray(x_data, dtype=float)
y = np.asarray(y_data, dtype=float)
if len(x) != len(y):
raise ValueError("x and y must have the same length.")
if len(x) < 2:
return 0.0, np.array([]), np.array([])
if left_v < x.min() or right_v > x.max():
raise ValueError("Integration limits are outside the available potential window.")
if right_v <= left_v:
raise ValueError("The right integration limit must be larger than the left limit.")
x_mid = x[(x > left_v) & (x < right_v)]
x_int = np.concatenate(([left_v], x_mid, [right_v]))
y_int = np.interp(x_int, x, y)
area = trapezoid_integral(x_int, y_int)
return float(area), x_int, y_int
def find_first_intersection_above_threshold(cycle1_anodic_df, cycle3_anodic_df, min_potential_v=0.50):
"""
Find the first intersection above the threshold potential by interpolating the
background current of cycle 3 onto the potential grid of cycle 1.
Uses iR-corrected potential for the intersection.
"""
x1 = cycle1_anodic_df["Potential_V_iR"].to_numpy()
y1 = cycle1_anodic_df["Current_mA"].to_numpy()
x3 = cycle3_anodic_df["Potential_V_iR"].to_numpy()
y3 = cycle3_anodic_df["Current_mA"].to_numpy()
common_min = max(min_potential_v, float(np.min(x1)), float(np.min(x3)))
common_max = min(float(np.max(x1)), float(np.max(x3)))
mask = (x1 >= common_min) & (x1 <= common_max)
x = x1[mask]
y1_use = y1[mask]
if len(x) < 2:
raise ValueError("Not enough overlapping points were found to determine the CO intersection.")
y3_interp = np.interp(x, x3, y3)
delta = y1_use - y3_interp
for i in range(len(delta) - 1):
if delta[i] == 0:
return float(x[i])
if delta[i] * delta[i + 1] < 0:
x_left, x_right = x[i], x[i + 1]
d_left, d_right = delta[i], delta[i + 1]
x_cross = x_left - d_left * (x_right - x_left) / (d_right - d_left)
return float(x_cross)
raise ValueError("No intersection between cycle 1 and cycle 3 was found above the chosen threshold.")
# ---------------- CO stripping ECSA ----------------
co_left_limit_v = find_first_intersection_above_threshold(
cycle_1_anodic, cycle_3_anodic, min_potential_v=INTERSECTION_SEARCH_MIN_V
)
_, co_right_limit_v = choose_nearest_index_and_value(
cycle_1_anodic["Potential_V_iR"].to_numpy(), CO_RIGHT_LIMIT_TARGET_V
)
if co_right_limit_v <= co_left_limit_v:
raise ValueError(
f"CO right integration limit ({co_right_limit_v:.4f} V) is not larger than the "
f"left integration limit ({co_left_limit_v:.4f} V)."
)
area_cycle1_co_mA_V, _, _ = integrate_between_limits_on_xy(
cycle_1_anodic["Potential_V_iR"].to_numpy(),
cycle_1_anodic["Current_mA"].to_numpy(),
co_left_limit_v,
co_right_limit_v,
)
area_cycle3_co_mA_V, _, _ = integrate_between_limits_on_xy(
cycle_3_anodic["Potential_V_iR"].to_numpy(),
cycle_3_anodic["Current_mA"].to_numpy(),
co_left_limit_v,
co_right_limit_v,
)
co_peak_area_mA_V = area_cycle1_co_mA_V - area_cycle3_co_mA_V
if SCAN_RATE_V_S <= 0:
raise ValueError("The scan rate must be positive.")
# mA·V / (V/s) = mC ; then convert mC to C
co_stripping_charge_C = (co_peak_area_mA_V / SCAN_RATE_V_S) / 1000.0
# Geometric Pt area from CO stripping
ecsa_co_cm2_pt = co_stripping_charge_C / CO_STRIPPING_CHARGE_DENSITY_C_PER_CM2
# Mass-normalized ECSA: cm^2/g -> m^2/g by multiplying by 1e-4
ecsa_co_m2_g_pt = (ecsa_co_cm2_pt / pt_mass_g) * 1e-4
# ---------------- Hupd ECSA ----------------
def first_positive_current_potential(cycle_df):
x = cycle_df["Potential_V_iR"].to_numpy()
y = cycle_df["Current_mA"].to_numpy()
pos_idx = np.where(y > 0)[0]
if len(pos_idx) == 0:
raise ValueError("No positive-current point was found in the selected anodic branch.")
idx = int(pos_idx[0])
return float(x[idx])
hupd_left_cycle1_v = first_positive_current_potential(cycle_1_anodic)
hupd_left_cycle3_v = first_positive_current_potential(cycle_3_anodic)
hupd_left_limit_v = max(hupd_left_cycle1_v, hupd_left_cycle3_v)
_, hupd_right_limit_v = choose_nearest_index_and_value(
cycle_3_anodic["Potential_V_iR"].to_numpy(), HUPD_RIGHT_LIMIT_TARGET_V
)
if hupd_right_limit_v <= hupd_left_limit_v:
raise ValueError(
f"Hupd right integration limit ({hupd_right_limit_v:.4f} V) is not larger than the "
f"left integration limit ({hupd_left_limit_v:.4f} V)."
)
area_cycle1_hupd_mA_V, _, _ = integrate_between_limits_on_xy(
cycle_1_anodic["Potential_V_iR"].to_numpy(),
cycle_1_anodic["Current_mA"].to_numpy(),
hupd_left_limit_v,
hupd_right_limit_v,
)
area_cycle3_hupd_mA_V, _, _ = integrate_between_limits_on_xy(
cycle_3_anodic["Potential_V_iR"].to_numpy(),
cycle_3_anodic["Current_mA"].to_numpy(),
hupd_left_limit_v,
hupd_right_limit_v,
)
# Hupd signal = cycle 3 - cycle 1 background
hupd_net_area_mA_V = area_cycle3_hupd_mA_V - area_cycle1_hupd_mA_V
hupd_charge_C = (hupd_net_area_mA_V / SCAN_RATE_V_S) / 1000.0
ecsa_hupd_cm2_pt = hupd_charge_C / HUPD_CHARGE_DENSITY_C_PER_CM2
ecsa_hupd_m2_g_pt = (ecsa_hupd_cm2_pt / pt_mass_g) * 1e-4
# ============================================================
# 5) OUTPUTS
# ============================================================
output_dir = input_file.parent
cycle1_output = cycle_1[["Potential_V_iR", "Current_mA"]].copy()
cycle3_output = cycle_3[["Potential_V_iR", "Current_mA"]].copy()
cycle1_output.columns = ["Potential_V_iR", "Current_mA"]
cycle3_output.columns = ["Potential_V_iR", "Current_mA"]
cycle1_output_path = output_dir / f"{input_file.stem}_cycle1_CO_stripping_iR_corrected.txt"
cycle3_output_path = output_dir / f"