Skip to content

Cycle Identification in Battery Test Data: Theory, Techniques, and Implementation

Version: 1.0 Last Updated: 2025-10-10 Related Documents: Battery Metrics Formulas Target Module: batteryproc/formulas.py (future implementation)

Document Purpose

This document provides comprehensive research and guidance on identifying charge/discharge cycles in battery test data. It addresses the challenge of robustly segmenting raw time-series data into discrete cycling events, which is fundamental to all downstream battery performance analysis.

Table of Contents

  1. Overview
  2. The Cycle Identification Problem
  3. Fundamental Approaches
  4. Robust Cycle Detection Techniques
  5. Edge Cases and Challenges
  6. Implementation Strategy for BatteryProc
  7. Validation and Testing
  8. References and Best Practices

Overview

Purpose

This document provides comprehensive research and guidance on identifying charge/discharge cycles in battery test data. It addresses the challenge of robustly segmenting raw time-series data (time, current, voltage, capacity) into discrete cycling events, which is fundamental to all downstream battery performance analysis.

Motivation

Current cycle detection in BatteryProc (via update_cycle_index() in metrics.py) relies primarily on parsing the "Step Type" string column and detecting transitions between charge/discharge states. While effective for clean, well-structured data, this approach can encounter issues when:

  • Step Type labels are inconsistent, missing, or non-standard
  • Data contains complex protocols (formation cycles, pulse tests, multi-step sequences)
  • Files are corrupted, truncated, or concatenated
  • Capacity resets unexpectedly mid-cycle
  • Rest periods or other intermediate steps interrupt normal cycling

As BatteryProc evolves toward a centralized formulas.py module (per the Battery Metrics Formulas document), cycle identification must become more robust, data-driven, and adaptable to diverse testing equipment and protocols.

Scope

This document covers:

  • Theory: Physical principles and definitions of battery cycles
  • Techniques: State-of-the-art methods for cycle boundary detection
  • Practice: Concrete algorithms suitable for implementation
  • Edge Cases: Common failure modes and mitigation strategies
  • Implementation: Design recommendations for formulas.py

The Cycle Identification Problem

What is a Battery Cycle?

A battery cycle typically consists of:

  1. Charge phase: Current flows into the battery (I > 0), increasing state of charge (SOC)
  2. Optional rest: Zero current (I ≈ 0), allowing voltage relaxation
  3. Discharge phase: Current flows out of the battery (I < 0), decreasing SOC
  4. Optional rest: Zero current, allowing voltage relaxation

The cycle is complete when the battery returns to a reference state (often the start of the next charge).

Key Parameters for Cycle Detection

From raw battery test data, the following columns are most useful:

Parameter Symbol Unit Role in Cycle Detection
Time t s Temporal ordering, segment boundaries
Current I A Primary indicator: sign change detects charge ↔ discharge transitions
Voltage V V Secondary indicator: plateau detection, SOC estimation
Capacity Q mAh Cumulative metric: resets/wraps indicate new cycles
Step Type - string Explicit label (if available and reliable)
Cycle Index n int Target output: cycle number for each row

Primary Challenge: Determining when one cycle ends and the next begins, especially when:

  • Rest periods separate charge and discharge
  • Multi-step protocols (CC-CV charge, pulse discharge) occur within a single cycle
  • Data is noisy, incomplete, or lacks explicit step labels

Fundamental Approaches

1. Step Type Parsing (Current BatteryProc Method)

Principle: Use explicit "Step Type" column to identify charge, discharge, and rest steps.

Algorithm (from update_cycle_index()):

# Identify charge/discharge rows via string matching
is_charge = _flag_charge(record["Step Type"])      # Regex for "chg", "charge"
is_discharge = _flag_discharge(record["Step Type"]) # Regex for "dchg", "discharge"
is_active = is_charge | is_discharge

# Determine first active step type (e.g., charge or discharge)
first_event_type = is_charge.loc[active_idx].iloc[0]

# Increment cycle index when returning to the first event type
# Example: [chg, dchg, chg, dchg] → [1, 1, 2, 2]
transitions = (events.diff() != 0) & (events == first_event_val)
cycle_index = transitions.cumsum() + 1

Strengths:

  • Fast and deterministic
  • Works well with standardized, clean data from major instruments (Neware, Bio-Logic)
  • Handles rest periods naturally (ignored in cycle counting)

Weaknesses:

  • Brittle to non-standard step names (e.g., "Formation", "Pulse", "OCV Hold")
  • Fails if "Step Type" column is missing, corrupted, or inconsistent
  • Cannot detect cycles in raw CSV data lacking explicit step labels
  • Assumes step sequence follows a specific pattern (initial type → other → initial)

Use Case: Primary method for instrument-generated data with reliable step metadata.


2. Current Sign Analysis (Data-Driven Method)

Principle: Detect charge/discharge boundaries by monitoring the sign of current.

Algorithm:

# Threshold for "active" current (filter out rest/noise)
I_threshold = 0.01  # A, or 0.005*C_rated

# Classify each point
is_charge = current > I_threshold
is_discharge = current < -I_threshold
is_rest = np.abs(current) <= I_threshold

# Detect transitions (ignoring rest periods)
active_current = np.where(np.abs(current) > I_threshold, current, np.nan)
current_sign = np.sign(active_current)
sign_changes = np.diff(current_sign, prepend=current_sign[0]) != 0

# Filter out spurious transitions (require sustained state change)
# Example: Require at least N consecutive points in new state
min_segment_points = 10
valid_transitions = filter_short_segments(sign_changes, min_segment_points)

# Increment cycle index at charge → discharge → charge boundaries
cycle_index = assign_cycles_from_transitions(valid_transitions, first_state='charge')

Strengths:

  • Works without "Step Type" column (instrument-agnostic)
  • Robust to label variations
  • Directly reflects physical current flow

Weaknesses:

  • Requires careful threshold selection (instrument-dependent noise floor)
  • Sensitive to current spikes, measurement artifacts
  • Multi-step protocols (CC-CV) may cause false transitions if current dips below threshold
  • Cannot distinguish between "formation cycle 1" and "normal cycle 1" without additional context

Use Case: Fallback method for unlabeled data or when step types are unreliable.


3. Capacity-Based Cycle Detection

Principle: Cumulative capacity typically increases during charge, then resets (or decreases to near-zero) at the start of a new cycle.

Algorithm:

# Compute capacity if not provided
if 'Capacity(mAh)' not in data.columns:
    Q = cumulative_trapezoid(current, time, initial=0) / 3.6  # mAh

# Detect capacity resets/wraps (indicating new cycle)
capacity_diff = np.diff(Q, prepend=Q[0])
capacity_drops = capacity_diff < -0.5 * np.max(Q)  # Drop > 50% of max capacity

# Alternative: Detect local minima in capacity
from scipy.signal import find_peaks
minima_idx, _ = find_peaks(-Q, prominence=0.1*np.max(Q))

# Assign cycle numbers based on reset points
cycle_boundaries = np.where(capacity_drops)[0]
cycle_index = np.searchsorted(cycle_boundaries, np.arange(len(Q)))

Strengths:

  • Handles capacity resets explicitly (common in many file formats)
  • Effective for CC-CV protocols where current varies but capacity is monotonic

Weaknesses:

  • Requires accurate capacity data (or ability to compute it from current)
  • Fails if capacity column is cumulative across all cycles (no resets)
  • May miss cycles if capacity doesn't reset (e.g., partial charge/discharge)
  • Sensitive to noise in capacity measurements

Use Case: Complementary check when capacity data is available and resets cleanly.


4. Voltage-Based State Detection

Principle: Voltage trajectories differ between charge (V increasing) and discharge (V decreasing), and plateaus indicate rest or CV holds.

Algorithm:

# Smooth voltage to reduce noise
from scipy.signal import savgol_filter
V_smooth = savgol_filter(voltage, window_length=51, polyorder=3)

# Compute voltage derivative
dV_dt = np.gradient(V_smooth, time)

# Classify states
is_charging_voltage = (dV_dt > threshold_pos) & (current > 0)
is_discharging_voltage = (dV_dt < threshold_neg) & (current < 0)
is_plateau = np.abs(dV_dt) < threshold_plateau

# Combine with current sign for robust detection
consistent_charge = is_charging_voltage & is_charge
consistent_discharge = is_discharging_voltage & is_discharge

Strengths:

  • Provides independent verification of charge/discharge state
  • Can detect CV (constant voltage) holds via plateau detection
  • Helps identify anomalous data (e.g., voltage rising during discharge → measurement error)

Weaknesses:

  • Requires smoothing, which can delay transition detection
  • Thresholds (dV/dt) are battery chemistry and rate-dependent
  • Less reliable at voltage plateaus characteristic of some chemistries (e.g., LFP)
  • Not a standalone method (should augment current-based detection)

Use Case: Validation layer to flag inconsistent or corrupted data.


5. Multi-Feature State Machine

Principle: Combine current, voltage, capacity, and time into a state machine that tracks cycling phases explicitly.

States:

  • IDLE: Initial state, or long rest period
  • CHARGING: I > threshold, dV/dt > 0 (or V increasing)
  • CHARGE_REST: I ≈ 0, following charge
  • DISCHARGING: I < -threshold, dV/dt < 0 (or V decreasing)
  • DISCHARGE_REST: I ≈ 0, following discharge
  • UNKNOWN: Conflicting signals (flag for review)

Transitions:

IDLE → CHARGING (if I > I_threshold)
CHARGING → CHARGE_REST (if I drops to ~0)
CHARGE_REST → DISCHARGING (if I < -I_threshold)
DISCHARGING → DISCHARGE_REST (if I rises to ~0)
DISCHARGE_REST → CHARGING (if I > I_threshold) → NEW CYCLE

Algorithm (pseudocode):

state = 'IDLE'
cycle_index = 0
cycle_indices = []

for i, row in data.iterrows():
    I, V, t = row['current'], row['voltage'], row['time']

    if state == 'IDLE' and I > I_threshold:
        state = 'CHARGING'
        cycle_index += 1
    elif state == 'CHARGING' and abs(I) < I_threshold:
        state = 'CHARGE_REST'
    elif state == 'CHARGE_REST' and I < -I_threshold:
        state = 'DISCHARGING'
    elif state == 'DISCHARGING' and abs(I) < I_threshold:
        state = 'DISCHARGE_REST'
    elif state == 'DISCHARGE_REST' and I > I_threshold:
        state = 'CHARGING'
        cycle_index += 1  # New cycle begins

    cycle_indices.append(cycle_index)

Strengths:

  • Explicit modeling of cycling protocol
  • Handles rest periods naturally
  • Can be extended to complex protocols (formation, pulse tests)
  • Provides clear logic for debugging and validation

Weaknesses:

  • More complex to implement and maintain
  • Requires tuning thresholds for each battery type/test
  • May need protocol-specific variants (e.g., GITT, PITT)

Use Case: Gold standard for robust cycle detection in diverse datasets.


Robust Cycle Detection Techniques

Multi-Level Detection Strategy

Recommended Approach: Implement a hierarchy of detection methods, using the most reliable available signals.

def detect_cycles(data: pd.DataFrame, method='auto') -> pd.Series:
    """
    Detect cycle boundaries using multiple signals.

    Priority (if method='auto'):
    1. Use 'Cycle Index' if present and valid
    2. Use 'Step Type' if present (via string parsing)
    3. Use current sign analysis (primary data-driven method)
    4. Validate with capacity resets (if available)
    5. Cross-check with voltage trends (secondary validation)
    """

    # Level 1: Trust existing cycle index if valid
    if 'Cycle Index' in data.columns and _validate_cycle_index(data['Cycle Index']):
        return data['Cycle Index']

    # Level 2: Parse Step Type (current BatteryProc method)
    if 'Step Type' in data.columns:
        try:
            cycle_idx = _detect_cycles_from_step_type(data)
            if _validate_cycle_index(cycle_idx):
                return cycle_idx
        except Exception as e:
            logging.warning(f"Step Type parsing failed: {e}")

    # Level 3: Current sign analysis (data-driven)
    cycle_idx = _detect_cycles_from_current(data)

    # Level 4: Validate with capacity (if available)
    if 'Capacity(mAh)' in data.columns:
        cycle_idx = _refine_with_capacity(data, cycle_idx)

    # Level 5: Flag anomalies using voltage
    if 'Voltage(V)' in data.columns:
        anomalies = _detect_voltage_anomalies(data, cycle_idx)
        if anomalies.any():
            logging.warning(f"Detected {anomalies.sum()} voltage anomalies")

    return cycle_idx

Handling Rest Periods

Rest periods (I ≈ 0) pose a challenge: should they belong to the preceding cycle, the following cycle, or be marked separately?

Recommended Strategy:

def assign_rest_to_cycles(data: pd.DataFrame, cycle_idx: pd.Series) -> pd.Series:
    """
    Assign rest periods to cycles using forward-fill logic.

    Rest periods inherit the cycle index of the preceding active step.
    """
    is_active = (np.abs(data['current']) > I_threshold)

    # Assign cycle index only to active steps
    active_cycle_idx = pd.Series(np.nan, index=data.index)
    active_cycle_idx[is_active] = cycle_idx[is_active]

    # Forward-fill to assign rest periods to preceding cycle
    filled_cycle_idx = active_cycle_idx.ffill()

    # Handle leading rests (before first active step)
    filled_cycle_idx = filled_cycle_idx.fillna(0)  # Or 1, depending on convention

    return filled_cycle_idx.astype(int)

Alternative: Assign rest to the following cycle (backward-fill), or mark as a separate "rest cycle."

BatteryProc Convention (from current code): Forward-fill is used, assigning rests to the preceding active cycle.


Detecting Capacity Resets and Wraps

Capacity may reset to zero at the start of each cycle (Neware, Bio-Logic) or accumulate continuously. Detecting resets helps validate cycle boundaries.

Algorithm:

def detect_capacity_resets(capacity: pd.Series, threshold_ratio=0.5) -> np.ndarray:
    """
    Detect points where capacity drops significantly (reset or wrap).

    Parameters
    ----------
    capacity : pd.Series
        Cumulative capacity data (mAh)
    threshold_ratio : float
        Reset if drop > threshold_ratio * max_capacity

    Returns
    -------
    reset_indices : np.ndarray
        Indices where capacity resets occur
    """
    cap_diff = capacity.diff()
    max_cap = capacity.max()

    # Detect large negative jumps
    resets = cap_diff < -threshold_ratio * max_cap

    return np.where(resets)[0]


def validate_cycle_boundaries_with_capacity(cycle_idx: pd.Series, capacity: pd.Series) -> bool:
    """
    Check if cycle boundaries align with capacity resets.
    """
    cycle_starts = np.where(cycle_idx.diff() != 0)[0]
    capacity_resets = detect_capacity_resets(capacity)

    # Allow some tolerance (within N samples)
    tolerance = 5
    matches = sum(any(abs(cs - cr) <= tolerance for cr in capacity_resets)
                  for cs in cycle_starts)

    match_rate = matches / len(cycle_starts) if len(cycle_starts) > 0 else 0

    return match_rate > 0.8  # 80% of cycle starts should align with resets

Filtering Spurious Transitions

Noise, current spikes, or brief interruptions can cause false cycle boundaries. Apply temporal and statistical filters:

Minimum Segment Duration:

def filter_short_segments(cycle_idx: pd.Series, time: pd.Series,
                          min_duration_s=60) -> pd.Series:
    """
    Remove cycles shorter than min_duration_s by merging with adjacent cycles.
    """
    cycle_durations = time.groupby(cycle_idx).apply(lambda t: t.max() - t.min())
    short_cycles = cycle_durations[cycle_durations < min_duration_s].index

    # Merge short cycles with the previous cycle
    new_cycle_idx = cycle_idx.copy()
    for short_cycle in short_cycles:
        new_cycle_idx[cycle_idx == short_cycle] = short_cycle - 1

    # Renumber cycles sequentially
    new_cycle_idx = new_cycle_idx.rank(method='dense').astype(int)

    return new_cycle_idx

Minimum Charge/Discharge Transfer:

def filter_incomplete_cycles(data: pd.DataFrame, cycle_idx: pd.Series,
                              min_capacity_mAh=1.0) -> pd.Series:
    """
    Remove cycles where charge or discharge capacity is below threshold.
    """
    for cycle in cycle_idx.unique():
        cycle_data = data[cycle_idx == cycle]

        charge_cap = integrate_capacity(cycle_data[cycle_data['current'] > 0])
        discharge_cap = abs(integrate_capacity(cycle_data[cycle_data['current'] < 0]))

        if charge_cap < min_capacity_mAh or discharge_cap < min_capacity_mAh:
            # Mark as incomplete (cycle 0 or merge with adjacent)
            cycle_idx[cycle_idx == cycle] = 0

    return cycle_idx

Handling Multi-Step Protocols

Complex protocols (e.g., CC-CV charge, pulse discharge, GITT) involve multiple sub-steps within a single cycle.

Strategy: Treat sub-steps as part of the same cycle if they share the same charge/discharge direction.

Example: CC-CV Charge:

Step 1: CC Charge (I = 1 A constant)
Step 2: CV Charge (V = 4.2 V constant, I tapers)
Step 3: Rest (I = 0)
Step 4: CC Discharge (I = -1 A constant)

All of Steps 1-2 belong to the charge phase of cycle N. Steps 3-4 belong to cycle N (rest) and the discharge phase of cycle N.

Algorithm:

def group_multistep_cycles(step_type: pd.Series) -> pd.Series:
    """
    Group CC-CV and similar multi-step protocols into single cycle phases.

    Example: ["CC Chg", "CV Chg", "Rest", "CC DChg"] →
             [charge,  charge,   rest,   discharge]
    """
    # Map step types to broad categories
    is_charge_step = _flag_charge(step_type) | step_type.str.contains('CV.*Chg', case=False)
    is_discharge_step = _flag_discharge(step_type) | step_type.str.contains('CV.*DChg', case=False)
    is_rest_step = _flag_rest(step_type)

    # Assign phase labels
    phase = pd.Series('unknown', index=step_type.index)
    phase[is_charge_step] = 'charge'
    phase[is_discharge_step] = 'discharge'
    phase[is_rest_step] = 'rest'

    return phase

Edge Cases and Challenges

1. Formation Cycles

Formation cycles (initial conditioning) often differ from normal cycling:

  • Higher voltage limits
  • Lower current rates
  • Additional rest periods

Challenge: Should formation cycles be numbered separately (Formation 1, 2, ...) or included in the main cycle count?

BatteryProc Convention (current): Treat formation cycles as cycles 1, 2, ... (no special handling).

Proposed Enhancement:

  • Add cycle_type column: 'formation', 'normal', 'diagnostic'
  • Parse from Step Type if available (e.g., "Formation Charge")
  • Infer from cycle characteristics (e.g., first 3 cycles with atypical current/voltage)

2. Incomplete or Truncated Data

Files may be truncated mid-cycle due to:

  • Test interruption
  • File export errors
  • Instrument crashes

Detection:

def detect_incomplete_final_cycle(data: pd.DataFrame, cycle_idx: pd.Series) -> bool:
    """
    Check if the last cycle is incomplete (missing charge or discharge).
    """
    final_cycle = cycle_idx.max()
    final_cycle_data = data[cycle_idx == final_cycle]

    has_charge = (final_cycle_data['current'] > 0).any()
    has_discharge = (final_cycle_data['current'] < 0).any()

    return not (has_charge and has_discharge)

Handling:

  • Flag incomplete cycles in metadata
  • Exclude from cycle-level aggregations (capacity fade, retention)
  • Warn user in log output

3. Capacity Wraps Without Resets

Some instruments accumulate capacity continuously without resetting each cycle:

Cycle 1: Q = 0 → 100 mAh
Cycle 2: Q = 100 → 200 mAh (no reset)
Cycle 3: Q = 200 → 300 mAh

Detection: Check if capacity ever decreases or resets:

capacity_resets = (capacity.diff() < 0).any()
if not capacity_resets:
    logging.info("Capacity is cumulative (no resets detected)")

Handling: Compute per-cycle capacity via differencing:

cycle_capacity = capacity.groupby(cycle_idx).apply(lambda x: x.max() - x.min())

4. Current Noise and Measurement Artifacts

Low-precision instruments or noisy environments can produce:

  • Current oscillations around zero (±0.001 A)
  • Spikes during transitions
  • Dropout/saturation artifacts

Mitigation:

  • Apply moving median filter to current before sign detection
  • Use hysteresis: require current to exceed threshold for N consecutive samples
  • Flag anomalous data points for exclusion
def apply_current_hysteresis(current: pd.Series, threshold=0.01, min_consecutive=5) -> pd.Series:
    """
    Smooth current sign changes using hysteresis and consecutive-sample requirement.
    """
    is_above = current > threshold
    is_below = current < -threshold

    # Require min_consecutive samples in new state before accepting transition
    # (Implementation via rolling window and .all())
    stable_above = is_above.rolling(min_consecutive, min_periods=min_consecutive).sum() == min_consecutive
    stable_below = is_below.rolling(min_consecutive, min_periods=min_consecutive).sum() == min_consecutive

    return stable_above.astype(int) - stable_below.astype(int)  # +1, 0, -1

5. Non-Monotonic Time

Concatenated files or instrument resets can cause time to jump backward:

t = [0, 10, 20, 5, 15, 25]  # Jump backward from 20 → 5

Detection:

time_reversals = np.where(np.diff(time) < 0)[0]
if len(time_reversals) > 0:
    logging.warning(f"Detected {len(time_reversals)} time reversals at indices {time_reversals}")

Handling:

  • Split data into separate segments at each reversal
  • Process each segment independently
  • Concatenate results with offset cycle indices

6. Missing or Inconsistent Step Type Labels

Examples:

  • "OCV_Hold_Rest" (contains "Rest" but is not a rest step per BatteryProc regex)
  • "Pulse" (neither charge nor discharge)
  • Custom user-defined step names

Detection:

unclassified = ~(is_charge | is_discharge | is_rest)
if unclassified.any():
    unique_unclassified = step_type[unclassified].unique()
    logging.warning(f"Unclassified step types: {unique_unclassified}")

Handling:

  • Fall back to current-based detection for unclassified steps
  • Provide configuration option for custom regex patterns
  • Prompt user to map unknown step types to charge/discharge/rest

Implementation Strategy for BatteryProc

Integration with formulas.py

The future formulas.py module (per Battery Metrics Formulas document) should contain:

# formulas.py

def detect_cycle_boundaries(
    data: pd.DataFrame,
    method: str = 'auto',
    current_threshold: float = 0.01,
    min_cycle_duration_s: float = 60,
    validate_with_capacity: bool = True
) -> pd.Series:
    """
    Detect cycle boundaries using multiple signals and fallback strategies.

    Parameters
    ----------
    data : pd.DataFrame
        Must contain 'time', 'current'. Optional: 'voltage', 'capacity', 'Step Type'
    method : str
        'auto' (multi-level), 'step_type', 'current', 'capacity', 'state_machine'
    current_threshold : float
        Threshold (A) for active current (filter rest periods)
    min_cycle_duration_s : float
        Minimum cycle duration (s) to filter spurious transitions
    validate_with_capacity : bool
        Cross-check cycle boundaries with capacity resets if available

    Returns
    -------
    cycle_index : pd.Series
        Integer cycle index (1, 2, 3, ...) for each row

    Notes
    -----
    - Uses multi-level detection strategy
    - Handles rest periods via forward-fill
    - Filters short or incomplete cycles
    """
    # Implementation per algorithms above
    pass


def assign_cycle_phases(
    data: pd.DataFrame,
    cycle_index: pd.Series
) -> pd.DataFrame:
    """
    Classify each row as 'charge', 'discharge', 'rest', or 'unknown'.

    Returns
    -------
    data_with_phases : pd.DataFrame
        Original data with added 'cycle_phase' column
    """
    pass


def validate_cycle_index(
    data: pd.DataFrame,
    cycle_index: pd.Series
) -> dict:
    """
    Validate cycle index quality and return diagnostics.

    Returns
    -------
    diagnostics : dict
        {
            'valid': bool,
            'num_cycles': int,
            'incomplete_cycles': list[int],
            'anomalies': list[str],
            'capacity_alignment': float  # 0-1 score
        }
    """
    pass

Migration from metrics.py

Current State (metrics.py):

  • update_cycle_index(): Step Type-based cycle detection
  • Inline logic within data processing functions

Proposed Refactor:

  1. Move cycle detection logic to formulas.py as standalone functions
  2. Keep update_cycle_index() in metrics.py as a wrapper for backward compatibility
  3. Gradually transition callers to use formulas.detect_cycle_boundaries()

Backward Compatibility:

# metrics.py (after refactor)

def update_cycle_index(record: pd.DataFrame) -> pd.DataFrame:
    """
    Legacy wrapper for cycle detection. Calls formulas.detect_cycle_boundaries().

    Deprecated: Use formulas.detect_cycle_boundaries() directly.
    """
    from batteryproc.formulas import detect_cycle_boundaries

    record = record.copy()
    record['Cycle Index'] = detect_cycle_boundaries(
        record,
        method='step_type',  # Preserve current behavior
        current_threshold=0.01
    )
    return record

Configuration and User Control

Provide CLI flags and config options to control cycle detection behavior:

Proposed CLI Flags:

# Use automatic multi-level detection (default)
batteryproc file.xlsx --cycle-detection auto

# Force current-based detection (ignore Step Type)
batteryproc file.xlsx --cycle-detection current

# Adjust current threshold for rest detection
batteryproc file.xlsx --current-threshold 0.05

# Validate cycle boundaries with capacity resets
batteryproc file.xlsx --validate-cycles

Config File (e.g., batteryproc.yaml):

cycle_detection:
  method: auto  # 'auto', 'step_type', 'current', 'state_machine'
  current_threshold: 0.01  # A
  min_cycle_duration: 60  # seconds
  filter_incomplete: true
  validate_with_capacity: true

Testing Strategy

Unit Tests:

  • Synthetic waveforms with known cycle boundaries
  • Edge cases: single charge, single discharge, no cycles
  • Noise injection (current spikes, time reversals)

Integration Tests:

  • Real Neware, Bio-Logic, CSV files
  • Files with formation cycles, pulse tests, GITT protocols
  • Truncated/corrupted files

Validation Datasets:

  • Create reference dataset with hand-labeled cycle boundaries
  • Compare automated detection to ground truth
  • Metrics: precision, recall, boundary alignment (±N samples)

Example Test:

def test_cycle_detection_synthetic():
    """Test cycle detection on synthetic charge-discharge waveform."""
    # Generate 3 complete cycles
    time = np.arange(0, 3000, 1)  # 3000 seconds
    current = np.zeros_like(time, dtype=float)

    # Cycle 1: 0-1000s (charge 0-500s, rest 500-600s, discharge 600-1000s)
    current[0:500] = 1.0
    current[500:600] = 0.0
    current[600:1000] = -1.0

    # Cycle 2: 1000-2000s
    current[1000:1500] = 1.0
    current[1500:1600] = 0.0
    current[1600:2000] = -1.0

    # Cycle 3: 2000-3000s
    current[2000:2500] = 1.0
    current[2500:2600] = 0.0
    current[2600:3000] = -1.0

    data = pd.DataFrame({'time': time, 'current': current})

    cycle_idx = detect_cycle_boundaries(data, method='current', current_threshold=0.01)

    # Expected: cycles 1, 2, 3 with boundaries at 1000, 2000
    assert cycle_idx.max() == 3
    assert (cycle_idx[:1000] == 1).all()
    assert (cycle_idx[1000:2000] == 2).all()
    assert (cycle_idx[2000:] == 3).all()

Validation and Testing

Metrics for Cycle Detection Quality

1. Boundary Precision: How accurately are cycle starts detected?

def boundary_precision(detected: np.ndarray, ground_truth: np.ndarray, tolerance=5):
    """
    Fraction of detected boundaries within ±tolerance samples of ground truth.
    """
    matches = sum(any(abs(d - gt) <= tolerance for gt in ground_truth) for d in detected)
    return matches / len(detected) if len(detected) > 0 else 0

2. Cycle Count Accuracy: Correct total number of cycles?

def cycle_count_error(detected_max: int, ground_truth_max: int):
    """Absolute error in total cycle count."""
    return abs(detected_max - ground_truth_max)

3. Phase Consistency: Do charge/discharge phases align with current sign?

def phase_consistency(cycle_phase: pd.Series, current: pd.Series):
    """
    Fraction of points where phase label matches current sign.
    """
    phase_matches_current = (
        ((cycle_phase == 'charge') & (current > 0)) |
        ((cycle_phase == 'discharge') & (current < 0)) |
        ((cycle_phase == 'rest') & (np.abs(current) < 0.01))
    )
    return phase_matches_current.mean()

Benchmark Datasets

Create a suite of test files representing common scenarios:

Dataset Description Expected Cycles Challenges
simple_3cycles.csv Clean CC charge/discharge 3 None (baseline)
cccv_charge.xlsx CC-CV charge protocol 5 Multi-step charge
formation_plus_cycling.xlsx 3 formation + 50 normal 53 Different protocols
noisy_current.csv Synthetic data with ±10 mA noise 10 Current noise
truncated_final.xlsx Incomplete last cycle 20 (19 complete) Truncation
gitt_protocol.mpt Galvanostatic intermittent titration 1 (many pulses) Complex protocol
concatenated_files.csv Two files merged, time resets 30 (2 segments) Time non-monotonicity

Validation Workflow:

  1. Hand-label cycle boundaries for each dataset
  2. Run cycle detection with various methods
  3. Compare results using precision/recall metrics
  4. Tune thresholds and filters to maximize accuracy
  5. Document method performance in a test report

References and Best Practices

Academic Literature

  1. "State-of-charge estimation for lithium-ion batteries" Plett, G. L. (2004). IEEE Transactions on Power Electronics. Discusses SOC estimation and cycle segmentation using current integration and Coulomb counting.

  2. "Cycle life prediction of lithium-ion batteries based on data-driven methods" Zhang, Y., et al. (2020). Applied Energy. Reviews methods for cycle counting and life prediction, including capacity fade tracking.

  3. "Battery management systems for large lithium-ion battery packs" Andrea, D. (2010). Artech House. Chapter on cycle counting and SOC tracking in BMS applications.

Industry Standards

  1. IEC 61960-3: Secondary lithium cells and batteries for portable applications Defines test procedures and cycle life definitions.

  2. ISO 12405-4: Electrically propelled road vehicles — Test specification for lithium-ion batteries Specifies cycle counting and performance metrics for EV batteries.

Software Libraries and Tools

  1. PyBAMM (Python Battery Mathematical Modeling) https://github.com/pybamm-team/PyBaMM Physics-based battery models; includes cycle simulation and SOC tracking.

  2. BEEP (Battery Evaluation and Early Prediction) https://github.com/TRI-AMDD/beep Battery data processing pipeline from Toyota Research Institute; includes cycle detection.

  3. Galvani (Bio-Logic .mpt parser) https://github.com/chatcannon/galvani Parses Bio-Logic files; shows how Bio-Logic encodes cycle information.

BatteryProc-Specific References

  1. Battery Metrics Formulas (docs/guides/formulas.md) Mathematical foundations for capacity, energy, and efficiency calculations.

  2. batteryproc/metrics.py (current implementation) update_cycle_index(): Step Type-based cycle detection.

  3. batteryproc/utils.py _flag_charge(), _flag_discharge(), _flag_rest(): Regex-based step classification.


Summary and Recommendations

Key Takeaways

  1. No Single Perfect Method: Cycle detection requires combining multiple signals (current, voltage, capacity, step labels) to handle diverse datasets robustly.

  2. Hierarchy of Reliability: Prefer explicit step labels when available, but implement data-driven fallbacks (current sign analysis) for unlabeled or corrupted data.

  3. Edge Cases are Common: Formation cycles, rest periods, truncated files, and noisy current require careful handling and validation.

  4. Validation is Essential: Cross-check cycle boundaries with capacity resets, voltage trends, and temporal consistency.

Phase 1: Core Functions (formulas.py)

  • Implement detect_cycle_boundaries() with multi-level strategy
  • Support 'step_type', 'current', and 'auto' methods
  • Handle rest periods via forward-fill
  • Filter short/incomplete cycles

Phase 2: Validation and Diagnostics

  • Add validate_cycle_index() with quality metrics
  • Detect anomalies (time reversals, voltage inconsistencies)
  • Generate warnings for incomplete or ambiguous cycles

Phase 3: Advanced Features

  • State machine for complex protocols (GITT, pulse tests)
  • Automatic protocol detection (formation vs. normal cycling)
  • Capacity-based validation and reset detection

Phase 4: Testing and Documentation

  • Create benchmark datasets with ground truth labels
  • Unit tests for each detection method
  • Integration tests with real instrument files
  • Update user documentation with cycle detection options

Phase 5: Migration and Deprecation

  • Refactor metrics.py to call formulas.py functions
  • Maintain backward compatibility during transition
  • Add CLI flags for cycle detection control
  • Deprecate old update_cycle_index() in favor of new API

Revision History

Version Date Author Changes
1.0 2025-10-10 Research Team Initial research document on cycle ID