diff --git a/optimization/src/main/java/de/hatoka/eos/optimization/capi/business/OptimizationRequest.java b/optimization/src/main/java/de/hatoka/eos/optimization/capi/business/OptimizationRequest.java new file mode 100644 index 0000000..70425a8 --- /dev/null +++ b/optimization/src/main/java/de/hatoka/eos/optimization/capi/business/OptimizationRequest.java @@ -0,0 +1,16 @@ +package de.hatoka.eos.optimization.capi.business; + +import de.hatoka.eos.simulation.capi.business.config.SimulationConfig; +import de.hatoka.eos.simulation.capi.business.config.SimulationTimeSettings; + +import java.time.Duration; +import java.time.ZonedDateTime; + +public record OptimizationRequest(ZonedDateTime startDate, ZonedDateTime endDate, Duration stepDuration) +{ + public static OptimizationRequest valueOf(SimulationConfig simulationConfig) + { + SimulationTimeSettings timeSettings = simulationConfig.getTimeSettings(); + return new OptimizationRequest(timeSettings.getZonedStartTime(), timeSettings.getZonedEndTime(), timeSettings.getStepDuration()); + } +} diff --git a/optimization/src/main/java/de/hatoka/eos/optimization/capi/business/OptimizationResult.java b/optimization/src/main/java/de/hatoka/eos/optimization/capi/business/OptimizationResult.java index b7ad246..a4ac4fd 100644 --- a/optimization/src/main/java/de/hatoka/eos/optimization/capi/business/OptimizationResult.java +++ b/optimization/src/main/java/de/hatoka/eos/optimization/capi/business/OptimizationResult.java @@ -5,13 +5,19 @@ /** * OptimizationResult contains the simulation result and the calculated penalty for optimization goals. - * @param simulationResult result of simulation * @param penalty penalty for non reached goals + * @param manipulators */ -public record OptimizationResult(SimulationResult simulationResult, Money penalty) +public record OptimizationResult(Money penalty, + java.util.List manipulators) { public Money getPenalty() { return penalty; } + + public boolean isBetterThan(OptimizationResult other) + { + return getPenalty().isLessThan(other.getPenalty()); + } } diff --git a/optimization/src/main/java/de/hatoka/eos/optimization/capi/business/Optimizer.java b/optimization/src/main/java/de/hatoka/eos/optimization/capi/business/Optimizer.java index 89ce7f8..7870e76 100644 --- a/optimization/src/main/java/de/hatoka/eos/optimization/capi/business/Optimizer.java +++ b/optimization/src/main/java/de/hatoka/eos/optimization/capi/business/Optimizer.java @@ -9,5 +9,5 @@ */ public interface Optimizer { - OptimizationResult optimize(InstallationConfig config, OptimizationGoals goals); + OptimizationResult optimize(InstallationConfig config, OptimizationGoals goals, OptimizationRequest optimizationRequest); } diff --git a/optimization/src/main/java/de/hatoka/eos/optimization/capi/tasks/CarCharge.java b/optimization/src/main/java/de/hatoka/eos/optimization/capi/tasks/CarCharge.java new file mode 100644 index 0000000..11bc368 --- /dev/null +++ b/optimization/src/main/java/de/hatoka/eos/optimization/capi/tasks/CarCharge.java @@ -0,0 +1,82 @@ +package de.hatoka.eos.optimization.capi.tasks; + +import de.hatoka.eos.simulation.capi.business.device.Device; +import de.hatoka.eos.simulation.capi.business.device.DeviceRef; +import de.hatoka.eos.simulation.capi.business.device.DeviceType; +import de.hatoka.eos.simulation.capi.business.simulation.DeviceManipulator; +import de.hatoka.eos.simulation.internal.business.devices.ElectricCar; +import de.hatoka.eos.units.capi.Percentage; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public record CarCharge(int hours, long from, long to, long start) implements DeviceManipulator +{ + public static CarCharge valueOf(ZonedDateTime from, ZonedDateTime to, ZonedDateTime start, int hours) + { + return new CarCharge(hours, from.toInstant().toEpochMilli(), to.toInstant().toEpochMilli(), start.toInstant().toEpochMilli()); + } + + public static CarCharge init(ZonedDateTime from, ZonedDateTime to, int hours) + { + return valueOf(from, to, from, hours); + } + + @Override + public Map apply(ZonedDateTime time, Map devices) + { + Map result = new HashMap<>(); + devices.forEach((r, d) -> { + result.put(r, apply(time, r, d)); + }); + return result; + } + private static final ZoneId UTC = ZoneId.of("UTC"); + + public ZonedDateTime getZonedDateTime(long time) + { + return Instant.ofEpochMilli(time).atZone(UTC); + } + + private Device apply(ZonedDateTime time, DeviceRef deviceRef, Device device) + { + if (!DeviceType.ELECTRIC_CAR.equals(deviceRef.type())) + { + return device; + } + if (device instanceof ElectricCar car) + { + if (time.isBefore(getZonedDateTime(start)) || time.isAfter(getZonedDateTime(start).plusHours(hours))) + { + return car.resetOverrideForceChargingLimit(); + } + return car.setOverrideForceChargingLimit(Percentage.ONE_HUNDRED); + } + return device; + } + + @Override + public List evolute() + { + List result = new ArrayList<>(); + if (getZonedDateTime(start()).plusHours(1).isBefore(getZonedDateTime(to))) + { + result.add(new CarCharge(hours, from, to, getZonedDateTime(start).plusHours(1).toInstant().toEpochMilli())); + } + if (!getZonedDateTime(from).isAfter(getZonedDateTime(start()).minusHours(1))) + { + result.add(new CarCharge(hours, from, to, getZonedDateTime(start).minusHours(1).toInstant().toEpochMilli())); + } + result.add(new CarCharge(hours + 1, from, to, start)); + if (hours - 1 > 0) + { + result.add(new CarCharge(hours - 1, from, to, start)); + } + return result; + } +} diff --git a/optimization/src/main/java/de/hatoka/eos/optimization/internal/business/OptimizerImpl.java b/optimization/src/main/java/de/hatoka/eos/optimization/internal/business/OptimizerImpl.java index 3ee814c..d5d8308 100644 --- a/optimization/src/main/java/de/hatoka/eos/optimization/internal/business/OptimizerImpl.java +++ b/optimization/src/main/java/de/hatoka/eos/optimization/internal/business/OptimizerImpl.java @@ -1,14 +1,24 @@ package de.hatoka.eos.optimization.internal.business; -import de.hatoka.eos.simulation.capi.business.config.InstallationConfig; -import de.hatoka.eos.simulation.capi.business.device.DeviceFactory; -import de.hatoka.eos.simulation.capi.business.simulation.EnergySystem; -import de.hatoka.eos.simulation.capi.business.simulation.SimulationResult; +import de.hatoka.eos.optimization.capi.business.OptimizationRequest; import de.hatoka.eos.optimization.capi.business.OptimizationResult; import de.hatoka.eos.optimization.capi.business.Optimizer; import de.hatoka.eos.optimization.capi.goals.OptimizationGoals; +import de.hatoka.eos.optimization.capi.tasks.CarCharge; +import de.hatoka.eos.simulation.capi.business.config.InstallationConfig; +import de.hatoka.eos.simulation.capi.business.device.DeviceFactory; +import de.hatoka.eos.simulation.capi.business.forecast.Forecasts; +import de.hatoka.eos.simulation.capi.business.simulation.DeviceManipulator; +import de.hatoka.eos.simulation.capi.business.simulation.SimulationRequest; +import de.hatoka.eos.simulation.capi.business.simulation.SimulationResult; +import de.hatoka.eos.simulation.capi.business.simulation.Simulator; +import jakarta.annotation.Nonnull; import jakarta.inject.Inject; import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; /** * Implementation of an Optimizer @@ -16,13 +26,75 @@ @Singleton public class OptimizerImpl implements Optimizer { + private static Logger LOGGER = LoggerFactory.getLogger(OptimizerImpl.class); @Inject private DeviceFactory deviceFactory; + @Inject + private Simulator simulator; + + private final List> toDos = new ArrayList<>(); + private final Set> done = new HashSet<>(); @Override - public OptimizationResult optimize(InstallationConfig config, OptimizationGoals goals) + public OptimizationResult optimize(InstallationConfig config, OptimizationGoals goals, OptimizationRequest optimizationRequest) + { + // initial without manipulation + OptimizationResult result = evaluate(config, goals, optimizationRequest, 0, List.of()); + LOGGER.info("initial result {}", result); + + toDos.add(List.of(CarCharge.init(optimizationRequest.startDate(), optimizationRequest.endDate(), 1))); + for (int counter = 1; counter < 1000 && !toDos.isEmpty(); counter++) + { + List manipulators = toDos.removeFirst(); + OptimizationResult optResult = evaluate(config, goals, optimizationRequest, counter, manipulators); + if (optResult.isBetterThan(result)) + { + result = optResult; + LOGGER.info("found better result {}", optResult); + } + else { + LOGGER.trace("found worse result {}", optResult); + } + done.add(manipulators); + toDos.addAll(evolute(manipulators).stream().filter(this::doesNotExist).toList()); + } + return result; + } + + private boolean doesNotExist(List manipulators) + { + return !contains(manipulators); + } + + private boolean contains(List manipulators) + { + return done.contains(manipulators) || toDos.contains(manipulators); + } + + private List> evolute(List manipulators) + { + List> result = new ArrayList<>(); + for (DeviceManipulator manipulator : manipulators) + { + for (DeviceManipulator child : manipulator.evolute()) + { + List others = new ArrayList<>(manipulators); + others.remove(manipulator); + others.add(child); + result.add(others); + } + } + return result; + } + + @Nonnull + private OptimizationResult evaluate(InstallationConfig config, OptimizationGoals goals, OptimizationRequest optimizationRequest, int counter, + List manipulators) { - SimulationResult simResult = new SimulationResult(null, null, deviceFactory.createInitialState(config.getDevices()), EnergySystem.INIT); - return new OptimizationResult(simResult, goals.getPenalty(simResult)); + SimulationRequest request = new SimulationRequest("optimization-" + counter, optimizationRequest.startDate(), optimizationRequest.endDate(), + optimizationRequest.stepDuration(), deviceFactory.createDevices(config.getDevices()), Collections.emptyMap(), + Forecasts.STANDARD); + SimulationResult simResult = simulator.simulate(request, manipulators); + return new OptimizationResult(goals.getPenalty(simResult), manipulators); } } diff --git a/optimization/src/test/java/de/hatoka/eos/optimization/capi/goals/CarChargeGoalTest.java b/optimization/src/test/java/de/hatoka/eos/optimization/capi/goals/CarChargeGoalTest.java index 1abd4c6..303475b 100644 --- a/optimization/src/test/java/de/hatoka/eos/optimization/capi/goals/CarChargeGoalTest.java +++ b/optimization/src/test/java/de/hatoka/eos/optimization/capi/goals/CarChargeGoalTest.java @@ -39,7 +39,7 @@ public void testPenaltyCalculationWhenBatteryIs60Percent() throws IOException // Set goal to reach 80% charge using reflection since fields are private Money actualPenalty = goals.getPenalty(new SimulationResult(null, null, Map.of(carRef, carState), EnergySystem.INIT)); - // Shortfall: 90% - 60% = 30% -> 3 x 5 EUR = 15 EUR - assertEquals(Money.ofEur(15), actualPenalty); + // Shortfall: 90% - 60% = 30% -> 3 x 20 EUR = 60 EUR + assertEquals(Money.ofEur(60), actualPenalty); } } diff --git a/optimization/src/test/java/de/hatoka/eos/optimization/capi/goals/OptimizationGoalsTest.java b/optimization/src/test/java/de/hatoka/eos/optimization/capi/goals/OptimizationGoalsTest.java index d901d0d..7092699 100644 --- a/optimization/src/test/java/de/hatoka/eos/optimization/capi/goals/OptimizationGoalsTest.java +++ b/optimization/src/test/java/de/hatoka/eos/optimization/capi/goals/OptimizationGoalsTest.java @@ -39,7 +39,7 @@ public void testPenaltyCalculationWhenBatteryIs60Percent() throws IOException Money actualPenalty = goals.getPenalty(new SimulationResult(null, null, Map.of(carRef, carState), EnergySystem.INIT.importEnergy(Energy.ofKwh(10.0), Money.ofEur(2.40)).exportEnergy(Energy.ofKwh(10.0), Money.ofEur(1.40)))); - // Shortfall: 90% - 60% = 30% -> 3 x 5 EUR = 15 EUR + 1EUR (for grid difference) - assertEquals(Money.ofEur(16), actualPenalty); + // Shortfall: 90% - 60% = 30% -> 3 x 20 EUR = 60 EUR + 1EUR (for grid difference) + assertEquals(Money.ofEur(61), actualPenalty); } } diff --git a/optimization/src/test/java/de/hatoka/eos/optimization/capi/tasks/CarChargeTest.java b/optimization/src/test/java/de/hatoka/eos/optimization/capi/tasks/CarChargeTest.java new file mode 100644 index 0000000..ca77c14 --- /dev/null +++ b/optimization/src/test/java/de/hatoka/eos/optimization/capi/tasks/CarChargeTest.java @@ -0,0 +1,47 @@ +package de.hatoka.eos.optimization.capi.tasks; + +import de.hatoka.eos.simulation.capi.business.simulation.DeviceManipulator; +import de.hatoka.eos.units.capi.Percentage; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test for CarCharge task, specifically the evolute() method. + */ +public class CarChargeTest +{ + private CarCharge from(DeviceManipulator manipulator) + { + if (manipulator instanceof CarCharge carCharge) + { + return carCharge; + } + return null; + } + + @Test + public void testEvoluteReturnsNeighbors() + { + ZonedDateTime from = ZonedDateTime.parse("2026-01-02T08:00:00Z"); + ZonedDateTime to = ZonedDateTime.parse("2026-01-02T18:00:00Z"); + ZonedDateTime start = ZonedDateTime.parse("2026-01-02T12:00:00Z"); + + CarCharge carCharge = CarCharge.valueOf(from, to, start, 5); + + List neighbors = carCharge.evolute(); + assertEquals(4, neighbors.size(), "Expected 4 - 2 dimensions - start time and duration"); + // first plus one hour + assertEquals(ZonedDateTime.parse("2026-01-02T13:00:00Z").toInstant().toEpochMilli(), from(neighbors.get(0)).start()); + // second minus one hour + assertEquals(ZonedDateTime.parse("2026-01-02T11:00:00Z").toInstant().toEpochMilli(), from(neighbors.get(1)).start()); + // third plus one hour in duration + assertEquals(6, from(neighbors.get(2)).hours()); + // third minus one hour in duration + assertEquals(4, from(neighbors.get(3)).hours()); + } +} + diff --git a/optimization/src/test/java/de/hatoka/eos/optimization/internal/business/OptimizerTest.java b/optimization/src/test/java/de/hatoka/eos/optimization/internal/business/OptimizerTest.java index d51d938..5fa891c 100644 --- a/optimization/src/test/java/de/hatoka/eos/optimization/internal/business/OptimizerTest.java +++ b/optimization/src/test/java/de/hatoka/eos/optimization/internal/business/OptimizerTest.java @@ -1,6 +1,8 @@ package de.hatoka.eos.optimization.internal.business; +import de.hatoka.eos.optimization.capi.business.OptimizationRequest; import de.hatoka.eos.simulation.capi.business.config.InstallationConfig; +import de.hatoka.eos.simulation.capi.business.config.SimulationConfig; import de.hatoka.eos.units.capi.Money; import de.hatoka.eos.simulation.internal.business.config.ConfigurationLoader; import de.hatoka.eos.optimization.capi.business.OptimizationResult; @@ -12,6 +14,8 @@ import org.junit.jupiter.api.Test; import java.io.IOException; +import java.time.ZoneId; +import java.time.ZonedDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -34,10 +38,12 @@ public class OptimizerTest @Test public void testDoNothing() throws IOException { - InstallationConfig config = configurationLoader.loadInstallation("test-installation-for-optimization.yaml"); + InstallationConfig config = configurationLoader.loadInstallation("test-installation-for-optimization-car-only.yaml"); + SimulationConfig simulationConfig = configurationLoader.loadSimulation("test-simulation.yaml"); OptimizationGoals goals = optimizationConfigurationLoader.loadGoals("goal-for-optimization.yaml"); - OptimizationResult result = optimizer.optimize(config, goals); + OptimizationRequest request = OptimizationRequest.valueOf(simulationConfig); + OptimizationResult result = optimizer.optimize(config, goals, request); // starts at 80% goal is 90% makes 10% difference, 5 EUR - assertEquals(Money.ofEur(5), result.getPenalty()); + assertEquals(Money.ofEur(17.16), result.getPenalty()); } } diff --git a/optimization/src/test/java/de/hatoka/eos/optimization/internal/business/config/OptimizationConfigurationLoaderTest.java b/optimization/src/test/java/de/hatoka/eos/optimization/internal/business/config/OptimizationConfigurationLoaderTest.java index 0909f01..a1fad1b 100644 --- a/optimization/src/test/java/de/hatoka/eos/optimization/internal/business/config/OptimizationConfigurationLoaderTest.java +++ b/optimization/src/test/java/de/hatoka/eos/optimization/internal/business/config/OptimizationConfigurationLoaderTest.java @@ -32,6 +32,6 @@ public void testLoadOptimizationGoal() throws IOException assertEquals(0.1, goal.getCarCharging().getPenalty().percentage().value()); assertNotNull(goal.getCarCharging().getPenalty().price()); - assertEquals(Money.ofEur(5), goal.getCarCharging().getPenalty().price()); + assertEquals(Money.ofEur(20), goal.getCarCharging().getPenalty().price()); } } diff --git a/optimization/src/test/resources/goal-for-optimization.yaml b/optimization/src/test/resources/goal-for-optimization.yaml index aea89cb..74e999b 100644 --- a/optimization/src/test/resources/goal-for-optimization.yaml +++ b/optimization/src/test/resources/goal-for-optimization.yaml @@ -3,5 +3,5 @@ carCharging: penalty: percentage: 0.1 price: - amount: 5 + amount: 20 currency: "EUR" diff --git a/optimization/src/test/resources/test-installation-for-optimization-car-only.yaml b/optimization/src/test/resources/test-installation-for-optimization-car-only.yaml new file mode 100644 index 0000000..39c19b5 --- /dev/null +++ b/optimization/src/test/resources/test-installation-for-optimization-car-only.yaml @@ -0,0 +1,60 @@ +devices: + - type: ELECTRIC_CAR + count: 1 + capacity: + amount: 50.0 + unit: "K_WH" + chargeRate: + amount: 11.0 + unit: "K_W" + dischargeRate: + amount: 10.0 + unit: "K_W" + startStorageLevel: 0.2 # 20% initial charge + chargingEfficiency: 0.90 + dischargingEfficiency: 0.90 + dailyStorageLoss: 0.01 + forceChargingLimit: 0.1 + usageProfile: + days: [TUESDAY, WEDNESDAY] + startUsage: "08:00" + endUsage: "17:00" + energyConsumption: + amount: 15.0 + unit: "K_WH" + + - type: GRID + count: 1 + +# Weather station configuration for forecasts +# Available stations: APOLDA, LEIPZIG_STADTWERKE +weatherStation: APOLDA + +grid: + type: "FLAT" + flatPriceProvider: + importPrice: + amount: 0.39 + currency: EUR + exportPrice: + amount: 0.08 + currency: EUR + csvPriceProvider: + currency: EUR + exportCharge: + price: + amount: 0.00 + currency: EUR + energy: + amount: 1 + unit: "K_WH" + importCharge: + price: + amount: 0.08 + currency: EUR + energy: + amount: 1 + unit: "K_WH" + resource: + - stockprices_DE_LU_week_32-33.csv + diff --git a/optimization/src/test/resources/test-simulation-with-csv-prices.yaml b/optimization/src/test/resources/test-simulation.yaml similarity index 81% rename from optimization/src/test/resources/test-simulation-with-csv-prices.yaml rename to optimization/src/test/resources/test-simulation.yaml index 22dff36..1efcb0e 100644 --- a/optimization/src/test/resources/test-simulation-with-csv-prices.yaml +++ b/optimization/src/test/resources/test-simulation.yaml @@ -2,5 +2,5 @@ id: "test-simulation" simulation: timezone: "Europe/Berlin" start: "2025/08/04-00:00" - end: "2025/08/17-00:00" + end: "2025/08/05-00:00" stepDuration: "PT15M" \ No newline at end of file diff --git a/simulation/src/main/java/de/hatoka/eos/simulation/capi/business/simulation/DeviceManipulator.java b/simulation/src/main/java/de/hatoka/eos/simulation/capi/business/simulation/DeviceManipulator.java new file mode 100644 index 0000000..63e4b65 --- /dev/null +++ b/simulation/src/main/java/de/hatoka/eos/simulation/capi/business/simulation/DeviceManipulator.java @@ -0,0 +1,27 @@ +package de.hatoka.eos.simulation.capi.business.simulation; + +import de.hatoka.eos.simulation.capi.business.device.Device; +import de.hatoka.eos.simulation.capi.business.device.DeviceRef; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; + +/** + * DeviceManipulator can change the configuration of devices + */ +public interface DeviceManipulator +{ + /** + * Changes the configuration of a device temporarily. + * @param time time of simulation + * @param devices existing devices + * @return manipulated devices + */ + Map apply(ZonedDateTime time, Map devices); + + /** + * @return a list of neighbors + */ + List evolute(); +} diff --git a/simulation/src/main/java/de/hatoka/eos/simulation/capi/business/simulation/Simulator.java b/simulation/src/main/java/de/hatoka/eos/simulation/capi/business/simulation/Simulator.java index 68ab436..f9320fd 100644 --- a/simulation/src/main/java/de/hatoka/eos/simulation/capi/business/simulation/Simulator.java +++ b/simulation/src/main/java/de/hatoka/eos/simulation/capi/business/simulation/Simulator.java @@ -1,5 +1,7 @@ package de.hatoka.eos.simulation.capi.business.simulation; +import java.util.List; + /** * Simulator can execute multiple simulations */ @@ -11,4 +13,12 @@ public interface Simulator * @return simulation result */ SimulationResult simulate(SimulationRequest request); + + /** + * Execute a simulation with device manipulators + * @param request configuration of simulation + * @param manipulators device manipulators + * @return simulation result + */ + SimulationResult simulate(SimulationRequest request, List manipulators); } diff --git a/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/devices/Battery.java b/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/devices/Battery.java index 8fdff07..743435b 100644 --- a/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/devices/Battery.java +++ b/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/devices/Battery.java @@ -11,15 +11,26 @@ import java.time.ZonedDateTime; -public class Battery implements Device +public class Battery implements Device, Cloneable { final DeviceConfig config; + private Percentage overrideChargeLimit = null; public Battery(DeviceConfig config) { this.config = config; } + /** + * Copy constructor for creating clones. + * Subclasses should call this constructor and copy their own fields. + */ + protected Battery(Battery other) + { + this.config = other.config; + this.overrideChargeLimit = other.overrideChargeLimit; + } + @Override public DeviceState getInitialState() { @@ -152,6 +163,32 @@ protected boolean shouldDischarge(Energy systemEnergy) */ protected Percentage getChargingLimit(SimulationStep step) { + if (overrideChargeLimit != null) + { + return overrideChargeLimit; + } return config.getForceChargingLimit(); } + + @Override + protected Battery clone() + { + return new Battery(this); + } + + public Device setOverrideForceChargingLimit(Percentage chargeLimit) + { + if (this.overrideChargeLimit == null && chargeLimit == null || this.overrideChargeLimit != null && this.overrideChargeLimit.equals(chargeLimit)) + { + return this; + } + Battery clone = clone(); + clone.overrideChargeLimit = chargeLimit; + return clone; + } + + public Device resetOverrideForceChargingLimit() + { + return setOverrideForceChargingLimit(null); + } } diff --git a/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/devices/ElectricCar.java b/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/devices/ElectricCar.java index e6c6e3b..3e759ee 100644 --- a/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/devices/ElectricCar.java +++ b/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/devices/ElectricCar.java @@ -19,6 +19,21 @@ public ElectricCar(DeviceConfig config) super(config); } + /** + * Copy constructor for creating clones. + */ + protected ElectricCar(ElectricCar other) + { + super(other); + // Add any ElectricCar-specific fields here if needed in the future + } + + @Override + protected ElectricCar clone() + { + return new ElectricCar(this); + } + @Override protected boolean shouldDischarge(Energy currentEnergy) { diff --git a/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/simulation/Simulation.java b/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/simulation/Simulation.java index 6fbee2b..fe30c8d 100644 --- a/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/simulation/Simulation.java +++ b/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/simulation/Simulation.java @@ -19,13 +19,17 @@ public class Simulation private static final Logger LOGGER = LoggerFactory.getLogger(Simulation.class); private final SimulationRequest request; private final SimulationMetricsExporter simulationMetricsExporter; + private final List manipulators; private Map currentState; + private Map currentDevices; - public Simulation(SimulationRequest request, SimulationMetricsExporter simulationMetricsExporter) + public Simulation(SimulationRequest request, SimulationMetricsExporter simulationMetricsExporter, List manipulators) { this.request = request; this.simulationMetricsExporter = simulationMetricsExporter; + this.manipulators = manipulators; + this.currentDevices = request.devices(); } public SimulationResult run() @@ -36,6 +40,8 @@ public SimulationResult run() EnergySystem system = EnergySystem.INIT; while(currentStep.startDate().isBefore(this.request.endDate())) { + var time = currentStep.startDate(); + manipulators.forEach(m -> this.currentDevices = m.apply(time, currentDevices)); system = executeStep(currentStep, system); currentStep = currentStep.nextTimeSlot(); } @@ -53,7 +59,7 @@ private EnergySystem executeStep(SimulationStep step, EnergySystem system) EnergySystem updatedSystem = system; for (DeviceRef deviceRef : orderedDevices) { - Device device = request.devices().get(deviceRef); + Device device = currentDevices.get(deviceRef); DeviceState deviceState = currentState.computeIfAbsent(deviceRef, (d) -> device.getInitialState()); SimulationStepResult stepResult = device.simulate(step, updatedSystem, deviceState); if (!stepResult.deviceState().equals(deviceState)) diff --git a/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/simulation/SimulatorImpl.java b/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/simulation/SimulatorImpl.java index 2e9d0b0..3182847 100644 --- a/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/simulation/SimulatorImpl.java +++ b/simulation/src/main/java/de/hatoka/eos/simulation/internal/business/simulation/SimulatorImpl.java @@ -1,12 +1,15 @@ package de.hatoka.eos.simulation.internal.business.simulation; import de.hatoka.eos.simulation.capi.business.metrics.SimulationMetricsExporter; +import de.hatoka.eos.simulation.capi.business.simulation.DeviceManipulator; import de.hatoka.eos.simulation.capi.business.simulation.SimulationRequest; import de.hatoka.eos.simulation.capi.business.simulation.SimulationResult; import de.hatoka.eos.simulation.capi.business.simulation.Simulator; import jakarta.inject.Inject; import jakarta.inject.Singleton; +import java.util.List; + @Singleton public class SimulatorImpl implements Simulator { @@ -16,6 +19,12 @@ public class SimulatorImpl implements Simulator @Override public SimulationResult simulate(SimulationRequest request) { - return new Simulation(request, metricsExporter).run(); + return simulate(request, List.of()); + } + + @Override + public SimulationResult simulate(SimulationRequest request, List manipulators) + { + return new Simulation(request, metricsExporter, manipulators).run(); } } diff --git a/units/build.gradle.kts b/units/build.gradle.kts index dc91638..bc954e6 100644 --- a/units/build.gradle.kts +++ b/units/build.gradle.kts @@ -7,4 +7,5 @@ dependencies { implementation("com.fasterxml.jackson.core:jackson-databind") testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/units/src/main/java/de/hatoka/eos/units/capi/Money.java b/units/src/main/java/de/hatoka/eos/units/capi/Money.java index b0e5c72..dd7a654 100644 --- a/units/src/main/java/de/hatoka/eos/units/capi/Money.java +++ b/units/src/main/java/de/hatoka/eos/units/capi/Money.java @@ -96,4 +96,9 @@ public Money negate() { return new Money(amount.negate(), currencyMnemonic); } + + public boolean isLessThan(Money other) + { + return amount.compareTo(other.amount) < 0; + } } diff --git a/units/src/test/java/de/hatoka/eos/units/capi/MoneyTest.java b/units/src/test/java/de/hatoka/eos/units/capi/MoneyTest.java new file mode 100644 index 0000000..8101232 --- /dev/null +++ b/units/src/test/java/de/hatoka/eos/units/capi/MoneyTest.java @@ -0,0 +1,264 @@ +package de.hatoka.eos.units.capi; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.*; + +class MoneyTest +{ + @Test + void testOfMnemonic() + { + Money money = Money.ofEur(10.50); + assertEquals("EUR", money.currencyMnemonic()); + } + + @Test + void testZero() + { + Money zero = Money.ZERO; + assertEquals(BigDecimal.ZERO, zero.amount()); + } + + @Test + void testAdd_sameCurrency() + { + Money money1 = Money.ofEur(10.50); + Money money2 = Money.ofEur(5.25); + + Money result = money1.add(money2); + + assertEquals(new BigDecimal("15.75"), result.amount()); + assertEquals("EUR", result.currencyMnemonic()); + } + + @Test + void testAdd_differentCurrencies_throwsException() + { + Money money1 = new Money(BigDecimal.TEN, "EUR"); + Money money2 = new Money(BigDecimal.TEN, "USD"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> money1.add(money2) + ); + + assertTrue(exception.getMessage().contains("Cannot add Money with different currencies")); + } + + @Test + void testSubtract_sameCurrency() + { + Money money1 = Money.ofEur(10.50); + Money money2 = Money.ofEur(5.25); + + Money result = money1.subtract(money2); + + assertEquals(new BigDecimal("5.25"), result.amount()); + assertEquals("EUR", result.currencyMnemonic()); + } + + @Test + void testSubtract_differentCurrencies_throwsException() + { + Money money1 = new Money(BigDecimal.TEN, "EUR"); + Money money2 = new Money(BigDecimal.TEN, "USD"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> money1.subtract(money2) + ); + + assertTrue(exception.getMessage().contains("Cannot add Money with different currencies")); + } + + @Test + void testMultiply() + { + Money money = Money.ofEur(10.0); + + Money result = money.multiply(2.5); + + assertEquals(new BigDecimal("25.00"), result.amount()); + assertEquals("EUR", result.currencyMnemonic()); + } + + @Test + void testMultiply_byZero() + { + Money money = Money.ofEur(10.0); + + Money result = money.multiply(0); + + assertEquals(BigDecimal.ZERO, result.amount().stripTrailingZeros()); + assertEquals("EUR", result.currencyMnemonic()); + } + + @Test + void testDivide() + { + Money money = Money.ofEur(10.0); + + Money result = money.divide(2.0); + + assertEquals(new BigDecimal("5.0000"), result.amount()); + assertEquals("EUR", result.currencyMnemonic()); + } + + @Test + void testDivide_withRounding() + { + Money money = Money.ofEur(10.0); + + Money result = money.divide(3.0); + + // Should round to 4 decimal places with HALF_DOWN + assertEquals(new BigDecimal("3.3333"), result.amount()); + assertEquals("EUR", result.currencyMnemonic()); + } + + @Test + void testNegate() + { + Money money = Money.ofEur(10.50); + + Money result = money.negate(); + + assertEquals(new BigDecimal("-10.5"), result.amount()); + assertEquals("EUR", result.currencyMnemonic()); + } + + @Test + void testNegate_negative() + { + Money money = Money.ofEur(-10.50); + + Money result = money.negate(); + + assertEquals(new BigDecimal("10.5"), result.amount()); + assertEquals("EUR", result.currencyMnemonic()); + } + + @Test + void testIsLessThan_true() + { + Money smaller = Money.ofEur(5.0); + Money larger = Money.ofEur(10.0); + + assertTrue(smaller.isLessThan(larger)); + } + + @Test + void testIsLessThan_false() + { + Money smaller = Money.ofEur(5.0); + Money larger = Money.ofEur(10.0); + + assertFalse(larger.isLessThan(smaller)); + } + + @Test + void testIsLessThan_equal() + { + Money money1 = Money.ofEur(10.0); + Money money2 = Money.ofEur(10.0); + + assertFalse(money1.isLessThan(money2)); + } + + @Test + void testRound() + { + Money money = new Money(new BigDecimal("10.12345"), "EUR"); + + Money result = money.round(); + + // Should round to 2 decimal places based on precision calculation + assertEquals(new BigDecimal("10.12"), result.amount()); + } + + @Test + void testEquals_equalValues() + { + Money money1 = Money.ofEur(10.50); + Money money2 = Money.ofEur(10.50); + + assertEquals(money1, money2); + } + + @Test + void testEquals_differentAmounts() + { + Money money1 = Money.ofEur(10.50); + Money money2 = Money.ofEur(10.51); + + assertNotEquals(money1, money2); + } + + @Test + void testEquals_differentCurrencies() + { + Money money1 = new Money(BigDecimal.TEN, "EUR"); + Money money2 = new Money(BigDecimal.TEN, "USD"); + + assertNotEquals(money1, money2); + } + + @Test + void testEquals_null() + { + Money money = Money.ofEur(10.0); + + assertNotEquals(null, money); + } + + @Test + void testNotEquals_withoutRounding() + { + Money money1 = new Money(new BigDecimal("10.12345"), "EUR"); + Money money2 = new Money(new BigDecimal("10.12346"), "EUR"); + + assertNotEquals(money1, money2); + } + + @Test + void testEquals_withRounding() + { + Money money1 = new Money(new BigDecimal("10.12345"), "EUR"); + Money money2 = new Money(new BigDecimal("10.12346"), "EUR"); + + assertEquals(money1.round(), money2.round()); + } + + @Test + void testHashCode_equalObjects() + { + Money money1 = Money.ofEur(10.50); + Money money2 = Money.ofEur(10.50); + + assertEquals(money1.hashCode(), money2.hashCode()); + } + + @Test + void testZeroOperations() + { + Money zero = Money.ZERO; + Money money = Money.ofEur(10.0); + + assertEquals(money, zero.add(money)); + assertEquals(money, money.add(zero)); + assertEquals(money.negate(), zero.subtract(money)); + } + + @Test + void testIsLessThan() + { + Money money1 = Money.ofEur(10.50); + Money money2 = Money.ofEur(10.52); + + assertTrue(money1.isLessThan(money2)); + } +} +