feat: add battery soh record

This commit is contained in:
Florian Bezannier
2023-11-14 14:49:17 +01:00
committed by Florian BEZANNIER
parent cddd0ad790
commit 38040130ea
9 changed files with 254 additions and 48 deletions
@@ -3,6 +3,7 @@ import threading
from datetime import datetime, timedelta, timezone
from json import JSONEncoder
from hashlib import md5
from sqlite3.dbapi2 import IntegrityError
from oauth2_client.credentials_manager import ServiceInformation
from urllib3.exceptions import InvalidHeader
@@ -194,17 +195,27 @@ class PSAClient:
Database.record_position(self.weather_api, car.vin, mileage, latitude, longitude, altitude, date, level,
level_fuel, moving)
self.abrp.call(car, Database.get_last_temp(car.vin))
try:
charging_status = car.status.get_energy('Electric').charging.status
charging_mode = car.status.get_energy('Electric').charging.charging_mode
charging_rate = car.status.get_energy('Electric').charging.charging_rate
autonomy = car.status.get_energy('Electric').autonomy
Charging.record_charging(car, charging_status, charge_date, level, latitude, longitude, self.country_code,
charging_mode, charging_rate, autonomy, mileage)
logger.debug("charging_status:%s ", charging_status)
except AttributeError as ex:
logger.error("charging status not available from api")
logger.debug(ex)
if car.has_battery():
electric_energy_status = car.status.get_energy('Electric')
try:
charging_status = electric_energy_status.charging.status
charging_mode = electric_energy_status.charging.charging_mode
charging_rate = electric_energy_status.charging.charging_rate
autonomy = electric_energy_status.autonomy
Charging.record_charging(car, charging_status, charge_date, level, latitude, longitude,
self.country_code,
charging_mode, charging_rate, autonomy, mileage)
logger.debug("charging_status:%s ", charging_status)
except AttributeError as ex:
logger.error("charging status not available from api")
logger.debug(ex)
try:
soh = electric_energy_status.battery.health.resistance
Database.record_battery_soh(car.vin, charge_date, soh)
except IntegrityError:
logger.debug("SOH already recorded")
except AttributeError as ex:
logger.debug("Failed to record SOH: %s", ex)
def __iter__(self):
for key, value in self.__dict__.items():
@@ -0,0 +1,10 @@
from datetime import datetime
from typing import List
class BatterySoh:
def __init__(self, vin, dates, levels):
self.vin = vin
self.dates: List[datetime] = dates
self.levels: List[float] = levels
+30 -1
View File
@@ -13,6 +13,7 @@ from geojson import dumps as geo_dumps
from psa_car_controller.common import utils
from psa_car_controller.psacc.model.battery_curve import BatteryCurveDto
from psa_car_controller.psacc.model.battery_soh import BatterySoh
from psa_car_controller.psacc.model.charge import Charge
from psa_car_controller.psacc.utils.utils import get_temp
@@ -103,6 +104,8 @@ class Database:
"start_level INTEGER, end_level INTEGER, co2 INTEGER, kw INTEGER);")
conn.execute("""CREATE TABLE IF NOT EXISTS battery_curve (start_at DATETIME, VIN TEXT, date DATETIME,
level INTEGER, UNIQUE(start_at, VIN, level));""")
conn.execute("""CREATE TABLE IF NOT EXISTS
battery_soh(date DATETIME, VIN TEXT, level FLOAT, UNIQUE(VIN, level));""")
table_to_update = [["position", NEW_POSITION_COLUMNS],
["battery", NEW_BATTERY_COLUMNS],
["battery_curve", NEW_BATTERY_CURVE_COLUMNS]]
@@ -182,7 +185,7 @@ class Database:
battery_curves = []
res = conn.execute("""SELECT date, level, rate, autonomy
FROM battery_curve
WHERE start_at=? and date<=? and VIN=?
WHERE start_at=? and date<=? and VIN=?
ORDER BY date asc;""",
(start_at, stop_at, vin)).fetchall()
for row in res:
@@ -267,6 +270,32 @@ class Database:
logger.debug("position already saved")
return False
@staticmethod
def record_battery_soh(vin: str, date: datetime, level: float):
conn = Database.get_db()
conn.execute("INSERT INTO battery_soh(date, VIN, level) VALUES(?,?,?)", (date, vin, level))
conn.commit()
conn.close()
@staticmethod
def get_soh_by_vin(vin) -> BatterySoh:
conn = Database.get_db()
res = conn.execute("SELECT date, level FROM battery_soh WHERE VIN=? ORDER BY date", (vin,)).fetchall()
dates = []
levels = []
for row in res:
dates.append(row[0])
levels.append(row[1])
return BatterySoh(vin, dates, levels)
@staticmethod
def get_last_soh_by_vin(vin) -> float:
conn = Database.get_db()
res = conn.execute("SELECT level FROM battery_soh WHERE VIN=? ORDER BY date DESC LIMIT 1", (vin,)).fetchall()
if res:
return res[0][0]
return None
@staticmethod
def get_last_charge(vin) -> Charge:
conn = Database.get_db()
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
width="24"
height="24"
version="1.1"
id="svg2"
sodipodi:docname="battery-soh.svg"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="50.333333"
inkscape:cx="12"
inkscape:cy="12"
inkscape:window-width="2530"
inkscape:window-height="1412"
inkscape:window-x="30"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<path
fill="none"
d="M0 0h24v24H0z"
id="path1" />
<path
d="M 11,6.0397351 H 7 V 20.039735 H 17 V 6.0397351 h -4 v -2 h -2 z m -2,-2 v -1 c 0,-0.5522847 0.4477153,-1 1,-1 h 4 c 0.552285,0 1,0.4477153 1,1 v 1 h 3 c 0.552285,0 1,0.4477153 1,1 V 21.039735 c 0,0.552285 -0.447715,1 -1,1 H 6 c -0.5522847,0 -1,-0.447715 -1,-1 V 5.0397351 c 0,-0.5522847 0.4477153,-1 1,-1 z"
id="path2"
sodipodi:nodetypes="ccccccccccsssscssssssssc" />
<rect
style="fill:#000000;stroke-width:0.83317"
id="rect4"
width="1.9867547"
height="8.543046"
x="11.026489"
y="8.9602642"
ry="0.72471911" />
<rect
style="fill:#000000;stroke-width:0.795508"
id="rect4-3"
width="1.9470197"
height="7.947021"
x="12.311257"
y="-15.999998"
ry="0.67415732"
transform="rotate(90)" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+36 -25
View File
@@ -1,9 +1,11 @@
import logging
from collections import OrderedDict
import dash_bootstrap_components as dbc
from dash import html
from psa_car_controller.psacc.application.psa_client import PSAClient
from psa_car_controller.psacc.repository.db import Database
from psa_car_controller.web.tools.Button import Button
from psa_car_controller.web.tools.Switch import Switch
from psa_car_controller.web.tools.utils import card_value_div, create_card
@@ -33,31 +35,40 @@ def get_control_tabs(config):
myp: PSAClient = config.myp
el = []
buttons_row = []
if config.remote_control and car.status is not None:
try:
preconditionning_state = car.status.preconditionning.air_conditioning.status != "Disabled"
charging_state = car.status.get_energy('Electric').charging.status == "InProgress"
cards = {"Battery": {"text": [card_value_div("battery_value", "%",
value=convert_value_to_str(
car.status.get_energy('Electric').level))],
"src": "assets/images/battery-charge.svg"},
"Mileage": {"text": [card_value_div("mileage_value", "km",
value=convert_value_to_str(
car.status.timed_odometer.mileage))],
"src": "assets/images/mileage.svg"}
}
el.append(dbc.Container(dbc.Row(children=create_card(cards)), fluid=True))
refresh_date = car.status.get_energy('Electric').updated_at.astimezone().strftime("%X %x")
buttons_row.extend([Button(REFRESH_SWITCH, car.vin,
html.Div([html.Img(src="assets/images/sync.svg", width="50px"),
refresh_date]),
myp.remote_client.wakeup).get_html(),
Switch(CHARGE_SWITCH, car.vin, "Charge", myp.remote_client.charge_now,
charging_state).get_html(),
Switch(PRECONDITIONING_SWITCH, car.vin, "Preconditioning",
myp.remote_client.preconditioning, preconditionning_state).get_html()])
except (AttributeError, TypeError):
logger.exception("get_control_tabs:")
if car.status is not None:
cards = OrderedDict({"Battery SOC": {"text": [card_value_div("battery_value", "%",
value=convert_value_to_str(
car.status.get_energy('Electric').level))],
"src": "assets/images/battery-charge.svg"},
"Mileage": {"text": [card_value_div("mileage_value", "km",
value=convert_value_to_str(
car.status.timed_odometer.mileage))],
"src": "assets/images/mileage.svg"}
})
soh = Database.get_last_soh_by_vin(car.vin)
if soh:
cards["Battery SOH"] = {"text": [card_value_div("battery_soh_value", "%",
value=convert_value_to_str(
soh))],
"src": "assets/images/battery-soh.svg"}
cards.move_to_end("Mileage")
el.append(dbc.Container(dbc.Row(children=create_card(cards)), fluid=True))
if config.remote_control:
try:
preconditionning_state = car.status.preconditionning.air_conditioning.status != "Disabled"
charging_state = car.status.get_energy('Electric').charging.status == "InProgress"
refresh_date = car.status.get_energy('Electric').updated_at.astimezone().strftime("%X %x")
buttons_row.extend([Button(REFRESH_SWITCH, car.vin,
html.Div([html.Img(src="assets/images/sync.svg", width="50px"),
refresh_date]),
myp.remote_client.wakeup).get_html(),
Switch(CHARGE_SWITCH, car.vin, "Charge", myp.remote_client.charge_now,
charging_state).get_html(),
Switch(PRECONDITIONING_SWITCH, car.vin, "Preconditioning",
myp.remote_client.preconditioning, preconditionning_state).get_html()])
except (AttributeError, TypeError):
logger.exception("get_control_tabs:")
if not config.offline:
buttons_row.append(Switch(ABRP_SWITCH, car.vin, "Send data to ABRP", myp.abrp.enable_abrp,
car.vin in config.myp.abrp.abrp_enable_vin).get_html())
+18
View File
@@ -37,3 +37,21 @@ ELECTRIC_CAR_STATUS = {
"vehicles": {
"href": "https://api.groupe-psa.com/connectedcar/v4/user/vehicles/aa"}},
"odometer": {"createdAt": None, "mileage": 3196.5}, "updatedAt": "2022-03-26T11:02:54Z"}
ELECTRIC_CAR_STATUS_V2 = {
"lastPosition": {"type": "Feature", "geometry": {"type": "Point", "coordinates": [-1.59008, 47.274, 30]},
"properties": {"updatedAt": "2021-03-29T06:22:51Z", "type": "Acquire", "signalQuality": 9}},
"preconditionning": {"airConditioning": {"updatedAt": "2022-03-26T10:52:11Z", "status": "Disabled"}},
"energy": [{"createdAt": "2021-09-14T20:39:06Z", "type": "Fuel", "level": 0},
{"createdAt": "2022-03-26T11:02:54Z", "type": "Electric", "level": 59, "autonomy": 122,
"charging": {"plugged": False, "status": "Disconnected", "remainingTime": "PT0S", "chargingRate": 0,
"chargingMode": "No", "nextDelayedTime": "PT22H31M"},
"battery": {"health": {"resistance": 90}}}],
"createdAt": "2022-03-26T11:02:54Z",
"battery": {"voltage": 83.5, "current": 0, "createdAt": "2022-03-26T10:52:11Z"},
"kinetic": {"createdAt": "2021-03-29T06:22:51Z", "moving": True},
"privacy": {"createdAt": "2022-03-26T11:02:53Z", "state": "None"},
"service": {"type": "Electric", "createdAt": "2022-03-26T11:02:54Z"}, "_links": {"self": {
"href": "https://api.groupe-psa.com/connectedcar/v4/user/vehicles/aa/status"},
"vehicles": {
"href": "https://api.groupe-psa.com/connectedcar/v4/user/vehicles/aa"}},
"odometer": {"createdAt": None, "mileage": 3196.5}, "updatedAt": "2022-03-26T11:02:54Z"}
+27
View File
@@ -0,0 +1,27 @@
import unittest
from sqlite3.dbapi2 import IntegrityError
from psa_car_controller.psacc.model.battery_soh import BatterySoh
from psa_car_controller.psacc.repository.db import Database
from tests.utils import get_new_test_db, compare_dict, get_date, vehicule_list
class TestUnit(unittest.TestCase):
def test_record_soh(self):
get_new_test_db()
car = vehicule_list[0]
soh_list = [99.0, 96.0, 90.2]
for x in range(len(soh_list)):
Database.record_battery_soh(car.vin, get_date(x), soh_list[x])
compare_dict(vars(BatterySoh(car.vin,
[get_date(0), get_date(1), get_date(2)],
soh_list)),
vars(Database.get_soh_by_vin(car.vin))
)
self.assertEqual(soh_list[-1], Database.get_last_soh_by_vin(car.vin))
def test_record_same_soh(self):
get_new_test_db()
car = vehicule_list[0]
Database.record_battery_soh(car.vin, get_date(0), 99.0)
self.assertRaises(IntegrityError, Database.record_battery_soh, car.vin, get_date(0), 99.0)
+44 -11
View File
@@ -30,19 +30,10 @@ from psa_car_controller.psacc.repository.config_repository import ConfigReposito
from psa_car_controller.psacc.repository.db import Database
from psa_car_controller.psacc.repository.trips import Trips
from psa_car_controller.psacc.utils.utils import get_temp
from tests.data.car_status import FUEL_CAR_STATUS, ELECTRIC_CAR_STATUS
from tests.data.car_status import FUEL_CAR_STATUS, ELECTRIC_CAR_STATUS, ELECTRIC_CAR_STATUS_V2
from tests.utils import DATA_DIR, record_position, latitude, longitude, date0, date1, date2, date3, record_charging, \
vehicule_list, get_new_test_db, get_date, date4
from psa_car_controller.web.figures import get_figures, get_battery_curve_fig, get_altitude_fig
from deepdiff import DeepDiff
def compare_dict(result, expected):
diff = DeepDiff(expected, result)
if diff != {}:
raise AssertionError(str(diff))
return True
dummy_value = 0
@@ -164,6 +155,37 @@ class TestUnit(unittest.TestCase):
True)
self.assertEqual(db_record_position_arg, expected_result)
@patch("psa_car_controller.psacc.repository.db.Database.record_battery_soh")
@patch("psa_car_controller.psacc.repository.db.Database.record_position")
def test_electric_record_info_v2(self, mock_db, moock_soh):
api = ApiClient()
status: psa.connected_car_api.models.status.Status = api._ApiClient__deserialize(
ELECTRIC_CAR_STATUS_V2, "Status")
get_new_test_db()
car = self.vehicule_list[0]
car.status = status
myp = PSAClient.load_config(DATA_DIR + "config.json")
myp.record_info(car)
db_record_position_arg = mock_db.call_args_list[0][0]
expected_result = (None, 'VR3UHZKX', 3196.5, 47.274, -1.59008, 30,
datetime(2022, 3, 26, 11, 2, 54, tzinfo=tzutc()),
59.0,
None,
True)
self.assertEqual(db_record_position_arg, expected_result)
self.assertEqual(
('VR3UHZKX',
datetime(
2022,
3,
26,
11,
2,
54,
tzinfo=tzutc()),
90),
moock_soh.call_args_list[0][0])
def test_record_position_charging(self):
get_new_test_db()
config_repository.CONFIG_FILENAME = DATA_DIR + "config.ini"
@@ -189,7 +211,18 @@ class TestUnit(unittest.TestCase):
start_level = 40
end_level = 85
mileage = 123456789.1
Charging.record_charging(car, "InProgress", date0, start_level, latitude, longitude, None, "slow", 20, 60, mileage)
Charging.record_charging(
car,
"InProgress",
date0,
start_level,
latitude,
longitude,
None,
"slow",
20,
60,
mileage)
Charging.record_charging(car, "InProgress", date1, 70, latitude, longitude, "FR", "slow", 20, 60, mileage)
Charging.record_charging(car, "InProgress", date1, 70, latitude, longitude, "FR", "slow", 20, 60, mileage)
Charging.record_charging(car, "InProgress", date2, 80, latitude, longitude, "FR", "slow", 20, 60, mileage)
+8
View File
@@ -5,6 +5,7 @@ from datetime import timedelta, datetime
from unittest.mock import MagicMock
import pytz
from deepdiff import DeepDiff
from psa_car_controller.psa.RemoteClient import RemoteClient
from psa_car_controller.psa.connected_car_api import Vehicles
@@ -62,3 +63,10 @@ def get_rc() -> RemoteClient:
account_info = MagicMock()
account_info.realm = ""
return RemoteClient(account_info, Vehicles, None, None)
def compare_dict(result, expected):
diff = DeepDiff(expected, result)
if diff != {}:
raise AssertionError(str(diff))
return True