You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
239 lines
9.8 KiB
239 lines
9.8 KiB
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; |
|
} |
|
}
|
|
|