From 3bc782c9d67490b874288e66fe25c72155d9ad3d Mon Sep 17 00:00:00 2001
From: Youen Toupin
Date: Fri, 8 Oct 2021 23:48:20 +0200
Subject: [PATCH] reimplementation of the simulation in javascript (wip)
---
simulator/src/app.scss | 50 ++++++++++++
simulator/src/simulator-core.ts | 132 ++++++++++++++++++++++++++++++++
simulator/src/simulator.html | 4 +-
simulator/src/simulator.scss | 51 +-----------
simulator/src/simulator.ts | 11 +++
5 files changed, 196 insertions(+), 52 deletions(-)
create mode 100644 simulator/src/app.scss
create mode 100644 simulator/src/simulator-core.ts
diff --git a/simulator/src/app.scss b/simulator/src/app.scss
new file mode 100644
index 0000000..5bfdfc5
--- /dev/null
+++ b/simulator/src/app.scss
@@ -0,0 +1,50 @@
+input[type=number] {
+ -moz-appearance:textfield;
+}
+
+.wide-label .field-label {
+ flex-grow: 2.5;
+}
+
+.dropdown.is-fullwidth {
+ display: flex;
+}
+
+.dropdown.is-fullwidth .dropdown-trigger,
+.dropdown.is-fullwidth .dropdown-menu {
+ width: 100%;
+}
+
+.dropdown-trigger.with-dropdown-icon::after {
+ border: 3px solid black;
+ border-radius: 2px;
+ border-right: 0;
+ border-top: 0;
+ content: " ";
+ display: block;
+ height: 0.625em;
+ margin-top: -0.4375em;
+ pointer-events: none;
+ position: absolute;
+ top: 50%;
+ right: 15px;
+ transform: rotate(-45deg);
+ transform-origin: center;
+ width: 0.625em;
+}
+
+.climate-zone {
+ cursor: pointer;
+}
+
+svg g {
+ filter: drop-shadow( 4px 4px 3px rgba(0, 0, 0, .7));
+}
+
+.climate-zone:hover {
+ filter: brightness(1.2);
+}
+
+svg text {
+ pointer-events: none;
+}
diff --git a/simulator/src/simulator-core.ts b/simulator/src/simulator-core.ts
new file mode 100644
index 0000000..638ee7f
--- /dev/null
+++ b/simulator/src/simulator-core.ts
@@ -0,0 +1,132 @@
+function clamp(x: number, mini: number, maxi: number) {
+ return x <= mini ? mini : (x >= maxi ? maxi : x);
+}
+
+class Vehicle {
+ batteryCapacity: number;
+ batteryEfficiency: number = 1.0; // TODO: typical efficiency of a Li-ion battery (round-trip) is 90%
+
+ solarPanelEfficiency: number = 0.15;
+ solarPanelArea: number = 1.0; // in square meters
+
+ additionalWeight: number; // additional weight, not counting cyclist and empty vehicle weight, in kg
+
+ motorConsumption(distance: number, ascendingElevation: number): number {
+ // empirical measures
+ let maxWeight = 200; // in kg
+ let maxWeightAdditionalConsumption = 4; // in Wh/km
+ let maxTestedElevation = 500; // in meters
+ let maxTestedElevationConsumption = 7; // in Wh/m
+ let baseConsumption = 14; // in Wh/km
+
+ let weightRelatedConsumption = clamp(this.additionalWeight * maxWeightAdditionalConsumption / maxWeight, 0, maxWeightAdditionalConsumption);
+
+ // TODO: should not be multiplied by distance
+ // TODO: should be multiplied by total vehicle weight
+ let elevationRelatedConsumption = clamp(ascendingElevation * maxTestedElevationConsumption / maxTestedElevation, 0, maxTestedElevationConsumption);
+
+ return distance * (baseConsumption + weightRelatedConsumption + elevationRelatedConsumption)
+ }
+
+ solarPower(irradiance: number): number {
+ return irradiance * this.solarPanelArea * this.solarPanelEfficiency;
+ }
+}
+
+interface Outing {
+ distance: number; // in km
+ ascendingElevation: number; // in meters
+}
+
+class OutingPlanning {
+ constructor(public dailyDistance: number, public dailyAscendingElevation: number) {
+ }
+
+ getOuting(dayOfWeek: number, hourOfDay: number, outing: Outing) {
+ let dailyRatio = 0;
+
+ if(dayOfWeek >= 5) {
+ // week end
+ dailyRatio = hourOfDay == 10 ? 1.0 : 0.0;
+ }
+ else {
+ // other week day
+ dailyRatio = hourOfDay == 8 || hourOfDay == 16 ? 0.5 : 0.0;
+ }
+
+ outing.distance = dailyRatio * this.dailyDistance;
+ outing.ascendingElevation = this.dailyAscendingElevation;
+ }
+}
+
+interface SimulationResult {
+ batteryLevel: number[]; // Remaining energy in the battery over time (one entry per hour), in Wh
+ cumulatedGridRechargeEnergy: number; // Cumulated energy added to the battery from the power grid, in Wh of battery charge (actual power grid consumption will be slightly higer due to losses)
+ cumulatedSolarRechargeEnergy: number; // Cumulated energy added to the battery from the solar panel, in Wh of battery charge (actual generated power is slightly higher due to losses)
+ cumulatedMotorConsumption: number; // Cumulated energy consumed by the motor, in Wh. In this simulation, this is equal to the energy drawn from the battery.
+}
+
+interface SimulationParameters {
+ batteryCapacity: number,
+ additionalWeight: number,
+ climateZone: string,
+ dailyDistance: number,
+ dailyAscendingElevation: number
+}
+
+function runSimulation(vehicle: Vehicle, solarIrradiance: number[], planning: OutingPlanning): SimulationResult {
+ let result: SimulationResult = {
+ batteryLevel: [],
+ cumulatedGridRechargeEnergy: 0,
+ cumulatedSolarRechargeEnergy: 0,
+ cumulatedMotorConsumption: 0
+ };
+
+ let remainingBatteryCharge = vehicle.batteryCapacity;
+
+ let outing: Outing = { distance: 0, ascendingElevation: 0 };
+
+ for(let day = 0; day < 365; ++day) {
+ for(let hour = 0; hour < 24; ++hour) {
+ let hourIdx = day * 24 + hour;
+
+ planning.getOuting(day % 7, hour, outing);
+
+ let consumption = vehicle.motorConsumption(outing.distance, outing.ascendingElevation);
+ let production = vehicle.solarPower(solarIrradiance[hourIdx]) * 1.0; // produced energy in Wh is equal to power (W) multiplied by time (h)
+
+ let solarCharge = production * vehicle.batteryEfficiency;
+
+ remainingBatteryCharge += solarCharge - consumption;
+ if(remainingBatteryCharge > vehicle.batteryCapacity) {
+ solarCharge -= remainingBatteryCharge - vehicle.batteryCapacity;
+ remainingBatteryCharge = vehicle.batteryCapacity;
+ }
+ else if(remainingBatteryCharge < 0) {
+ let rechargeEnergy = vehicle.batteryCapacity - remainingBatteryCharge;
+ remainingBatteryCharge += rechargeEnergy;
+ result.cumulatedGridRechargeEnergy += rechargeEnergy;
+ }
+
+ result.cumulatedMotorConsumption += consumption;
+ result.cumulatedSolarRechargeEnergy += solarCharge;
+
+ result.batteryLevel[hourIdx] = remainingBatteryCharge;
+ }
+ }
+
+ return result;
+}
+
+function startSimulation(parameters: SimulationParameters) {
+ let climateData = (window)['climate-zones-data.csv'];
+
+ let vehicle = new Vehicle();
+ vehicle.batteryCapacity = parameters.batteryCapacity;
+ vehicle.additionalWeight = parameters.additionalWeight;
+ let solarIrradiance: number[] = climateData[parameters.climateZone.toLowerCase()];
+ let planning = new OutingPlanning(parameters.dailyDistance, parameters.dailyAscendingElevation);
+
+ let simulationResult = runSimulation(vehicle, solarIrradiance, planning);
+ console.log(simulationResult);
+}
diff --git a/simulator/src/simulator.html b/simulator/src/simulator.html
index 5a2278e..605ba33 100644
--- a/simulator/src/simulator.html
+++ b/simulator/src/simulator.html
@@ -12,7 +12,7 @@
- kWh
+ Wh
@@ -99,7 +99,7 @@
diff --git a/simulator/src/simulator.scss b/simulator/src/simulator.scss
index cb5ec55..7e688fe 100644
--- a/simulator/src/simulator.scss
+++ b/simulator/src/simulator.scss
@@ -23,55 +23,6 @@
& > section, & > footer {
font-size: $body-font-size; // we must set this on each direct child of the #simulator div, because if it's specified in em, it's multiplied by the parent font-size
}
-
- input[type=number] {
- -moz-appearance:textfield;
- }
-
- .wide-label .field-label {
- flex-grow: 2.5;
- }
-
- .dropdown.is-fullwidth {
- display: flex;
- }
-
- .dropdown.is-fullwidth .dropdown-trigger,
- .dropdown.is-fullwidth .dropdown-menu {
- width: 100%;
- }
-
- .dropdown-trigger.with-dropdown-icon::after {
- border: 3px solid black;
- border-radius: 2px;
- border-right: 0;
- border-top: 0;
- content: " ";
- display: block;
- height: 0.625em;
- margin-top: -0.4375em;
- pointer-events: none;
- position: absolute;
- top: 50%;
- right: 15px;
- transform: rotate(-45deg);
- transform-origin: center;
- width: 0.625em;
- }
-
- .climate-zone {
- cursor: pointer;
- }
-
- svg g {
- filter: drop-shadow( 4px 4px 3px rgba(0, 0, 0, .7));
- }
-
- .climate-zone:hover {
- filter: brightness(1.2);
- }
- svg text {
- pointer-events: none;
- }
+ @import "app.scss";
}
diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts
index 502f8f8..d3c24ed 100644
--- a/simulator/src/simulator.ts
+++ b/simulator/src/simulator.ts
@@ -33,4 +33,15 @@ document.addEventListener('DOMContentLoaded', function() {
closest(elt, e => e.classList.contains('modal')).classList.toggle('is-active', false);
});
});
+
+ document.getElementById('simulate-button').addEventListener('click', e => {
+ let parameters: SimulationParameters = {
+ batteryCapacity: Number((document.querySelector('[name=battery-capacity]')).value),
+ additionalWeight: Number((document.querySelector('[name=additional-weight]')).value),
+ climateZone: (document.getElementById('zone-selector')).value,
+ dailyDistance: Number((document.querySelector('[name=daily-distance]')).value),
+ dailyAscendingElevation: Number((document.querySelector('[name=daily-elevation]')).value),
+ };
+ startSimulation(parameters);
+ });
});