Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ ENV/
env.bak/
venv.bak/

# VSCode settings
.vscode/

# Spyder project settings
.spyderproject
.spyproject
Expand Down
2 changes: 1 addition & 1 deletion battdat/io/arbin.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def read_file(self, file: str, file_number: int = 0, start_cycle: int = 0,
# TODO (wardlt): This function should move to post-processing
def compute_state(x):
if abs(x) < 1e-6:
return ChargingState.hold
return ChargingState.rest
return ChargingState.charging if x > 0 else ChargingState.discharging

df_out['state'] = df_out['current'].apply(compute_state)
Expand Down
2 changes: 1 addition & 1 deletion battdat/io/maccor.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def _parse_time(time: str) -> float:
df_out['time'] = df['DPt Time'].apply(_parse_time)

# 0 is rest, 1 is charge, -1 is discharge
df_out.loc[df_out['state'] == 'R', 'state'] = ChargingState.hold
df_out.loc[df_out['state'] == 'R', 'state'] = ChargingState.rest
df_out.loc[df_out['state'] == 'C', 'state'] = ChargingState.charging
df_out.loc[df_out['state'] == 'D', 'state'] = ChargingState.discharging
df_out.loc[df_out['state'].apply(lambda x: x not in {'R', 'C', 'D'}), 'state'] = ChargingState.unknown
Expand Down
56 changes: 46 additions & 10 deletions battdat/postprocess/integral.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def __init__(self, reuse_integrals: bool = True):
"""

Args:
reuse_integrals: Whether to reuse the ``cycle_capacity`` and ``cycle_energy`` if they are available
reuse_integrals: Whether to reuse the ``cycled_charge`` and ``cycled_energy`` if they are available
"""
self.reuse_integrals = reuse_integrals

Expand Down Expand Up @@ -87,9 +87,9 @@ def _summarize(self, raw_data: pd.DataFrame, cycle_data: pd.DataFrame):
continue

# Perform the integration
if self.reuse_integrals and 'cycle_energy' in cycle_subset.columns and 'cycle_capacity' in cycle_subset.columns:
capacity_change = cycle_subset['cycle_capacity'].values * 3600 # To A-s
energy_change = cycle_subset['cycle_energy'].values * 3600 # To J
if self.reuse_integrals and 'cycled_energy' in cycle_subset.columns and 'cycled_charge' in cycle_subset.columns:
capacity_change = cycle_subset['cycled_charge'].values * 3600 # To A-s
energy_change = cycle_subset['cycled_energy'].values * 3600 # To J
else:
capacity_change = cumulative_trapezoid(cycle_subset['current'], x=cycle_subset['test_time'])
energy_change = cumulative_trapezoid(cycle_subset['current'] * cycle_subset['voltage'], x=cycle_subset['test_time'])
Expand Down Expand Up @@ -132,12 +132,45 @@ class StateOfCharge(RawDataEnhancer):
The energy change is determined by integrating the product
of current and voltage.

Output dataframe has 2 new columns:
- ``cycle_capacity``: Amount of charge charged since the beginning of the cycle, in A-hr
- ``cycle_energy``: Amount of energy charged since the beginning of the cycle, in J
Output dataframe has 3 new columns:
- ``cycled_charge``: Amount of observed charge cycled since the beginning of the cycle, in A-hr
- ``cycled_energy``: Amount of observed energy cycled since the beginning of the cycle, in W-hr
- ``CE_charge``: Amount of charge in the battery relative to the beginning of the cycle, accounting for Coulombic
Efficiency, in A-hr
"""
def __init__(self, coulombic_efficiency: float = 1.0):
"""
Args:
coulombic_efficiency: Coulombic efficiency to use when computing the state of charge
"""
self.coulombic_efficiency = coulombic_efficiency

column_names = ['cycle_capacity', 'cycle_energy']
@property
def coulombic_efficiency(self) -> float:
return self._ce

@coulombic_efficiency.setter
def coulombic_efficiency(self, value: float):
if value < 0 or value > 1:
raise ValueError('Coulombic efficiency must be between 0 and 1')
self._ce = value

@property
def column_names(self) -> List[str]:
return ['cycled_charge', 'cycled_energy', 'CE_charge']

def _get_CE_adjusted_curr(self, current: np.ndarray) -> np.ndarray:
"""Adjust the current based on the coulombic efficiency

Args:
current: Current array in A

Returns:
Adjusted current array in A
"""
adjusted_current = current.copy()
adjusted_current[current > 0] *= self.coulombic_efficiency
return adjusted_current

def enhance(self, data: pd.DataFrame):
# Add columns for the capacity and energy
Expand All @@ -153,9 +186,12 @@ def enhance(self, data: pd.DataFrame):
cycle_subset = ordered_copy.iloc[start_ind:stop_ind]

# Perform the integration
ce_adj_curr = self._get_CE_adjusted_curr(cycle_subset['current'].to_numpy())
capacity_change = cumulative_trapezoid(cycle_subset['current'], x=cycle_subset['test_time'], initial=0)
ce_charge = cumulative_trapezoid(ce_adj_curr, x=cycle_subset['test_time'], initial=0)
energy_change = cumulative_trapezoid(cycle_subset['current'] * cycle_subset['voltage'], x=cycle_subset['test_time'], initial=0)

# Store them in the raw data
data.loc[cycle_subset['index'], 'cycle_capacity'] = capacity_change / 3600 # To A-hr
data.loc[cycle_subset['index'], 'cycle_energy'] = energy_change / 3600 # To W-hr
data.loc[cycle_subset['index'], 'cycled_charge'] = capacity_change / 3600 # To A-hr
data.loc[cycle_subset['index'], 'CE_charge'] = ce_charge / 3600 # To A-hr
data.loc[cycle_subset['index'], 'cycled_energy'] = energy_change / 3600 # To W-hr
49 changes: 30 additions & 19 deletions battdat/postprocess/tagging.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,16 @@ class AddMethod(RawDataEnhancer):
of these points then assigning regions to constant voltage or current if one varied
more than twice the other.
"""
def __init__(self, short_period_threshold: float = 30.0):
"""
Args:
short_period_threshold: Maximum duration of a step to be considered a short step, in seconds
"""
self.short_period_threshold = short_period_threshold

column_names = ['method']
@property
def column_names(self) -> List[str]:
return ['method']

def enhance(self, df: pd.DataFrame):
# Insert a new column into the dataframe, starting with everything marked as other
Expand All @@ -43,23 +51,26 @@ def enhance(self, df: pd.DataFrame):
ind = cycle.index.values
state = cycle['state'].values

if len(ind) < 5 and state[0] == ChargingState.hold:
# if there's a very short rest (less than 5 points)
# we label as "anomalous rest"
df.loc[ind, 'method'] = ControlMethod.short_rest
elif state[0] == ChargingState.hold:
# if there are 5 or more points it's a
# standard "rest"
if t[-1] - t[0] < self.short_period_threshold:
# The step is shorter than 30 seconds
if state[0] == ChargingState.rest:
# If the step is a rest, we label it as a short rest
df.loc[ind, 'method'] = ControlMethod.short_rest
elif len(ind) < 5:
# The step contains fewer than 5 data points, so it is innapropriate to label it as anything
# definitive other than a short non-rest
df.loc[ind, 'method'] = ControlMethod.short_nonrest
else:
# The step is a pulse
df.loc[ind, 'method'] = ControlMethod.pulse
elif state[0] == ChargingState.rest:
# This is a standard rest, which lasts longer than 30 seconds
df.loc[ind, 'method'] = ControlMethod.rest
elif len(ind) < 5:
# if it's a charge or discharge and there
# are fewer than 5 points it is an
# "anomalous charge or discharge"
df.loc[ind, 'method'] = ControlMethod.short_nonrest
elif t[-1] - t[0] < 30:
# if the step is less than 30 seconds
# index as "pulse"
df.loc[ind, 'method'] = ControlMethod.pulse
# The step spans over 30 seconds, but has fewer than 5 data points, rendering inadequate for control
# method determination
df.loc[ind, 'method'] = ControlMethod.unknown

else:
# Normalize the voltage and current before determining which one moves "more"
for x in [voltage, current]:
Expand Down Expand Up @@ -191,7 +202,7 @@ def _determine_steps(df: DataFrame, column: str, output_col: str):
def _determine_state(
row: pd.Series,
zero_threshold: float = 1.0e-4
) -> Literal[ChargingState.charging, ChargingState.discharging, ChargingState.hold]:
) -> Literal[ChargingState.charging, ChargingState.discharging, ChargingState.rest]:
"""
Function to help determine the state of the cell based on the current

Expand All @@ -200,11 +211,11 @@ def _determine_state(
zero_threshold: Maximum absolute value a current can take to be assigned rest. Defaults to 0.1 mA

Returns
State of the cell, which can be either 'charging', 'discharging', or 'hold'
State of the cell, which can be either 'charging', 'discharging', or 'rest'
"""
current = row['current']
if abs(current) <= zero_threshold:
return ChargingState.hold
return ChargingState.rest
elif current > 0.:
return ChargingState.charging
return ChargingState.discharging
16 changes: 11 additions & 5 deletions battdat/schemas/column.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class ChargingState(str, Enum):
"""Potential charging states of the battery"""

charging = "charging"
hold = "hold"
rest = "resting"
discharging = "discharging"
unknown = "unknown"

Expand All @@ -20,19 +20,25 @@ class ControlMethod(str, Enum):
"""Method used to control battery during a certain step"""

short_rest = "short_rest"
"""A very short rest period. Defined as a step with 4 or fewer measurements with near-zero current"""
"""A very short rest period.
Defined as a step with with near-zero current lasting for a short period of time, which defaults to 30 seconds."""
rest = "rest"
"""An extended period of neither charging nor discharging"""
short_nonrest = "short_nonrest"
"""A very short period of charging or discharging. Defined as a step with 4 or fewer measurements with at least one non-zero current."""
"""A very short period of charging or discharging.
Defined as a step with a non-zero current lasting for a short period of time (defaults to 30 seconds), but with
fewer than 5 data points."""
pulse = "pulse"
"""A short period of a large current lasting for a short period of time, which defaults to 30 seconds.
Must contain at least 5 data points."""
constant_current = "constant_current"
"""A step where the current is held constant"""
constant_voltage = "constant_voltage"
"""A step where the voltage is held constant"""
constant_power = "constant_power"
"""A step where the power is held constant"""
pulse = "pulse"
"""A short period of a large current"""
unknown = "unknown"
"""A step where the control method is not known"""
other = "other"


Expand Down
72 changes: 43 additions & 29 deletions docs/user-guide/post-processing/cell-capacity.ipynb

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions tests/files/example-data/resistor-only_complex-cycling.ipynb

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions tests/files/example-data/resistor-only_simple-cycling.ipynb

Large diffs are not rendered by default.

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
8 changes: 4 additions & 4 deletions tests/postprocess/test_integral.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ def test_capacity(file_path, from_charged):
assert np.isclose(raw_data.drop_duplicates('cycle_number', keep='first')[soc.column_names], 0).all()

# Last cell of the capacity should be zero for our test cases
assert np.isclose(raw_data['cycle_capacity'].iloc[-1], 0., atol=1e-3)
assert np.isclose(raw_data['cycled_charge'].iloc[-1], 0., atol=1e-3)

# The capacity for the first few steps should be I*t/3600s
first_steps = raw_data.iloc[:3]
current = first_steps['current'].iloc[0]
assert np.isclose(first_steps['cycle_capacity'], current * first_steps['test_time'] / 3600).all()
assert np.isclose(first_steps['cycled_charge'], current * first_steps['test_time'] / 3600).all()

# The energy for the first few steps should be
# discharging = I * \int_0^t (2.9 - t/3600) = I * (2.9t - t^2/7200)
Expand All @@ -70,7 +70,7 @@ def test_capacity(file_path, from_charged):
else:
answer = current * (2.1 * first_steps['test_time'] + first_steps['test_time'] ** 2 / 7200)
assert (answer[1:] > 0).all()
assert np.isclose(first_steps['cycle_energy'], answer / 3600, rtol=1e-3).all()
assert np.isclose(first_steps['cycled_energy'], answer / 3600, rtol=1e-3).all()


def test_against_battery_data_gov(file_path):
Expand Down Expand Up @@ -102,7 +102,7 @@ def test_reuse_integrals(file_path):

# Compute the integrals then intentionally increase capacity and energy 2x
StateOfCharge().compute_features(example_data)
for c in ['cycle_energy', 'cycle_capacity']:
for c in ['cycled_energy', 'cycled_charge']:
example_data.tables['raw_data'][c] *= 2

# Recompute capacity and energy measurements, which should have increased by 2x
Expand Down
10 changes: 5 additions & 5 deletions tests/postprocess/test_tagging.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ def synthetic_data() -> BatteryDataset:
# Make the segments
rest_v = [3.5] * 16
rest_i = [0.] * 16
rest_s = [ChargingState.hold] * 16
rest_s = [ChargingState.rest] * 16
discharge_v = np.linspace(3.5, 3.25, 16)
discharge_i = [-0.125] * 16
discharge_s = [ChargingState.discharging] * 16
shortrest_v = [3.25] * 4
shortrest_i = [0] * 4
shortrest_s = [ChargingState.hold] * 4
shortrest_s = [ChargingState.rest] * 4
shortnon_v = [3.25] * 4
shortnon_i = [-0.1] * 4
shortnon_s = [ChargingState.discharging] * 4
Expand Down Expand Up @@ -105,10 +105,10 @@ def test_state_detection(synthetic_data):
AddState().enhance(data=raw_data)

# assert False, len(synthetic_data.raw_data)
assert (raw_data['state'].iloc[:16] == ChargingState.hold).all(), raw_data['state'].iloc[:16]
assert (raw_data['state'].iloc[:16] == ChargingState.rest).all(), raw_data['state'].iloc[:16]
assert (raw_data['state'].iloc[16:32] == ChargingState.discharging).all(), raw_data['state'].iloc[16:32].to_numpy()
assert (raw_data['state'].iloc[32:36] == ChargingState.hold).all()
assert (raw_data['state'].iloc[32:36] == ChargingState.rest).all()
assert (raw_data['state'].iloc[36:40] == ChargingState.discharging).all()
assert (raw_data['state'].iloc[40:48] == ChargingState.charging).all()
assert (raw_data['state'].iloc[48:52] == ChargingState.hold).all()
assert (raw_data['state'].iloc[48:52] == ChargingState.rest).all()
assert (raw_data['state'].iloc[52:] == ChargingState.charging).all()
2 changes: 1 addition & 1 deletion tests/schemas/test_cycling.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def example_df() -> pd.DataFrame:
'test_time': [0, 0.1],
'voltage': [0.1, 0.2],
'current': [0.1, -0.1],
'state': ['charging', 'hold']
'state': ['charging', 'resting']
})


Expand Down
Loading