diff --git a/simulator/src/app.scss b/simulator/src/app.scss new file mode 100644 index 0000000..5bfdfc5 --- /dev/null +++ b/simulator/src/app.scss @@ -0,0 +1,50 @@ +input[type=number] { + -moz-appearance:textfield; +} + +.wide-label .field-label { + flex-grow: 2.5; +} + +.dropdown.is-fullwidth { + display: flex; +} + +.dropdown.is-fullwidth .dropdown-trigger, +.dropdown.is-fullwidth .dropdown-menu { + width: 100%; +} + +.dropdown-trigger.with-dropdown-icon::after { + border: 3px solid black; + border-radius: 2px; + border-right: 0; + border-top: 0; + content: " "; + display: block; + height: 0.625em; + margin-top: -0.4375em; + pointer-events: none; + position: absolute; + top: 50%; + right: 15px; + transform: rotate(-45deg); + transform-origin: center; + width: 0.625em; +} + +.climate-zone { + cursor: pointer; +} + +svg g { + filter: drop-shadow( 4px 4px 3px rgba(0, 0, 0, .7)); +} + +.climate-zone:hover { + filter: brightness(1.2); +} + +svg text { + pointer-events: none; +} diff --git a/simulator/src/simulator-core.ts b/simulator/src/simulator-core.ts new file mode 100644 index 0000000..638ee7f --- /dev/null +++ b/simulator/src/simulator-core.ts @@ -0,0 +1,132 @@ +function clamp(x: number, mini: number, maxi: number) { + return x <= mini ? mini : (x >= maxi ? maxi : x); +} + +class Vehicle { + batteryCapacity: number; + batteryEfficiency: number = 1.0; // TODO: typical efficiency of a Li-ion battery (round-trip) is 90% + + solarPanelEfficiency: number = 0.15; + solarPanelArea: number = 1.0; // in square meters + + additionalWeight: number; // additional weight, not counting cyclist and empty vehicle weight, in kg + + motorConsumption(distance: number, ascendingElevation: number): number { + // empirical measures + let maxWeight = 200; // in kg + let maxWeightAdditionalConsumption = 4; // in Wh/km + let maxTestedElevation = 500; // in meters + let maxTestedElevationConsumption = 7; // in Wh/m + let baseConsumption = 14; // in Wh/km + + let weightRelatedConsumption = clamp(this.additionalWeight * maxWeightAdditionalConsumption / maxWeight, 0, maxWeightAdditionalConsumption); + + // TODO: should not be multiplied by distance + // TODO: should be multiplied by total vehicle weight + let elevationRelatedConsumption = clamp(ascendingElevation * maxTestedElevationConsumption / maxTestedElevation, 0, maxTestedElevationConsumption); + + return distance * (baseConsumption + weightRelatedConsumption + elevationRelatedConsumption) + } + + solarPower(irradiance: number): number { + return irradiance * this.solarPanelArea * this.solarPanelEfficiency; + } +} + +interface Outing { + distance: number; // in km + ascendingElevation: number; // in meters +} + +class OutingPlanning { + constructor(public dailyDistance: number, public dailyAscendingElevation: number) { + } + + getOuting(dayOfWeek: number, hourOfDay: number, outing: Outing) { + let dailyRatio = 0; + + if(dayOfWeek >= 5) { + // week end + dailyRatio = hourOfDay == 10 ? 1.0 : 0.0; + } + else { + // other week day + dailyRatio = hourOfDay == 8 || hourOfDay == 16 ? 0.5 : 0.0; + } + + outing.distance = dailyRatio * this.dailyDistance; + outing.ascendingElevation = this.dailyAscendingElevation; + } +} + +interface SimulationResult { + batteryLevel: number[]; // Remaining energy in the battery over time (one entry per hour), in Wh + cumulatedGridRechargeEnergy: number; // Cumulated energy added to the battery from the power grid, in Wh of battery charge (actual power grid consumption will be slightly higer due to losses) + cumulatedSolarRechargeEnergy: number; // Cumulated energy added to the battery from the solar panel, in Wh of battery charge (actual generated power is slightly higher due to losses) + cumulatedMotorConsumption: number; // Cumulated energy consumed by the motor, in Wh. In this simulation, this is equal to the energy drawn from the battery. +} + +interface SimulationParameters { + batteryCapacity: number, + additionalWeight: number, + climateZone: string, + dailyDistance: number, + dailyAscendingElevation: number +} + +function runSimulation(vehicle: Vehicle, solarIrradiance: number[], planning: OutingPlanning): SimulationResult { + let result: SimulationResult = { + batteryLevel: [], + cumulatedGridRechargeEnergy: 0, + cumulatedSolarRechargeEnergy: 0, + cumulatedMotorConsumption: 0 + }; + + let remainingBatteryCharge = vehicle.batteryCapacity; + + let outing: Outing = { distance: 0, ascendingElevation: 0 }; + + for(let day = 0; day < 365; ++day) { + for(let hour = 0; hour < 24; ++hour) { + let hourIdx = day * 24 + hour; + + planning.getOuting(day % 7, hour, outing); + + let consumption = vehicle.motorConsumption(outing.distance, outing.ascendingElevation); + let production = vehicle.solarPower(solarIrradiance[hourIdx]) * 1.0; // produced energy in Wh is equal to power (W) multiplied by time (h) + + let solarCharge = production * vehicle.batteryEfficiency; + + remainingBatteryCharge += solarCharge - consumption; + if(remainingBatteryCharge > vehicle.batteryCapacity) { + solarCharge -= remainingBatteryCharge - vehicle.batteryCapacity; + remainingBatteryCharge = vehicle.batteryCapacity; + } + else if(remainingBatteryCharge < 0) { + let rechargeEnergy = vehicle.batteryCapacity - remainingBatteryCharge; + remainingBatteryCharge += rechargeEnergy; + result.cumulatedGridRechargeEnergy += rechargeEnergy; + } + + result.cumulatedMotorConsumption += consumption; + result.cumulatedSolarRechargeEnergy += solarCharge; + + result.batteryLevel[hourIdx] = remainingBatteryCharge; + } + } + + return result; +} + +function startSimulation(parameters: SimulationParameters) { + let climateData = (window)['climate-zones-data.csv']; + + let vehicle = new Vehicle(); + vehicle.batteryCapacity = parameters.batteryCapacity; + vehicle.additionalWeight = parameters.additionalWeight; + let solarIrradiance: number[] = climateData[parameters.climateZone.toLowerCase()]; + let planning = new OutingPlanning(parameters.dailyDistance, parameters.dailyAscendingElevation); + + let simulationResult = runSimulation(vehicle, solarIrradiance, planning); + console.log(simulationResult); +} diff --git a/simulator/src/simulator.html b/simulator/src/simulator.html index 5a2278e..605ba33 100644 --- a/simulator/src/simulator.html +++ b/simulator/src/simulator.html @@ -12,7 +12,7 @@

- kWh + Wh

@@ -99,7 +99,7 @@
diff --git a/simulator/src/simulator.scss b/simulator/src/simulator.scss index cb5ec55..7e688fe 100644 --- a/simulator/src/simulator.scss +++ b/simulator/src/simulator.scss @@ -23,55 +23,6 @@ & > section, & > footer { font-size: $body-font-size; // we must set this on each direct child of the #simulator div, because if it's specified in em, it's multiplied by the parent font-size } - - input[type=number] { - -moz-appearance:textfield; - } - - .wide-label .field-label { - flex-grow: 2.5; - } - - .dropdown.is-fullwidth { - display: flex; - } - - .dropdown.is-fullwidth .dropdown-trigger, - .dropdown.is-fullwidth .dropdown-menu { - width: 100%; - } - - .dropdown-trigger.with-dropdown-icon::after { - border: 3px solid black; - border-radius: 2px; - border-right: 0; - border-top: 0; - content: " "; - display: block; - height: 0.625em; - margin-top: -0.4375em; - pointer-events: none; - position: absolute; - top: 50%; - right: 15px; - transform: rotate(-45deg); - transform-origin: center; - width: 0.625em; - } - - .climate-zone { - cursor: pointer; - } - - svg g { - filter: drop-shadow( 4px 4px 3px rgba(0, 0, 0, .7)); - } - - .climate-zone:hover { - filter: brightness(1.2); - } - svg text { - pointer-events: none; - } + @import "app.scss"; } diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts index 502f8f8..d3c24ed 100644 --- a/simulator/src/simulator.ts +++ b/simulator/src/simulator.ts @@ -33,4 +33,15 @@ document.addEventListener('DOMContentLoaded', function() { closest(elt, e => e.classList.contains('modal')).classList.toggle('is-active', false); }); }); + + document.getElementById('simulate-button').addEventListener('click', e => { + let parameters: SimulationParameters = { + batteryCapacity: Number((document.querySelector('[name=battery-capacity]')).value), + additionalWeight: Number((document.querySelector('[name=additional-weight]')).value), + climateZone: (document.getElementById('zone-selector')).value, + dailyDistance: Number((document.querySelector('[name=daily-distance]')).value), + dailyAscendingElevation: Number((document.querySelector('[name=daily-elevation]')).value), + }; + startSimulation(parameters); + }); });