Browse Source

reorganized ts files

master
Youen Toupin 3 years ago
parent
commit
a4fbc4c36e
  1. 6
      simulator/src/html-utils.ts
  2. 5
      simulator/src/math-utils.ts
  3. 146
      simulator/src/simulator-core.ts
  4. 82
      simulator/src/simulator-ui.ts
  5. 166
      simulator/src/simulator.ts
  6. 77
      simulator/src/svg-drawing.ts

6
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 && <Element>el.parentNode);
}
}

5
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);
}
}

146
simulator/src/simulator-core.ts

@ -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 = (<any>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;
}

82
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 = (<any>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 += (<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.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 = <HTMLSelectElement>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((<HTMLInputElement>container.querySelector('[name=battery-capacity]')).value),
additionalWeight: Number((<HTMLInputElement>container.querySelector('[name=additional-weight]')).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),
};
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);
});
});

166
simulator/src/simulator.ts

@ -1,75 +1,115 @@
function closest (el: Element, predicate: (e: Element) => boolean) { namespace Simulator {
do if (predicate(el)) return el; export class Vehicle {
while (el = el && <Element>el.parentNode); batteryCapacity: number;
} batteryEfficiency: number = 1.0; // TODO: typical efficiency of a Li-ion battery (round-trip) is 90%
document.addEventListener('DOMContentLoaded', function() { solarPanelEfficiency: number = 0.15;
let container = document.getElementById('simulator'); solarPanelArea: number = 1.0; // in square meters
// Insert HTML code in the container additionalWeight: number; // additional weight, not counting cyclist and empty vehicle weight, in kg
container.innerHTML += (<any>window)['simulator.html'];
motorConsumption(distance: number, ascendingElevation: number): number {
// 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) // empirical measures
// For this purpose, the SVG file contents are embedded in a javascript file let maxWeight = 200; // in kg
container.querySelector('#zones-map').innerHTML = (<any>window)['climate-zones-map.svg']; 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 => { export interface Outing {
elt.addEventListener('click', e => { distance: number; // in km
container.querySelector('#'+elt.getAttribute('data-activate-modal')).classList.toggle('is-active', true); ascendingElevation: number; // in meters
}); }
});
container.querySelectorAll('.modal-close, .modal-card-head .delete').forEach(elt => { export class OutingPlanning {
elt.addEventListener('click', e => { constructor(public dailyDistance: number, public dailyAscendingElevation: number) {
closest(elt, e => e.classList.contains('modal')).classList.toggle('is-active', false); }
});
}); 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 = <HTMLSelectElement>container.querySelector('#zone-selector'); export interface SimulationResult {
container.querySelectorAll('.climate-zone').forEach(elt => { batteryLevel: number[]; // Remaining energy in the battery over time (one entry per hour), in Wh
elt.addEventListener('click', e => { gridChargeCount: number;
let zoneName = elt.getAttribute('id'); 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)
zoneSelector.value = zoneName; 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)
closest(elt, e => e.classList.contains('modal')).classList.toggle('is-active', false); 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 => { export function simulate(vehicle: Vehicle, solarIrradiance: number[], planning: OutingPlanning): SimulationResult {
let parameters: SimulationParameters = { let result: SimulationResult = {
batteryCapacity: Number((<HTMLInputElement>container.querySelector('[name=battery-capacity]')).value), batteryLevel: [],
additionalWeight: Number((<HTMLInputElement>container.querySelector('[name=additional-weight]')).value), gridChargeCount: 0,
climateZone: (<HTMLSelectElement>container.querySelector('#zone-selector')).value, cumulatedGridRechargeEnergy: 0,
dailyDistance: Number((<HTMLInputElement>container.querySelector('[name=daily-distance]')).value), cumulatedSolarRechargeEnergy: 0,
dailyAscendingElevation: Number((<HTMLInputElement>container.querySelector('[name=daily-elevation]')).value), cumulatedMotorConsumption: 0
}; };
let simulationResult = startSimulation(parameters);
let resultsContainer = container.querySelector('.simulation-results'); let remainingBatteryCharge = vehicle.batteryCapacity;
let batteryChargeGraph = resultsContainer.querySelector('.battery-charge-graph'); let outing: Outing = { distance: 0, ascendingElevation: 0 };
let batteryChargeGraphSvg = batteryChargeGraph.querySelector('svg');
let coordinates = ''; for(let day = 0; day < 365; ++day) {
let view = [1000, 300]; for(let hour = 0; hour < 24; ++hour) {
let hoursInYear = 365 * 24; let hourIdx = day * 24 + hour;
for(let dayOfYear = 0; dayOfYear < 365; ++dayOfYear) {
for(let hourOfDay = 0; hourOfDay < 24; ++hourOfDay) {
let h = dayOfYear * 24 + hourOfDay;
let batteryLevel = simulationResult.batteryLevel[h];
if(h == 0) coordinates += 'M'; planning.getOuting(day % 7, hour, outing);
else if(h == 1) coordinates += ' L';
else coordinates += ' ';
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;
}); }
}); }

77
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;
}
}
}
Loading…
Cancel
Save