diff --git a/simulator/src/html-utils.ts b/simulator/src/html-utils.ts
new file mode 100644
index 0000000..3fb718b
--- /dev/null
+++ b/simulator/src/html-utils.ts
@@ -0,0 +1,6 @@
+namespace HtmlUtils {
+ export function closest (el: Element, predicate: (e: Element) => boolean) {
+ do if (predicate(el)) return el;
+ while (el = el && el.parentNode);
+ }
+}
diff --git a/simulator/src/math-utils.ts b/simulator/src/math-utils.ts
new file mode 100644
index 0000000..206a70c
--- /dev/null
+++ b/simulator/src/math-utils.ts
@@ -0,0 +1,5 @@
+namespace MathUtils {
+ export function clamp(x: number, mini: number, maxi: number) {
+ return x <= mini ? mini : (x >= maxi ? maxi : x);
+ }
+}
diff --git a/simulator/src/simulator-core.ts b/simulator/src/simulator-core.ts
deleted file mode 100644
index 86b6e28..0000000
--- a/simulator/src/simulator-core.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-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 {
- // TODO: should decompose climate data in normal radiance (modulated by incident angle) and diffuse irradiance
- // TODO: should add a shadowing factor (the panel won't be always exposed to the sun)
- 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 == 7 || hourOfDay == 15 ? 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
- gridChargeCount: number;
- 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: [],
- gridChargeCount: 0,
- 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;
-
- // TODO: we should keep a margin because real users will recharge before they reach the bare minimum required for an outing
- 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.gridChargeCount += 1;
- }
-
- result.cumulatedMotorConsumption += consumption;
- result.cumulatedSolarRechargeEnergy += solarCharge;
-
- result.batteryLevel[hourIdx] = remainingBatteryCharge;
- }
- }
-
- return result;
-}
-
-function startSimulation(parameters: SimulationParameters): SimulationResult {
- 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(solarIrradiance);
- console.log(simulationResult);
-
- let averageKwhCost = 0.192; // in €/kWh TODO: to verify, this price seems too high
- console.log('Grid recharge cost: ' + (Math.round(simulationResult.gridChargeCount*(vehicle.batteryCapacity/1000)*averageKwhCost*100)/100) + '€');
-
- console.log('Solar energy ratio: ' + Math.round(100*(simulationResult.cumulatedMotorConsumption-(simulationResult.gridChargeCount+1)*vehicle.batteryCapacity)/simulationResult.cumulatedMotorConsumption) + '%');
-
- return simulationResult;
-}
diff --git a/simulator/src/simulator-ui.ts b/simulator/src/simulator-ui.ts
new file mode 100644
index 0000000..bd05761
--- /dev/null
+++ b/simulator/src/simulator-ui.ts
@@ -0,0 +1,82 @@
+
+
+interface SimulationParameters {
+ batteryCapacity: number,
+ additionalWeight: number,
+ climateZone: string,
+ dailyDistance: number,
+ dailyAscendingElevation: number
+}
+
+function runSimulation(parameters: SimulationParameters): Simulator.SimulationResult {
+ let climateData = (window)['climate-zones-data.csv'];
+
+ let vehicle = new Simulator.Vehicle();
+ vehicle.batteryCapacity = parameters.batteryCapacity;
+ vehicle.additionalWeight = parameters.additionalWeight;
+ let solarIrradiance: number[] = climateData[parameters.climateZone.toLowerCase()];
+ let planning = new Simulator.OutingPlanning(parameters.dailyDistance, parameters.dailyAscendingElevation);
+
+ let simulationResult = Simulator.simulate(vehicle, solarIrradiance, planning);
+ //console.log(solarIrradiance);
+ console.log(simulationResult);
+
+ let averageKwhCost = 0.192; // in €/kWh TODO: to verify, this price seems too high
+ console.log('Grid recharge cost: ' + (Math.round(simulationResult.gridChargeCount*(vehicle.batteryCapacity/1000)*averageKwhCost*100)/100) + '€');
+
+ console.log('Solar energy ratio: ' + Math.round(100*(simulationResult.cumulatedMotorConsumption-(simulationResult.gridChargeCount+1)*vehicle.batteryCapacity)/simulationResult.cumulatedMotorConsumption) + '%');
+
+ return simulationResult;
+}
+
+document.addEventListener('DOMContentLoaded', function() {
+ let container = document.getElementById('simulator');
+
+ // Insert HTML code in the container
+ container.innerHTML += (window)['simulator.html'];
+
+ // In order to be able to style SVG elements with CSS, and register events with javascript, we must use inline SVG (we can't use an img tag)
+ // For this purpose, the SVG file contents are embedded in a javascript file
+ container.querySelector('#zones-map').innerHTML = (window)['climate-zones-map.svg'];
+
+ container.querySelectorAll("[data-activate-modal]").forEach(elt => {
+ elt.addEventListener('click', e => {
+ container.querySelector('#'+elt.getAttribute('data-activate-modal')).classList.toggle('is-active', true);
+ });
+ });
+
+ container.querySelectorAll('.modal-close, .modal-card-head .delete').forEach(elt => {
+ elt.addEventListener('click', e => {
+ HtmlUtils.closest(elt, e => e.classList.contains('modal')).classList.toggle('is-active', false);
+ });
+ });
+
+ let zoneSelector = container.querySelector('#zone-selector');
+ container.querySelectorAll('.climate-zone').forEach(elt => {
+ elt.addEventListener('click', e => {
+ let zoneName = elt.getAttribute('id');
+ zoneSelector.value = zoneName;
+ HtmlUtils.closest(elt, e => e.classList.contains('modal')).classList.toggle('is-active', false);
+ });
+ });
+
+ container.querySelector('#simulate-button').addEventListener('click', e => {
+ let parameters: SimulationParameters = {
+ batteryCapacity: Number((container.querySelector('[name=battery-capacity]')).value),
+ additionalWeight: Number((container.querySelector('[name=additional-weight]')).value),
+ climateZone: (container.querySelector('#zone-selector')).value,
+ dailyDistance: Number((container.querySelector('[name=daily-distance]')).value),
+ dailyAscendingElevation: Number((container.querySelector('[name=daily-elevation]')).value),
+ };
+
+ let simulationResult = runSimulation(parameters);
+
+ let resultsContainer = container.querySelector('.simulation-results');
+
+ let batteryChargeGraph = new SvgDrawing.SvgElement(resultsContainer.querySelector('.battery-charge-graph svg'));
+ batteryChargeGraph.viewport.logical = { x: 0, y: 0, width: 365*24, height: parameters.batteryCapacity }
+ batteryChargeGraph.graph(simulationResult.batteryLevel);
+
+ resultsContainer.classList.toggle('is-hidden', false);
+ });
+});
diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts
index c758a29..6191fb6 100644
--- a/simulator/src/simulator.ts
+++ b/simulator/src/simulator.ts
@@ -1,75 +1,115 @@
-function closest (el: Element, predicate: (e: Element) => boolean) {
- do if (predicate(el)) return el;
- while (el = el && el.parentNode);
-}
-
-document.addEventListener('DOMContentLoaded', function() {
- let container = document.getElementById('simulator');
-
- // Insert HTML code in the container
- container.innerHTML += (window)['simulator.html'];
-
- // In order to be able to style SVG elements with CSS, and register events with javascript, we must use inline SVG (we can't use an img tag)
- // For this purpose, the SVG file contents are embedded in a javascript file
- container.querySelector('#zones-map').innerHTML = (window)['climate-zones-map.svg'];
+namespace Simulator {
+ export 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 = MathUtils.clamp(this.additionalWeight * maxWeightAdditionalConsumption / maxWeight, 0, maxWeightAdditionalConsumption);
+
+ // TODO: should not be multiplied by distance
+ // TODO: should be multiplied by total vehicle weight
+ let elevationRelatedConsumption = MathUtils.clamp(ascendingElevation * maxTestedElevationConsumption / maxTestedElevation, 0, maxTestedElevationConsumption);
+
+ return distance * (baseConsumption + weightRelatedConsumption + elevationRelatedConsumption)
+ }
+
+ solarPower(irradiance: number): number {
+ // TODO: should decompose climate data in normal radiance (modulated by incident angle) and diffuse irradiance
+ // TODO: should add a shadowing factor (the panel won't be always exposed to the sun)
+ return irradiance * this.solarPanelArea * this.solarPanelEfficiency;
+ }
+ }
- container.querySelectorAll("[data-activate-modal]").forEach(elt => {
- elt.addEventListener('click', e => {
- container.querySelector('#'+elt.getAttribute('data-activate-modal')).classList.toggle('is-active', true);
- });
- });
+ export interface Outing {
+ distance: number; // in km
+ ascendingElevation: number; // in meters
+ }
- container.querySelectorAll('.modal-close, .modal-card-head .delete').forEach(elt => {
- elt.addEventListener('click', e => {
- closest(elt, e => e.classList.contains('modal')).classList.toggle('is-active', false);
- });
- });
+ export 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 == 7 || hourOfDay == 15 ? 0.5 : 0.0;
+ }
+
+ outing.distance = dailyRatio * this.dailyDistance;
+ outing.ascendingElevation = this.dailyAscendingElevation;
+ }
+ }
- let zoneSelector = container.querySelector('#zone-selector');
- container.querySelectorAll('.climate-zone').forEach(elt => {
- elt.addEventListener('click', e => {
- let zoneName = elt.getAttribute('id');
- zoneSelector.value = zoneName;
- closest(elt, e => e.classList.contains('modal')).classList.toggle('is-active', false);
- });
- });
+ export interface SimulationResult {
+ batteryLevel: number[]; // Remaining energy in the battery over time (one entry per hour), in Wh
+ gridChargeCount: number;
+ 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.
+ }
- container.querySelector('#simulate-button').addEventListener('click', e => {
- let parameters: SimulationParameters = {
- batteryCapacity: Number((container.querySelector('[name=battery-capacity]')).value),
- additionalWeight: Number((container.querySelector('[name=additional-weight]')).value),
- climateZone: (container.querySelector('#zone-selector')).value,
- dailyDistance: Number((container.querySelector('[name=daily-distance]')).value),
- dailyAscendingElevation: Number((container.querySelector('[name=daily-elevation]')).value),
+ export function simulate(vehicle: Vehicle, solarIrradiance: number[], planning: OutingPlanning): SimulationResult {
+ let result: SimulationResult = {
+ batteryLevel: [],
+ gridChargeCount: 0,
+ cumulatedGridRechargeEnergy: 0,
+ cumulatedSolarRechargeEnergy: 0,
+ cumulatedMotorConsumption: 0
};
- let simulationResult = startSimulation(parameters);
- let resultsContainer = container.querySelector('.simulation-results');
+ let remainingBatteryCharge = vehicle.batteryCapacity;
- let batteryChargeGraph = resultsContainer.querySelector('.battery-charge-graph');
- let batteryChargeGraphSvg = batteryChargeGraph.querySelector('svg');
+ let outing: Outing = { distance: 0, ascendingElevation: 0 };
- let coordinates = '';
- let view = [1000, 300];
- let hoursInYear = 365 * 24;
- for(let dayOfYear = 0; dayOfYear < 365; ++dayOfYear) {
- for(let hourOfDay = 0; hourOfDay < 24; ++hourOfDay) {
- let h = dayOfYear * 24 + hourOfDay;
- let batteryLevel = simulationResult.batteryLevel[h];
+ for(let day = 0; day < 365; ++day) {
+ for(let hour = 0; hour < 24; ++hour) {
+ let hourIdx = day * 24 + hour;
- if(h == 0) coordinates += 'M';
- else if(h == 1) coordinates += ' L';
- else coordinates += ' ';
+ planning.getOuting(day % 7, hour, outing);
- coordinates += Math.round(h * view[0] / hoursInYear)+','+Math.round(view[1] - batteryLevel * view[1] / parameters.batteryCapacity);
- }
+ 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;
+
+ // TODO: we should keep a margin because real users will recharge before they reach the bare minimum required for an outing
+ 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.gridChargeCount += 1;
+ }
+
+ result.cumulatedMotorConsumption += consumption;
+ result.cumulatedSolarRechargeEnergy += solarCharge;
+
+ result.batteryLevel[hourIdx] = remainingBatteryCharge;
+ }
}
- let path = document.createElementNS('http://www.w3.org/2000/svg','path');
- path.setAttribute('class','graph');
- path.setAttribute('d', coordinates);
- path.setAttribute('shape-rendering', 'optimizeQuality')
- batteryChargeGraphSvg.append(path);
- resultsContainer.classList.toggle('is-hidden', false);
- });
-});
+ return result;
+ }
+}
diff --git a/simulator/src/svg-drawing.ts b/simulator/src/svg-drawing.ts
new file mode 100644
index 0000000..9889a11
--- /dev/null
+++ b/simulator/src/svg-drawing.ts
@@ -0,0 +1,77 @@
+namespace SvgDrawing {
+ export interface Rect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ }
+
+ export class Viewport {
+ constructor(public logical: Rect, public view: Rect) {}
+
+ xLogicalToView(x: number) { return (x - this.logical.x) / this.logical.width * this.view.width + this.view.x; }
+ yLogicalToView(y: number) { return (y - this.logical.y) / this.logical.height * this.view.height + this.view.y; }
+ }
+
+ export class SvgElement {
+ public viewport: Viewport;
+
+ constructor(private htmlElement: HTMLElement) {
+ let viewBox = htmlElement.getAttribute('viewBox').split(' ');
+ let r: Rect = { x: Number(viewBox[0]), y: Number(viewBox[1]), width: Number(viewBox[2]), height: Number(viewBox[3]) };
+ this.viewport = new Viewport(r, { x: r.x, y: r.y + r.height, width: r.width, height: -r.height });
+ }
+
+ graph(y: number[]): SVGPathElement;
+ graph(x: number[], y: number[]): SVGPathElement;
+ graph(arg1: number[], arg2?: number[]) {
+ let x: number[] | null = arg1;
+ let y: number[] = arg2;
+ if(!y) {
+ y = arg1;
+ x = null;
+ }
+
+ let num = y.length;
+ console.assert(!x || num == x.length);
+
+ if(num <= 1) return null;
+
+ let xStep = 6;
+
+ let coordinates = 'M'+Math.round(this.viewport.xLogicalToView(x? x[0] : 0))+','+Math.round(this.viewport.yLogicalToView(y[0]));
+ coordinates += ' L';
+ let lineStartX = x ? x[0] : 0;
+ let prevX = lineStartX;
+ let prevY = y[0];
+ let yDir = y[1] > y[0] ? 1 : -1;
+
+ let count = 0;
+ for(let idx = 0; idx < num; ++idx) {
+ let isLast = (idx == num - 1);
+
+ let newX = x ? x[idx] : idx;
+ let newY = y[idx];
+ let dir = isLast ? 0 : (y[idx+1] > newY ? 1 : -1);
+
+ if(newX >= lineStartX + xStep || dir != yDir || isLast) {
+ coordinates += Math.round(this.viewport.xLogicalToView(newX))+','+Math.round(this.viewport.yLogicalToView(newY));
+ if(!isLast) coordinates += ' ';
+ lineStartX = newX;
+ yDir = isLast ? 0 : (y[idx+1] > newY ? 1 : -1);
+ ++count;
+ }
+ prevY = newY;
+ }
+
+ console.log(count);
+
+ let path = document.createElementNS('http://www.w3.org/2000/svg','path');
+ path.setAttribute('class','graph');
+ path.setAttribute('d', coordinates);
+ this.htmlElement.append(path);
+
+ return path;
+ }
+ }
+}