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; + } + } +}