From a18700b100fd886437c1226a152d8a4a6018b5e9 Mon Sep 17 00:00:00 2001 From: stevoh6 Date: Fri, 13 Jan 2023 08:55:17 +0100 Subject: [PATCH 1/2] Add mileage to the charge log --- psa_car_controller/psacc/application/charging.py | 7 ++++--- psa_car_controller/psacc/application/psa_client.py | 2 +- psa_car_controller/psacc/model/charge.py | 3 ++- psa_car_controller/psacc/repository/db.py | 7 ++++--- psa_car_controller/web/figures.py | 4 +++- tests/test_unit.py | 14 ++++++++------ tests/utils.py | 10 +++++----- 7 files changed, 27 insertions(+), 20 deletions(-) diff --git a/psa_car_controller/psacc/application/charging.py b/psa_car_controller/psacc/application/charging.py index 336d2ec..70f59a3 100644 --- a/psa_car_controller/psacc/application/charging.py +++ b/psa_car_controller/psacc/application/charging.py @@ -60,14 +60,14 @@ class Charging: @staticmethod def record_charging(car: Car, charging_status, charge_date: datetime, level, latitude, # pylint: disable=too-many-locals - longitude, country_code, charging_mode, charging_rate, autonomy): + longitude, country_code, charging_mode, charging_rate, autonomy, mileage): conn = Database.get_db() charge_date = charge_date.replace(microsecond=0) if charging_status == "InProgress": last_charge = Database.get_last_charge(car.vin) if Charging.is_charge_ended(last_charge): - conn.execute("INSERT INTO battery(start_at,start_level,charging_mode,VIN) VALUES(?,?,?,?)", - (charge_date, level, charging_mode, car.vin)) + conn.execute("INSERT INTO battery(start_at,start_level,charging_mode,VIN,mileage) VALUES(?,?,?,?,?)", + (charge_date, level, charging_mode, car.vin, mileage)) start_at = charge_date else: start_at = last_charge.start_at @@ -93,6 +93,7 @@ class Charging: last_charge.co2 = co2_per_kw last_charge.kw = consumption_kw last_charge.stop_at = charge_date + last_charge.mileage = mileage Charging.update_chargings(conn, last_charge, car) conn.commit() conn.close() diff --git a/psa_car_controller/psacc/application/psa_client.py b/psa_car_controller/psacc/application/psa_client.py index 0dfa1c9..a3f1a6f 100644 --- a/psa_car_controller/psacc/application/psa_client.py +++ b/psa_car_controller/psacc/application/psa_client.py @@ -200,7 +200,7 @@ class PSAClient: 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) + 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") diff --git a/psa_car_controller/psacc/model/charge.py b/psa_car_controller/psacc/model/charge.py index 2a6e55f..2d654a7 100644 --- a/psa_car_controller/psacc/model/charge.py +++ b/psa_car_controller/psacc/model/charge.py @@ -15,7 +15,7 @@ class ChargingMode(Enum): class Charge: # pylint: disable=too-many-arguments def __init__(self, start_at: datetime, stop_at: datetime = None, vin=None, start_level=None, end_level=None, - co2=None, kw=None, price=None, charging_mode=None): + co2=None, kw=None, price=None, charging_mode=None, mileage=None): assert isinstance(start_at, datetime) self.charging_mode: ChargingMode = ChargingMode(charging_mode) self.start_at = start_at @@ -26,3 +26,4 @@ class Charge: self.co2 = co2 self.kw = kw self.price = price + self.mileage = mileage diff --git a/psa_car_controller/psacc/repository/db.py b/psa_car_controller/psacc/repository/db.py index 0236bab..f5a8121 100644 --- a/psa_car_controller/psacc/repository/db.py +++ b/psa_car_controller/psacc/repository/db.py @@ -18,7 +18,7 @@ from psa_car_controller.psacc.utils.utils import get_temp logger = logging.getLogger(__name__) -NEW_BATTERY_COLUMNS = [["price", "INTEGER"], ["charging_mode", "TEXT"]] +NEW_BATTERY_COLUMNS = [["price", "INTEGER"], ["charging_mode", "TEXT"], ["mileage", "REAL"]] NEW_POSITION_COLUMNS = [["level_fuel", "INTEGER"], ["altitude", "INTEGER"]] NEW_BATTERY_CURVE_COLUMNS = [["rate", "INTEGER"], ["autonomy", "INTEGER"]] @@ -269,8 +269,7 @@ class Database: @staticmethod def get_last_charge(vin) -> Charge: conn = Database.get_db() - res = conn.execute("SELECT start_at, stop_at, vin, start_level, end_level, co2, kw, price, charging_mode " - "FROM battery WHERE VIN=? ORDER BY start_at DESC limit 1", (vin,)).fetchone() + res = conn.execute("SELECT * FROM battery WHERE VIN=? ORDER BY start_at DESC limit 1", (vin,)).fetchone() if res: return Charge(**dict_key_to_lower_case(**res)) return None @@ -294,6 +293,8 @@ class Database: @staticmethod def update_charge(charge: Charge): + # we don't need to update mileage, since it should be inserted at beginning of charge, + # maybe in future this will be supported conn = Database.get_db() res = conn.execute( "UPDATE battery set stop_at=?, end_level=?, co2=?, kw=?, price=? WHERE start_at=? and VIN=?", diff --git a/psa_car_controller/web/figures.py b/psa_car_controller/web/figures.py index 4bb0986..af9259b 100644 --- a/psa_car_controller/web/figures.py +++ b/psa_car_controller/web/figures.py @@ -128,7 +128,9 @@ def get_figures(car: Car): {'id': 'kw', 'name': 'consumption', 'type': 'numeric', 'format': deepcopy(nb_format).symbol_suffix(" kWh").precision(2)}, {'id': 'price', 'name': 'price', 'type': 'numeric', - 'format': deepcopy(nb_format).symbol_suffix(" " + CURRENCY).precision(2), 'editable': True} + 'format': deepcopy(nb_format).symbol_suffix(" " + CURRENCY).precision(2), 'editable': True}, + {'id': 'charging_mode', 'name': 'charging mode', 'type': 'string'}, + {'id': 'mileage', 'name': 'mileage', 'type': 'numeric', 'format': nb_format}, ], data=[], style_data_conditional=[ diff --git a/tests/test_unit.py b/tests/test_unit.py index fc52e6a..b8d3186 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -188,11 +188,12 @@ class TestUnit(unittest.TestCase): Charging.elec_price = ConfigRepository.read_config(DATA_DIR + "config.ini").Electricity_config start_level = 40 end_level = 85 - Charging.record_charging(car, "InProgress", date0, start_level, latitude, longitude, None, "slow", 20, 60) - Charging.record_charging(car, "InProgress", date1, 70, latitude, longitude, "FR", "slow", 20, 60) - Charging.record_charging(car, "InProgress", date1, 70, latitude, longitude, "FR", "slow", 20, 60) - Charging.record_charging(car, "InProgress", date2, 80, latitude, longitude, "FR", "slow", 20, 60) - Charging.record_charging(car, "Stopped", date3, end_level, latitude, longitude, "FR", "slow", 20, 60) + mileage = 123456789.1 + 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) + Charging.record_charging(car, "Stopped", date3, end_level, latitude, longitude, "FR", "slow", 20, 60, mileage) chargings = Charging.get_chargings() co2 = chargings[0]["co2"] assert isinstance(co2, float) @@ -204,7 +205,8 @@ class TestUnit(unittest.TestCase): 'co2': co2, 'kw': 20.7, 'price': 4.29, - 'charging_mode': 'slow'}]) + 'charging_mode': 'slow', + 'mileage': 123456789.1}]) assert get_figures(car) row = {"start_at": date0.strftime('%Y-%m-%dT%H:%M:%S.000Z'), "stop_at": date3.strftime('%Y-%m-%dT%H:%M:%S.000Z'), "start_level": start_level, "end_level": end_level} diff --git a/tests/utils.py b/tests/utils.py index 0882df5..0f49725 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -47,11 +47,11 @@ def record_position(): def record_charging(): - Charging.record_charging(car, "InProgress", date0, 50, latitude, longitude, "FR", "slow", 20, 60) - Charging.record_charging(car, "InProgress", date1, 75, latitude, longitude, "FR", "slow", 20, 60) - Charging.record_charging(car, "InProgress", date2, 85, latitude, longitude, "FR", "slow", 20, 60) - Charging.record_charging(car, "InProgress", date3, 90, latitude, longitude, "FR", "slow", 20, 60) - Charging.record_charging(car, "Stopped", date4, 91, latitude, longitude, "FR", "slow", 20, 60) + Charging.record_charging(car, "InProgress", date0, 50, latitude, longitude, "FR", "slow", 20, 60, 123456789.1) + Charging.record_charging(car, "InProgress", date1, 75, latitude, longitude, "FR", "slow", 20, 60, 123456789.1) + Charging.record_charging(car, "InProgress", date2, 85, latitude, longitude, "FR", "slow", 20, 60, 123456789.1) + Charging.record_charging(car, "InProgress", date3, 90, latitude, longitude, "FR", "slow", 20, 60, 123456789.1) + Charging.record_charging(car, "Stopped", date4, 91, latitude, longitude, "FR", "slow", 20, 60, 123456789.1) def get_date(offset): From 0e73405bed41364506ff91bf6920da875783d6f1 Mon Sep 17 00:00:00 2001 From: stevoh6 Date: Fri, 13 Jan 2023 08:57:38 +0100 Subject: [PATCH 2/2] Add export functionality, color adjustments --- .../psacc/repository/config_repository.py | 3 + .../web/assets/99_custom_overides.css | 13 ++++ psa_car_controller/web/figures.py | 60 ++++++++++++--- psa_car_controller/web/view/views.py | 74 ++++++++++++++++++- 4 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 psa_car_controller/web/assets/99_custom_overides.css diff --git a/psa_car_controller/psacc/repository/config_repository.py b/psa_car_controller/psacc/repository/config_repository.py index b59976c..473784a 100644 --- a/psa_car_controller/psacc/repository/config_repository.py +++ b/psa_car_controller/psacc/repository/config_repository.py @@ -14,6 +14,8 @@ logger = logging.getLogger(__name__) DEFAULT_CONFIG = """[General] currency = € +# define format for data export, can be csv or xlsx +export_format = csv # minimum trip length in km so it's added to stats and map in website minimum trip length = # for future use @@ -83,6 +85,7 @@ class GeneralConfig(BaseModel): currency: str = "€" length_unit: str = "km" minimum_trip_length: float = 10 + export_format = "csv" class ElectricityPriceConfig(BaseModel): diff --git a/psa_car_controller/web/assets/99_custom_overides.css b/psa_car_controller/web/assets/99_custom_overides.css new file mode 100644 index 0000000..b3e1ed5 --- /dev/null +++ b/psa_car_controller/web/assets/99_custom_overides.css @@ -0,0 +1,13 @@ +#battery-table button.export, +#trips-table button.export { + display: none; +} + +div.export-load-anim div.dash-sk-circle { + width: 20px; + height: 20px; +} + +.dash-table-container .dash-spreadsheet-container .dash-spreadsheet-inner tr:hover > td:not(.focused) { + background-image: linear-gradient(#ffeb3b75 0 0); +} diff --git a/psa_car_controller/web/figures.py b/psa_car_controller/web/figures.py index af9259b..7dda480 100644 --- a/psa_car_controller/web/figures.py +++ b/psa_car_controller/web/figures.py @@ -5,7 +5,7 @@ from dash import html from dash.dash_table import DataTable import plotly.express as px import plotly.graph_objects as go -from dash.dash_table.Format import Scheme, Symbol, Format +from dash.dash_table.Format import Scheme, Symbol, Group, Format from dash.dcc import Graph from psa_car_controller.psacc.application.charging import Charging @@ -35,6 +35,7 @@ ELEC_CONSUM_PRICE = "elec_consum_price" AVG_CONSUM_KW = "avg_consum_kw" AVG_CONSUM_PRICE = "avg_consum_price" CURRENCY = "€" +EXPORT_FORMAT = "csv" SUMMARY_CARDS = {"Average consumption": {"text": [card_value_div(AVG_CONSUM_KW, "kWh/100km"), card_value_div(AVG_CONSUM_PRICE, f"{CURRENCY}/100km")], @@ -62,7 +63,7 @@ def get_figures(car: Car): lon=[lons[0]], lat=[lats[0]], showlegend=False, name="Last Position")) # table - nb_format = Format(precision=2, scheme=Scheme.fixed, symbol=Symbol.yes) # pylint: disable=no-member + nb_format = Format(precision=2, scheme=Scheme.fixed, symbol=Symbol.yes, group=Group.yes) style_cell_conditional = [] if car.is_electric(): style_cell_conditional.append({'if': {'column_id': 'consumption_fuel_km', }, 'display': 'None', }) @@ -70,15 +71,22 @@ def get_figures(car: Car): style_cell_conditional.append({'if': {'column_id': 'consumption_km', }, 'display': 'None', }) table_fig = DataTable( id='trips-table', + export_format=EXPORT_FORMAT, sort_action='custom', sort_by=[{'column_id': 'id', 'direction': 'desc'}], + style_data={ + 'width': '10%', + 'maxWidth': '10%', + 'minWidth': '10%', + 'color': 'gray' + }, columns=[{'id': 'id', 'name': '#', 'type': 'numeric'}, {'id': 'start_at_str', 'name': 'start at', 'type': 'datetime'}, {'id': 'duration', 'name': 'duration', 'type': 'numeric', 'format': deepcopy(nb_format).symbol_suffix(" min").precision(0)}, - {'id': 'speed_average', 'name': 'average speed', 'type': 'numeric', + {'id': 'speed_average', 'name': 'avg. speed', 'type': 'numeric', 'format': deepcopy(nb_format).symbol_suffix(" km/h").precision(0)}, - {'id': 'consumption_km', 'name': 'average consumption', 'type': 'numeric', + {'id': 'consumption_km', 'name': 'avg. consumption', 'type': 'numeric', 'format': deepcopy(nb_format).symbol_suffix(" kWh/100km")}, {'id': 'consumption_fuel_km', 'name': 'average consumption fuel', 'type': 'numeric', 'format': deepcopy(nb_format).symbol_suffix(" L/100km")}, @@ -86,14 +94,17 @@ def get_figures(car: Car): 'format': nb_format.symbol_suffix(" km").precision(1)}, {'id': 'mileage', 'name': 'mileage', 'type': 'numeric', 'format': nb_format}, - {'id': 'altitude_diff', 'name': 'Altitude diff', 'type': 'numeric', + {'id': 'altitude_diff', 'name': 'altitude diff', 'type': 'numeric', 'format': deepcopy(nb_format).symbol_suffix(" m").precision(0)} ], style_data_conditional=[ + { + 'if': {'column_id': ['id']}, + 'width': '5%' + }, { 'if': {'column_id': ['altitude_diff']}, - 'color': 'dodgerblue', - "text-decoration": "underline" + 'color': 'black' } ], style_cell_conditional=style_cell_conditional, @@ -113,11 +124,17 @@ def get_figures(car: Car): consumption_fig_by_speed.add_trace(go.Scatter(mode="markers", x=[0], y=[0], name="Trips")) consumption_fig_by_speed.update_layout(xaxis_title="average Speed km/h", yaxis_title="Consumption kWh/100Km") - # battery_table battery_table = DataTable( id='battery-table', + export_format=EXPORT_FORMAT, sort_action='custom', + style_data={ + 'width': '10%', + 'maxWidth': '10%', + 'minWidth': '10%', + 'color': 'gray' + }, sort_by=[{'column_id': 'start_at_str', 'direction': 'desc'}], columns=[{'id': 'start_at_str', 'name': 'start at', 'type': 'datetime'}, {'id': 'stop_at_str', 'name': 'stop at', 'type': 'datetime'}, @@ -133,15 +150,36 @@ def get_figures(car: Car): {'id': 'mileage', 'name': 'mileage', 'type': 'numeric', 'format': nb_format}, ], data=[], + page_size=50, style_data_conditional=[ { 'if': {'column_id': ['start_level', "end_level"]}, - 'color': 'dodgerblue', - "text-decoration": "underline" + 'color': 'black' + }, + { + 'if': { + 'filter_query': '{start_level} < 15', + 'column_id': 'start_level' + }, + 'color': 'red' + }, + { + 'if': { + 'filter_query': '{end_level} > 85', + 'column_id': 'end_level' + }, + 'color': 'green' + }, + { + 'if': { + 'filter_query': '{charging_mode} = "Quick"' + }, + 'backgroundColor': 'ivory' }, { 'if': {'column_id': 'price'}, - 'backgroundColor': '#ABE2FB' + 'color': 'dodgerblue', + 'font-weihgt': 'bold' } ], ) diff --git a/psa_car_controller/web/view/views.py b/psa_car_controller/web/view/views.py index ac344a0..abd3571 100644 --- a/psa_car_controller/web/view/views.py +++ b/psa_car_controller/web/view/views.py @@ -7,6 +7,7 @@ from dash import dcc, html from dash.dependencies import Output, Input, State from dash.exceptions import PreventUpdate from flask import jsonify, request, Response as FlaskResponse +import time from psa_car_controller.common.mylogger import CustomLogger from psa_car_controller.psacc.application.car_controller import PSACarController @@ -125,7 +126,39 @@ def create_callback(): # noqa: MC0001 return figures.get_altitude_fig(trips[active_cell["row_id"] - 1]), True return "", False + @dash_app.callback(Output("loading-output-trips", "children"), Input("export-trips-table", "n_clicks")) + def export_trips_loading_animation(n_clicks): # pylint: disable=unused-argument + time.sleep(3) + + @dash_app.callback(Output("loading-output-battery", "children"), Input("export-battery-table", "n_clicks")) + def export_batt_loading_animation(n_clicks): # pylint: disable=unused-argument + time.sleep(3) + # Emulate click on original Export datatables button, since original button is hard to modify + dash_app.clientside_callback( + """ + function(n_clicks) { + if (n_clicks > 0) + document.querySelector("#trips-table button.export").click() + return "" + } + """, + Output("trips-table", "data-dummy"), + [Input("export-trips-table", "n_clicks")] + ) + dash_app.clientside_callback( + """ + function(n_clicks) { + if (n_clicks > 0) + document.querySelector("#battery-table button.export").click() + return "" + } + """, + Output("battery-table", "data-dummy"), + [Input("export-battery-table", "n_clicks")] + ) + figures.CURRENCY = APP.config.General.currency + figures.EXPORT_FORMAT = APP.config.General.export_format CALLBACK_CREATED = True @@ -343,7 +376,25 @@ def serve_layout(): dbc.Tabs([ dbc.Tab(label="Summary", tab_id="summary", children=summary_tab), dbc.Tab(label="Trips", tab_id="trips", id="tab_trips", - children=[html.Div(id="tab_trips_fig", children=figures.table_fig), + children=[dbc.Row( + dbc.Col([ + dcc.Loading( + id="loading-div-trips", + children=[html.Div([html.Div(id="loading-output-trips")])], + type="circle", + className="export-load-anim" + ), + dbc.Button("Export trips data", + id="export-trips-table", + n_clicks=0, + size="sm", + color="light", + className="m-1 w-200" + )], + className="d-grid gap-2 d-md-flex justify-content-md-end" + ) + ), + html.Div(id="tab_trips_fig", children=figures.table_fig), dbc.Modal( [ dbc.ModalHeader("Altitude"), @@ -360,7 +411,25 @@ def serve_layout(): ) ]), dbc.Tab(label="Charge", tab_id="charge", id="tab_charge", - children=[figures.battery_table, + children=[dbc.Row( + dbc.Col([ + dcc.Loading( + id="loading-div-battery", + children=[html.Div([html.Div(id="loading-output-battery")])], + type="circle", + className="export-load-anim" + ), + dbc.Button("Export charging data", + id="export-battery-table", + n_clicks=0, + size="sm", + color="light", + className="m-1 w-200" + )], + className="d-grid gap-2 d-md-flex justify-content-md-end" + ) + ), + figures.battery_table, dbc.Modal( [ dbc.ModalHeader( @@ -399,6 +468,7 @@ try: Charging.set_default_price(APP.myp.vehicles_list) Database.set_db_callback(update_trips) figures.CURRENCY = APP.config.General.currency + figures.EXPORT_FORMAT = APP.config.General.export_format update_trips() except (IndexError, TypeError): logger.debug("Failed to get trips, there is probably not enough data yet:", exc_info=True)