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¶
- Overview
- The Cycle Identification Problem
- Fundamental Approaches
- Robust Cycle Detection Techniques
- Edge Cases and Challenges
- Implementation Strategy for BatteryProc
- Validation and Testing
- 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:
- Charge phase: Current flows into the battery (I > 0), increasing state of charge (SOC)
- Optional rest: Zero current (I ≈ 0), allowing voltage relaxation
- Discharge phase: Current flows out of the battery (I < 0), decreasing SOC
- 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_typecolumn: '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:
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:
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:
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:
- Move cycle detection logic to
formulas.pyas standalone functions - Keep
update_cycle_index()inmetrics.pyas a wrapper for backward compatibility - 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:
- Hand-label cycle boundaries for each dataset
- Run cycle detection with various methods
- Compare results using precision/recall metrics
- Tune thresholds and filters to maximize accuracy
- Document method performance in a test report
References and Best Practices¶
Academic Literature¶
-
"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.
-
"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.
-
"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¶
-
IEC 61960-3: Secondary lithium cells and batteries for portable applications Defines test procedures and cycle life definitions.
-
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¶
-
PyBAMM (Python Battery Mathematical Modeling) https://github.com/pybamm-team/PyBaMM Physics-based battery models; includes cycle simulation and SOC tracking.
-
BEEP (Battery Evaluation and Early Prediction) https://github.com/TRI-AMDD/beep Battery data processing pipeline from Toyota Research Institute; includes cycle detection.
-
Galvani (Bio-Logic .mpt parser) https://github.com/chatcannon/galvani Parses Bio-Logic files; shows how Bio-Logic encodes cycle information.
BatteryProc-Specific References¶
-
Battery Metrics Formulas (docs/guides/formulas.md) Mathematical foundations for capacity, energy, and efficiency calculations.
-
batteryproc/metrics.py (current implementation)
update_cycle_index(): Step Type-based cycle detection. -
batteryproc/utils.py
_flag_charge(),_flag_discharge(),_flag_rest(): Regex-based step classification.
Summary and Recommendations¶
Key Takeaways¶
-
No Single Perfect Method: Cycle detection requires combining multiple signals (current, voltage, capacity, step labels) to handle diverse datasets robustly.
-
Hierarchy of Reliability: Prefer explicit step labels when available, but implement data-driven fallbacks (current sign analysis) for unlabeled or corrupted data.
-
Edge Cases are Common: Formation cycles, rest periods, truncated files, and noisy current require careful handling and validation.
-
Validation is Essential: Cross-check cycle boundaries with capacity resets, voltage trends, and temporal consistency.
Recommended Implementation Plan¶
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.pyto callformulas.pyfunctions - 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 |