namespace Simulator { interface ConsumptionData { motorEnergy: number; humanEnergy: number; averageSpeed: number; } export class Vehicle { batteryCapacity: number; 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 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 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 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 requiredEnergy = Math.max(0, distance * (baseConsumption + totalWeight * additionalConsumptionPerKg) + potentialEnergy); 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") 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 { // 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; } } export interface Outing { distance: number; // in km ascendingElevation: number; // in meters } export class OutingPlanning { constructor(public dailyDistance: number, public dailyAscendingElevation: number, public flatTerrainRatio: 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 = 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, 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) { let hourIdx = day * 24 + hour; planning.getOuting(day % 7, hour, outing); 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; let remainingBatteryChargeBeforeOuting = remainingBatteryCharge; remainingBatteryCharge += solarCharge - consumption.motorEnergy; let gridRechargeFrom = -1; let lowBatteryThreshold = vehicle.rechargeMargin * vehicle.batteryCapacity; if(remainingBatteryCharge > vehicle.batteryCapacity) { solarCharge -= remainingBatteryCharge - vehicle.batteryCapacity; remainingBatteryCharge = vehicle.batteryCapacity; } 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.motorEnergy; result.cumulatedSolarRechargeEnergy += solarCharge; result.batteryLevel[hourIdx] = gridRechargeFrom >= 0 ? gridRechargeFrom : remainingBatteryCharge; } } return result; } }