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 = (