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