Compare commits

...

10 Commits
1.0 ... master

Author SHA1 Message Date
Youen Toupin 1b14a62f6e Improved battery recharge logic 2 years ago
Youen Toupin ed3e9f7dc4 Improved UI 2 years ago
Youen Toupin b811e128cd Improved speed simulation 2 years ago
Youen Toupin fa6a4f5b09 Added advanced parameters 2 years ago
Youen Toupin a14d37582c updated motor consumption with a more realistic model, based on potential energy 2 years ago
Youen Toupin 1b39d85cb5 using HTML classes instead of ids to allow multiple simulators 3 years ago
Youen Toupin 54a9d49d83 Improved statistics display 3 years ago
Youen Toupin 030a95095e Merge branch 'master' of https://gitea.youb.fr/youen/vehicle-simulator 3 years ago
Youen Toupin 4895ac2bb4 CSS is now embedded in simulator.js 3 years ago
youen 98d1e6ecbc Mise à jour de 'README.md' 3 years ago
  1. 2
      README.md
  2. 2886
      simulator/package-lock.json
  3. 19
      simulator/src/app.scss
  4. 78
      simulator/src/simulator-ui.ts
  5. 275
      simulator/src/simulator.html
  6. 16
      simulator/src/simulator.scss
  7. 172
      simulator/src/simulator.ts
  8. 1
      simulator/tools/embed.js
  9. 4
      simulator/tools/purify.js
  10. 1
      simulator/www/vhelio-simulator.html

2
README.md

@ -6,7 +6,7 @@ Vehicle energy consumption and production simulator. This tool has been created
The easiest way is to use the [online simulator](https://vhelio.org/simulateur-vhelio/).
You can also download the latest pre-built stand-alone version and open `vhelio-simulator.html` in your web browser. When executed in this way, the program will run in your browser, but won't communicate with any server, this is a purely local application.
You can also download the [latest pre-built stand-alone version](https://gitea.youb.fr/youen/vehicle-simulator/releases) and open `vhelio-simulator.html` in your web browser. When executed in this way, the program will run in your browser, but won't communicate with any server, this is a purely local application.
## Building from source

2886
simulator/package-lock.json generated

File diff suppressed because it is too large Load Diff

19
simulator/src/app.scss

@ -6,6 +6,10 @@ input[type=number] {
flex-grow: 2.5;
}
.simulation-warning {
color: red;
}
.dropdown.is-fullwidth {
display: flex;
}
@ -80,3 +84,18 @@ input[type=number] {
.battery-charge-graph text {
font-size: 10px;
}
input[name="parameters-accordion"], .panel-block {
display: none;
}
label.panel-heading {
cursor: pointer;
display: block;
}
#advanced-parameters:checked ~ .panel > .advanced-parameters-body,
#main-parameters:checked ~ .panel > .main-parameters-body {
display: flex;
}

78
simulator/src/simulator-ui.ts

@ -1,11 +1,16 @@
interface SimulationParameters {
batteryCapacity: number,
additionalWeight: number,
climateZone: string,
dailyDistance: number,
dailyAscendingElevation: number
batteryCapacity: number;
emptyVehicleWeight: number;
driverWeight: number;
additionalWeight: number;
humanPower: number;
speedLimit: number;
climateZone: string;
dailyDistance: number;
dailyAscendingElevation: number;
flatTerrainRatio: number;
}
function runSimulation(parameters: SimulationParameters): Simulator.SimulationResult {
@ -13,35 +18,32 @@ function runSimulation(parameters: SimulationParameters): Simulator.SimulationRe
let vehicle = new Simulator.Vehicle();
vehicle.batteryCapacity = parameters.batteryCapacity;
vehicle.emptyVehicleWeight = parameters.emptyVehicleWeight;
vehicle.driverWeight= parameters.driverWeight;
vehicle.additionalWeight = parameters.additionalWeight;
vehicle.humanPower = parameters.humanPower;
vehicle.speedLimit = parameters.speedLimit;
let solarIrradiance: number[] = climateData[parameters.climateZone.toLowerCase()];
let planning = new Simulator.OutingPlanning(parameters.dailyDistance, parameters.dailyAscendingElevation);
let planning = new Simulator.OutingPlanning(parameters.dailyDistance, parameters.dailyAscendingElevation, parameters.flatTerrainRatio);
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');
function initializeSimulator(container: HTMLElement) {
// Insert HTML code in the container
container.innerHTML += (<any>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 = (<any>window)['climate-zones-map.svg'];
container.querySelector('.zones-map').innerHTML = (<any>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.querySelector('.'+elt.getAttribute('data-activate-modal')).classList.toggle('is-active', true);
});
});
@ -51,7 +53,7 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
let zoneSelector = <HTMLSelectElement>container.querySelector('#zone-selector');
let zoneSelector = <HTMLSelectElement>container.querySelector('.zone-selector');
container.querySelectorAll('.climate-zone').forEach(elt => {
elt.addEventListener('click', e => {
let zoneName = elt.getAttribute('id');
@ -60,25 +62,43 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
container.querySelector('#simulate-button').addEventListener('click', e => {
container.querySelector('.simulate-button').addEventListener('click', e => {
let parameters: SimulationParameters = {
batteryCapacity: Number((<HTMLInputElement>container.querySelector('[name=battery-capacity]')).value),
emptyVehicleWeight: Number((<HTMLInputElement>container.querySelector('[name=empty-weight]')).value),
driverWeight: Number((<HTMLInputElement>container.querySelector('[name=driver-weight]')).value),
additionalWeight: Number((<HTMLInputElement>container.querySelector('[name=additional-weight]')).value),
climateZone: (<HTMLSelectElement>container.querySelector('#zone-selector')).value,
humanPower: Number((<HTMLInputElement>container.querySelector('[name=human-power]')).value),
speedLimit: Number((<HTMLInputElement>container.querySelector('[name=speed-limit]')).value),
climateZone: (<HTMLSelectElement>container.querySelector('.zone-selector')).value,
dailyDistance: Number((<HTMLInputElement>container.querySelector('[name=daily-distance]')).value),
dailyAscendingElevation: Number((<HTMLInputElement>container.querySelector('[name=daily-elevation]')).value),
flatTerrainRatio: Number((<HTMLInputElement>container.querySelector('[name=flat-ratio]')).value) / 100.0,
};
let simulationResult = runSimulation(parameters);
console.log(simulationResult);
let resultsContainer = container.querySelector('.simulation-results');
let averageKwhCost = 0.192; // in €/kWh TODO: to verify, this price seems too high
let averageKwhCost = 0.1558; // in €/kWh
let totalConsumedGridPower = simulationResult.cumulatedGridRechargeEnergy / simulationResult.vehicle.batteryEfficiency / simulationResult.vehicle.gridTransformerEfficiency;
let solarRechargeRatio = Math.round(100*(simulationResult.cumulatedSolarRechargeEnergy/(simulationResult.cumulatedSolarRechargeEnergy + simulationResult.cumulatedGridRechargeEnergy)));
let dailyDuration = parameters.dailyDistance / simulationResult.averageSpeed;
let dailyDurationHours = Math.floor(dailyDuration);
let dailyDurationMinutes = Math.round((dailyDuration - dailyDurationHours) * 60);
resultsContainer.querySelector('.result-info').innerHTML = `
<p>Il faudra recharger le vhélio sur secteur environ ${simulationResult.gridChargeCount} fois sur l'année</p>
<p>Cela coûtera ${Math.round(simulationResult.gridChargeCount*(parameters.batteryCapacity/1000)*averageKwhCost*100)/100} sur l'année</p>
<p>La couverture solaire du vhélio est de ${Math.round(100*(simulationResult.cumulatedMotorConsumption-(simulationResult.gridChargeCount+1)*parameters.batteryCapacity)/simulationResult.cumulatedMotorConsumption)}%</p>
`;
<p>Il faudra recharger le vhélio sur secteur environ ${simulationResult.gridChargeCount} fois sur l'année.</p>
<p>Cela coûtera ${Math.round(totalConsumedGridPower/1000*averageKwhCost*100)/100} sur l'année. Le vhélio sera rechargé à ${solarRechargeRatio}% par le soleil, ${100-solarRechargeRatio}% sur secteur.</p>
<p><br/></p>
<p>Vitesse moyenne : ${Math.round(simulationResult.averageSpeed*10.0)/10.0} km/h (${Math.round(simulationResult.flatTerrainSpeed*10.0)/10.0} km/h sur plat, ${Math.round(simulationResult.uphillSpeed*10.0)/10.0} km/h en côte à ${Math.round((parameters.dailyAscendingElevation/1000.0)/(parameters.dailyDistance*(1.0-parameters.flatTerrainRatio)*0.5)*100.0)}%, ${Math.round(simulationResult.downhillSpeed*10.0)/10.0} km/h en descente)</p>
<p>Durée de trajet quotidienne : ${dailyDurationHours}h ${dailyDurationMinutes}min. Distance annuelle : ${Math.round(simulationResult.cumulatedDistance)} km.</p>
`
+ (simulationResult.outOfBatteryDistance >= 1 ? `<p><br/></p><p class="simulation-warning">/!\\ En raison d'une capacité de batterie insuffisante, il faudra faire ${Math.round(simulationResult.outOfBatteryDistance)}km/an sans assistance électrique</p>` : "");
//<p>${Math.round(100*(simulationResult.cumulatedSolarRechargeEnergy/simulationResult.vehicle.batteryEfficiency) / simulationResult.totalProducedSolarEnergy)}% de l'énergie produite par le panneau photovoltaïque sera utilisée pour recharger le vhélio.</p>
let batteryChargeGraph = new SvgDrawing.SvgElement(resultsContainer.querySelector('.battery-charge-graph svg'));
@ -97,7 +117,7 @@ document.addEventListener('DOMContentLoaded', function() {
batteryChargeGraph.viewport.setData({ x: 0, y: 0, width: 365*24, height: parameters.batteryCapacity });
batteryChargeGraph.viewport.setView({ x: marginLeft, y: batteryChargeGraph.height - marginBottom, width: batteryChargeGraph.width - (marginLeft+marginRight), height: -batteryChargeGraph.height+(marginTop+marginBottom) });
batteryChargeGraph.graph(simulationResult.batteryLevel, simulationResult.batteryLevel.map(x => x == 0 ? 1 : 0), [{className: ''}, {className: 'grid-recharge'}]);
batteryChargeGraph.graph(simulationResult.batteryLevel, simulationResult.batteryLevel.map((x, idx) => x == 0 || idx == simulationResult.batteryLevel.length - 2 ? 1 : 0), [{className: ''}, {className: 'grid-recharge'}]);
let months = ['Jan', 'Fev', 'Mar', 'Avr', 'Mai', 'Jui', 'Jui', 'Aou', 'Sep', 'Oct', 'Nov', 'Dec'];
let monthWidth = 365*24/12
for(let month = 0; month < 12; ++month) {
@ -112,4 +132,12 @@ document.addEventListener('DOMContentLoaded', function() {
resultsContainer.classList.toggle('is-hidden', false);
});
}
document.addEventListener('DOMContentLoaded', function() {
// Load CSS
document.getElementsByTagName('head')[0].innerHTML += (<any>window)['simulator.css'];
let container = document.getElementById('simulator');
initializeSimulator(container);
});

275
simulator/src/simulator.html

@ -1,110 +1,199 @@
<section class="section">
<div class="container">
<div class="simulation-parameters">
<div class="wide-label">
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Capacité de la batterie</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="battery-capacity" class="input" type="number" min="1" value="700"/>
</p>
<p class="control">
<a class="button is-static">Wh</a>
</p>
<!-- -------------- main parameters ---------------- -->
<input type="checkbox" id="main-parameters" name="parameters-accordion" checked="checked" disabled="disabled">
<div class="panel">
<label class="panel-heading" for="main-parameters">Paramètres</label>
<div class="panel-block main-parameters-body">
<div class="container wide-label">
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Capacité de la batterie</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="battery-capacity" class="input" type="number" min="1" value="700"/>
</p>
<p class="control">
<a class="button is-static">Wh</a>
</p>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Poids (bagages + passagers)</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="additional-weight" class="input" type="number" min="1" value="130"/>
</p>
<p class="control">
<a class="button is-static">kg</a>
</p>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Poids du pilote</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="driver-weight" class="input" type="number" min="1" value="70"/>
</p>
<p class="control">
<a class="button is-static">kg</a>
</p>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Zone climatique</label>
</div>
<div class="field-body">
<div class="field has-addons">
<div class="control is-expanded">
<div class="select is-fullwidth">
<select id="zone-selector">
<option>H1a</option>
<option>H1b</option>
<option>H1c</option>
<option>H2a</option>
<option>H2b</option>
<option>H2c</option>
<option>H2d</option>
<option>H3</option>
</select>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Poids passagers et chargement</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="additional-weight" class="input" type="number" min="1" value="50"/>
</p>
<p class="control">
<a class="button is-static">kg</a>
</p>
</div>
</div>
<p class="control">
<a class="button" data-activate-modal="zones-map-modal">Choix sur la carte...</a>
</p>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Distance quotidienne</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="daily-distance" class="input" type="number" min="1" value="10">
</p>
<p class="control">
<a class="button is-static">km/jour</a>
</p>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Zone climatique</label>
</div>
<div class="field-body">
<div class="field has-addons">
<div class="control is-expanded">
<div class="select is-fullwidth">
<select class="zone-selector">
<option>H1a</option>
<option>H1b</option>
<option>H1c</option>
<option>H2a</option>
<option>H2b</option>
<option>H2c</option>
<option>H2d</option>
<option>H3</option>
</select>
</div>
</div>
<p class="control">
<a class="button" data-activate-modal="zones-map-modal">Choix sur la carte...</a>
</p>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Dénivelé positif quotidien</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="daily-elevation" class="input" type="number" min="0" value="100">
</p>
<p class="control">
<a class="button is-static">m/jour</a>
</p>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Distance quotidienne</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="daily-distance" class="input" type="number" min="1" value="10">
</p>
<p class="control">
<a class="button is-static">km/jour</a>
</p>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Dénivelé positif quotidien</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="daily-elevation" class="input" type="number" min="0" value="100">
</p>
<p class="control">
<a class="button is-static">m/jour</a>
</p>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">
<a id="simulate-button" class="button is-primary is-fullwidth">Simuler</a>
</p>
</div>
<!-- ------------------- Advanced parameters ---------------------- -->
<!-- set input type to "radio" to change behavior (accordion) -->
<input type="checkbox" id="advanced-parameters" name="parameters-accordion">
<div class="panel">
<label class="panel-heading" for="advanced-parameters">Paramètres avancés</label>
<div class="panel-block advanced-parameters-body">
<div class="container wide-label">
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Poids véhicule</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="empty-weight" class="input" type="number" min="1" value="80"/>
</p>
<p class="control">
<a class="button is-static">kg</a>
</p>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Puissance pilote (pédalage)</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="human-power" class="input" type="number" min="0" value="100"/>
</p>
<p class="control">
<a class="button is-static">W</a>
</p>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Contrainte vitesse (circulation, etc.)</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="speed-limit" class="input" type="number" min="1" value="30"/>
</p>
<p class="control">
<a class="button is-static">km/h</a>
</p>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Ratio de terrain plat</label>
</div>
<div class="field-body">
<div class="field has-addons">
<p class="control is-expanded">
<input name="flat-ratio" class="input" type="number" min="0" max="100" value="50"/>
</p>
<p class="control">
<a class="button is-static">%</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<a class="simulate-button button is-primary is-fullwidth">Simuler</a>
</div>
<div class="simulation-results is-hidden">
@ -126,7 +215,7 @@
</div>
</footer>
<div id="zones-map-modal" class="modal">
<div class="modal zones-map-modal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
@ -134,7 +223,7 @@
<button class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body">
<svg id="zones-map" xmlns="http://www.w3.org/2000/svg" width="595" height="564" version="1.0"></svg>
<svg class="zones-map" xmlns="http://www.w3.org/2000/svg" width="595" height="564" version="1.0"></svg>
</section>
</div>
</div>

16
simulator/src/simulator.scss

@ -26,3 +26,19 @@
@import "app.scss";
}
// Integration in a wordpress page
body.page {
#simulator {
width: 100%;
max-width: 100%;
button.delete {
padding: 0;
}
.select:not(.is-multiple):not(.is-loading)::after {
border: none; // wordpress has its own icon for select elements
}
}
}

172
simulator/src/simulator.ts

@ -1,28 +1,71 @@
namespace Simulator {
interface ConsumptionData {
motorEnergy: number;
humanEnergy: number;
averageSpeed: number;
}
export class Vehicle {
batteryCapacity: number;
batteryEfficiency: number = 1.0; // TODO: typical efficiency of a Li-ion battery (round-trip) is 90%
batteryEfficiency: number = 0.9;
gridTransformerEfficiency: number = 0.85;
rechargeMargin: number = 0.15; // We recharge the battery before an outing if it would otherwise go below this charge ratio
solarPanelEfficiency: number = 0.15;
solarPanelArea: number = 1.0; // in square meters
additionalWeight: number; // additional weight, not counting cyclist and empty vehicle weight, in kg
emptyVehicleWeight: number = 80; // kg
driverWeight: number = 60; // kg
additionalWeight: number = 0; // additional weight, not counting cyclist and empty vehicle weight, in kg
humanPower: number = 100; // W
speedLimit: number = 20; // average speed in km/h, when the vehicle is moving (this is important, because driver does not provide power when stopped)
nominalMotorPower: number = 250; // W
assistanceSpeedLimit: number = 25; // km/h
motorConsumption(distance: number, ascendingElevation: number): number {
consumption(distance: number, ascendingElevation: number, inOutConsumption: ConsumptionData) {
if(distance <= 0)
{
return;
}
const g = 9.8;
let totalWeight = this.emptyVehicleWeight + this.driverWeight + this.additionalWeight;
let potentialEnergy = totalWeight * g * ascendingElevation; // Ep = m*g*h (result in Joules)
potentialEnergy = potentialEnergy / 3600; // convert joules to watt-hour
// 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 baseConsumption = 13; // in Wh/km, when human power is 0
let additionalConsumptionPerKg = 0.01; // in Wh/km per kg of total vehicle weight (additional losses due to increased friction, mostly independent of speed)
let weightRelatedConsumption = MathUtils.clamp(this.additionalWeight * maxWeightAdditionalConsumption / maxWeight, 0, maxWeightAdditionalConsumption);
let requiredEnergy = Math.max(0, distance * (baseConsumption + totalWeight * additionalConsumptionPerKg) + potentialEnergy);
// TODO: should not be multiplied by distance
// TODO: should be multiplied by total vehicle weight
let elevationRelatedConsumption = MathUtils.clamp(ascendingElevation * maxTestedElevationConsumption / maxTestedElevation, 0, maxTestedElevationConsumption);
let motorPowerLimit = this.nominalMotorPower;
let tripDuration = Math.max(0.0001, requiredEnergy / (motorPowerLimit + this.humanPower));
let actualSpeed = distance / tripDuration;
//console.log("Max vehicle speed, according to available power: " + (Math.round(actualSpeed*10)/10) + " km/h")
return distance * (baseConsumption + weightRelatedConsumption + elevationRelatedConsumption)
if(actualSpeed > this.assistanceSpeedLimit) {
let assistTripDuration = distance / this.assistanceSpeedLimit
motorPowerLimit = Math.max(0, requiredEnergy/assistTripDuration - this.humanPower);
if(motorPowerLimit + this.humanPower > 0)
tripDuration = requiredEnergy / (motorPowerLimit + this.humanPower);
actualSpeed = distance / tripDuration;
//console.log("Vehicle speed accounting for assistance speed limit: " + (Math.round(actualSpeed*10)/10) + " km/h (motor power limited to: " + Math.round(motorPowerLimit) + " W)")
}
if(actualSpeed > this.speedLimit) {
actualSpeed = this.speedLimit;
tripDuration = distance / actualSpeed;
motorPowerLimit = Math.max(0, requiredEnergy/tripDuration - this.humanPower);
}
let humanEnergy = Math.max(requiredEnergy, tripDuration * this.humanPower);
inOutConsumption.motorEnergy += Math.max(motorPowerLimit * tripDuration, requiredEnergy - humanEnergy);
inOutConsumption.humanEnergy += humanEnergy;
inOutConsumption.averageSpeed += actualSpeed;
}
solarPower(irradiance: number): number {
@ -38,7 +81,7 @@ namespace Simulator {
}
export class OutingPlanning {
constructor(public dailyDistance: number, public dailyAscendingElevation: number) {
constructor(public dailyDistance: number, public dailyAscendingElevation: number, public flatTerrainRatio: number) {
}
getOuting(dayOfWeek: number, hourOfDay: number, outing: Outing) {
@ -54,30 +97,84 @@ namespace Simulator {
}
outing.distance = dailyRatio * this.dailyDistance;
outing.ascendingElevation = this.dailyAscendingElevation;
outing.ascendingElevation = dailyRatio * this.dailyAscendingElevation;
}
}
export interface SimulationResult {
vehicle: Vehicle;
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)
totalProducedSolarEnergy: number; // Cumulated energy produced (used or unused), before accounting for the battery recharge efficiency.
cumulatedMotorConsumption: number; // Cumulated energy consumed by the motor, in Wh. In this simulation, this is equal to the energy drawn from the battery.
cumulatedHumanEnergy: number;
cumulatedDistance: number;
flatTerrainSpeed: number;
uphillSpeed: number;
downhillSpeed: number;
averageSpeed: number;
outOfBatteryDistance: number; // distance, in km, that the driver had to pedal without assistance, because the battery was empty
}
export function simulate(vehicle: Vehicle, solarIrradiance: number[], planning: OutingPlanning): SimulationResult {
let result: SimulationResult = {
vehicle: vehicle,
batteryLevel: [],
gridChargeCount: 0,
cumulatedGridRechargeEnergy: 0,
cumulatedSolarRechargeEnergy: 0,
cumulatedMotorConsumption: 0
totalProducedSolarEnergy: 0,
cumulatedMotorConsumption: 0,
cumulatedHumanEnergy: 0,
cumulatedDistance: 0,
flatTerrainSpeed: 0,
uphillSpeed: 0,
downhillSpeed: 0,
averageSpeed: 0,
outOfBatteryDistance: 0
};
let remainingBatteryCharge = vehicle.batteryCapacity;
let outing: Outing = { distance: 0, ascendingElevation: 0 };
let consumption: ConsumptionData = { motorEnergy: 0, humanEnergy: 0, averageSpeed: 0 };
let resetConsumption = function(outConsumption: ConsumptionData) {
outConsumption.motorEnergy = 0; outConsumption.humanEnergy = 0; outConsumption.averageSpeed = 0;
};
let flatTerrainRatio = MathUtils.clamp(planning.flatTerrainRatio, 0.0, 1.0);
if(planning.dailyAscendingElevation <= 0) flatTerrainRatio = 1.0;
let flatDistance = planning.dailyDistance * flatTerrainRatio;
resetConsumption(consumption);
vehicle.consumption(flatDistance, 0, consumption);
result.flatTerrainSpeed = consumption.averageSpeed;
let uphillDistance = planning.dailyDistance * (1.0 - flatTerrainRatio) * 0.5;
resetConsumption(consumption);
vehicle.consumption(uphillDistance, planning.dailyAscendingElevation, consumption);
result.uphillSpeed = consumption.averageSpeed;
let downhillDistance = planning.dailyDistance * (1.0 - flatTerrainRatio) * 0.5;
resetConsumption(consumption);
vehicle.consumption(downhillDistance, -planning.dailyAscendingElevation, consumption);
result.downhillSpeed = consumption.averageSpeed;
let dailyTripDuration =
(flatDistance > 0 ? flatDistance / result.flatTerrainSpeed : 0)
+ (uphillDistance > 0 ? uphillDistance / result.uphillSpeed : 0)
+ (downhillDistance > 0 ? downhillDistance / result.downhillSpeed : 0);
result.averageSpeed = planning.dailyDistance / dailyTripDuration;
for(let day = 0; day < 365; ++day) {
for(let hour = 0; hour < 24; ++hour) {
@ -85,32 +182,55 @@ namespace Simulator {
planning.getOuting(day % 7, hour, outing);
let consumption = vehicle.motorConsumption(outing.distance, outing.ascendingElevation);
resetConsumption(consumption);
vehicle.consumption(outing.distance * flatTerrainRatio, 0, consumption);
vehicle.consumption(outing.distance * (1.0 - flatTerrainRatio) * 0.5, outing.ascendingElevation, consumption);
vehicle.consumption(outing.distance * (1.0 - flatTerrainRatio) * 0.5, -outing.ascendingElevation, consumption);
result.cumulatedDistance += outing.distance;
let production = vehicle.solarPower(solarIrradiance[hourIdx]) * 1.0; // produced energy in Wh is equal to power (W) multiplied by time (h)
result.totalProducedSolarEnergy += production;
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;
let remainingBatteryChargeBeforeOuting = remainingBatteryCharge;
remainingBatteryCharge += solarCharge - consumption.motorEnergy;
let gridRecharge = false;
let gridRechargeFrom = -1;
let lowBatteryThreshold = vehicle.rechargeMargin * vehicle.batteryCapacity;
if(remainingBatteryCharge > vehicle.batteryCapacity) {
solarCharge -= remainingBatteryCharge - vehicle.batteryCapacity;
remainingBatteryCharge = vehicle.batteryCapacity;
}
else if(remainingBatteryCharge <= 0) {
// TODO: detect if battery capacity is too low for a single outing, abort simulation and display an explanation for the user
gridRecharge = true;
let rechargeEnergy = vehicle.batteryCapacity - remainingBatteryCharge;
else if(remainingBatteryCharge <= lowBatteryThreshold || (day==364 && hour==23)) {
gridRechargeFrom = remainingBatteryChargeBeforeOuting;
let rechargeEnergy = vehicle.batteryCapacity - remainingBatteryChargeBeforeOuting;
remainingBatteryCharge += rechargeEnergy;
result.cumulatedGridRechargeEnergy += rechargeEnergy;
result.gridChargeCount += 1;
if(remainingBatteryCharge < 0)
{
// battery was exhausted during outing
let missingEnergy = -remainingBatteryCharge;
result.outOfBatteryDistance += outing.distance * missingEnergy / consumption.motorEnergy;
consumption.motorEnergy -= missingEnergy;
consumption.humanEnergy += missingEnergy;
// charge battery again after outing
let secondRechargeEnergy = vehicle.batteryCapacity;
remainingBatteryCharge = vehicle.batteryCapacity;
result.cumulatedGridRechargeEnergy += secondRechargeEnergy;
result.gridChargeCount += 1;
gridRechargeFrom = 0;
}
}
result.cumulatedMotorConsumption += consumption;
result.cumulatedMotorConsumption += consumption.motorEnergy;
result.cumulatedSolarRechargeEnergy += solarCharge;
result.batteryLevel[hourIdx] = gridRecharge ? 0 : remainingBatteryCharge;
result.batteryLevel[hourIdx] = gridRechargeFrom >= 0 ? gridRechargeFrom : remainingBatteryCharge;
}
}

1
simulator/tools/embed.js

@ -84,3 +84,4 @@ let intermediateDir = toolsDir + "/../.intermediate";
embedHtml(srcDir+'/simulator.html', intermediateDir+'/simulator.html.ts');
embedSvg(dataDir+'/climate-zones-map.svg', intermediateDir+'/climate-zones-map.svg.ts');
embedCsv(dataDir+'/climate-zones-data.csv', intermediateDir+'/climate-zones-data.ts');
embedCss(intermediateDir+'/simulator.css', intermediateDir+'/simulator.css.ts');

4
simulator/tools/purify.js

@ -7,8 +7,8 @@ let content = ['./*.html'];
let css = ['../.intermediate/simulator.css'];
let options = {
output: '../www/simulator.css',
whitelist: ['is-multiple', 'is-loading', 'is-narrow', 'is-active', 'climate-zone', 'grid-recharge', 'is-max-desktop', 'is-max-widescreen', 'line'],
output: '../.intermediate/simulator.css',
whitelist: ['is-multiple', 'is-loading', 'is-narrow', 'is-active', 'climate-zone', 'grid-recharge', 'is-max-desktop', 'is-max-widescreen', 'line', 'page', 'simulation-warning'],
minify: false,
info: false
};

1
simulator/www/vhelio-simulator.html

@ -3,7 +3,6 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="simulator.css">
<style>
html, body {

Loading…
Cancel
Save