diff --git a/csharp/App/SinexcelCommunication/DataTypes/DynamicPricingMode.cs b/csharp/App/SinexcelCommunication/DataTypes/DynamicPricingMode.cs new file mode 100644 index 000000000..2391f93b4 --- /dev/null +++ b/csharp/App/SinexcelCommunication/DataTypes/DynamicPricingMode.cs @@ -0,0 +1,8 @@ +namespace InnovEnergy.App.SinexcelCommunication.DataTypes; + +public enum DynamicPricingMode +{ + Disabled, + Tou, + SpotPrice +} \ No newline at end of file diff --git a/csharp/App/SinexcelCommunication/ESS/DynamicPriceDataType.cs b/csharp/App/SinexcelCommunication/ESS/DynamicPriceDataType.cs new file mode 100644 index 000000000..bb2638b7e --- /dev/null +++ b/csharp/App/SinexcelCommunication/ESS/DynamicPriceDataType.cs @@ -0,0 +1,10 @@ +namespace InnovEnergy.App.SinexcelCommunication.ESS; + +internal enum DesiredAction +{ + OptimizeSelfUse, + Charge, + Discharge +} + + diff --git a/csharp/App/SinexcelCommunication/ESS/DynamicPricingEngine.cs b/csharp/App/SinexcelCommunication/ESS/DynamicPricingEngine.cs new file mode 100644 index 000000000..52e95809b --- /dev/null +++ b/csharp/App/SinexcelCommunication/ESS/DynamicPricingEngine.cs @@ -0,0 +1,203 @@ +using System; +using InnovEnergy.App.SinexcelCommunication.DataTypes; +using InnovEnergy.App.SinexcelCommunication.SystemConfig; +using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType; + +namespace InnovEnergy.App.SinexcelCommunication.ESS; +/* +public static class DynamicPricingEngine + { + + public static readonly TimeSpan CheapStart = TimeSpan.FromHours(22); // 22:00 + public static readonly TimeSpan CheapEnd = TimeSpan.FromHours(6); // 06:00 + + // Expensive (High tariff) + public static readonly TimeSpan HighStart = TimeSpan.FromHours(17); // 17:00 + public static readonly TimeSpan HighEnd = TimeSpan.FromHours(21); // 21:00 + + /// + /// Call this from your main loop. It sets statusrecord.Mode. + /// + /// liveSpotPrice is only needed when DynamicPricingMode == SpotPrice. + /// If your inverter cannot directly force Charge/Discharge, set inverterSupportsDirectForce=false + /// and it will execute via TimeChargeDischarge + rolling short time window. + /// + public static void Apply( + DateTime nowLocal, + Decimal? liveSpotPrice, + StatusRecord statusrecord, + Boolean inverterSupportsDirectForce, + Int32 rollingWindowMinutes = 10) + { + if (statusrecord == null) throw new ArgumentNullException(nameof(statusrecord)); + if (statusrecord.Config == null) throw new ArgumentNullException(nameof(statusrecord.Config)); + + var c = statusrecord.Config; + + // 0) Manual override (optional) + if (!c.DynamicPricingEnabled) + { + Console.WriteLine(" Dynamic pricing is disabled"); + return; + } + + if (statusrecord.InverterRecord.OperatingPriority == OperatingPriority.ModeNotSynched) + { + Console.WriteLine(" Inverter mode are not synched "); + return; + } +/* + + } + + // 1) Base operating mode: explicit modes ignore dynamic pricing + if (c.OperatingPriority == OperatingPriority.GridPriority) + { + SetMode(statusrecord, OperatingPriority.GridPriority); + return; + } + + if (c.OperatingPriority == OperatingPriority.BatteryPriority) + { + SetMode(statusrecord, OperatingPriority.BatteryPriority); + return; + } + + // 2) OperatingMode == OptimizeSelfUse -> dynamic pricing can apply + var desired = DecideDesiredAction(nowLocal, liveSpotPrice, c); + + if (desired == DesiredAction.OptimizeSelfUse) + { + SetMode(statusrecord, OperatingPriority.LoadPriority); + return; + } + + // 3) Execute desired action + if (inverterSupportsDirectForce) + { + statusrecord.Mode = desired == DesiredAction.Charge + ? BatteryMode.Charge + : BatteryMode.Discharge; + return; + } + + // 4) Inverter limitation: execute via TimeChargeDischarge rolling window + statusrecord.Mode = BatteryMode.TimeChargeDischarge; + + var (start, end) = MakeRollingWindow(nowLocal, rollingWindowMinutes); + + if (desired == DesiredAction.Charge) + { + c.TimeChargeStart = start; + c.TimeChargeEnd = end; + + // clear discharge window to avoid overlap + c.TimeDischargeStart = TimeSpan.Zero; + c.TimeDischargeEnd = TimeSpan.Zero; + } + else // Discharge + { + c.TimeDischargeStart = start; + c.TimeDischargeEnd = end; + + // clear charge window to avoid overlap + c.TimeChargeStart = TimeSpan.Zero; + c.TimeChargeEnd = TimeSpan.Zero; + } + } + + + private static void SetMode( StatusRecord statusrecord, OperatingPriority o) + { + var operatingMode = o switch + { + OperatingPriority.LoadPriority => WorkingMode.SpontaneousSelfUse, + OperatingPriority.BatteryPriority => WorkingMode.TimeChargeDischarge, + OperatingPriority.GridPriority => WorkingMode.PrioritySellElectricity, + _ => WorkingMode.SpontaneousSelfUse + }; + + if (statusrecord.InverterRecord.OperatingPriority != OperatingPriority.ModeNotSynched) + { + foreach (var inv in statusrecord?.InverterRecord.Devices) + { + inv.WorkingMode = operatingMode; + } + } + else + { + foreach (var inv in statusrecord?.InverterRecord.Devices) + { + Console.WriteLine(" Inverter mode are not synched"); + inv.WorkingMode = WorkingMode.SpontaneousSelfUse; + } + } + return; + } + + // ---------------------------- + // Decision logic + // ---------------------------- + private static DesiredAction DecideDesiredAction(DateTime nowLocal, decimal? liveSpotPrice, Config c) + { + if (c.DynamicPricingMode == DynamicPricingMode.Disabled) + return DesiredAction.OptimizeSelfUse; + + TimeSpan now = nowLocal.TimeOfDay; + + if (c.DynamicPricingMode == DynamicPricingMode.Tou) + { + bool isCheap = IsInTimeWindow(now,CheapStart, CheapEnd); + bool isHigh = IsInTimeWindow(now, HighStart, HighEnd); + + // Priority: cheap -> charge, then high -> discharge + if (isCheap) return DesiredAction.Charge; + if (isHigh) return DesiredAction.Discharge; + return DesiredAction.OptimizeSelfUse; + } + + if (c.DynamicPricingMode == DynamicPricingMode.SpotPrice) + { + if (!liveSpotPrice.HasValue) + return DesiredAction.OptimizeSelfUse; // safe fallback + + if (c.CheapPrice >= c.HighPrice) + throw new ArgumentException("Config error: CheapPrice must be lower than HighPrice."); + + decimal p = liveSpotPrice.Value; + + if (p <= c.CheapPrice) return DesiredAction.Charge; + if (p >= c.HighPrice) return DesiredAction.Discharge; + } + + return DesiredAction.OptimizeSelfUse; + } + + // ---------------------------- + // Helpers + // ---------------------------- + /// + /// [start, end) window, supports overnight. start==end means disabled. + /// + private static Boolean IsInTimeWindow(TimeSpan now, TimeSpan start, TimeSpan end) + { + if (start == end) return false; + + // Same-day + if (start < end) + return now >= start && now < end; + + // Overnight + return now >= start || now < end; + } + + private static (TimeSpan start, TimeSpan end) MakeRollingWindow(DateTime nowLocal, int minutes) + { + if (minutes <= 0) throw new ArgumentOutOfRangeException(nameof(minutes)); + + var start = nowLocal.TimeOfDay; + var end = (nowLocal + TimeSpan.FromMinutes(minutes)).TimeOfDay; + return (start, end); + } + } +}*/ \ No newline at end of file