Public script

ECSA_CO+Hupd_Final

by Alessio
Category: ECSA calculations Saves: 0 Completed runs: 0

Description

Calculate ECSA of Pt from Hupd region and CO stripping peak and print plottable outputs (first and third CV cycles).

This is a public GetHug script. You can save it to your dashboard or open it in GetHug to run and modify it. The raw 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"