# EV Battery Health Score

As published in <https://dimo.zone/news/dimo-insights-3-ev-battery-health-score>, EV driving data can be scored to indicate how the battery has been treated. Batteries that have been supercharged (charged at a level 3 charger) frequently, frequently have a state of charge (`soc`) outside of 20% to 80%, and frequently discharge the battery nearly all the way to 0% (depth of discharge, which is `1 - state of charge`) will tend to reduce their full capacity faster than vehicles that are rarely supercharged, generally keep state of charge between 20% and 80% and the average depth of discharge is small.

### Driving outside of 20% to 80% state of charge

For an example vehicle, using this code, we find that this vehicle is driven while under 20% state of charge for 3.5% of the total miles drive and driven while over 80% state of charge for 8.37% of the total miles.&#x20;

{% code overflow="wrap" lineNumbers="true" %}

```python
gdf = gdf.sort_values('timestamp').reset_index(drop = True)
gdf['distance_added'] = gdf['odometer'].diff()

vals = []
for left, right in zip(np.arange(0,0.95,0.05), np.arange(0.05,0.951,0.05)):
     vals.append(gdf['distance_added'].loc[(gdf['soc'] >= round(left,2)) & (gdf['soc'] < round(right,2))].sum())
vals.append(temp['distance_added'].loc[(temp['soc'] >= 0.95)].sum())

lst = []

lst += (['(' + str(round(left,2)) +', ' + str(round(right,2)) + ']' + '_all' for left, right in zip(np.arange(0,0.95,0.05), np.arange(0.05,0.951,0.05))])
lst.append('[0.95, 1.0]_all')

miles_with_charges = pd.DataFrame(columns = lst)

miles_with_charges.loc[0] = vals

pct_20_to_80 = 100* miles_with_charges[miles_with_charges.columns[4:16]].sum(axis = 1)[0] / miles_with_charges.sum(axis = 1)

pct_0_to_20 = 100* miles_with_charges[miles_with_charges.columns[0:4]].sum(axis = 1)[0] / miles_with_charges.sum(axis = 1)

pct_80_to_100 = 100* miles_with_charges[miles_with_charges.columns[16:]].sum(axis = 1)[0] / miles_with_charges.sum(axis = 1)

```

{% endcode %}

<figure><img src="https://3212476574-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Foh9c3w7RI0Bl5tVCxcyz%2Fuploads%2FGzaTorG63O5TNiBCSsNt%2Fstate_of_charge_total_driving_cumsum.jpg?alt=media&#x26;token=45ce7310-d004-4cc4-8b14-97a4bdd2c250" alt=""><figcaption><p>Percentage of total miles driven with at least the given state of charge for an example vehicle</p></figcaption></figure>

<figure><img src="https://3212476574-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Foh9c3w7RI0Bl5tVCxcyz%2Fuploads%2FaHFCmAmK7GsVxPSlgOMw%2Fstate_of_charge_total_driving_cumsum.jpg?alt=media&#x26;token=fbcdd088-724a-4ec0-8f39-4219c468a334" alt=""><figcaption><p>Percentage of total miles driven with a specific state of charge (in bins of size 5%) for an example vehicle</p></figcaption></figure>

In this manner, we have quantified how frequently the vehicle is driven while under- and over-charged as a percentage of the total amount of driving.

### Charging At Level 3 Chargers

In this code, range added serves as a proxy for energy added, because [charger.power](https://docs.dimo.org/dimo-explorer/field-descriptions-in-detail/electric-vehicle-tesla-only/charger.power "mention") does not exist before June 2023, so many charging sessions will not have it. Further, the approximate range added per hour for a level 1 charger is assumed to be between 0 and 8 kilometers per hour, a level 2 charger is assumed to be between 8 and 60 kilometers per hour, and a level 3 charger is assumed to be 60 or more kilometers per hour.

For an example vehicle, the percent of range charged from level 3 chargers is 8.53% out of the total. This vehicle added 25814.09 kilometers from level 1 chargers, 4412.35 kilometers from level 2 chargers, and 2818.46 kilometers from level 3 chargers.

{% code overflow="wrap" lineNumbers="true" %}

```python
charging_df = calculate_charging_sessions(gdf, urban, states, counties)

charging_df['avg_range_per_hour'] = charging_df['net_range_added']/ (charging_df['time_charging']/np.timedelta64('1','h'))

charging_df['Charger_Level'] = pd.cut(charging_df['avg_range_per_hour'], bins = [0,8,60,np.Inf], labels = ['Level_1','Level_2','Level_3'])

charging_levels_gb =charging_df.groupby('Charger_Level')['net_range_added'].sum()

level_3_charging_pct = 100*charging_levels_gb.loc['Level_3']/charging_levels_gb.sum()
```

{% endcode %}

### Depth of Discharge

Since every charging session is captured in `charging_df`, as above, the depth of discharge reached during vehicle operation is `1-soc` when a charging session begins. For an example vehicle, the average depth of discharge is 59.85% (meaning the average `soc` when the vehicle begins to charge is 40.15%). &#x20;

{% code overflow="wrap" lineNumbers="true" %}

```python
avg_dod_when_charging_starts_pct = 100-100*charging_df['start_soc'].mean()
```

{% endcode %}

### Creating a score

These four pieces of information about the electric vehicle's battery can be used to score the treatment of the battery, for example:

```python
w_supercharging = 0.20
w_undercharged  = 0.20
w_overcharged   = 0.30
w_avg_dod = 0.3

score = (100 - pct_0_to_20)*w_undercharged
      + (100 - pct_80_to_100)*w_overcharged
      + (100 - level_3_charging_pct)*w_supercharging
      + (100 - avg_dod_when_charging_starts_pct)*w_avg_dod
```

For an example vehicle with pct\_0\_to\_20 = 3.50, pct\_80\_to\_100 = 8.37,  level\_3\_charging\_pct = 8.53, and avg\_dod\_when\_charging\_starts\_pct = 59.85, the vehicle's battery health score is 77.128, where 100 would indicate the best battery health score and 0 would indicate the worst possible battery health score.

### Code Appendix

{% code overflow="wrap" lineNumbers="true" %}

```python
def calculate_charging_sessions(gdf):
    # This function calculates charging sessions and joins against 
    # Input:
    #     gdf: a GeoDataFrame
    # Output:
    #     aggregated charging sessions dataframe. 

    gdf.loc[:,'lat_rounded_2'] = gdf.loc[:,'latitude'].round(decimals=2)
    gdf.loc[:,'lng_rounded_2'] = gdf.loc[:,'longitude'].round(decimals=2)

    gdf['rounded_point'] = gpd.GeoSeries.from_xy(gdf['lng_rounded_2'],gdf['lat_rounded_2'])
    gdf['timestamp'] = pd.to_datetime(gdf['timestamp'])
    gdf = gdf.sort_values('timestamp').reset_index(drop = True)
    gdf['timestamp_diff'] = gdf['timestamp'].diff()
    
    gdf['charging_event_id'] = gdf['charging'].diff().cumsum().fillna(0)
    gdf['delta_soc_per_minute'] = gdf['soc'].diff()/(gdf['timestamp'].diff()/np.timedelta64(1, 'm'))
    
    if 'batteryCapacity' in gdf.columns:
        avg_battery_capacity = gdf['batteryCapacity'].mean() 
    else:
        avg_battery_capacity = np.nan
        
    gb = gdf.loc[gdf['charging']].groupby('charging_event_id')
    charging_df = gpd.GeoDataFrame(gb['timestamp'].max() - gb['timestamp'].min())
    charging_df.columns = ['time_charging']
    
    charging_df['start_time'] = gb['timestamp'].first()
    charging_df['end_time'] = gb['timestamp'].nth(-1)
    
    if 'batteryCapacity' in gdf.columns:
        charging_df['batteryCapacity'] = gb['batteryCapacity'].first().fillna(avg_battery_capacity)
    else:
        charging_df['batteryCapacity'] = np.nan
    
    charging_df['start_location'] = gb['geometry'].first()
    charging_df['end_location'] = gb['geometry'].nth(-1)
    charging_df['start_location'].crs = 'EPSG:4326'
    charging_df['end_location'].crs = 'EPSG:4326'
    charging_df['diff_start_end_location_m'] = charging_df['end_location'].to_crs('EPSG:6340')\
                                            .distance(charging_df['start_location'].to_crs('EPSG:6340'))
    charging_df['start_location'] = gb['geometry'].first()
    charging_df['end_location'] = gb['geometry'].nth(-1)

    
    charging_df['start_soc'] = gb['soc'].first()
    charging_df['end_soc'] = gb['soc'].nth(-1)
    charging_df['net_charge_added'] = charging_df['end_soc']- charging_df['start_soc']
    charging_df['max_charge_added'] = gb['soc'].max() -gb['soc'].min()

    charging_df['max_charging_rate'] = gb['delta_soc_per_minute'].max()
    charging_df['min_charging_rate'] = gb['delta_soc_per_minute'].min()
    charging_df['mean_charging_rate'] = gb['delta_soc_per_minute'].mean()
    charging_df['overall_mean_charging_rate'] =\
            (charging_df['end_soc'] - charging_df['start_soc'])/\
                (charging_df['time_charging']/np.timedelta64(1, 'm'))

    charging_df['start_range'] = gb['range'].first()
    charging_df['end_range'] = gb['range'].nth(-1)
    charging_df['net_range_added'] = charging_df['end_range']- charging_df['start_range']
    charging_df['max_range_added'] = gb['range'].max() -gb['range'].min()

    return charging_df

```

{% endcode %}
