From 0e94d9c60d03f0f0267eb8eadaddc6646ab39bde Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 28 Feb 2025 16:08:12 +0100 Subject: [PATCH] Create SodiStore solution and update Battery communication unit --- .../DeligreenBatteryCommunication.csproj | 1 + .../DeligreenBatteryCommunication/Program.cs | 51 +- .../SodiStoreMax/Doc/SalimaxConfigReadme.txt | 110 ++ csharp/App/SodiStoreMax/Doc/States_Table.xlsx | Bin 0 -> 13885 bytes .../Doc/TransitionToGridTied.graphml | 501 +++++++++ .../Doc/TransitionToIsland.graphml | 487 +++++++++ csharp/App/SodiStoreMax/HostList.txt | 14 + csharp/App/SodiStoreMax/SodiStoreMax.csproj | 32 + csharp/App/SodiStoreMax/deploy.sh | 21 + .../SodiStoreMax/deploy_all_installations.sh | 37 + .../downloadBatteryLogs/download-bms-log | 284 ++++++ .../download_battery_logs.sh | 70 ++ csharp/App/SodiStoreMax/resources/PublicKey | 1 + .../SodiStoreMax/resources/Salimax.Service | 13 + .../src/AggregationService/Aggregator.cs | 381 +++++++ .../src/AggregationService/HourlyData.cs | 130 +++ .../src/DataTypes/AlarmOrWarning.cs | 9 + .../src/DataTypes/Configuration.cs | 12 + .../src/DataTypes/StatusMessage.cs | 20 + .../SodiStoreMax/src/Devices/AcPowerDevice.cs | 8 + .../SodiStoreMax/src/Devices/DcPowerDevice.cs | 8 + .../SodiStoreMax/src/Devices/DeviceState.cs | 8 + .../SodiStoreMax/src/Devices/SalimaxDevice.cs | 8 + csharp/App/SodiStoreMax/src/Ess/Controller.cs | 273 +++++ csharp/App/SodiStoreMax/src/Ess/EssControl.cs | 53 + csharp/App/SodiStoreMax/src/Ess/EssLimit.cs | 20 + csharp/App/SodiStoreMax/src/Ess/EssMode.cs | 12 + .../SodiStoreMax/src/Ess/SalimaxAlarmState.cs | 8 + .../App/SodiStoreMax/src/Ess/StatusRecord.cs | 33 + csharp/App/SodiStoreMax/src/Ess/SystemLog.cs | 11 + csharp/App/SodiStoreMax/src/Flow.cs | 56 + .../SodiStoreMax/src/LogFileConcatenator.cs | 34 + csharp/App/SodiStoreMax/src/Logfile.cs | 49 + csharp/App/SodiStoreMax/src/Logger.cs | 40 + .../src/MiddlewareClasses/MiddlewareAgent.cs | 93 ++ .../src/MiddlewareClasses/RabbitMQManager.cs | 61 ++ csharp/App/SodiStoreMax/src/Program.cs | 962 ++++++++++++++++++ csharp/App/SodiStoreMax/src/S3Config.cs | 79 ++ .../SaliMaxRelays/CombinedAdamRelaysRecord.cs | 211 ++++ .../src/SaliMaxRelays/IRelaysRecord.cs | 78 ++ .../src/SaliMaxRelays/RelaysDeviceADAM6360.cs | 40 + .../src/SaliMaxRelays/RelaysDeviceAdam6060.cs | 38 + .../src/SaliMaxRelays/RelaysDeviceAmax.cs | 37 + .../src/SaliMaxRelays/RelaysRecordAdam6060.cs | 24 + .../SaliMaxRelays/RelaysRecordAdam6360D.cs | 81 ++ .../src/SaliMaxRelays/RelaysRecordAmax.cs | 134 +++ csharp/App/SodiStoreMax/src/Switch.cs | 15 + .../App/SodiStoreMax/src/System/Controller.cs | 726 +++++++++++++ .../SodiStoreMax/src/System/StateMachine.cs | 9 + .../src/SystemConfig/AcDcConfig.cs | 8 + .../src/SystemConfig/CalibrationChargeType.cs | 8 + .../SodiStoreMax/src/SystemConfig/Config.cs | 272 +++++ .../src/SystemConfig/DcDcConfig.cs | 15 + .../src/SystemConfig/DeviceConfig.cs | 21 + .../src/SystemConfig/DevicesConfig.cs | 7 + csharp/App/SodiStoreMax/src/Topology.cs | 530 ++++++++++ csharp/App/SodiStoreMax/tunnelstoSalimaxX.sh | 44 + .../App/SodiStoreMax/uploadBatteryFw/AF0A.bin | Bin 0 -> 589824 bytes .../uploadBatteryFw/update_firmware.sh | 29 + .../uploadBatteryFw/upload-bms-firmware | 288 ++++++ csharp/InnovEnergy.sln | 7 + .../Doc/flowchart_LEDSetting_20241216_ps.odp | Bin 0 -> 39170 bytes csharp/Lib/Devices/BatteryDeligreen/Alarms.cs | 21 + .../BatteryDeligreenAlarmRecord.cs | 26 + .../BatteryDeligreenDataRecord.cs | 54 + .../BatteryDeligreenDevice.cs | 205 ++-- .../BatteryDeligreenDevices.cs | 35 + .../BatteryDeligreenRecord.cs | 13 + .../BatteryDeligreenRecords.cs | 46 + .../Doc/retrieve Telecommand.py | 480 +++++++++ .../Doc/retrieve Telemetry.py | 277 +++++ .../TelecommandFrameParser.cs | 141 ++- .../BatteryDeligreen/TelemetryFrameParser.cs | 135 ++- .../Devices/BatteryDeligreen/Temperatures.cs | 26 + 74 files changed, 7951 insertions(+), 120 deletions(-) create mode 100644 csharp/App/SodiStoreMax/Doc/SalimaxConfigReadme.txt create mode 100644 csharp/App/SodiStoreMax/Doc/States_Table.xlsx create mode 100644 csharp/App/SodiStoreMax/Doc/TransitionToGridTied.graphml create mode 100644 csharp/App/SodiStoreMax/Doc/TransitionToIsland.graphml create mode 100755 csharp/App/SodiStoreMax/HostList.txt create mode 100644 csharp/App/SodiStoreMax/SodiStoreMax.csproj create mode 100755 csharp/App/SodiStoreMax/deploy.sh create mode 100755 csharp/App/SodiStoreMax/deploy_all_installations.sh create mode 100644 csharp/App/SodiStoreMax/downloadBatteryLogs/download-bms-log create mode 100755 csharp/App/SodiStoreMax/downloadBatteryLogs/download_battery_logs.sh create mode 100644 csharp/App/SodiStoreMax/resources/PublicKey create mode 100644 csharp/App/SodiStoreMax/resources/Salimax.Service create mode 100644 csharp/App/SodiStoreMax/src/AggregationService/Aggregator.cs create mode 100644 csharp/App/SodiStoreMax/src/AggregationService/HourlyData.cs create mode 100644 csharp/App/SodiStoreMax/src/DataTypes/AlarmOrWarning.cs create mode 100644 csharp/App/SodiStoreMax/src/DataTypes/Configuration.cs create mode 100644 csharp/App/SodiStoreMax/src/DataTypes/StatusMessage.cs create mode 100644 csharp/App/SodiStoreMax/src/Devices/AcPowerDevice.cs create mode 100644 csharp/App/SodiStoreMax/src/Devices/DcPowerDevice.cs create mode 100644 csharp/App/SodiStoreMax/src/Devices/DeviceState.cs create mode 100644 csharp/App/SodiStoreMax/src/Devices/SalimaxDevice.cs create mode 100644 csharp/App/SodiStoreMax/src/Ess/Controller.cs create mode 100644 csharp/App/SodiStoreMax/src/Ess/EssControl.cs create mode 100644 csharp/App/SodiStoreMax/src/Ess/EssLimit.cs create mode 100644 csharp/App/SodiStoreMax/src/Ess/EssMode.cs create mode 100644 csharp/App/SodiStoreMax/src/Ess/SalimaxAlarmState.cs create mode 100644 csharp/App/SodiStoreMax/src/Ess/StatusRecord.cs create mode 100644 csharp/App/SodiStoreMax/src/Ess/SystemLog.cs create mode 100644 csharp/App/SodiStoreMax/src/Flow.cs create mode 100644 csharp/App/SodiStoreMax/src/LogFileConcatenator.cs create mode 100644 csharp/App/SodiStoreMax/src/Logfile.cs create mode 100644 csharp/App/SodiStoreMax/src/Logger.cs create mode 100644 csharp/App/SodiStoreMax/src/MiddlewareClasses/MiddlewareAgent.cs create mode 100644 csharp/App/SodiStoreMax/src/MiddlewareClasses/RabbitMQManager.cs create mode 100644 csharp/App/SodiStoreMax/src/Program.cs create mode 100644 csharp/App/SodiStoreMax/src/S3Config.cs create mode 100644 csharp/App/SodiStoreMax/src/SaliMaxRelays/CombinedAdamRelaysRecord.cs create mode 100644 csharp/App/SodiStoreMax/src/SaliMaxRelays/IRelaysRecord.cs create mode 100644 csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysDeviceADAM6360.cs create mode 100644 csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysDeviceAdam6060.cs create mode 100644 csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysDeviceAmax.cs create mode 100644 csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysRecordAdam6060.cs create mode 100644 csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysRecordAdam6360D.cs create mode 100644 csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysRecordAmax.cs create mode 100644 csharp/App/SodiStoreMax/src/Switch.cs create mode 100644 csharp/App/SodiStoreMax/src/System/Controller.cs create mode 100644 csharp/App/SodiStoreMax/src/System/StateMachine.cs create mode 100644 csharp/App/SodiStoreMax/src/SystemConfig/AcDcConfig.cs create mode 100644 csharp/App/SodiStoreMax/src/SystemConfig/CalibrationChargeType.cs create mode 100644 csharp/App/SodiStoreMax/src/SystemConfig/Config.cs create mode 100644 csharp/App/SodiStoreMax/src/SystemConfig/DcDcConfig.cs create mode 100644 csharp/App/SodiStoreMax/src/SystemConfig/DeviceConfig.cs create mode 100644 csharp/App/SodiStoreMax/src/SystemConfig/DevicesConfig.cs create mode 100644 csharp/App/SodiStoreMax/src/Topology.cs create mode 100755 csharp/App/SodiStoreMax/tunnelstoSalimaxX.sh create mode 100644 csharp/App/SodiStoreMax/uploadBatteryFw/AF0A.bin create mode 100755 csharp/App/SodiStoreMax/uploadBatteryFw/update_firmware.sh create mode 100755 csharp/App/SodiStoreMax/uploadBatteryFw/upload-bms-firmware create mode 100644 csharp/Lib/Devices/Adam6360D/Doc/flowchart_LEDSetting_20241216_ps.odp create mode 100644 csharp/Lib/Devices/BatteryDeligreen/Alarms.cs create mode 100644 csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenAlarmRecord.cs create mode 100644 csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenDataRecord.cs create mode 100644 csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenDevices.cs create mode 100644 csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenRecord.cs create mode 100644 csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenRecords.cs create mode 100644 csharp/Lib/Devices/BatteryDeligreen/Doc/retrieve Telecommand.py create mode 100644 csharp/Lib/Devices/BatteryDeligreen/Doc/retrieve Telemetry.py create mode 100644 csharp/Lib/Devices/BatteryDeligreen/Temperatures.cs diff --git a/csharp/App/DeligreenBatteryCommunication/DeligreenBatteryCommunication.csproj b/csharp/App/DeligreenBatteryCommunication/DeligreenBatteryCommunication.csproj index 6ab0149e5..0017ee879 100644 --- a/csharp/App/DeligreenBatteryCommunication/DeligreenBatteryCommunication.csproj +++ b/csharp/App/DeligreenBatteryCommunication/DeligreenBatteryCommunication.csproj @@ -9,6 +9,7 @@ + diff --git a/csharp/App/DeligreenBatteryCommunication/Program.cs b/csharp/App/DeligreenBatteryCommunication/Program.cs index 9d73e76e2..469624ac4 100644 --- a/csharp/App/DeligreenBatteryCommunication/Program.cs +++ b/csharp/App/DeligreenBatteryCommunication/Program.cs @@ -1,10 +1,12 @@ using InnovEnergy.Lib.Devices.BatteryDeligreen; +namespace InnovEnergy.App.DeligreenBatteryCommunication; + internal static class Program { private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(2); - // private static readonly Channel? BatteriesChannel; + // private static readonly Channel? BatteriesChannel; private const String Port = "/dev/ttyUSB0"; @@ -13,34 +15,57 @@ internal static class Program { Console.WriteLine("Hello, Deligreen World!"); - // BatteriesChannel = new SerialPortChannel(Port, BaudRate, Parity, DataBits, StopBits); + // BatteriesChannel = new SerialPortChannel(Port, BaudRate, Parity, DataBits, StopBits); } public static async Task Main(string[] args) { - Console.WriteLine("Starting Battery Communication"); - - var batteryDevices = new BatteryDeligreenDevice(Port); - - while (true) + var listOfBatteries = new List { + new BatteryDeligreenDevice(Port, 0), + new BatteryDeligreenDevice(Port, 1), + new BatteryDeligreenDevice(Port, 2), + new BatteryDeligreenDevice(Port, 3), + new BatteryDeligreenDevice(Port, 4) + }; + + var batteryDevices = new BatteryDeligreenDevices(listOfBatteries); + + Console.WriteLine("Starting Battery Communication"); + + while (true) + { try { - Console.WriteLine("***************************** New Frame *********************************"); - Console.WriteLine($"First Reading Timestamp: {DateTime.Now:HH:mm:ss.fff}"); - // Read telemetry data asynchronously - await batteryDevices.ReadTelemetryData(); - Console.WriteLine($"Last Timestamp: {DateTime.Now:HH:mm:ss.fff}"); + var startTime = DateTime.Now; + Console.WriteLine("***************************** Reading Battery Data *********************************************"); + Console.WriteLine($"Start Reading all Batteries: {startTime}"); + var batteriesRecord = batteryDevices.Read(); + var stopTime = DateTime.Now; + Console.WriteLine($"Finish Reading all Batteries: {stopTime}"); + + Console.WriteLine("Time used for reading all batteries:" + (stopTime - startTime)); + + Console.WriteLine("Average SOC " + batteriesRecord?.Soc); + Console.WriteLine("SOC Battery 0 : " + batteriesRecord?.Devices[0].BatteryDeligreenDataRecord.Soc); + Console.WriteLine("SOC Battery 1 : " + batteriesRecord?.Devices[1].BatteryDeligreenDataRecord.Soc); + Console.WriteLine("SOC Battery 2 : " + batteriesRecord?.Devices[2].BatteryDeligreenDataRecord.Soc); + Console.WriteLine("SOC Battery 3 : " + batteriesRecord?.Devices[3].BatteryDeligreenDataRecord.Soc); + Console.WriteLine("SOC Battery 4 : " + batteriesRecord?.Devices[4].BatteryDeligreenDataRecord.Soc); + Console.WriteLine("Min Soc " + batteriesRecord?.CurrentMinSoc); + Console.WriteLine("count " + batteriesRecord?.Devices.Count); + // Wait for 2 seconds before the next reading await Task.Delay(2000); // Delay in milliseconds (2000ms = 2 seconds) } catch (Exception e) { // Handle exception and print the error - Console.WriteLine(e); + Console.WriteLine(e + " This the first try loop "); await Task.Delay(2000); // Delay in milliseconds (2000ms = 2 seconds) } } } + } \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/Doc/SalimaxConfigReadme.txt b/csharp/App/SodiStoreMax/Doc/SalimaxConfigReadme.txt new file mode 100644 index 000000000..559eb6ca8 --- /dev/null +++ b/csharp/App/SodiStoreMax/Doc/SalimaxConfigReadme.txt @@ -0,0 +1,110 @@ +"MinSoc": Number, 0 - 100 this is the minimum State of Charge that the batteries must not go below, + "ForceCalibrationCharge": Boolean (true or false), A flag to force a calibration charge, + "DisplayIndividualBatteries": Boolean (true or false), To display the indvidual batteries + "PConstant": Number 0 - 1, P value of our controller. + "GridSetPoint": Number in Watts, The set point of our controller. + "BatterySelfDischargePower": Number, 200, this a physical measurement of the self discharging power. + "HoldSocZone": Number, 1, This is magic number for the soft landing factor. + "IslandMode": { // Dc Link Voltage in Island mode + "AcDc": { + "MaxDcLinkVoltage": Number, 810, Max Dc Link Voltage, + "MinDcLinkVoltage": Number, 690, Min Dc Link Voltage, + "ReferenceDcLinkVoltage": Number, 750, Reference Dc Link + }, + "DcDc": { + "LowerDcLinkVoltage": Number, 50, Lower Dc Link Window , + "ReferenceDcLinkVoltage": 750, reference Dc Link + "UpperDcLinkVoltage": Number, 50, Upper Dc Link Window , + } + }, + "GridTie": {// Dc Link Voltage in GrieTie mode + "AcDc": { + "MaxDcLinkVoltage":Number, 780, Max Dc Link Voltage, + "MinDcLinkVoltage": Number, 690, Min Dc Link Voltage, + "ReferenceDcLinkVoltage": Number, 750, Reference Dc Link + }, + "DcDc": { + "LowerDcLinkVoltage": Number, 20, Lower Dc Link Window , + "ReferenceDcLinkVoltage": 750, reference Dc Link + "UpperDcLinkVoltage": Number, 20, Upper Dc Link Window , + } + }, + "MaxBatteryChargingCurrent":Number, 0 - 210, Max Charging current by DcDc + "MaxBatteryDischargingCurrent":Number, 0 - 210, Max Discharging current by DcDc + "MaxDcPower": Number, 0 - 10000, Max Power exported/imported by DcDc (10000 is the maximum) + "MaxChargeBatteryVoltage": Number, 57, Max Charging battery Voltage + "MinDischargeBatteryVoltage": Number, 0, Min Charging Battery Voltage + "Devices": { This is All Salimax devices (including offline ones) + "RelaysIp": { + "DeviceState": 1, // 0: is not present, 1: Present and Can be mesured, 2: Present but must be computed/calculted + "Host": "10.0.1.1", // Ip @ of the device in the local network + "Port": 502 // port + }, + "GridMeterIp": { + "DeviceState": 1, + "Host": "10.0.4.1", + "Port": 502 + }, + "PvOnAcGrid": { + "DeviceState": 0, // If a device is not present + "Host": "false", // this is not important + "Port": 0 // this is not important + }, + "LoadOnAcGrid": { + "DeviceState": 2, // this is a computed device + "Host": "true", + "Port": 0 + }, + "PvOnAcIsland": { + "DeviceState": 0, + "Host": "false", + "Port": 0 + }, + "IslandBusLoadMeterIp": { + "DeviceState": 1, + "Host": "10.0.4.2", + "Port": 502 + }, + "TruConvertAcIp": { + "DeviceState": 1, + "Host": "10.0.2.1", + "Port": 502 + }, + "PvOnDc": { + "DeviceState": 1, + "Host": "10.0.5.1", + "Port": 502 + }, + "LoadOnDc": { + "DeviceState": 0, + "Host": "false", + "Port": 0 + }, + "TruConvertDcIp": { + "DeviceState": 1, + "Host": "10.0.3.1", + "Port": 502 + }, + "BatteryIp": { + "DeviceState": 1, + "Host": "localhost", + "Port": 6855 + }, + "BatteryNodes": [ // this is a list of nodes + 2, + 3, + 4, + 5, + 6 + ] + }, + "S3": { // this is parameters of S3 Buckets and co + "Bucket": "8-3e5b3069-214a-43ee-8d85-57d72000c19d", + "Region": "sos-ch-dk-2", + "Provider": "exo.io", + "Key": "EXO502627299197f83e8b090f63", + "Secret": "jUNYJL6B23WjndJnJlgJj4rc1i7uh981u5Aba5xdA5s", + "ContentType": "text/plain; charset=utf-8", + "Host": "8-3e5b3069-214a-43ee-8d85-57d72000c19d.sos-ch-dk-2.exo.io", + "Url": "https://8-3e5b3069-214a-43ee-8d85-57d72000c19d.sos-ch-dk-2.exo.io" + } diff --git a/csharp/App/SodiStoreMax/Doc/States_Table.xlsx b/csharp/App/SodiStoreMax/Doc/States_Table.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..776df481c302db83a3432e19368da995773906b0 GIT binary patch literal 13885 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 + + + + + + + + + + + + + + + + + + + + + + + 19 + K1 ✓ +K2 ✓ +K3 ✘ + + + + + + + + + + + + + + 3 + K1 ✓ +K2 ✓ +K3 ✘ + + + + + + + + + + + + + + 9 + K1 ✓ +K2 ✘ +K3 ✘ + + + + + + + + + + + + + + 1 + K1 ✓ +K2 ✘ +K3 ✘ + + + + + + + + + + + + + + 13 + K1 ✓ +K2 ✘ +K3 ✓ + + + + + + + + + + + + + + 29 + K1 ✓ +K2 ✘ +K3 ✓ + + + + + + + + + + + + + + 23 + K1 ✓ +K2 ✓ +K3 ✓ + + + + + + + + + + + + + + 5 + K1 ✓ +K2 ✘ +K3 ✓ + + + + + + + + + + + + + + 7 + K1 ✓ +K2 ✓ +K3 ✓ + + + + + + + + + + + + + + + 11 + K1 ✓ +K2 ✓ +K3 ✘ + + + + + + + + + + + + + + + 15 + K1 ✓ +K2 ✓ +K3 ✓ + + + + + + + + + + + + + + 21 + K1 ✓ +K2 ✘ +K3 ✓ + + + + + + + + + + + + + + 17 + K1 ✓ +K2 ✘ +K3 ✘ + + + + + + + + + + + + + + + 25 + K1 ✓ +K2 ✘ +K3 ✘ + + + + + + + + + + + + + + + 27 + K1 ✓ +K2 ✓ +K3 ✘ + + + + + + + + + + + + + + + 31 + K1 ✓ +K2 ✓ +K3 ✓ + + + + + + + + + + + + + turn off +Inverters + + + + + + + + + + + + switch to +grid tie + + + + + + + + + + + + + + + K3's open + + + + + + + + + + + + close K2 + + + + + + + + + + + + turn on +Inverters + + + + + + + + + + + + K3's close + + + + + + + + + + + + open K2 + + + + + + + + + + + + + + + open K2 + + + + + + + + + + + + + + + turn on +Inverters + + + + + + + + + + + + close K2 + + + + + + + + + + + + + + + turn off +inverter + + + + + + + + + + + + turn off +inverters + + + + + + + + + + + + turn off +inverters + + + + + + + + + + + + turn off +inverters + + + + + + + + + + + + + + + turn off +inverters + + + + + + + + + diff --git a/csharp/App/SodiStoreMax/Doc/TransitionToIsland.graphml b/csharp/App/SodiStoreMax/Doc/TransitionToIsland.graphml new file mode 100644 index 000000000..800dffa96 --- /dev/null +++ b/csharp/App/SodiStoreMax/Doc/TransitionToIsland.graphml @@ -0,0 +1,487 @@ + + + + + + + + + + + + + + + + + + + + + + + + 28 + K1 ✘ +K2 ✘ +K3 ✓ + + + + + + + + + + + + + + 24 + K1 ✘ +K2 ✘ +K3 ✘ + + + + + + + + + + + + + + 8 + K1 ✘ +K2 ✘ +K3 ✘ + + + + + + + + + + + + + + 6 + K1 ✘ +K2 ✓ +K3 ✓ + + + + + + + + + + + + + + 0 + K1 ✘ +K2 ✘ +K3 ✘ + + + + + + + + + + + + + + 4 + K1 ✘ +K2 ✘ +K3 ✓ + + + + + + + + + + + + + + 22 + K1 ✘ +K2 ✓ +K3 ✓ + + + + + + + + + + + + + + 16 + K1 ✘ +K2 ✘ +K3 ✘ + + + + + + + + + + + + + + 20 + K1 ✘ +K2 ✘ +K3 ✓ + + + + + + + + + + + + + + 18 + K1 ✘ +K2 ✓ +K3 ✘ + + + + + + + + + + + + + + 2 + K1 ✘ +K2 ✓ +K3 ✘ + + + + + + + + + + + + + + 10 + K1 ✘ +K2 ✓ +K3 ✘ + + + + + + + + + + + + + + 12 + K1 ✘ +K2 ✘ +K3 ✓ + + + + + + + + + + + + + + 14 + K1 ✘ +K2 ✓ +K3 ✓ + + + + + + + + + + + + + + 26 + K1 ✘ +K2 ✓ +K3 ✘ + + + + + + + + + + + + + + 30 + K1 ✘ +K2 ✓ +K3 ✓ + + + + + + + + + + + + + K3's open + + + + + + + + + + + K3's close + + + + + + + + + + + turn off +Inverters + + + + + + + + + + + + + + + switch to +island mode + + + + + + + + + + + + turn on +inverters + + + + + + + + + + + turn off +Inverters + + + + + + + + + + + + + + K3 opens + + + + + + + + + + + + open K2 + + + + + + + + + + + + turn off +inverters + + + + + + + + + + + + + + + open K2 + + + + + + + + + + + + open K2 + + + + + + + + + + + + + + + K3 opens + + + + + + + + + + + + open K2 + + + + + + + + + + + + turn off +inverters + + + + + + + + + + + + turn off +inverters + + + + + + + + + diff --git a/csharp/App/SodiStoreMax/HostList.txt b/csharp/App/SodiStoreMax/HostList.txt new file mode 100755 index 000000000..9a03162c9 --- /dev/null +++ b/csharp/App/SodiStoreMax/HostList.txt @@ -0,0 +1,14 @@ + +Prototype ie-entwicklung@10.2.3.115 Prototype +Salimax0001 ie-entwicklung@10.2.3.104 Marti Technik (Bern) +Salimax0002 ie-entwicklung@10.2.4.29 Weidmann d (ZG) +Salimax0003 ie-entwicklung@10.2.4.33 Elektrotechnik Stefan GmbH +Salimax0004 ie-entwicklung@10.2.4.32 Biohof Gubelmann (Walde) +Salimax0004A ie-entwicklung@10.2.4.153 +Salimax0005 ie-entwicklung@10.2.4.36 Schreinerei Schönthal (Thun) +Salimax0006 ie-entwicklung@10.2.4.35 Steakhouse Mettmenstetten +Salimax0007 ie-entwicklung@10.2.4.154 LerchenhofHerr Twannberg +Salimax0008 ie-entwicklung@10.2.4.113 Wittmann Kottingbrunn +Salimax0010 ie-entwicklung@10.2.4.211 Mohatech 1 (Beat Moser) +Salimax0011 ie-entwicklung@10.2.4.239 Thomas Tschirren (Enggistein) +SalidomoServer ig@134.209.238.170 \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/SodiStoreMax.csproj b/csharp/App/SodiStoreMax/SodiStoreMax.csproj new file mode 100644 index 000000000..c40ce5aa8 --- /dev/null +++ b/csharp/App/SodiStoreMax/SodiStoreMax.csproj @@ -0,0 +1,32 @@ + + + + + InnovEnergy.App.SodiStoreMax + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/csharp/App/SodiStoreMax/deploy.sh b/csharp/App/SodiStoreMax/deploy.sh new file mode 100755 index 000000000..941647e33 --- /dev/null +++ b/csharp/App/SodiStoreMax/deploy.sh @@ -0,0 +1,21 @@ +#!/bin/bash +dotnet_version='net6.0' +salimax_ip="$1" +username='ie-entwicklung' +root_password='Salimax4x25' +set -e + +echo -e "\n============================ Build ============================\n" + +dotnet publish \ + ./SodiStoreMax.csproj \ + -p:PublishTrimmed=false \ + -c Release \ + -r linux-x64 + +echo -e "\n============================ Deploy ============================\n" + +rsync -v \ + --exclude '*.pdb' \ + ./bin/Release/$dotnet_version/linux-x64/publish/* \ + $username@"$salimax_ip":~/salimax \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/deploy_all_installations.sh b/csharp/App/SodiStoreMax/deploy_all_installations.sh new file mode 100755 index 000000000..533946856 --- /dev/null +++ b/csharp/App/SodiStoreMax/deploy_all_installations.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +dotnet_version='net6.0' +salimax_ip="$1" +username='ie-entwicklung' +root_password='Salimax4x25' + +set -e + +echo -e "\n============================ Build ============================\n" + +dotnet publish \ + ./SaliMax.csproj \ + -p:PublishTrimmed=false \ + -c Release \ + -r linux-x64 + +echo -e "\n============================ Deploy ============================\n" +#ip_addresses=("10.2.3.115" "10.2.3.104" "10.2.4.29" "10.2.4.33" "10.2.4.32" "10.2.4.36" "10.2.4.35" "10.2.4.154" "10.2.4.113" "10.2.4.211") +#ip_addresses=("10.2.4.154" "10.2.4.29") +ip_addresses=("10.2.3.115" "10.2.3.104" "10.2.4.29" "10.2.4.33" "10.2.4.32" "10.2.4.36" "10.2.4.35" "10.2.4.154" ) + + + +for ip_address in "${ip_addresses[@]}"; do + rsync -v \ + --exclude '*.pdb' \ + ./bin/Release/$dotnet_version/linux-x64/publish/* \ + $username@"$ip_address":~/salimax + + ssh "$username"@"$ip_address" "cd salimax && echo '$root_password' | sudo -S ./restart" + + echo "Deployed and ran commands on $ip_address" +done + + + diff --git a/csharp/App/SodiStoreMax/downloadBatteryLogs/download-bms-log b/csharp/App/SodiStoreMax/downloadBatteryLogs/download-bms-log new file mode 100644 index 000000000..b9c2e8f23 --- /dev/null +++ b/csharp/App/SodiStoreMax/downloadBatteryLogs/download-bms-log @@ -0,0 +1,284 @@ +#!/usr/bin/python2 -u +# coding=utf-8 +import os +import re +import struct +import serial +import logging +from sys import argv, exit +from datetime import datetime +from pymodbus.pdu import ModbusRequest, ModbusResponse, ExceptionResponse +from pymodbus.other_message import ReportSlaveIdRequest +from pymodbus.exceptions import ModbusException +from pymodbus.pdu import ExceptionResponse +from pymodbus.factory import ClientDecoder +from pymodbus.client import ModbusSerialClient as Modbus +logging.basicConfig(level=logging.INFO) + + + +# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime +# noinspection PyUnreachableCode +if False: + from typing import List, Optional, NoReturn + +RESET_REGISTER = 0x2087 +FIRMWARE_VERSION_REGISTER = 1054 +SERIAL_STARTER_DIR = '/opt/victronenergy/serial-starter/' +INSTALLATION_NAME_FILE = '/data/innovenergy/openvpn/installation-name' +OUTPUT_DIR = '/data/innovenergy' + + +class ReadLogRequest(ModbusRequest): + + function_code = 0x42 + _rtu_frame_size = 5 # not used + + def __init__(self, address = None, **kwargs): + + ModbusRequest.__init__(self, **kwargs) + self.sub_function = 0 if address is None else 1 + self.address = address + + # FUGLY as hell, but necessary bcs PyModbus cannot deal + # with responses that have lengths depending on the sub_function. + # it goes without saying that this isn't thread-safe + ReadLogResponse._rtu_frame_size = 9 if self.sub_function == 0 else 9+128 + + def encode(self): + + if self.sub_function == 0: + return struct.pack('>B', self.sub_function) + else: + return struct.pack('>BI', self.sub_function, self.address) + + def decode(self, data): + self.sub_function = struct.unpack('>B', data) + + def execute(self, context): + print("EXECUTE1") + + def get_response_pdu_size(self): + return ReadLogResponse._rtu_frame_size - 3 + + def __str__(self): + return "ReadLogAddressRequest" + + +class ReadLogResponse(ModbusResponse): + + function_code = 0x42 + _rtu_frame_size = 9 # the WHOLE frame incl crc + + def __init__(self, sub_function=0, address=b'\x00', data=None, **kwargs): + ModbusResponse.__init__(self, **kwargs) + self.sub_function = sub_function + self.address = address + self.data = data + + def encode(self): + pass + + def decode(self, data): + self.address, self.address = struct.unpack_from(">BI", data) + self.data = data[5:] + + def __str__(self): + arguments = (self.function_code, self.address) + return "ReadLogAddressResponse(%s, %s)" % arguments + +# unfortunately we have to monkey-patch this global table because +# the current (victron) version of PyModbus does not have a +# way to "register" new function-codes yet +ClientDecoder.function_table.append(ReadLogResponse) + + +class LockTTY(object): + + def __init__(self, tty): + # type: (str) -> None + self.tty = tty + + def __enter__(self): + os.system(SERIAL_STARTER_DIR + 'stop-tty.sh ' + self.tty) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + os.system(SERIAL_STARTER_DIR + 'start-tty.sh ' + self.tty) + + +def wrap_try_except(error_msg): + def decorate(f): + def applicator(*args, **kwargs): + try: + return f(*args, **kwargs) + except: + print(error_msg) + exit(1) + return applicator + return decorate + + +def init_modbus(tty): + # type: (str) -> Modbus + + return Modbus( + port='/dev/' + tty, + method='rtu', + baudrate=115200, + stopbits=1, + bytesize=8, + timeout=0.5, # seconds + parity=serial.PARITY_ODD) + + +@wrap_try_except("Failed to download BMS log!") +def download_log(modbus, node_id, battery_id): + # type: (Modbus, int, str) -> NoReturn + + # Get address of latest log entry + # request = ReadLogRequest(unit=slave_id) + + print ('downloading BMS log from node ' + str(node_id) + ' ...') + + progress = -1 + log_file = battery_id + "-node" + str(node_id) + "-" + datetime.now().strftime('%d-%m-%Y') + ".bin" + print(log_file) + + with open(log_file, 'w') as f: + + eof = 0x200000 + record = 0x40 + for address in range(0, eof, 2*record): + + percent = int(100*address/eof) + + if percent != progress: + progress = percent + print('\r{}% '.format(progress),end='') + + request = ReadLogRequest(address, slave=node_id) + result = modbus.execute(request) # type: ReadLogResponse + + address1 = "{:06X}".format(address) + address2 = "{:06X}".format(address+record) + + data1 = result.data[:record] + data2 = result.data[record:] + + line1 = address1 + ":" + ''.join('{:02X}'.format(byte) for byte in data1) + line2 = address2 + ":" + ''.join('{:02X}'.format(byte) for byte in data2) + + lines = line1 + "\n" + line2 + "\n" + f.write(lines) + + print("\r100%") + print("done") + print("wrote log to " + log_file) + + return True + + +@wrap_try_except("Failed to contact battery!") +def identify_battery(modbus, node_id): + # type: (Modbus, int) -> str + + target = 'battery #' + str(node_id) + print('contacting ' + target + ' ...') + + request = ReportSlaveIdRequest(slave=node_id) + response = modbus.execute(request) + + index_of_ff = response.identifier.find(b'\xff') + sid_response = response.identifier[index_of_ff + 1:].decode('utf-8').split(' ') + + response = modbus.read_input_registers(address=FIRMWARE_VERSION_REGISTER, count=1, slave=node_id) + + fw = '{0:0>4X}'.format(response.registers[0]) + print("log string is",sid_response[0]+"-"+sid_response[1]+"-"+fw) + + #return re.sub(" +", "-", sid + " " + fw) + return sid_response[0]+"-"+sid_response[1]+"-"+fw + + +def is_int(value): + # type: (str) -> bool + try: + _ = int(value) + return True + except ValueError: + return False + + +def print_usage(): + print ('Usage: ' + __file__ + ' ') + print ('Example: ' + __file__ + ' 2 ttyUSB0') + print ('') + print ('You can omit the "ttyUSB" prefix of the serial device:') + print (' ' + __file__ + ' 2 0') + print ('') + print ('You can omit the serial device entirely when the "com.victronenergy.battery." service is running:') + print (' ' + __file__ + ' 2') + print ('') + + +def get_tty_from_battery_service_name(): + # type: () -> Optional[str] + + import dbus + bus = dbus.SystemBus() + + tty = ( + name.split('.')[-1] + for name in bus.list_names() + if name.startswith('com.victronenergy.battery.') + ) + + return next(tty, None) + + +def parse_tty(tty): + # type: (Optional[str]) -> str + + if tty is None: + return get_tty_from_battery_service_name() + + if is_int(tty): + return 'ttyUSB' + argv[1] + else: + return tty + + +def parse_cmdline_args(argv): + # type: (List[str]) -> (str, int) + + slave_id = element_at_or_none(argv, 0) + tty = parse_tty(element_at_or_none(argv, 1)) + + if slave_id is None or tty is None: + print_usage() + exit(2) + + print("tty=",tty) + print("slave id= ",slave_id) + + return tty, int(slave_id) + + +def element_at_or_none(lst, index): + return next(iter(lst[index:]), None) + + +def main(argv): + # type: (List[str]) -> () + + tty, node_id = parse_cmdline_args(argv) + + with init_modbus(tty) as modbus: + battery_id = identify_battery(modbus, node_id) + download_log(modbus, node_id, battery_id) + + exit(0) + + +main(argv[1:]) diff --git a/csharp/App/SodiStoreMax/downloadBatteryLogs/download_battery_logs.sh b/csharp/App/SodiStoreMax/downloadBatteryLogs/download_battery_logs.sh new file mode 100755 index 000000000..20c3f05b4 --- /dev/null +++ b/csharp/App/SodiStoreMax/downloadBatteryLogs/download_battery_logs.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +#Prototype 10.2.3.115 Prototype +#Salimax0001 10.2.3.104 Marti Technik (Bern) +#Salimax0002 10.2.4.29 Weidmann d (ZG) +#Salimax0003 10.2.4.33 Elektrotechnik Stefan GmbH +#Salimax0004 10.2.4.32 Biohof Gubelmann (Walde) +#Salimax0005 10.2.4.36 Schreinerei Schönthal (Thun) +#Salimax0006 10.2.4.35 Steakhouse Mettmenstetten +#Salimax0007 10.2.4.154 LerchenhofHerr Twannberg +#Salimax0008 10.2.4.113 Wittmann Kottingbrunn + +dotnet_version='net6.0' +ip_address="$1" +battery_ids="$2" +username='ie-entwicklung' +root_password='Salimax4x25' + +if [ "$#" -lt 2 ]; then + echo "Error: Insufficient arguments. Usage: $0 " + exit 1 +fi + +# Function to expand battery ids from a range +expand_battery_ids() { + local range="$1" + local expanded_ids=() + + IFS='-' read -r start end <<< "$range" + for ((i = start; i <= end; i++)); do + expanded_ids+=("$i") + done + + echo "${expanded_ids[@]}" +} + +# Check if battery_ids_arg contains a hyphen indicating a range +if [[ "$battery_ids" == *-* ]]; then + # Expand battery ids from the range + battery_ids=$(expand_battery_ids "$battery_ids") +else + # Use the provided battery ids + battery_ids=("$battery_ids") +fi + +echo "ip_address: $ip_address" +echo "Battery_ids: ${battery_ids[@]}" + +#ip_addresses=("10.2.3.115" "10.2.3.104" "10.2.4.33" "10.2.4.32" "10.2.4.36" "10.2.4.35" "10.2.4.154" "10.2.4.113" "10.2.4.29") +#battery_ids=("2" "3" "4" "5" "6" "7" "8" "9" "10" "11") + +set -e + +scp download-bms-log "$username"@"$ip_address":/home/"$username" +ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S systemctl stop battery.service" +ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S apt install python3-pip -y" +ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S pip3 install pymodbus" + +for battery in "${battery_ids[@]}"; do + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S python3 download-bms-log " "$battery" " ttyUSB0" +done +ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S systemctl start battery.service" +ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S rm download-bms-log" +scp "$username"@"$ip_address":/home/"$username/*.bin" . +ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S rm *.bin" + +echo "Deployed and ran commands on $ip_address" +done + + diff --git a/csharp/App/SodiStoreMax/resources/PublicKey b/csharp/App/SodiStoreMax/resources/PublicKey new file mode 100644 index 000000000..ae41b2935 --- /dev/null +++ b/csharp/App/SodiStoreMax/resources/PublicKey @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCed5ANekhbdV/8nEwFyaqxbPGON+NZKAkZXKx2aMAbX6jYQpusXSf4lKxEp4vHX9q2ScWycluUEhlzwe9vaWIK6mxEG9gjtU0/tKIavqZ6qpcuiglal750e8tlDh+lAgg5K3v4tvV4uVEfFc42UzSC9cIBBKPBC41dc0xQKyFIDsSH6Qha1nyncKRC3OXUkOiiRvmbd4PVc9A5ah2vt+661pghZE19Qeh5ROn/Sma9C+9QIyUDCylezqptnT+Jdvs+JMCHk8nKK2A0bz1w0a8zzO7M1RLHfBLQ6o1SQAdV/Pmon8uQ9vLHc86l5r7WSTMEcjAqY3lGE9mdxsSZWNmp InnovEnergy \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/resources/Salimax.Service b/csharp/App/SodiStoreMax/resources/Salimax.Service new file mode 100644 index 000000000..d823b591f --- /dev/null +++ b/csharp/App/SodiStoreMax/resources/Salimax.Service @@ -0,0 +1,13 @@ +[Unit] +Description=Salimax Controller +Wants=battery.service + +[Service] +WorkingDirectory=/home/ie-entwicklung/salimax +ExecStart=/home/ie-entwicklung/salimax/SaliMax +WatchdogSec=30s +Restart=always +RestartSec=500ms + +[Install] +WantedBy=multi-user.target diff --git a/csharp/App/SodiStoreMax/src/AggregationService/Aggregator.cs b/csharp/App/SodiStoreMax/src/AggregationService/Aggregator.cs new file mode 100644 index 000000000..0fa03f49e --- /dev/null +++ b/csharp/App/SodiStoreMax/src/AggregationService/Aggregator.cs @@ -0,0 +1,381 @@ +using InnovEnergy.App.SodiStoreMax.Ess; +using InnovEnergy.Lib.Utils; +using static System.Double; + +namespace InnovEnergy.App.SodiStoreMax.AggregationService; + +public static class Aggregator +{ + + public static async Task HourlyDataAggregationManager() + { + var currentDateTime = DateTime.Now; + var nextRoundedHour = currentDateTime.AddHours(1).AddMinutes(-currentDateTime.Minute).AddSeconds(-currentDateTime.Second); + + // Calculate the time until the next rounded hour + var timeUntilNextHour = nextRoundedHour - currentDateTime; + + // Output the current and next rounded hour times + Console.WriteLine("------------------------------------------HourlyDataAggregationManager-------------------------------------------"); + Console.WriteLine("Current Date and Time: " + currentDateTime); + Console.WriteLine("Next Rounded Hour: " + nextRoundedHour); + // Output the time until the next rounded hour + Console.WriteLine("Waiting for " + timeUntilNextHour.TotalMinutes + " minutes..."); + Console.WriteLine("-----------------------------------------------------------------------------------------------------------------"); + + // Wait until the next rounded hour + await Task.Delay(timeUntilNextHour); + + while (true) + { + try + { + AggregatedData hourlyAggregatedData = CreateHourlyData("LogDirectory",DateTime.Now.AddHours(-1).ToUnixTime(),DateTime.Now.ToUnixTime()); + hourlyAggregatedData.Save("HourlyData"); + } + catch (Exception e) + { + Console.WriteLine("An error has occured when calculating hourly aggregated data, exception is:\n" + e); + } + await Task.Delay(TimeSpan.FromHours(1)); + } + } + + public static async Task DailyDataAggregationManager() + { + var currentDateTime = DateTime.Now; + var nextRoundedHour = currentDateTime.AddDays(1).AddHours(-currentDateTime.Hour).AddMinutes(-currentDateTime.Minute).AddSeconds(-currentDateTime.Second); + + // Calculate the time until the next rounded hour + var timeUntilNextDay = nextRoundedHour - currentDateTime; + Console.WriteLine("------------------------------------------DailyDataAggregationManager-------------------------------------------"); + // Output the current and next rounded hour times + Console.WriteLine("Current Date and Time: " + currentDateTime); + Console.WriteLine("Next Rounded Hour: " + nextRoundedHour); + // Output the time until the next rounded hour + Console.WriteLine("Waiting for " + timeUntilNextDay.TotalHours + " hours..."); + Console.WriteLine("-----------------------------------------------------------------------------------------------------------------"); + + // Wait until the next rounded hour + await Task.Delay(timeUntilNextDay); + + while (true) + { + try + { + var currentTime = DateTime.Now; + AggregatedData dailyAggregatedData = CreateDailyData("HourlyData",currentTime.AddDays(-1).ToUnixTime(),currentTime.ToUnixTime()); + dailyAggregatedData.Save("DailyData"); + if (await dailyAggregatedData.PushToS3()) + { + //DeleteHourlyData("HourlyData",currentTime.ToUnixTime()); + //AggregatedData.DeleteDailyData("DailyData"); + } + + } + catch (Exception e) + { + Console.WriteLine("An error has occured when calculating daily aggregated data, exception is:\n" + e); + } + await Task.Delay(TimeSpan.FromDays(1)); + } + } + + private static void DeleteHourlyData(String myDirectory, Int64 beforeTimestamp) + { + var csvFiles = Directory.GetFiles(myDirectory, "*.csv"); + Console.WriteLine("Delete data before"+beforeTimestamp); + foreach (var csvFile in csvFiles) + { + if (IsFileWithinTimeRange(csvFile, 0, beforeTimestamp)) + { + File.Delete(csvFile); + Console.WriteLine($"Deleted hourly data file: {csvFile}"); + } + } + } + + // this for test + private static AggregatedData CreateHourlyData(String myDirectory, Int64 afterTimestamp, Int64 beforeTimestamp) + { + // Get all CSV files in the specified directory + var csvFiles = Directory.GetFiles(myDirectory, "*.csv"); + var batterySoc = new List(); + var pvPowerSum = new List(); + var heatingPower = new List(); + var gridPowerImport = new List(); + var gridPowerExport = new List(); + var batteryDischargePower = new List(); + var batteryChargePower = new List(); + + + Console.WriteLine("File timestamp should start after "+ afterTimestamp); + + foreach (var csvFile in csvFiles) + { + if (csvFile == "LogDirectory/log.csv") + { + continue; + } + + if (IsFileWithinTimeRange(csvFile, afterTimestamp, beforeTimestamp)) + { + using var reader = new StreamReader(csvFile); + + while (!reader.EndOfStream) + { + + var line = reader.ReadLine(); + var lines = line?.Split(';'); + + // Assuming there are always three columns (variable name and its value) + if (lines is { Length: 3 }) + { + var variableName = lines[0].Trim(); + + if (TryParse(lines[1].Trim(), out var value)) + { + switch (variableName) + { + case "/Battery/Soc": + batterySoc.Add(value); + break; + + case "/PvOnDc/DcWh" : + pvPowerSum.Add(value); + break; + + case "/Battery/Dc/Power": + + if (value < 0) + { + batteryDischargePower.Add(value); + } + else + { + batteryChargePower.Add(value); + + } + break; + + case "/GridMeter/ActivePowerExportT3": + // we are using different register to check which value from the grid meter we need to use + // At the moment register 8002 amd 8012. in KWh + gridPowerExport.Add(value); + break; + case "/GridMeter/ActivePowerImportT3": + gridPowerImport.Add(value); + break; + case "/Battery/HeatingPower": + heatingPower.Add(value); + break; + // Add more cases as needed + default: + // Code to execute when variableName doesn't match any condition + break; + } + + } + else + { + //Handle cases where variableValue is not a valid number + // Console.WriteLine( + // $"Invalid numeric value for variable {variableName}:{lines[1].Trim()}"); + } + } + else + { + // Handle invalid column format + //Console.WriteLine("Invalid format in column"); + } + } + } + } + + //Average Power (Watts)= Sum of Power Readings/Number of Readings + + //Then, you can use the average power in the energy formula: + // + //Energy (kWh)= (Average Power / 3600) × Time (1 seconds) + // + // Dividing the Average power readings by 3600 converts the result from watt-seconds to kilowatt-hours. + + var dischargingEnergy = (batteryDischargePower.Any() ? batteryDischargePower.Average() : 0.0) / 3600; + var chargingEnergy = (batteryChargePower.Any() ? batteryChargePower.Average() : 0.0) / 3600; + var heatingPowerAvg = (heatingPower.Any() ? heatingPower.Average() : 0.0) / 3600; + + var dMaxSoc = batterySoc.Any() ? batterySoc.Max() : 0.0; + var dMinSoc = batterySoc.Any() ? batterySoc.Min() : 0.0; + var dSumGridExportPower = gridPowerExport.Any() ? gridPowerExport.Max() - gridPowerExport.Min(): 0.0; + var dSumGridImportPower = gridPowerImport.Any() ? gridPowerImport.Max() - gridPowerImport.Min(): 0.0; + var dSumPvPower = pvPowerSum.Any() ? pvPowerSum.Max() : 0.0; + + + AggregatedData aggregatedData = new AggregatedData + { + MaxSoc = dMaxSoc, + MinSoc = dMinSoc, + DischargingBatteryPower = dischargingEnergy, + ChargingBatteryPower = chargingEnergy, + GridExportPower = dSumGridExportPower, + GridImportPower = dSumGridImportPower, + PvPower = dSumPvPower, + HeatingPower = heatingPowerAvg + }; + + // Print the stored CSV data for verification + Console.WriteLine($"Max SOC: {aggregatedData.MaxSoc}"); + Console.WriteLine($"Min SOC: {aggregatedData.MinSoc}"); + + Console.WriteLine($"DischargingBatteryBattery: {aggregatedData.DischargingBatteryPower}"); + Console.WriteLine($"ChargingBatteryPower: {aggregatedData.ChargingBatteryPower}"); + + Console.WriteLine($"SumGridExportPower: {aggregatedData.GridExportPower}"); + Console.WriteLine($"SumGridImportPower: {aggregatedData.GridImportPower}"); + + Console.WriteLine($"Min SOC: {aggregatedData.MinSoc}"); + + + Console.WriteLine("CSV data reading and storage completed."); + Console.WriteLine("-----------------------------------------------------------------------------------------------------------------"); + + return aggregatedData; + } + + private static AggregatedData CreateDailyData(String myDirectory, Int64 afterTimestamp, Int64 beforeTimestamp) + { + // Get all CSV files in the specified directory + var csvFiles = Directory.GetFiles(myDirectory, "*.csv"); + var batterySoc = new List(); + var pvPower = new List(); + var gridPowerImport = new List(); + var gridPowerExport = new List(); + var batteryDischargePower = new List(); + var batteryChargePower = new List(); + var heatingPowerAvg = new List(); + + + + Console.WriteLine("File timestamp should start after "+ afterTimestamp); + + foreach (var csvFile in csvFiles) + { + if (csvFile == "LogDirectory/log.csv") + { + continue; + } + + if (IsFileWithinTimeRange(csvFile, afterTimestamp, beforeTimestamp)) + { + using var reader = new StreamReader(csvFile); + + while (!reader.EndOfStream) + { + + var line = reader.ReadLine(); + var lines = line?.Split(';'); + + // Assuming there are always three columns (variable name and its value) + if (lines is { Length: 3 }) + { + var variableName = lines[0].Trim(); + + if (TryParse(lines[1].Trim(), out var value)) + { + switch (variableName) + { + case "/MinSoc" or "/MaxSoc": + batterySoc.Add(value); + break; + + case "/PvPower": + pvPower.Add(value); + break; + + case "/DischargingBatteryPower" : + batteryDischargePower.Add(value); + break; + + case "/ChargingBatteryPower" : + batteryChargePower.Add(value); + break; + + case "/GridExportPower": + gridPowerExport.Add(value); + break; + + case "/GridImportPower": + gridPowerImport.Add(value); + break; + + case "/HeatingPower": + heatingPowerAvg.Add(value); + break; + // Add more cases as needed + default: + // Code to execute when variableName doesn't match any condition + break; + } + + } + else + { + //Handle cases where variableValue is not a valid number + // Console.WriteLine( + // $"Invalid numeric value for variable {variableName}:{lines[1].Trim()}"); + } + } + else + { + // Handle invalid column format + //Console.WriteLine("Invalid format in column"); + } + } + } + } + + AggregatedData aggregatedData = new AggregatedData + { + MaxSoc = batterySoc.Any() ? batterySoc.Max() : 0.0, + MinSoc = batterySoc.Any() ? batterySoc.Min() : 0.0, + DischargingBatteryPower = batteryDischargePower.Any() ? batteryDischargePower.Average(): 0.0, + ChargingBatteryPower = batteryChargePower.Any() ? batteryChargePower.Average() : 0.0, + GridExportPower = gridPowerExport.Any() ? gridPowerExport.Sum() : 0.0, + GridImportPower = gridPowerImport.Any() ? gridPowerImport.Sum() : 0.0, + PvPower = pvPower.Any() ? pvPower.Last() : 0.0, + HeatingPower = heatingPowerAvg.Any() ? heatingPowerAvg.Average() : 0.0, + }; + + // Print the stored CSV data for verification + Console.WriteLine($"Pv Power: {aggregatedData.PvPower}"); + Console.WriteLine($"Heating Power: {aggregatedData.HeatingPower}"); + Console.WriteLine($"Max SOC: {aggregatedData.MaxSoc}"); + Console.WriteLine($"Min SOC: {aggregatedData.MinSoc}"); + + Console.WriteLine($"ChargingBatteryPower: {aggregatedData.DischargingBatteryPower}"); + Console.WriteLine($"DischargingBatteryBattery: {aggregatedData.ChargingBatteryPower}"); + + Console.WriteLine($"SumGridExportPower: {aggregatedData.GridExportPower}"); + Console.WriteLine($"SumGridImportPower: {aggregatedData.GridImportPower}"); + + + + Console.WriteLine("CSV data reading and storage completed."); + Console.WriteLine("-----------------------------------------------------------------------------------------------------------------"); + + return aggregatedData; + } + + // Custom method to check if a string is numeric + private static Boolean GetVariable(String value, String path) + { + return value == path; + } + + private static Boolean IsFileWithinTimeRange(string filePath, long startTime, long endTime) + { + var fileTimestamp = long.TryParse(Path.GetFileNameWithoutExtension(filePath).Replace("log_", ""), out var fileTimestamp1) ? fileTimestamp1 : -1; + + return fileTimestamp >= startTime && fileTimestamp < endTime; + } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/AggregationService/HourlyData.cs b/csharp/App/SodiStoreMax/src/AggregationService/HourlyData.cs new file mode 100644 index 000000000..a6294a20b --- /dev/null +++ b/csharp/App/SodiStoreMax/src/AggregationService/HourlyData.cs @@ -0,0 +1,130 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using Flurl.Http; +using InnovEnergy.App.SodiStoreMax.Devices; +using InnovEnergy.App.SodiStoreMax.SystemConfig; +using InnovEnergy.Lib.Units; +using InnovEnergy.Lib.Utils; +using static System.Text.Json.JsonSerializer; + +namespace InnovEnergy.App.SodiStoreMax.AggregationService; +// shut up trim warnings +#pragma warning disable IL2026 + +public class AggregatedData +{ + public required Double MinSoc { get; set; } + public required Double MaxSoc { get; set; } + public required Double PvPower { get; set; } + public required Double DischargingBatteryPower { get; set; } + public required Double ChargingBatteryPower { get; set; } + public required Double GridExportPower { get; set; } + public required Double GridImportPower { get; set; } + public required Double HeatingPower { get; set; } + + + private readonly S3Config? _S3Config = Config.Load().S3; + + public void Save(String directory) + { + var date = DateTime.Now.ToUnixTime(); + var defaultHDataPath = Environment.CurrentDirectory + "/" + directory + "/"; + var dataFilePath = defaultHDataPath + date + ".csv"; + + if (!Directory.Exists(defaultHDataPath)) + { + Directory.CreateDirectory(defaultHDataPath); + Console.WriteLine("Directory created successfully."); + } + Console.WriteLine("data file path is " + dataFilePath); + + try + { + var csvString = this.ToCsv(); + File.WriteAllText(dataFilePath, csvString); + } + catch (Exception e) + { + $"Failed to write config file {dataFilePath}\n{e}".WriteLine(); + throw; + } + } + + public static void DeleteDailyData(String directory) + { + + var csvFiles = Directory.GetFiles(directory, "*.csv"); + foreach (var csvFile in csvFiles) + { + File.Delete(csvFile); + Console.WriteLine($"Deleted daily data file: {csvFile}"); + } + } + + public async Task PushToS3() + { + var csv = this.ToCsv(); + if (_S3Config is null) + return false; + + var s3Path = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd") + ".csv"; + var request = _S3Config.CreatePutRequest(s3Path); + + // Compress CSV data to a byte array + byte[] compressedBytes; + using (var memoryStream = new MemoryStream()) + { + //Create a zip directory and put the compressed file inside + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) + { + var entry = archive.CreateEntry("data.csv", CompressionLevel.SmallestSize); // Add CSV data to the ZIP archive + using (var entryStream = entry.Open()) + using (var writer = new StreamWriter(entryStream)) + { + writer.Write(csv); + } + } + + compressedBytes = memoryStream.ToArray(); + } + + // Encode the compressed byte array as a Base64 string + string base64String = Convert.ToBase64String(compressedBytes); + + // Create StringContent from Base64 string + var stringContent = new StringContent(base64String, Encoding.UTF8, "application/base64"); + + // Upload the compressed data (ZIP archive) to S3 + var response = await request.PutAsync(stringContent); + + // + // var request = _S3Config.CreatePutRequest(s3Path); + // var response = await request.PutAsync(new StringContent(csv)); + + if (response.StatusCode != 200) + { + Console.WriteLine("ERROR: PUT"); + var error = await response.GetStringAsync(); + Console.WriteLine(error); + return false; + } + + return true; + } + + // public static HourlyData? Load(String dataFilePath) + // { + // try + // { + // var csvString = File.ReadAllText(dataFilePath); + // return Deserialize(jsonString)!; + // } + // catch (Exception e) + // { + // $"Failed to read config file {dataFilePath}, using default config\n{e}".WriteLine(); + // return null; + // } + // } + +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/DataTypes/AlarmOrWarning.cs b/csharp/App/SodiStoreMax/src/DataTypes/AlarmOrWarning.cs new file mode 100644 index 000000000..762caad37 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/DataTypes/AlarmOrWarning.cs @@ -0,0 +1,9 @@ +namespace InnovEnergy.App.SodiStoreMax.DataTypes; + +public class AlarmOrWarning +{ + public String? Date { get; set; } + public String? Time { get; set; } + public String? Description { get; set; } + public String? CreatedBy { get; set; } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/DataTypes/Configuration.cs b/csharp/App/SodiStoreMax/src/DataTypes/Configuration.cs new file mode 100644 index 000000000..8610e2601 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/DataTypes/Configuration.cs @@ -0,0 +1,12 @@ +using InnovEnergy.App.SodiStoreMax.SystemConfig; + +namespace InnovEnergy.App.SodiStoreMax.DataTypes; + +public class Configuration +{ + public Double MinimumSoC { get; set; } + public Double GridSetPoint { get; set; } + public CalibrationChargeType CalibrationChargeState { get; set; } + public DateTime CalibrationChargeDate { get; set; } +} + diff --git a/csharp/App/SodiStoreMax/src/DataTypes/StatusMessage.cs b/csharp/App/SodiStoreMax/src/DataTypes/StatusMessage.cs new file mode 100644 index 000000000..d54d1f586 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/DataTypes/StatusMessage.cs @@ -0,0 +1,20 @@ +using InnovEnergy.App.SodiStoreMax.Ess; + +namespace InnovEnergy.App.SodiStoreMax.DataTypes; + +public class StatusMessage +{ + public required Int32 InstallationId { get; set; } + public required Int32 Product { get; set; } + public required SalimaxAlarmState Status { get; set; } + public required MessageType Type { get; set; } + public List? Warnings { get; set; } + public List? Alarms { get; set; } + public Int32 Timestamp { get; set; } +} + +public enum MessageType +{ + AlarmOrWarning, + Heartbit +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Devices/AcPowerDevice.cs b/csharp/App/SodiStoreMax/src/Devices/AcPowerDevice.cs new file mode 100644 index 000000000..0b1033a05 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Devices/AcPowerDevice.cs @@ -0,0 +1,8 @@ +using InnovEnergy.Lib.Units.Composite; + +namespace InnovEnergy.App.SodiStoreMax.Devices; + +public class AcPowerDevice +{ + public required AcPower Power { get; init; } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Devices/DcPowerDevice.cs b/csharp/App/SodiStoreMax/src/Devices/DcPowerDevice.cs new file mode 100644 index 000000000..5579c5f12 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Devices/DcPowerDevice.cs @@ -0,0 +1,8 @@ +using InnovEnergy.Lib.Units.Power; + +namespace InnovEnergy.App.SodiStoreMax.Devices; + +public class DcPowerDevice +{ + public required DcPower Power { get; init; } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Devices/DeviceState.cs b/csharp/App/SodiStoreMax/src/Devices/DeviceState.cs new file mode 100644 index 000000000..6814e10ec --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Devices/DeviceState.cs @@ -0,0 +1,8 @@ +namespace InnovEnergy.App.SodiStoreMax.Devices; + +public enum DeviceState +{ + Disabled, + Measured, + Computed +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Devices/SalimaxDevice.cs b/csharp/App/SodiStoreMax/src/Devices/SalimaxDevice.cs new file mode 100644 index 000000000..61281bb24 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Devices/SalimaxDevice.cs @@ -0,0 +1,8 @@ +using InnovEnergy.Lib.Utils.Net; + +namespace InnovEnergy.App.SodiStoreMax.Devices; + +public class SalimaxDevice : Ip4Address +{ + public required DeviceState DeviceState { get; init; } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Ess/Controller.cs b/csharp/App/SodiStoreMax/src/Ess/Controller.cs new file mode 100644 index 000000000..ff8daea6d --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Ess/Controller.cs @@ -0,0 +1,273 @@ +using InnovEnergy.App.SodiStoreMax.SystemConfig; +using InnovEnergy.Lib.Devices.BatteryDeligreen; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertAc.DataTypes; +using InnovEnergy.Lib.Units; +using InnovEnergy.Lib.Utils; + +namespace InnovEnergy.App.SodiStoreMax.Ess; + +public static class Controller +{ + private static readonly Double MaxDischargePower = -4000; // By battery TODO: move to config + private static readonly Double MaxChargePower = 3500; // By battery TODO: move to config + + public static EssMode SelectControlMode(this StatusRecord s) + { + //return EssMode.OptimizeSelfConsumption; + + return s.StateMachine.State != 23 ? EssMode.Off + : s.MustReachMinSoc() ? EssMode.ReachMinSoc + : s.GridMeter is null ? EssMode.NoGridMeter + : EssMode.OptimizeSelfConsumption; + } + + + public static EssControl ControlEss(this StatusRecord s) + { + var mode = s.SelectControlMode().WriteLine(); + + if (mode is EssMode.Off) // to test on prototype + { + if (s.StateMachine.State == 28 ) + { + return new EssControl + { + LimitedBy = EssLimit.NoLimit, + Mode = EssMode.OffGrid, + PowerCorrection = 0, + PowerSetpoint = 0 + }; + } + return EssControl.Default; + } + + // if we have no reading from the Grid meter, but we have a grid power (K1 is close), + // then we do only heat the battery to avoid discharging the battery and the oscillation between reach min soc and off mode + if (mode is EssMode.NoGridMeter) + return new EssControl + { + LimitedBy = EssLimit.NoLimit, + Mode = EssMode.NoGridMeter, + PowerCorrection = 0, + PowerSetpoint = 0, //s.Battery == null ? 1000 : s.Battery.Devices.Count * s.Config.BatterySelfDischargePower // 1000 default value for heating the battery + }; + + var essDelta = s.ComputePowerDelta(mode); + essDelta.WriteLine("Power Correction"); + + var unlimitedControl = new EssControl + { + Mode = mode, + LimitedBy = EssLimit.NoLimit, + PowerCorrection = essDelta, + PowerSetpoint = 0 + }; + + var limitedControl = unlimitedControl + .LimitChargePower(s) + .LimitDischargePower(s) + .LimitInverterPower(s); + + var currentPowerSetPoint = s.CurrentPowerSetPoint(); + + return limitedControl with { PowerSetpoint = currentPowerSetPoint + limitedControl.PowerCorrection }; + } + + private static EssControl LimitInverterPower(this EssControl control, StatusRecord s) + { + var powerDelta = control.PowerCorrection.Value; + + var acDcs = s.AcDc.Devices; + + var nInverters = acDcs.Count; + + if (nInverters < 2) + return control; // current loop cannot happen + + var nominalPower = acDcs.Average(d => d.Status.Nominal.Power); + var maxStep = nominalPower / 25; //TODO magic number to config + + var clampedPowerDelta = powerDelta.Clamp(-maxStep, maxStep); + + var dcLimited = acDcs.Any(d => d.Status.PowerLimitedBy == PowerLimit.DcLink); + + if (!dcLimited) + return control with { PowerCorrection = clampedPowerDelta }; + + var maxPower = acDcs.Max(d => d.Status.Ac.Power.Active.Value); + var minPower = acDcs.Min(d => d.Status.Ac.Power.Active.Value); + + var powerDifference = maxPower - minPower; + + if (powerDifference < maxStep) + return control with { PowerCorrection = clampedPowerDelta }; + + var correction = powerDifference / 4; //TODO magic number to config + + + // find out if we reach the lower or upper Dc limit by comparing the current Dc voltage to the reference voltage + return s.AcDc.Dc.Voltage > s.Config.GridTie.AcDc.ReferenceDcLinkVoltage + ? control with { PowerCorrection = clampedPowerDelta.ClampMax(-correction), LimitedBy = EssLimit.ChargeLimitedByMaxDcBusVoltage } + : control with { PowerCorrection = clampedPowerDelta.ClampMin(correction), LimitedBy = EssLimit.DischargeLimitedByMinDcBusVoltage }; + } + + + private static EssControl LimitChargePower(this EssControl control, StatusRecord s) + { + + //var maxInverterChargePower = s.ControlInverterPower(s.Config.MaxInverterPower); + var maxBatteryChargePower = s.MaxBatteryChargePower(); + maxBatteryChargePower.WriteLine(" Max Battery Charge Power"); + + return control + //.LimitChargePower(, EssLimit.ChargeLimitedByInverterPower) + .LimitChargePower(maxBatteryChargePower, EssLimit.ChargeLimitedByBatteryPower); + + } + + private static EssControl LimitDischargePower(this EssControl control, StatusRecord s) + { + var maxBatteryDischargeDelta = s.Battery?.Devices.Count * MaxDischargePower ?? 0; + var keepMinSocLimitDelta = s.ControlBatteryPower(s.HoldMinSocPower()); + maxBatteryDischargeDelta.WriteLine(" Max Battery Discharge Power"); + + + return control + .LimitDischargePower(maxBatteryDischargeDelta , EssLimit.DischargeLimitedByBatteryPower) + .LimitDischargePower(keepMinSocLimitDelta , EssLimit.DischargeLimitedByMinSoc); + } + + private static Double ComputePowerDelta(this StatusRecord s, EssMode mode) + { + var chargePower = s.AcDc.Devices.Sum(d => d.Status.Nominal.Power.Value); + + + s.Config.GridSetPoint.WriteLine(" GridSetPoint"); + + return mode switch + { + EssMode.ReachMinSoc => s.ControlInverterPower(chargePower), + EssMode.OptimizeSelfConsumption => s.ControlGridPower(s.Config.GridSetPoint), + EssMode.Off => 0, + EssMode.OffGrid => 0, + EssMode.NoGridMeter => 0, + _ => throw new ArgumentException(null, nameof(mode)) + }; + } + + // private static Boolean MustHeatBatteries(this StatusRecord s) + // { + // var batteries = s.GetBatteries(); +// + // if (batteries.Count <= 0) + // return true; // batteries might be there but BMS is without power +// + // return batteries + // .Select(b => b.Temperatures.State) + // .Contains(TemperatureState.Cold); + // } + + private static Double MaxBatteryChargePower(this StatusRecord s) + { + // This introduces a limit when we don't have communication with batteries + // Otherwise the limit will be 0 and the batteries will be not heated + + var batteries = s.GetBatteries(); + + var maxChargePower = batteries.Count == 0 + ? 0 + : batteries.Count * MaxChargePower; + + return maxChargePower; + } + + private static Double CurrentPowerSetPoint(this StatusRecord s) + { + return s + .AcDc + .Devices + .Select(d => + { + var acPowerControl = d.Control.Ac.Power; + + return acPowerControl.L1.Active + + acPowerControl.L2.Active + + acPowerControl.L3.Active; + }) + .Sum(p => p); + } + + private static Boolean MustReachMinSoc(this StatusRecord s) + { + var batteries = s.GetBatteries(); + + return batteries.Count > 0 + && batteries.Any(b => b.BatteryDeligreenDataRecord.Soc < s.Config.MinSoc); + } + + private static IReadOnlyList GetBatteries(this StatusRecord s) + { + return s.Battery?.Devices ?? Array.Empty(); + } + + private static Double ControlGridPower(this StatusRecord status, Double targetPower) + { + return ControlPower + ( + measurement : status.GridMeter!.Ac.Power.Active, + target : targetPower, + pConstant : status.Config.PConstant + ); + } + + private static Double ControlInverterPower(this StatusRecord status, Double targetInverterPower) + { + return ControlPower + ( + measurement : status.AcDc.Ac.Power.Active, + target : targetInverterPower, + pConstant : status.Config.PConstant + ); + } + + private static Double ControlBatteryPower(this StatusRecord status, Double targetBatteryPower) + { + return ControlPower + ( + measurement: status.GetBatteries().Sum(b => b.BatteryDeligreenDataRecord.Power), + target: targetBatteryPower, + pConstant: status.Config.PConstant + ); + } + + private static Double HoldMinSocPower(this StatusRecord s) + { + // TODO: explain LowSOC curve + + var batteries = s.GetBatteries(); + + if (batteries.Count == 0) + return Double.NegativeInfinity; + + var a = -2 * s.Config.BatterySelfDischargePower * batteries.Count / s.Config.HoldSocZone; + var b = -a * (s.Config.MinSoc + s.Config.HoldSocZone); + + return batteries.Min(d => d.BatteryDeligreenDataRecord.Soc.Value) * a + b; + } + + private static Double ControlPower(Double measurement, Double target, Double pConstant) + { + var error = target - measurement; + return error * pConstant; + } + + // ReSharper disable once UnusedMember.Local, TODO + private static Double ControlPowerWithIntegral(Double measurement, Double target, Double p, Double i) + { + var errorSum = 0; // this is must be sum of error + var error = target - measurement; + var kp = p * error; + var ki = i * errorSum; + return ki + kp; + } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Ess/EssControl.cs b/csharp/App/SodiStoreMax/src/Ess/EssControl.cs new file mode 100644 index 000000000..66c3b3681 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Ess/EssControl.cs @@ -0,0 +1,53 @@ +using InnovEnergy.Lib.Units.Power; + +namespace InnovEnergy.App.SodiStoreMax.Ess; + +public record EssControl +{ + public required EssMode Mode { get; init; } + public required EssLimit LimitedBy { get; init; } + public required ActivePower PowerCorrection { get; init; } + public required ActivePower PowerSetpoint { get; init; } + + public static EssControl Default { get; } = new() + { + Mode = EssMode.Off, + LimitedBy = EssLimit.NoLimit, + PowerCorrection = 0, + PowerSetpoint = 0 + }; + + + public EssControl LimitChargePower(Double controlDelta, EssLimit reason) + { + var overload = PowerCorrection - controlDelta; + + if (overload <= 0) + return this; + + return this with + { + LimitedBy = reason, + PowerCorrection = controlDelta, + PowerSetpoint = PowerSetpoint - overload + }; + } + + public EssControl LimitDischargePower(Double controlDelta, EssLimit reason) + { + var overload = PowerCorrection - controlDelta; + + if (overload >= 0) + return this; + + return this with + { + LimitedBy = reason, + PowerCorrection = controlDelta, + PowerSetpoint = PowerSetpoint - overload + }; + } +} + + + diff --git a/csharp/App/SodiStoreMax/src/Ess/EssLimit.cs b/csharp/App/SodiStoreMax/src/Ess/EssLimit.cs new file mode 100644 index 000000000..4a814a790 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Ess/EssLimit.cs @@ -0,0 +1,20 @@ +namespace InnovEnergy.App.SodiStoreMax.Ess; + +public enum EssLimit +{ + NoLimit, + DischargeLimitedByMinSoc, + DischargeLimitedByBatteryPower, + DischargeLimitedByInverterPower, + ChargeLimitedByInverterPower, + ChargeLimitedByBatteryPower, + ChargeLimitedByMaxDcBusVoltage, + DischargeLimitedByMinDcBusVoltage, +} + + +// limitedBy = $"limiting discharging power in order to stay above min SOC: {s.Config.MinSoc}%"; +// limitedBy = $"limited by max battery discharging power: {maxDischargePower}"; +// limitedBy = $"limited by max inverter Dc to Ac power: {-s.Config.MaxInverterPower}W"; +// limitedBy = $"limited by max battery charging power: {maxChargePower}"; +// limitedBy = "limited by max inverter Ac to Dc power"; \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Ess/EssMode.cs b/csharp/App/SodiStoreMax/src/Ess/EssMode.cs new file mode 100644 index 000000000..c81c5953c --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Ess/EssMode.cs @@ -0,0 +1,12 @@ +namespace InnovEnergy.App.SodiStoreMax.Ess; + +public enum EssMode +{ + Off, + OffGrid, + HeatBatteries, + CalibrationCharge, + ReachMinSoc, + NoGridMeter, + OptimizeSelfConsumption +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Ess/SalimaxAlarmState.cs b/csharp/App/SodiStoreMax/src/Ess/SalimaxAlarmState.cs new file mode 100644 index 000000000..1cd7b3bd6 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Ess/SalimaxAlarmState.cs @@ -0,0 +1,8 @@ +namespace InnovEnergy.App.SodiStoreMax.Ess; + +public enum SalimaxAlarmState +{ + Green, + Orange, + Red +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Ess/StatusRecord.cs b/csharp/App/SodiStoreMax/src/Ess/StatusRecord.cs new file mode 100644 index 000000000..151ee0ff4 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Ess/StatusRecord.cs @@ -0,0 +1,33 @@ +using InnovEnergy.App.SodiStoreMax.Devices; +using InnovEnergy.App.SodiStoreMax.SaliMaxRelays; +using InnovEnergy.App.SodiStoreMax.System; +using InnovEnergy.App.SodiStoreMax.SystemConfig; +using InnovEnergy.Lib.Devices.AMPT; +using InnovEnergy.Lib.Devices.BatteryDeligreen; +using InnovEnergy.Lib.Devices.EmuMeter; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertAc; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertDc; + +namespace InnovEnergy.App.SodiStoreMax.Ess; + +public record StatusRecord +{ + public required AcDcDevicesRecord AcDc { get; init; } + public required DcDcDevicesRecord DcDc { get; init; } + public required BatteryDeligreenRecords? Battery { get; init; } + public required EmuMeterRegisters? GridMeter { get; init; } + public required EmuMeterRegisters? LoadOnAcIsland { get; init; } + public required AcPowerDevice? LoadOnAcGrid { get; init; } + public required AmptStatus? PvOnAcGrid { get; init; } + public required AmptStatus? PvOnAcIsland { get; init; } + public required AcPowerDevice? AcGridToAcIsland { get; init; } + public required DcPowerDevice? AcDcToDcLink { get; init; } + public required DcPowerDevice? LoadOnDc { get; init; } + public required IRelaysRecord? Relays { get; init; } + public required AmptStatus? PvOnDc { get; init; } + public required Config Config { get; set; } + public required SystemLog Log { get; init; } // TODO: init only + + public required EssControl EssControl { get; set; } // TODO: init only + public required StateMachine StateMachine { get; init; } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Ess/SystemLog.cs b/csharp/App/SodiStoreMax/src/Ess/SystemLog.cs new file mode 100644 index 000000000..71b58a9af --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Ess/SystemLog.cs @@ -0,0 +1,11 @@ +using InnovEnergy.App.SodiStoreMax.DataTypes; + +namespace InnovEnergy.App.SodiStoreMax.Ess; + +public record SystemLog +{ + public required String? Message { get; init; } + public required SalimaxAlarmState SalimaxAlarmState { get; init; } + public required List? SalimaxAlarms { get; set; } + public required List? SalimaxWarnings { get; set; } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Flow.cs b/csharp/App/SodiStoreMax/src/Flow.cs new file mode 100644 index 000000000..99e0ff452 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Flow.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; +using InnovEnergy.Lib.Units; +using InnovEnergy.Lib.Utils; + + +namespace InnovEnergy.App.SodiStoreMax; + +public static class Flow +{ + private static readonly String RightArrowChar = ">"; + private static readonly String LeftArrowChar = "<"; + private static readonly String DownArrowChar = "V"; + private static readonly String UpArrowChar = "^"; + private static readonly String UnknownArrowChar = "?"; + + public static TextBlock Horizontal(Unit? amount) => Horizontal(amount, 10); + + public static TextBlock Horizontal(Unit? amount, Int32 width) + { + var label = amount?.ToDisplayString() ?? ""; + + var arrowChar = amount switch + { + { Value: < 0 } => LeftArrowChar, + { Value: >= 0 } => RightArrowChar, + _ => UnknownArrowChar, + }; + + //var arrowChar = amount.Value < 0 ? LeftArrowChar : RightArrowChar; + var arrow = Enumerable.Repeat(arrowChar, width).Join(); + + // note : appending "fake label" below to make it vertically symmetric + return TextBlock.AlignCenterHorizontal(label, arrow, ""); + } + + public static TextBlock Vertical(Unit? amount) => Vertical(amount, 4); + + [SuppressMessage("ReSharper", "PossibleMultipleEnumeration")] + [SuppressMessage("ReSharper", "CoVariantArrayConversion")] + public static TextBlock Vertical(Unit? amount, Int32 height) + { + var label = amount?.ToDisplayString() ?? UnknownArrowChar; + var arrowChar = amount switch + { + { Value: < 0 } => UpArrowChar, + { Value: >= 0 } => DownArrowChar, + _ => UnknownArrowChar, + }; + + // var arrowChar = amount is null ? UnknownArrowChar + // : amount.Value < 0 ? UpArrowChar + // : DownArrowChar; + + return TextBlock.AlignCenterHorizontal(arrowChar, arrowChar, label, arrowChar, arrowChar); + } +} diff --git a/csharp/App/SodiStoreMax/src/LogFileConcatenator.cs b/csharp/App/SodiStoreMax/src/LogFileConcatenator.cs new file mode 100644 index 000000000..9ee7b4e41 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/LogFileConcatenator.cs @@ -0,0 +1,34 @@ +using System.Text; + +namespace InnovEnergy.App.SodiStoreMax; + +public class LogFileConcatenator +{ + private readonly string _logDirectory; + + public LogFileConcatenator(String logDirectory = "LogDirectory/") + { + _logDirectory = logDirectory; + } + + public String ConcatenateFiles(int numberOfFiles) + { + var logFiles = Directory + .GetFiles(_logDirectory, "log_*.csv") + .OrderByDescending(file => file) + .Take(numberOfFiles) + .OrderBy(file => file) + .ToList(); + + var concatenatedContent = new StringBuilder(); + + foreach (var fileContent in logFiles.Select(File.ReadAllText)) + { + concatenatedContent.AppendLine(fileContent); + //concatenatedContent.AppendLine(); // Append an empty line to separate the files // maybe we don't need this + } + + return concatenatedContent.ToString(); + } +} + diff --git a/csharp/App/SodiStoreMax/src/Logfile.cs b/csharp/App/SodiStoreMax/src/Logfile.cs new file mode 100644 index 000000000..75739b56d --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Logfile.cs @@ -0,0 +1,49 @@ +using InnovEnergy.Lib.Utils; +using Microsoft.Extensions.Logging; + +namespace InnovEnergy.App.SodiStoreMax; + +public class CustomLogger : ILogger +{ + private readonly String _logFilePath; + //private readonly Int64 _maxFileSizeBytes; + private readonly Int32 _maxLogFileCount; + private Int64 _currentFileSizeBytes; + + public CustomLogger(String logFilePath, Int32 maxLogFileCount) + { + _logFilePath = logFilePath; + _maxLogFileCount = maxLogFileCount; + _currentFileSizeBytes = File.Exists(logFilePath) ? new FileInfo(logFilePath).Length : 0; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => throw new NotImplementedException(); + + public Boolean IsEnabled(LogLevel logLevel) => true; // Enable logging for all levels + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var logMessage = formatter(state, exception!); + + // Check the log file count and delete the oldest file if necessary + var logFileDir = Path.GetDirectoryName(_logFilePath)!; + var logFileExt = Path.GetExtension(_logFilePath); + var logFileBaseName = Path.GetFileNameWithoutExtension(_logFilePath); + + var logFiles = Directory + .GetFiles(logFileDir, $"{logFileBaseName}_*{logFileExt}") + .OrderBy(file => file) + .ToList(); + + if (logFiles.Count >= _maxLogFileCount) + { + File.Delete(logFiles.First()); + } + + var roundedUnixTimestamp = DateTime.Now.ToUnixTime() % 2 == 0 ? DateTime.Now.ToUnixTime() : DateTime.Now.ToUnixTime() + 1; + var timestamp = "Timestamp;" + roundedUnixTimestamp + Environment.NewLine; + + var logFileBackupPath = Path.Combine(logFileDir, $"{logFileBaseName}_{DateTime.Now.ToUnixTime()}{logFileExt}"); + File.AppendAllText(logFileBackupPath, timestamp + logMessage + Environment.NewLine); + } +} diff --git a/csharp/App/SodiStoreMax/src/Logger.cs b/csharp/App/SodiStoreMax/src/Logger.cs new file mode 100644 index 000000000..fadd6babe --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Logger.cs @@ -0,0 +1,40 @@ +using InnovEnergy.App.SodiStoreMax; +using Microsoft.Extensions.Logging; + +namespace InnovEnergy.App.SodiStoreMax; + +public static class Logger +{ + // Specify the maximum log file size in bytes (e.g., 1 MB) + + //private const Int32 MaxFileSizeBytes = 2024 * 30; // TODO: move to settings + private const Int32 MaxLogFileCount = 5000; // TODO: move to settings + private const String LogFilePath = "LogDirectory/log.csv"; // TODO: move to settings + + // ReSharper disable once InconsistentNaming + private static readonly ILogger _logger = new CustomLogger(LogFilePath, MaxLogFileCount); + + public static T LogInfo(this T t) where T : notnull + { + _logger.LogInformation(t.ToString()); // TODO: check warning + return t; + } + + public static T LogDebug(this T t) where T : notnull + { + _logger.LogDebug(t.ToString()); // TODO: check warning + return t; + } + + public static T LogError(this T t) where T : notnull + { + _logger.LogError(t.ToString()); // TODO: check warning + return t; + } + + public static T LogWarning(this T t) where T : notnull + { + _logger.LogWarning(t.ToString()); // TODO: check warning + return t; + } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/MiddlewareClasses/MiddlewareAgent.cs b/csharp/App/SodiStoreMax/src/MiddlewareClasses/MiddlewareAgent.cs new file mode 100644 index 000000000..6e1bd8c36 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/MiddlewareClasses/MiddlewareAgent.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using InnovEnergy.App.SodiStoreMax.DataTypes; + +namespace InnovEnergy.App.SodiStoreMax.MiddlewareClasses; + +public static class MiddlewareAgent +{ + private static UdpClient _udpListener = null!; + private static IPAddress? _controllerIpAddress; + private static EndPoint? _endPoint; + + public static void InitializeCommunicationToMiddleware() + { + _controllerIpAddress = FindVpnIp(); + if (Equals(IPAddress.None, _controllerIpAddress)) + { + Console.WriteLine("There is no VPN interface, exiting..."); + } + + const Int32 udpPort = 9000; + _endPoint = new IPEndPoint(_controllerIpAddress, udpPort); + + _udpListener = new UdpClient(); + _udpListener.Client.Blocking = false; + _udpListener.Client.Bind(_endPoint); + } + + private static IPAddress FindVpnIp() + { + const String interfaceName = "innovenergy"; + + var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces(); + + foreach (var networkInterface in networkInterfaces) + { + if (networkInterface.Name == interfaceName) + { + var ipProps = networkInterface.GetIPProperties(); + var uniCastIPs = ipProps.UnicastAddresses; + var controllerIpAddress = uniCastIPs[0].Address; + + Console.WriteLine("VPN IP is: "+ uniCastIPs[0].Address); + return controllerIpAddress; + } + } + + return IPAddress.None; + } + + public static Configuration? SetConfigurationFile() + { + if (_udpListener.Available > 0) + { + + IPEndPoint? serverEndpoint = null; + + var replyMessage = "ACK"; + var replyData = Encoding.UTF8.GetBytes(replyMessage); + + var udpMessage = _udpListener.Receive(ref serverEndpoint); + var message = Encoding.UTF8.GetString(udpMessage); + + var config = JsonSerializer.Deserialize(message); + + if (config != null) + { + Console.WriteLine($"Received a configuration message: GridSetPoint is " + config.GridSetPoint + + ", MinimumSoC is " + config.MinimumSoC + " and ForceCalibrationCharge is " + + config.CalibrationChargeState + " and CalibrationChargeDate is " + + config.CalibrationChargeDate); + + // Send the reply to the sender's endpoint + _udpListener.Send(replyData, replyData.Length, serverEndpoint); + Console.WriteLine($"Replied to {serverEndpoint}: {replyMessage}"); + return config; + } + } + + if (_endPoint != null && !_endPoint.Equals(_udpListener.Client.LocalEndPoint as IPEndPoint)) + { + Console.WriteLine("UDP address has changed, rebinding..."); + InitializeCommunicationToMiddleware(); + } + + + return null; + } + +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/MiddlewareClasses/RabbitMQManager.cs b/csharp/App/SodiStoreMax/src/MiddlewareClasses/RabbitMQManager.cs new file mode 100644 index 000000000..2de9f5665 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/MiddlewareClasses/RabbitMQManager.cs @@ -0,0 +1,61 @@ +using System.Text; +using System.Text.Json; +using InnovEnergy.App.SodiStoreMax.DataTypes; +using RabbitMQ.Client; + +namespace InnovEnergy.App.SodiStoreMax.MiddlewareClasses; + +public static class RabbitMqManager +{ + public static ConnectionFactory? Factory ; + public static IConnection ? Connection; + public static IModel? Channel; + + public static Boolean SubscribeToQueue(StatusMessage currentSalimaxState, String? s3Bucket,String VpnServerIp) + { + try + { + //_factory = new ConnectionFactory { HostName = VpnServerIp }; + + Factory = new ConnectionFactory + { + HostName = VpnServerIp, + Port = 5672, + VirtualHost = "/", + UserName = "producer", + Password = "b187ceaddb54d5485063ddc1d41af66f", + + }; + + Connection = Factory.CreateConnection(); + Channel = Connection.CreateModel(); + Channel.QueueDeclare(queue: "statusQueue", durable: true, exclusive: false, autoDelete: false, arguments: null); + + Console.WriteLine("The controller sends its status to the middleware for the first time"); + if (s3Bucket != null) InformMiddleware(currentSalimaxState); + + + } + catch (Exception ex) + { + Console.WriteLine("An error occurred while connecting to the RabbitMQ queue: " + ex.Message); + return false; + } + return true; + } + + public static void InformMiddleware(StatusMessage status) + { + var message = JsonSerializer.Serialize(status); + var body = Encoding.UTF8.GetBytes(message); + + Channel.BasicPublish(exchange: string.Empty, + routingKey: "statusQueue", + basicProperties: null, + body: body); + + Console.WriteLine($"Producer sent message: {message}"); + } + + +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Program.cs b/csharp/App/SodiStoreMax/src/Program.cs new file mode 100644 index 000000000..10e3ca99e --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Program.cs @@ -0,0 +1,962 @@ +#undef Amax +#undef GridLimit + +using System.Diagnostics; +using System.IO.Compression; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Reflection.Metadata; +using System.Security; +using System.Text; +using Flurl.Http; +using InnovEnergy.App.SodiStoreMax; +using InnovEnergy.App.SodiStoreMax.Devices; +using InnovEnergy.App.SodiStoreMax.Ess; +using InnovEnergy.App.SodiStoreMax.MiddlewareClasses; +using InnovEnergy.App.SodiStoreMax.SaliMaxRelays; +using InnovEnergy.App.SodiStoreMax.System; +using InnovEnergy.App.SodiStoreMax.SystemConfig; +using InnovEnergy.Lib.Devices.AMPT; +using InnovEnergy.Lib.Devices.BatteryDeligreen; +using InnovEnergy.Lib.Devices.EmuMeter; +using InnovEnergy.Lib.Devices.Trumpf.SystemControl; +using InnovEnergy.Lib.Devices.Trumpf.SystemControl.DataTypes; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertAc; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertAc.DataTypes; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertDc; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertDc.Control; +using InnovEnergy.Lib.Protocols.Modbus.Channels; +using InnovEnergy.Lib.Units; +using InnovEnergy.Lib.Utils; +using InnovEnergy.App.SodiStoreMax.DataTypes; +using InnovEnergy.Lib.Utils.Net; +using static System.Int32; +using static InnovEnergy.App.SodiStoreMax.AggregationService.Aggregator; +using static InnovEnergy.App.SodiStoreMax.MiddlewareClasses.MiddlewareAgent; +using static InnovEnergy.Lib.Devices.Trumpf.SystemControl.DataTypes.SystemConfig; +using DeviceState = InnovEnergy.App.SodiStoreMax.Devices.DeviceState; + +#pragma warning disable IL2026 + +namespace InnovEnergy.App.SodiStoreMax; + +internal static class Program +{ + private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(2); + + private static readonly IReadOnlyList BatteryNodes; + + private static readonly Channel TruConvertAcChannel; + private static readonly Channel TruConvertDcChannel; + private static readonly Channel GridMeterChannel; + private static readonly Channel IslandBusLoadChannel; + private static readonly Channel PvOnDc; + private static readonly Channel PvOnAcGrid; + private static readonly Channel PvOnAcIsland; + private static readonly Channel RelaysChannel; + private static readonly Channel RelaysTsChannel; + private static readonly Channel BatteriesChannel; + + private static Boolean _curtailFlag = false; + private const String VpnServerIp = "10.2.0.11"; + private static Boolean _subscribedToQueue = false; + private static Boolean _subscribeToQueueForTheFirstTime = false; + private static SalimaxAlarmState _prevSalimaxState = SalimaxAlarmState.Green; + private const UInt16 NbrOfFileToConcatenate = 30; + private static UInt16 _counterOfFile = 0; + private static SalimaxAlarmState _salimaxAlarmState = SalimaxAlarmState.Green; + private const String Port = "/dev/ttyUSB0"; + + + static Program() + { + var config = Config.Load(); + var d = config.Devices; + + Channel CreateChannel(SalimaxDevice device) => device.DeviceState == DeviceState.Disabled + ? new NullChannel() + : new TcpChannel(device); + + + TruConvertAcChannel = CreateChannel(d.TruConvertAcIp); + TruConvertDcChannel = CreateChannel(d.TruConvertDcIp); + GridMeterChannel = CreateChannel(d.GridMeterIp); + IslandBusLoadChannel = CreateChannel(d.IslandBusLoadMeterIp); + PvOnDc = CreateChannel(d.PvOnDc); + PvOnAcGrid = CreateChannel(d.PvOnAcGrid); + PvOnAcIsland = CreateChannel(d.PvOnAcIsland); + RelaysChannel = CreateChannel(d.RelaysIp); + RelaysTsChannel = CreateChannel(d.TsRelaysIp); + BatteriesChannel = CreateChannel(d.BatteryIp); + + BatteryNodes = config + .Devices + .BatteryNodes + .Select(n => n.ConvertTo()) + .ToArray(config.Devices.BatteryNodes.Length); + } + + public static async Task Main(String[] args) + { + //Do not await + HourlyDataAggregationManager() + .ContinueWith(t=>t.Exception.WriteLine(), TaskContinuationOptions.OnlyOnFaulted) + .SupressAwaitWarning(); + + DailyDataAggregationManager() + .ContinueWith(t=>t.Exception.WriteLine(), TaskContinuationOptions.OnlyOnFaulted) + .SupressAwaitWarning(); + + InitializeCommunicationToMiddleware(); + + while (true) + { + try + { + await Run(); + } + catch (Exception e) + { + e.LogError(); + } + } + } + + + private static async Task Run() + { + "Starting SodiStore Max".WriteLine(); + + Watchdog.NotifyReady(); + + var batteryDeligreenDevice = BatteryNodes.Select(n => new BatteryDeligreenDevice(Port, n)) + .ToList(); + + var batteryDevices = new BatteryDeligreenDevices(batteryDeligreenDevice); + var acDcDevices = new TruConvertAcDcDevices(TruConvertAcChannel); + var dcDcDevices = new TruConvertDcDcDevices(TruConvertDcChannel); + var gridMeterDevice = new EmuMeterDevice(GridMeterChannel); + var acIslandLoadMeter = new EmuMeterDevice(IslandBusLoadChannel); + var pvOnDcDevice = new AmptDevices(PvOnDc); + var pvOnAcGridDevice = new AmptDevices(PvOnAcGrid); + var pvOnAcIslandDevice = new AmptDevices(PvOnAcIsland); + var saliMaxTsRelaysDevice = new RelaysDeviceAdam6060(RelaysTsChannel); + + +#if Amax + var saliMaxRelaysDevice = new RelaysDeviceAmax(RelaysChannel); +#else + var saliMaxRelaysDevice = new RelaysDeviceAdam6360(RelaysChannel); +#endif + + + StatusRecord ReadStatus() + { + var config = Config.Load(); + var devices = config.Devices; + var acDc = acDcDevices.Read(); + var dcDc = dcDcDevices.Read(); + var relays = saliMaxRelaysDevice.Read(); + var tsRelays = saliMaxTsRelaysDevice.Read(); + var loadOnAcIsland = acIslandLoadMeter.Read(); + var gridMeter = gridMeterDevice.Read(); + var pvOnDc = pvOnDcDevice.Read(); + var battery = batteryDevices.Read(); + + var pvOnAcGrid = pvOnAcGridDevice.Read(); + var pvOnAcIsland = pvOnAcIslandDevice.Read(); + + var gridBusToIslandBus = Topology.CalculateGridBusToIslandBusPower(pvOnAcIsland, loadOnAcIsland, acDc); + + var gridBusLoad = devices.LoadOnAcGrid.DeviceState == DeviceState.Disabled + ? new AcPowerDevice { Power = 0 } + : Topology.CalculateGridBusLoad(gridMeter, pvOnAcGrid, gridBusToIslandBus); + + var dcLoad = devices.LoadOnDc.DeviceState == DeviceState.Disabled + ? new DcPowerDevice { Power = 0 } + : Topology.CalculateDcLoad(acDc, pvOnDc, dcDc); + + var acDcToDcLink = devices.LoadOnDc.DeviceState == DeviceState.Disabled ? + Topology.CalculateAcDcToDcLink(pvOnDc, dcDc, acDc) + : new DcPowerDevice{ Power = acDc.Dc.Power}; + +#if Amax + var combinedRelays = relays; +#else + var combinedRelays = new CombinedAdamRelaysRecord(tsRelays, relays); +#endif + + return new StatusRecord + { + AcDc = acDc, + DcDc = dcDc, + Battery = battery, + Relays = combinedRelays, + GridMeter = gridMeter, + PvOnAcGrid = pvOnAcGrid, + PvOnAcIsland = pvOnAcIsland, + PvOnDc = pvOnDc, + AcGridToAcIsland = gridBusToIslandBus, + AcDcToDcLink = acDcToDcLink, + LoadOnAcGrid = gridBusLoad, + LoadOnAcIsland = loadOnAcIsland, + LoadOnDc = dcLoad, + StateMachine = StateMachine.Default, + EssControl = EssControl.Default, + Log = new SystemLog { SalimaxAlarmState = SalimaxAlarmState.Green, Message = null, SalimaxAlarms = null, SalimaxWarnings = null}, //TODO: Put real stuff + Config = config // load from disk every iteration, so config can be changed while running + }; + } + + void WriteControl(StatusRecord r) + { + if (r.Relays is not null) + { +#if Amax + saliMaxRelaysDevice.Write((RelaysRecordAmax)r.Relays); +#else + + if (r.Relays is CombinedAdamRelaysRecord adamRelays) + { + saliMaxRelaysDevice.Write(adamRelays.GetAdam6360DRecord() ?? throw new InvalidOperationException()); + saliMaxTsRelaysDevice.Write(adamRelays.GetAdam6060Record() ?? throw new InvalidOperationException()); + } +#endif + } + + acDcDevices.Write(r.AcDc); + dcDcDevices.Write(r.DcDc); + } + + Console.WriteLine("press ctrl-c to stop"); + + while (true) + { + await Observable + .Interval(UpdateInterval) + .Select(_ => RunIteration()) + .SelectMany(r => UploadCsv(r, DateTime.Now.Round(UpdateInterval))) + .SelectError() + .ToTask(); + } + + + StatusRecord RunIteration() + { + Watchdog.NotifyAlive(); + + var record = ReadStatus(); + /* + if (record.Relays != null) + { + record.Relays.Do0StartPulse = true; + + record.Relays.PulseOut0HighTime = 20000; + record.Relays.PulseOut0LowTime = 20000; + record.Relays.DigitalOutput0Mode = 2; + + record.Relays.LedGreen = false; + + record.Relays.Do0StartPulse.WriteLine(" = start pulse 0"); + + record.Relays.PulseOut0HighTime.WriteLine(" = PulseOut0HighTime"); + + record.Relays.PulseOut0LowTime.WriteLine(" = PulseOut0LowTime"); + + record.Relays.DigitalOutput0Mode.WriteLine(" = DigitalOutput0Mode"); + + record.Relays.LedGreen.WriteLine(" = LedGreen"); + + record.Relays.LedRed.WriteLine(" = LedRed"); + + } + else + { + " Relays are null".WriteLine(); + }*/ + + SendSalimaxStateAlarm(GetSalimaxStateAlarm(record), record); // to improve + + record.ControlConstants(); + record.ControlSystemState(); + + record.ControlPvPower(record.Config.CurtailP, record.Config.PvInstalledPower); + + var essControl = record.ControlEss().WriteLine(); + + record.EssControl = essControl; + + record.AcDc.SystemControl.ApplyAcDcDefaultSettings(); + record.DcDc.SystemControl.ApplyDcDcDefaultSettings(); + + DistributePower(record, essControl); + + record.PerformLed(); + + WriteControl(record); + + $"{DateTime.Now.Round(UpdateInterval).ToUnixTime()} : {record.StateMachine.State}: {record.StateMachine.Message}".WriteLine(); + + record.CreateTopologyTextBlock().WriteLine(); + + (record.Relays is null ? "No relay Data available" : record.Relays.FiWarning ? "Alert: Fi Warning Detected" : "No Fi Warning Detected").WriteLine(); + (record.Relays is null ? "No relay Data available" : record.Relays.FiError ? "Alert: Fi Error Detected" : "No Fi Error Detected") .WriteLine(); + + record.Config.Save(); + + "===========================================".WriteLine(); + + return record; + } + + // ReSharper disable once FunctionNeverReturns + } + + private static void SendSalimaxStateAlarm(StatusMessage currentSalimaxState, StatusRecord record) + { + var s3Bucket = Config.Load().S3?.Bucket; + var subscribedNow = false; + + //Every 15 iterations(30 seconds), the installation sends a heartbit message to the queue + //_heartBitInterval++; + + //When the controller boots, it tries to subscribe to the queue + if (_subscribeToQueueForTheFirstTime == false) + { + subscribedNow = true; + _subscribeToQueueForTheFirstTime = true; + _prevSalimaxState = currentSalimaxState.Status; + _subscribedToQueue = RabbitMqManager.SubscribeToQueue(currentSalimaxState, s3Bucket, VpnServerIp); + } + + //If already subscribed to the queue and the status has been changed, update the queue + if (!subscribedNow && _subscribedToQueue && currentSalimaxState.Status != _prevSalimaxState) + { + _prevSalimaxState = currentSalimaxState.Status; + if (s3Bucket != null) + RabbitMqManager.InformMiddleware(currentSalimaxState); + } + // else if (_subscribedToQueue && _heartBitInterval >= 30) + // { + // //Send a heartbit to the backend + // Console.WriteLine("----------------------------------------Sending Heartbit----------------------------------------"); + // _heartBitInterval = 0; + // currentSalimaxState.Type = MessageType.Heartbit; + // + // if (s3Bucket != null) + // RabbitMqManager.InformMiddleware(currentSalimaxState); + // } + + //If there is an available message from the RabbitMQ Broker, apply the configuration file + Configuration? config = SetConfigurationFile(); + if (config != null) + { + record.ApplyConfigFile(config); + } + } + + // This preparing a message to send to salimax monitor + private static StatusMessage GetSalimaxStateAlarm(StatusRecord record) + { + var alarmCondition = record.DetectAlarmStates(); // this need to be emailed to support or customer + var s3Bucket = Config.Load().S3?.Bucket; + + var alarmList = new List(); + var warningList = new List(); + var bAlarmList = new List(); + var bWarningList = new List(); + + /* + if (record.Battery != null) + { + var i = 0; + + foreach (var battery in record.Battery.Devices) + { + var devicesBatteryNode = record.Config.Devices.BatteryNodes[i]; + + if (battery.LimpBitMap == 0) + { + // "All String are Active".WriteLine(); + } + else if (IsPowerOfTwo(battery.LimpBitMap)) + { + "1 String is disabled".WriteLine(); + Console.WriteLine(" ****************** "); + + warningList.Add(new AlarmOrWarning + { + Date = DateTime.Now.ToString("yyyy-MM-dd"), + Time = DateTime.Now.ToString("HH:mm:ss"), + CreatedBy = "Battery node" + devicesBatteryNode, + Description = "1 String is disabled" + }); + + bWarningList.Add("/"+i+1 + "/1 String is disabled"); // battery id instead ( i +1 ) of node id: requested from the frontend + } + else + { + "2 or more string are disabled".WriteLine(); + Console.WriteLine(" ****************** "); + + alarmList.Add(new AlarmOrWarning + { + Date = DateTime.Now.ToString("yyyy-MM-dd"), + Time = DateTime.Now.ToString("HH:mm:ss"), + CreatedBy = "Battery node" + devicesBatteryNode, + Description = "2 or more string are disabled" + }); + bAlarmList.Add(i +";2 or more string are disabled"); + } + + foreach (var warning in record.Battery.Warnings) + { + warningList.Add(new AlarmOrWarning + { + Date = DateTime.Now.ToString("yyyy-MM-dd"), + Time = DateTime.Now.ToString("HH:mm:ss"), + CreatedBy = "Battery node" + devicesBatteryNode, + Description = warning + }); + bWarningList.Add(i +";" + warning); + } + + foreach (var alarm in battery.Alarms) + { + alarmList.Add(new AlarmOrWarning + { + Date = DateTime.Now.ToString("yyyy-MM-dd"), + Time = DateTime.Now.ToString("HH:mm:ss"), + CreatedBy = "Battery node" + devicesBatteryNode, + Description = alarm + }); + bWarningList.Add(i +";" + alarm); + } + i++; + } + }*/ + + if (alarmCondition is not null) + { + alarmCondition.WriteLine(); + + alarmList.Add(new AlarmOrWarning + { + Date = DateTime.Now.ToString("yyyy-MM-dd"), + Time = DateTime.Now.ToString("HH:mm:ss"), + CreatedBy = "Salimax", + Description = alarmCondition + }); + } + + foreach (var alarm in record.AcDc.Alarms) + { + alarmList.Add(new AlarmOrWarning + { + Date = DateTime.Now.ToString("yyyy-MM-dd"), + Time = DateTime.Now.ToString("HH:mm:ss"), + CreatedBy = "AcDc", + Description = alarm.ToString() + }); + } + + foreach (var alarm in record.DcDc.Alarms) + { + alarmList.Add(new AlarmOrWarning + { + Date = DateTime.Now.ToString("yyyy-MM-dd"), + Time = DateTime.Now.ToString("HH:mm:ss"), + CreatedBy = "DcDc", + Description = alarm.ToString() + }); + } + + foreach (var warning in record.AcDc.Warnings) + { + warningList.Add(new AlarmOrWarning + { + Date = DateTime.Now.ToString("yyyy-MM-dd"), + Time = DateTime.Now.ToString("HH:mm:ss"), + CreatedBy = "AcDc", + Description = warning.ToString() + }); + } + + foreach (var warning in record.DcDc.Warnings) + { + warningList.Add(new AlarmOrWarning + { + Date = DateTime.Now.ToString("yyyy-MM-dd"), + Time = DateTime.Now.ToString("HH:mm:ss"), + CreatedBy = "DcDc", + Description = warning.ToString() + }); + } + + _salimaxAlarmState = warningList.Any() + ? SalimaxAlarmState.Orange + : SalimaxAlarmState.Green; // this will be replaced by LedState + + _salimaxAlarmState = alarmList.Any() + ? SalimaxAlarmState.Red + : _salimaxAlarmState; // this will be replaced by LedState + + TryParse(s3Bucket?.Split("-")[0], out var installationId); + + var returnedStatus = new StatusMessage + { + InstallationId = installationId, + Product = 0, + Status = _salimaxAlarmState, + Type = MessageType.AlarmOrWarning, + Alarms = alarmList, + Warnings = warningList + }; + + return returnedStatus; + } + + private static String? DetectAlarmStates(this StatusRecord r) => r.Relays switch + { + { K2ConnectIslandBusToGridBus: false, K2IslandBusIsConnectedToGridBus: true } => " Contradiction: R0 is opening the K2 but the K2 is still close ", + { K1GridBusIsConnectedToGrid : false, K2IslandBusIsConnectedToGridBus: true } => " Contradiction: K1 is open but the K2 is still close ", + { FiError: true, K2IslandBusIsConnectedToGridBus: true } => " Contradiction: Fi error occured but the K2 is still close ", + _ => null + }; + + private static void ControlConstants(this StatusRecord r) + { + var inverters = r.AcDc.Devices; + var dcDevices = r.DcDc.Devices; + var configFile = r.Config; + //var maxBatteryDischargingCurrentLive = 0.0; //never used with deligreenBattery + var devicesConfig = r.AcDc.Devices.All(d => d.Control.Ac.GridType == GridType.GridTied400V50Hz) ? configFile.GridTie : configFile.IslandMode; // TODO if any of the grid tie mode + /* + // This adapting the max discharging current to the current Active Strings + if (r.Battery != null) + { + const Int32 stringsByBattery = 5; + var numberOfBatteriesConfigured = r.Config.Devices.BatteryNodes.Length; + var numberOfTotalStrings = stringsByBattery * numberOfBatteriesConfigured; + var dischargingCurrentByString = devicesConfig.DcDc.MaxBatteryDischargingCurrent / numberOfTotalStrings; + + var boolList = new List(); + + foreach (var stringActive in r.Battery.Devices.Select(b => b.BatteryStrings).ToList()) + { + boolList.Add(stringActive.String1Active); + boolList.Add(stringActive.String2Active); + boolList.Add(stringActive.String3Active); + boolList.Add(stringActive.String4Active); + boolList.Add(stringActive.String5Active); + } + + var numberOfBatteriesStringActive = boolList.Count(b => b); + + if (numberOfTotalStrings != 0) + { + maxBatteryDischargingCurrentLive = dischargingCurrentByString * numberOfBatteriesStringActive; + } + } + */ + // TODO The discharging current is well calculated but not communicated to live. But Written in S3 + + + inverters.ForEach(d => d.Control.Dc.MaxVoltage = devicesConfig.AcDc.MaxDcLinkVoltage); + inverters.ForEach(d => d.Control.Dc.MinVoltage = devicesConfig.AcDc.MinDcLinkVoltage); + inverters.ForEach(d => d.Control.Dc.ReferenceVoltage = devicesConfig.AcDc.ReferenceDcLinkVoltage); + + inverters.ForEach(d => d.Control.Dc.PrechargeConfig = DcPrechargeConfig.PrechargeDcWithInternal); + + dcDevices.ForEach(d => d.Control.DroopControl.UpperVoltage = devicesConfig.DcDc.UpperDcLinkVoltage); + dcDevices.ForEach(d => d.Control.DroopControl.LowerVoltage = devicesConfig.DcDc.LowerDcLinkVoltage); + dcDevices.ForEach(d => d.Control.DroopControl.ReferenceVoltage = devicesConfig.DcDc.ReferenceDcLinkVoltage); + + dcDevices.ForEach(d => d.Control.CurrentControl.MaxBatteryChargingCurrent = devicesConfig.DcDc.MaxBatteryChargingCurrent); + dcDevices.ForEach(d => d.Control.CurrentControl.MaxBatteryDischargingCurrent = devicesConfig.DcDc.MaxBatteryDischargingCurrent); + dcDevices.ForEach(d => d.Control.MaxDcPower = devicesConfig.DcDc.MaxDcPower); + + dcDevices.ForEach(d => d.Control.VoltageLimits.MaxBatteryVoltage = devicesConfig.DcDc.MaxChargeBatteryVoltage); + dcDevices.ForEach(d => d.Control.VoltageLimits.MinBatteryVoltage = devicesConfig.DcDc.MinDischargeBatteryVoltage); + dcDevices.ForEach(d => d.Control.ControlMode = DcControlMode.VoltageDroop); + + r.DcDc.ResetAlarms(); + r.AcDc.ResetAlarms(); + } + + // This will be used for provider throttling, this example is only for either 100% or 0 % + private static void ControlPvPower(this StatusRecord r, UInt16 exportLimit = 0, UInt16 pvInstalledPower = 20) + { + // Maybe add a condition to do this only if we are in optimised Self consumption, this is not true + + if (r.GridMeter?.Ac.Power.Active == null) + { + Console.WriteLine(" No reading from Grid meter"); + return; + } + + if (pvInstalledPower == 0) + { + Console.WriteLine(" No curtailing, because Pv installed is equal to 0"); + return; + } + + const Int32 constantDeadBand = 5000; // magic number + const Double voltageRange = 100; // 100 Voltage configured rang for PV slope, if the configured slope change this must change also + var configFile = r.Config; + var inverters = r.AcDc.Devices; + var systemExportLimit = - exportLimit * 1000 ; // Conversion from Kw in W // the config file value is positive and limit should be negative from 0 to ... + var stepSize = ClampStepSize((UInt16)Math.Floor(voltageRange/ pvInstalledPower)); // in Voltage per 1 Kw + var deadBand = constantDeadBand/stepSize; + + // LINQ query to select distinct ActiveUpperVoltage + var result = r.AcDc.Devices + .Select(device => device?.Status?.DcVoltages?.Active?.ActiveUpperVoltage) + .Select(voltage => voltage.Value) // Extract the value since we've confirmed it's non-null + .Distinct() + .ToList(); + + Double upperVoltage; + + if (result.Count == 1) + { + upperVoltage = result[0]; + } + else + { + Console.WriteLine(" Different ActiveUpperVoltage between inverters "); // this should be reported to salimax Alarm + return; + } + + /************* For debugging purposes ********************/ + + systemExportLimit.WriteLine(" Export Limit in W"); + upperVoltage.WriteLine(" Upper Voltage"); + r.GridMeter.Ac.Power.Active.WriteLine(" Active Export"); + Console.WriteLine(" ****************** "); + + /*********************************************************/ + + if (r.GridMeter.Ac.Power.Active < systemExportLimit) + { + _curtailFlag = true; + upperVoltage = IncreaseInverterUpperLimit(upperVoltage, stepSize); + upperVoltage.WriteLine("Upper Voltage Increased: New Upper limit"); + } + else + { + if (_curtailFlag) + { + if (r.GridMeter.Ac.Power.Active > (systemExportLimit + deadBand)) + { + upperVoltage = DecreaseInverterUpperLimit(upperVoltage, stepSize); + + if (upperVoltage <= configFile.GridTie.AcDc.MaxDcLinkVoltage) + { + _curtailFlag = false; + upperVoltage = configFile.GridTie.AcDc.MaxDcLinkVoltage; + upperVoltage.WriteLine(" New Upper limit"); + Console.WriteLine("Upper Voltage decreased: Smaller than the default value, value clamped"); + } + else + { + Console.WriteLine("Upper Voltage decreased: New Upper limit"); + upperVoltage.WriteLine(" New Upper limit"); + } + } + else + { + deadBand.WriteLine("W :We are in Dead band area"); + upperVoltage.WriteLine(" same Upper limit from last cycle"); + } + } + else + { + Console.WriteLine("Curtail Flag is false , no need to curtail"); + upperVoltage.WriteLine(" same Upper limit from last cycle"); + } + } + inverters.ForEach(d => d.Control.Dc.MaxVoltage = upperVoltage); + Console.WriteLine(" ****************** "); + } + + // why this is not in Controller? + private static void DistributePower(StatusRecord record, EssControl essControl) + { + var nInverters = record.AcDc.Devices.Count; + + var powerPerInverterPhase = nInverters > 0 + ? essControl.PowerSetpoint / nInverters / 3 + : 0; + + record.AcDc.Devices.ForEach(d => + { + d.Control.Ac.PhaseControl = PhaseControl.Asymmetric; + d.Control.Ac.Power.L1 = powerPerInverterPhase; + d.Control.Ac.Power.L2 = powerPerInverterPhase; + d.Control.Ac.Power.L3 = powerPerInverterPhase; + }); + } + + // To test, most probably the curtailing flag will not work + private static void PerformLed(this StatusRecord record) + { + if (record.StateMachine.State == 23) + { + switch (record.EssControl.Mode) + { + case EssMode.CalibrationCharge: + record.Relays?.PerformSlowFlashingGreenLed(); + break; + case EssMode.OptimizeSelfConsumption when !_curtailFlag: + record.Relays?.PerformSolidGreenLed(); + break; + case EssMode.Off: + break; + case EssMode.OffGrid: + break; + case EssMode.HeatBatteries: + break; + case EssMode.ReachMinSoc: + break; + case EssMode.NoGridMeter: + break; + default: + { + if (_curtailFlag) + { + record.Relays?.PerformFastFlashingGreenLed(); + } + + break; + } + } + } + else if (record.Battery?.Soc != null && record.StateMachine.State != 23 && record.Battery.Soc > 50) + { + record.Relays?.PerformSolidOrangeLed(); + } + else if (record.Battery?.Soc != null && record.StateMachine.State != 23 && record.Battery.Soc < 50 && record.Battery.Soc > 20) + { + record.Relays?.PerformSlowFlashingOrangeLed(); + } + else if (record.Battery?.Soc != null && record.StateMachine.State != 23 && record.Battery.Soc < 20) + { + record.Relays?.PerformFastFlashingOrangeLed(); + } + + var criticalAlarm = record.DetectAlarmStates(); + + if (criticalAlarm is not null) + { + record.Relays?.PerformFastFlashingRedLed(); + } + } + + private static Double IncreaseInverterUpperLimit(Double upperLimit, Double stepSize) + { + return upperLimit + stepSize; + } + + private static Double DecreaseInverterUpperLimit(Double upperLimit, Double stepSize) + { + return upperLimit - stepSize; + } + + private static UInt16 ClampStepSize(UInt16 stepSize) + { + return stepSize switch + { + > 5 => 5, + <= 1 => 1, + _ => stepSize + }; + } + + private static void ApplyAcDcDefaultSettings(this SystemControlRegisters? sc) + { + if (sc is null) + return; + + sc.ReferenceFrame = ReferenceFrame.Consumer; + sc.SystemConfig = AcDcAndDcDc; + + #if DEBUG + sc.CommunicationTimeout = TimeSpan.FromMinutes(2); + #else + sc.CommunicationTimeout = TimeSpan.FromSeconds(20); + #endif + + sc.PowerSetPointActivation = PowerSetPointActivation.Immediate; + sc.UseSlaveIdForAddressing = true; + sc.SlaveErrorHandling = SlaveErrorHandling.Relaxed; + sc.SubSlaveErrorHandling = SubSlaveErrorHandling.Off; + + sc.ResetAlarmsAndWarnings = true; + } + + private static void ApplyDcDcDefaultSettings(this SystemControlRegisters? sc) + { + + if (sc is null) + return; + + sc.SystemConfig = DcDcOnly; + #if DEBUG + sc.CommunicationTimeout = TimeSpan.FromMinutes(2); + #else + sc.CommunicationTimeout = TimeSpan.FromSeconds(20); + #endif + + sc.PowerSetPointActivation = PowerSetPointActivation.Immediate; + sc.UseSlaveIdForAddressing = true; + sc.SlaveErrorHandling = SlaveErrorHandling.Relaxed; + sc.SubSlaveErrorHandling = SubSlaveErrorHandling.Off; + + sc.ResetAlarmsAndWarnings = true; + } + + private static async Task UploadCsv(StatusRecord status, DateTime timeStamp) + { + + var csv = status.ToCsv().LogInfo(); + + await RestApiSavingFile(csv); + + var s3Config = status.Config.S3; + + if (s3Config is null) + return false; + + //Concatenating 15 files in one file + return await ConcatinatingAndCompressingFiles(timeStamp, s3Config); + } + + private static async Task ConcatinatingAndCompressingFiles(DateTime timeStamp, S3Config s3Config) + { + if (_counterOfFile >= NbrOfFileToConcatenate) + { + _counterOfFile = 0; + + var logFileConcatenator = new LogFileConcatenator(); + + var s3Path = timeStamp.ToUnixTime() + ".csv"; + s3Path.WriteLine(""); + var csvToSend = logFileConcatenator.ConcatenateFiles(NbrOfFileToConcatenate); + + var request = s3Config.CreatePutRequest(s3Path); + + //Use this for no compression + //var response = await request.PutAsync(new StringContent(csv)); + var compressedBytes = CompresseBytes(csvToSend); + + // Encode the compressed byte array as a Base64 string + string base64String = Convert.ToBase64String(compressedBytes); + + // Create StringContent from Base64 string + var stringContent = new StringContent(base64String, Encoding.UTF8, "application/base64"); + + // Upload the compressed data (ZIP archive) to S3 + var response = await request.PutAsync(stringContent); + + if (response.StatusCode != 200) + { + Console.WriteLine("ERROR: PUT"); + var error = await response.GetStringAsync(); + Console.WriteLine(error); + Heartbit(new DateTime(0)); + return false; + } + + Console.WriteLine("----------------------------------------Sending Heartbit----------------------------------------"); + + Heartbit(timeStamp); + } + _counterOfFile++; + + return true; + } + + private static void Heartbit(DateTime timeStamp) + { + var s3Bucket = Config.Load().S3?.Bucket; + var tryParse = TryParse(s3Bucket?.Split("-")[0], out var installationId); + var parse = TryParse(timeStamp.ToUnixTime().ToString(), out var nameOfCsvFile); + + if (tryParse) + { + var returnedStatus = new StatusMessage + { + InstallationId = installationId, + Product = 0, // Salimax is always 0 + Status = _salimaxAlarmState, + Type = MessageType.Heartbit, + Timestamp = nameOfCsvFile + }; + if (s3Bucket != null) + RabbitMqManager.InformMiddleware(returnedStatus); + } + } + + private static Byte[] CompresseBytes(String csvToSend) + { + //Compress CSV data to a byte array + Byte[] compressedBytes; + using (var memoryStream = new MemoryStream()) + { + //Create a zip directory and put the compressed file inside + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) + { + var entry = archive.CreateEntry("data.csv", CompressionLevel.SmallestSize); // Add CSV data to the ZIP archive + using (var entryStream = entry.Open()) + using (var writer = new StreamWriter(entryStream)) + { + writer.Write(csvToSend); + } + } + + compressedBytes = memoryStream.ToArray(); + } + + return compressedBytes; + } + + private static async Task RestApiSavingFile(String csv) + { + // This is for the Rest API + // Check if the directory exists, and create it if it doesn't + const String directoryPath = "/var/www/html"; + + if (!Directory.Exists(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + string filePath = Path.Combine(directoryPath, "status.csv"); + + await File.WriteAllTextAsync(filePath, csv.SplitLines().Where(l => !l.Contains("Secret")).JoinLines()); + } + + private static Boolean IsPowerOfTwo(Int32 n) + { + return n > 0 && (n & (n - 1)) == 0; + } + + private static void ApplyConfigFile(this StatusRecord status, Configuration? config) + { + if (config == null) return; + + status.Config.MinSoc = config.MinimumSoC; + status.Config.GridSetPoint = config.GridSetPoint * 1000; // converted from kW to W + // status.Config.ForceCalibrationChargeState = config.CalibrationChargeState; + // + // if (config.CalibrationChargeState == CalibrationChargeType.RepetitivelyEvery) + // { + // status.Config.DayAndTimeForRepetitiveCalibration = config.CalibrationChargeDate; + // } + // else if (config.CalibrationChargeState == CalibrationChargeType.AdditionallyOnce) + // { + // status.Config.DayAndTimeForAdditionalCalibration = config.CalibrationChargeDate; + // } + } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/S3Config.cs b/csharp/App/SodiStoreMax/src/S3Config.cs new file mode 100644 index 000000000..407e93330 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/S3Config.cs @@ -0,0 +1,79 @@ +using System.Security.Cryptography; +using Flurl; +using Flurl.Http; +using InnovEnergy.Lib.Utils; +using static System.Text.Encoding; +using Convert = System.Convert; + +namespace InnovEnergy.App.SodiStoreMax; + +public record S3Config +{ + public required String Bucket { get; init; } + public required String Region { get; init; } + public required String Provider { get; init; } + public required String Key { get; init; } + public required String Secret { get; init; } + public required String ContentType { get; init; } + + public String Host => $"{Bucket}.{Region}.{Provider}"; + public String Url => $"https://{Host}"; + + public IFlurlRequest CreatePutRequest(String s3Path) => CreateRequest("PUT", s3Path); + public IFlurlRequest CreateGetRequest(String s3Path) => CreateRequest("GET", s3Path); + + private IFlurlRequest CreateRequest(String method, String s3Path) + { + var date = DateTime.UtcNow.ToString("r"); + var auth = CreateAuthorization(method, s3Path, date); + + return Url + .AppendPathSegment(s3Path) + .WithHeader("Host", Host) + .WithHeader("Date", date) + .WithHeader("Authorization", auth) + .AllowAnyHttpStatus(); + } + + private String CreateAuthorization(String method, + String s3Path, + String date) + { + return CreateAuthorization + ( + method : method, + bucket : Bucket, + s3Path : s3Path, + date : date, + s3Key : Key, + s3Secret : Secret, + contentType: ContentType + ); + } + + + + private static String CreateAuthorization(String method, + String bucket, + String s3Path, + String date, + String s3Key, + String s3Secret, + String contentType = "application/base64", + String md5Hash = "") + { + + contentType = "application/base64; charset=utf-8"; + //contentType = "text/plain; charset=utf-8"; //this to use when sending plain csv to S3 + + var payload = $"{method}\n{md5Hash}\n{contentType}\n{date}\n/{bucket.Trim('/')}/{s3Path.Trim('/')}"; + using var hmacSha1 = new HMACSHA1(UTF8.GetBytes(s3Secret)); + + var signature = UTF8 + .GetBytes(payload) + .Apply(hmacSha1.ComputeHash) + .Apply(Convert.ToBase64String); + + return $"AWS {s3Key}:{signature}"; + } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/SaliMaxRelays/CombinedAdamRelaysRecord.cs b/csharp/App/SodiStoreMax/src/SaliMaxRelays/CombinedAdamRelaysRecord.cs new file mode 100644 index 000000000..d126b92ea --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SaliMaxRelays/CombinedAdamRelaysRecord.cs @@ -0,0 +1,211 @@ +using System.Reflection.Metadata; + +namespace InnovEnergy.App.SodiStoreMax.SaliMaxRelays; +#pragma warning disable CS8602 // Dereference of a possibly null reference. + +public class CombinedAdamRelaysRecord : IRelaysRecord +{ + private const UInt16 SlowFreq = 3000; + private const UInt16 HighFreq = 1000; + + public CombinedAdamRelaysRecord(RelaysRecordAdam6060? relaysRecordAdam6060, RelaysRecordAdam6360D? relaysRecordAdam6360D) + { + _recordAdam6060 = relaysRecordAdam6060; + _recordAdam6360D = relaysRecordAdam6360D; + } + + private static RelaysRecordAdam6060? _recordAdam6060; + private static RelaysRecordAdam6360D? _recordAdam6360D; + + public static IRelaysRecord Instance { get; } = new CombinedAdamRelaysRecord(_recordAdam6060, _recordAdam6360D); + + + public Boolean K1GridBusIsConnectedToGrid => _recordAdam6360D.K1GridBusIsConnectedToGrid; + + public Boolean K2IslandBusIsConnectedToGridBus => _recordAdam6360D.K2IslandBusIsConnectedToGridBus; + public Boolean FiWarning => _recordAdam6360D.FiWarning; + public Boolean FiError => _recordAdam6360D.FiError; + public Boolean K2ConnectIslandBusToGridBus + { + get => _recordAdam6360D.K2ConnectIslandBusToGridBus; + set => _recordAdam6360D.K2ConnectIslandBusToGridBus = value; + } + + public Boolean Inverter1WagoStatus => _recordAdam6360D.Inverter1WagoStatus; + public Boolean Inverter2WagoStatus => _recordAdam6360D.Inverter2WagoStatus; + public Boolean Inverter3WagoStatus => _recordAdam6360D.Inverter3WagoStatus; + public Boolean Inverter4WagoStatus => _recordAdam6360D.Inverter4WagoStatus; + + public Boolean Dc1WagoStatus => _recordAdam6060.Dc1WagoStatus; + public Boolean Dc2WagoStatus => _recordAdam6060.Dc2WagoStatus; + public Boolean Dc3WagoStatus => _recordAdam6060.Dc3WagoStatus; + public Boolean Dc4WagoStatus => _recordAdam6060.Dc4WagoStatus; + public Boolean DcSystemControlWagoStatus => _recordAdam6060.DcSystemControlWagoStatus; + + public Boolean LedGreen { get => _recordAdam6360D.LedGreen; set => _recordAdam6360D.LedGreen = value;} + public Boolean LedRed { get => _recordAdam6360D.LedRed; set => _recordAdam6360D.LedRed = value;} + public Boolean Harvester1Step => _recordAdam6360D.Harvester1Step; + public Boolean Harvester2Step => _recordAdam6360D.Harvester2Step; + public Boolean Harvester3Step => _recordAdam6360D.Harvester3Step; + public Boolean Harvester4Step => _recordAdam6360D.Harvester4Step; + + public UInt16 DigitalOutput0Mode { get => _recordAdam6360D.DigitalOutput0Mode; set => _recordAdam6360D.DigitalOutput0Mode = value; } + + public UInt16 DigitalOutput1Mode + { + get => _recordAdam6360D.DigitalOutput1Mode; + set => _recordAdam6360D.DigitalOutput1Mode = value; + } + + public UInt16 DigitalOutput2Mode + { + get => _recordAdam6360D.DigitalOutput2Mode; + set => _recordAdam6360D.DigitalOutput2Mode = value; + } + + public UInt16 DigitalOutput3Mode + { + get => _recordAdam6360D.DigitalOutput3Mode; + set => _recordAdam6360D.DigitalOutput3Mode = value; + } + + public UInt16 DigitalOutput4Mode + { + get => _recordAdam6360D.DigitalOutput4Mode; + set => _recordAdam6360D.DigitalOutput4Mode = value; + } + + public UInt16 DigitalOutput5Mode + { + get => _recordAdam6360D.DigitalOutput5Mode; + set => _recordAdam6360D.DigitalOutput5Mode = value; + } + + public Boolean Do0StartPulse { get => _recordAdam6360D.Do0Pulse; set => _recordAdam6360D.Do0Pulse = value; } + public Boolean Do1StartPulse { get => _recordAdam6360D.Do1Pulse; set => _recordAdam6360D.Do1Pulse = value; } + public Boolean Do2StartPulse { get => _recordAdam6360D.Do2Pulse; set => _recordAdam6360D.Do2Pulse = value; } + public Boolean Do3StartPulse { get => _recordAdam6360D.Do3Pulse; set => _recordAdam6360D.Do3Pulse = value; } + public Boolean Do4StartPulse { get => _recordAdam6360D.Do4Pulse; set => _recordAdam6360D.Do4Pulse = value; } + public Boolean Do5StartPulse { get => _recordAdam6360D.Do5Pulse; set => _recordAdam6360D.Do5Pulse = value; } + + + public UInt16 PulseOut0LowTime { get => _recordAdam6360D.PulseOut0LowTime; set => _recordAdam6360D.PulseOut0LowTime = value; } + public UInt16 PulseOut1LowTime { get => _recordAdam6360D.PulseOut1LowTime; set => _recordAdam6360D.PulseOut1LowTime = value; } + public UInt16 PulseOut2LowTime { get => _recordAdam6360D.PulseOut2LowTime; set => _recordAdam6360D.PulseOut2LowTime = value; } + public UInt16 PulseOut3LowTime { get => _recordAdam6360D.PulseOut3LowTime; set => _recordAdam6360D.PulseOut3LowTime = value; } + public UInt16 PulseOut4LowTime { get => _recordAdam6360D.PulseOut4LowTime; set => _recordAdam6360D.PulseOut4LowTime = value; } + public UInt16 PulseOut5LowTime { get => _recordAdam6360D.PulseOut5LowTime; set => _recordAdam6360D.PulseOut5LowTime = value; } + + public UInt16 PulseOut0HighTime { get => _recordAdam6360D.PulseOut0HighTime; set => _recordAdam6360D.PulseOut0HighTime = value; } + public UInt16 PulseOut1HighTime { get => _recordAdam6360D.PulseOut1HighTime; set => _recordAdam6360D.PulseOut1HighTime = value; } + public UInt16 PulseOut2HighTime { get => _recordAdam6360D.PulseOut2HighTime; set => _recordAdam6360D.PulseOut2HighTime = value; } + public UInt16 PulseOut3HighTime { get => _recordAdam6360D.PulseOut3HighTime; set => _recordAdam6360D.PulseOut3HighTime = value; } + public UInt16 PulseOut4HighTime { get => _recordAdam6360D.PulseOut4HighTime; set => _recordAdam6360D.PulseOut4HighTime = value; } + public UInt16 PulseOut5HighTime { get => _recordAdam6360D.PulseOut5HighTime; set => _recordAdam6360D.PulseOut5HighTime = value; } + + /**************************** Green LED *********************************/ + + public void PerformSolidGreenLed() + { + DigitalOutput0Mode = 0; + DigitalOutput1Mode = 0; + LedGreen = true; + LedRed = false; + } + + public void PerformSlowFlashingGreenLed() + { + PulseOut0HighTime = SlowFreq; + PulseOut0LowTime = SlowFreq; + DigitalOutput0Mode = 2; + Do0StartPulse = true; + Do1StartPulse = false; // make sure the red LED is off + + Console.WriteLine("Green Slow Flashing Starting"); + } + + public void PerformFastFlashingGreenLed() + { + PulseOut0HighTime = HighFreq; + PulseOut0LowTime = HighFreq; + DigitalOutput0Mode = 2; + Do0StartPulse = true; + Do1StartPulse = false;// make sure the red LED is off + + Console.WriteLine("Green Slow Flashing Starting"); + } + + /**************************** Orange LED *********************************/ + + public void PerformSolidOrangeLed() + { + DigitalOutput0Mode = 0; + DigitalOutput1Mode = 0; + LedGreen = true; + LedRed = true; + } + + public void PerformSlowFlashingOrangeLed() + { + PerformSlowFlashingGreenLed(); + PerformSlowFlashingRedLed(); + Do0StartPulse = true; + Do1StartPulse = true; + + Console.WriteLine("Orange Slow Flashing Starting"); + } + + public void PerformFastFlashingOrangeLed() + { + PerformFastFlashingGreenLed(); + PerformFastFlashingRedLed(); + Do0StartPulse = true; + Do1StartPulse = true; + Console.WriteLine("Orange Fast Flashing Starting"); + } + + /**************************** RED LED *********************************/ + + public void PerformSolidRedLed() + { + DigitalOutput0Mode = 0; + DigitalOutput1Mode = 0; + LedGreen = false; + LedRed = true; + } + + public void PerformSlowFlashingRedLed() + { + PulseOut1HighTime = SlowFreq; + PulseOut1LowTime = SlowFreq; + DigitalOutput1Mode = 2; + Do0StartPulse = false; // make sure the green LED is off + Do1StartPulse = true; + + Console.WriteLine("Red Slow Flashing Starting"); + } + + public void PerformFastFlashingRedLed() + { + PulseOut1HighTime = HighFreq; + PulseOut1LowTime = HighFreq; + DigitalOutput1Mode = 2; + Do0StartPulse = false; // make sure the green LED is off + Do1StartPulse = true; + + Console.WriteLine("Red Fast Flashing Starting"); + } + + public RelaysRecordAdam6360D? GetAdam6360DRecord() + { + return _recordAdam6360D; + } + + public RelaysRecordAdam6060? GetAdam6060Record() + { + return _recordAdam6060; + } + + public IEnumerable K3InverterIsConnectedToIslandBus => _recordAdam6360D.K3InverterIsConnectedToIslandBus; + +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/SaliMaxRelays/IRelaysRecord.cs b/csharp/App/SodiStoreMax/src/SaliMaxRelays/IRelaysRecord.cs new file mode 100644 index 000000000..a971f06dc --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SaliMaxRelays/IRelaysRecord.cs @@ -0,0 +1,78 @@ +namespace InnovEnergy.App.SodiStoreMax.SaliMaxRelays; + + +public interface IRelaysRecord +{ + Boolean K1GridBusIsConnectedToGrid { get; } + Boolean K2IslandBusIsConnectedToGridBus { get; } + IEnumerable K3InverterIsConnectedToIslandBus { get; } + Boolean FiWarning { get; } + Boolean FiError { get; } + Boolean K2ConnectIslandBusToGridBus { get; set; } + + // Boolean Inverter1WagoRelay { get; set; } // to add in the future + // Boolean Inverter2WagoRelay { get; set; } // to add in the future + // Boolean Inverter3WagoRelay { get; set; } // to add in the future + // Boolean Inverter4WagoRelay { get; set; } // to add in the future + + Boolean Inverter1WagoStatus { get; } + Boolean Inverter2WagoStatus { get; } + Boolean Inverter3WagoStatus { get; } + Boolean Inverter4WagoStatus { get; } + + Boolean Dc1WagoStatus { get; } // to test + Boolean Dc2WagoStatus { get; } // to test + Boolean Dc3WagoStatus { get; } // to test + Boolean Dc4WagoStatus { get; } // to test + + Boolean DcSystemControlWagoStatus { get; } // to test + + Boolean LedGreen { get; set; } + Boolean LedRed { get; } + Boolean Harvester1Step { get; } + Boolean Harvester2Step { get; } + Boolean Harvester3Step { get; } + Boolean Harvester4Step { get; } + + Boolean Do0StartPulse { get; set; } + Boolean Do1StartPulse { get; set; } + Boolean Do2StartPulse { get; set; } + Boolean Do3StartPulse { get; set; } + Boolean Do4StartPulse { get; set; } + Boolean Do5StartPulse { get; set; } + + UInt16 DigitalOutput0Mode { get; set; } + UInt16 DigitalOutput1Mode { get; set; } + UInt16 DigitalOutput2Mode { get; set; } + UInt16 DigitalOutput3Mode { get; set; } + UInt16 DigitalOutput4Mode { get; set; } + UInt16 DigitalOutput5Mode { get; set; } + + UInt16 PulseOut0LowTime { get; set; } + UInt16 PulseOut1LowTime { get; set; } + UInt16 PulseOut2LowTime { get; set; } + UInt16 PulseOut3LowTime { get; set; } + UInt16 PulseOut4LowTime { get; set; } + UInt16 PulseOut5LowTime { get; set; } + + UInt16 PulseOut0HighTime { get; set; } + UInt16 PulseOut1HighTime { get; set; } + UInt16 PulseOut2HighTime { get; set; } + UInt16 PulseOut3HighTime { get; set; } + UInt16 PulseOut4HighTime { get; set; } + UInt16 PulseOut5HighTime { get; set; } + + void PerformSolidGreenLed(); + void PerformSlowFlashingGreenLed(); + void PerformFastFlashingGreenLed(); + + + void PerformSolidOrangeLed(); + void PerformSlowFlashingOrangeLed(); + void PerformFastFlashingOrangeLed(); + + void PerformSolidRedLed(); + void PerformSlowFlashingRedLed(); + void PerformFastFlashingRedLed(); + +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysDeviceADAM6360.cs b/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysDeviceADAM6360.cs new file mode 100644 index 000000000..4a47e914c --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysDeviceADAM6360.cs @@ -0,0 +1,40 @@ +using InnovEnergy.Lib.Devices.Adam6360D; +using InnovEnergy.Lib.Protocols.Modbus.Channels; + +namespace InnovEnergy.App.SodiStoreMax.SaliMaxRelays; + +public class RelaysDeviceAdam6360 +{ + private Adam6360DDevice AdamDevice6360D { get; } + + public RelaysDeviceAdam6360(String hostname) => AdamDevice6360D = new Adam6360DDevice(hostname, 2); + public RelaysDeviceAdam6360(Channel channel) => AdamDevice6360D = new Adam6360DDevice(channel, 2); + + + public RelaysRecordAdam6360D? Read() + { + try + { + return AdamDevice6360D.Read(); + } + catch (Exception e) + { + $"Failed to read from {nameof(RelaysDeviceAdam6360)}\n{e}".LogError(); + return null; + } + } + + public void Write(RelaysRecordAdam6360D r) + { + try + { + AdamDevice6360D.Write(r); + } + catch (Exception e) + { + $"Failed to write to {nameof(RelaysDeviceAdam6360)}\n{e}".LogError(); + } + } +} + + diff --git a/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysDeviceAdam6060.cs b/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysDeviceAdam6060.cs new file mode 100644 index 000000000..c53175c24 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysDeviceAdam6060.cs @@ -0,0 +1,38 @@ +using InnovEnergy.Lib.Devices.Adam6060; +using InnovEnergy.Lib.Protocols.Modbus.Channels; + +namespace InnovEnergy.App.SodiStoreMax.SaliMaxRelays; + +public class RelaysDeviceAdam6060 +{ + private Adam6060Device AdamDevice6060 { get; } + + public RelaysDeviceAdam6060(String hostname) => AdamDevice6060 = new Adam6060Device(hostname, 2); + public RelaysDeviceAdam6060(Channel channel) => AdamDevice6060 = new Adam6060Device(channel, 2); + + + public RelaysRecordAdam6060? Read() + { + try + { + return AdamDevice6060.Read(); + } + catch (Exception e) + { + $"Failed to read from {nameof(RelaysDeviceAdam6060)}\n{e}".LogError(); + return null; + } + } + + public void Write(RelaysRecordAdam6060 r) + { + try + { + AdamDevice6060.Write(r); + } + catch (Exception e) + { + $"Failed to write to {nameof(RelaysDeviceAdam6060)}\n{e}".LogError(); + } + } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysDeviceAmax.cs b/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysDeviceAmax.cs new file mode 100644 index 000000000..c5cbbd010 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysDeviceAmax.cs @@ -0,0 +1,37 @@ +using InnovEnergy.Lib.Devices.Amax5070; +using InnovEnergy.Lib.Protocols.Modbus.Channels; + + +namespace InnovEnergy.App.SodiStoreMax.SaliMaxRelays; + +public class RelaysDeviceAmax +{ + private Amax5070Device AmaxDevice { get; } + + public RelaysDeviceAmax(Channel channel) => AmaxDevice = new Amax5070Device(channel); + + public RelaysRecordAmax? Read() + { + try + { + return AmaxDevice.Read(); + } + catch (Exception e) + { + $"Failed to read from {nameof(RelaysDeviceAmax)}\n{e}".LogError(); + return null; + } + } + + public void Write(RelaysRecordAmax r) + { + try + { + AmaxDevice.Write(r); + } + catch (Exception e) + { + $"Failed to write to {nameof(RelaysDeviceAmax)}\n{e}".LogError(); + } + } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysRecordAdam6060.cs b/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysRecordAdam6060.cs new file mode 100644 index 000000000..50c40f251 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysRecordAdam6060.cs @@ -0,0 +1,24 @@ +using InnovEnergy.Lib.Devices.Adam6060; + + +namespace InnovEnergy.App.SodiStoreMax.SaliMaxRelays; + +public class RelaysRecordAdam6060 +{ + private readonly Adam6060Registers _Regs; + + private RelaysRecordAdam6060(Adam6060Registers regs) => _Regs = regs; + + + public Boolean Dc1WagoStatus => _Regs.DigitalInput0; // to test + public Boolean Dc2WagoStatus => _Regs.DigitalInput1; // to test + public Boolean Dc3WagoStatus => _Regs.DigitalInput4; // to test + public Boolean Dc4WagoStatus => _Regs.DigitalInput5; // to test + + public Boolean DcSystemControlWagoStatus => _Regs.DigitalInput3; // to test + + + public static implicit operator Adam6060Registers(RelaysRecordAdam6060 d) => d._Regs; + public static implicit operator RelaysRecordAdam6060(Adam6060Registers d) => new RelaysRecordAdam6060(d); + +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysRecordAdam6360D.cs b/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysRecordAdam6360D.cs new file mode 100644 index 000000000..2e87b78f2 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysRecordAdam6360D.cs @@ -0,0 +1,81 @@ +using InnovEnergy.Lib.Devices.Adam6360D; + +namespace InnovEnergy.App.SodiStoreMax.SaliMaxRelays; + +public class RelaysRecordAdam6360D +{ + private readonly Adam6360DRegisters _Regs; + + private RelaysRecordAdam6360D(Adam6360DRegisters regs) => _Regs = regs; + + public Boolean K1GridBusIsConnectedToGrid => _Regs.DigitalInput6; + public Boolean K2IslandBusIsConnectedToGridBus => !_Regs.DigitalInput4; + + public Boolean Inverter1WagoStatus => _Regs.DigitalInput8; + public Boolean Inverter2WagoStatus => _Regs.DigitalInput9; + public Boolean Inverter3WagoStatus => _Regs.DigitalInput10; + public Boolean Inverter4WagoStatus => _Regs.DigitalInput11; + + public IEnumerable K3InverterIsConnectedToIslandBus + { + get + { + yield return K3Inverter1IsConnectedToIslandBus; + yield return K3Inverter2IsConnectedToIslandBus; + yield return K3Inverter3IsConnectedToIslandBus; + yield return K3Inverter4IsConnectedToIslandBus; + } + } + + private Boolean K3Inverter1IsConnectedToIslandBus => !_Regs.DigitalInput0; // change it to private should be ok + private Boolean K3Inverter2IsConnectedToIslandBus => !_Regs.DigitalInput1; + private Boolean K3Inverter3IsConnectedToIslandBus => !_Regs.DigitalInput2; + private Boolean K3Inverter4IsConnectedToIslandBus => !_Regs.DigitalInput3; + + public Boolean FiWarning => !_Regs.DigitalInput5; + public Boolean FiError => !_Regs.DigitalInput7; + + public Boolean Harvester1Step =>_Regs.DigitalOutput2; + public Boolean Harvester2Step =>_Regs.DigitalOutput3; + public Boolean Harvester3Step =>_Regs.DigitalOutput4; + public Boolean Harvester4Step =>_Regs.DigitalOutput5; + + public Boolean LedGreen { get =>_Regs.DigitalOutput0; set => _Regs.DigitalOutput0 = value;} + public Boolean LedRed { get =>_Regs.DigitalOutput1; set => _Regs.DigitalOutput1 = value;} + + public Boolean Do0Pulse { get => _Regs.Do0Pulse; set => _Regs.Do0Pulse = value;} + public Boolean Do1Pulse { get => _Regs.Do1Pulse; set => _Regs.Do1Pulse = value;} + public Boolean Do2Pulse { get => _Regs.Do2Pulse; set => _Regs.Do2Pulse = value;} + public Boolean Do3Pulse { get => _Regs.Do3Pulse; set => _Regs.Do3Pulse = value;} + public Boolean Do4Pulse { get => _Regs.Do4Pulse; set => _Regs.Do4Pulse = value;} + public Boolean Do5Pulse { get => _Regs.Do5Pulse; set => _Regs.Do5Pulse = value;} + + public UInt16 PulseOut0LowTime { get => _Regs.PulseOut0LowTime; set => _Regs.PulseOut0LowTime = value;} //in milleseconds + public UInt16 PulseOut1LowTime { get => _Regs.PulseOut1LowTime; set => _Regs.PulseOut1LowTime = value;} + public UInt16 PulseOut2LowTime { get => _Regs.PulseOut2LowTime; set => _Regs.PulseOut2LowTime = value;} + public UInt16 PulseOut3LowTime { get => _Regs.PulseOut3LowTime; set => _Regs.PulseOut3LowTime = value;} + public UInt16 PulseOut4LowTime { get => _Regs.PulseOut4LowTime; set => _Regs.PulseOut4LowTime = value;} + public UInt16 PulseOut5LowTime { get => _Regs.PulseOut5LowTime; set => _Regs.PulseOut5LowTime = value;} + + public UInt16 PulseOut0HighTime { get => _Regs.PulseOut0HighTime; set => _Regs.PulseOut0HighTime = value;} // in milleseconds + public UInt16 PulseOut1HighTime { get => _Regs.PulseOut1HighTime; set => _Regs.PulseOut1HighTime = value;} + public UInt16 PulseOut2HighTime { get => _Regs.PulseOut2HighTime; set => _Regs.PulseOut2HighTime = value;} + public UInt16 PulseOut3HighTime { get => _Regs.PulseOut3HighTime; set => _Regs.PulseOut3HighTime = value;} + public UInt16 PulseOut4HighTime { get => _Regs.PulseOut4HighTime; set => _Regs.PulseOut4HighTime = value;} + public UInt16 PulseOut5HighTime { get => _Regs.PulseOut5HighTime; set => _Regs.PulseOut5HighTime = value;} + + public UInt16 DigitalOutput0Mode { get => _Regs.DigitalOutput0Mode; set => _Regs.DigitalOutput0Mode = value;} // To test: 0, 1 or 2 + public UInt16 DigitalOutput1Mode { get => _Regs.DigitalOutput1Mode; set => _Regs.DigitalOutput1Mode = value;} + public UInt16 DigitalOutput2Mode { get => _Regs.DigitalOutput2Mode; set => _Regs.DigitalOutput2Mode = value;} + public UInt16 DigitalOutput3Mode { get => _Regs.DigitalOutput3Mode; set => _Regs.DigitalOutput3Mode = value;} + public UInt16 DigitalOutput4Mode { get => _Regs.DigitalOutput4Mode; set => _Regs.DigitalOutput4Mode = value;} + public UInt16 DigitalOutput5Mode { get => _Regs.DigitalOutput5Mode; set => _Regs.DigitalOutput5Mode = value;} + + public Boolean K2ConnectIslandBusToGridBus { get => _Regs.Relay0; set => _Regs.Relay0 = value;} + + public static implicit operator Adam6360DRegisters(RelaysRecordAdam6360D d) => d._Regs; + public static implicit operator RelaysRecordAdam6360D(Adam6360DRegisters d) => new RelaysRecordAdam6360D(d); + +} + + diff --git a/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysRecordAmax.cs b/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysRecordAmax.cs new file mode 100644 index 000000000..7ee522c49 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SaliMaxRelays/RelaysRecordAmax.cs @@ -0,0 +1,134 @@ +using InnovEnergy.Lib.Devices.Amax5070; + +namespace InnovEnergy.App.SodiStoreMax.SaliMaxRelays; + +public class RelaysRecordAmax : IRelaysRecord +{ + private readonly Amax5070Registers _Regs; + + private RelaysRecordAmax(Amax5070Registers regs) => _Regs = regs; + + public Boolean K1GridBusIsConnectedToGrid => _Regs.DigitalInput22; + public Boolean K2IslandBusIsConnectedToGridBus => !_Regs.DigitalInput20; + + public Boolean Inverter1WagoStatus => _Regs.DigitalInput0; + public Boolean Inverter2WagoStatus => _Regs.DigitalInput1; + public Boolean Inverter3WagoStatus => _Regs.DigitalInput2; + public Boolean Inverter4WagoStatus => _Regs.DigitalInput3; + + public Boolean Dc1WagoStatus => _Regs.DigitalInput6; + public Boolean Dc2WagoStatus => _Regs.DigitalInput7; + public Boolean Dc3WagoStatus => _Regs.DigitalInput10; + public Boolean Dc4WagoStatus => _Regs.DigitalInput11; + public Boolean DcSystemControlWagoStatus => _Regs.DigitalInput9; + + public Boolean LedGreen + { + get => _Regs.DigitalOutput0; + set => _Regs.DigitalOutput0 = value; + } + + public Boolean LedRed => _Regs.DigitalOutput1; + public Boolean Harvester1Step => _Regs.DigitalOutput2; + public Boolean Harvester2Step => _Regs.DigitalOutput3; + public Boolean Harvester3Step => _Regs.DigitalOutput4; + public Boolean Harvester4Step => _Regs.DigitalOutput5; + public Boolean Do0StartPulse { get; set; } + public Boolean Do1StartPulse { get; set; } + public Boolean Do2StartPulse { get; set; } + public Boolean Do3StartPulse { get; set; } + public Boolean Do4StartPulse { get; set; } + public Boolean Do5StartPulse { get; set; } + public UInt16 DigitalOutput0Mode { get; set; } + public UInt16 DigitalOutput1Mode { get; set; } + public UInt16 DigitalOutput2Mode { get; set; } + public UInt16 DigitalOutput3Mode { get; set; } + public UInt16 DigitalOutput4Mode { get; set; } + public UInt16 DigitalOutput5Mode { get; set; } + public UInt16 PulseOut0LowTime { get; set; } + public UInt16 PulseOut1LowTime { get; set; } + public UInt16 PulseOut2LowTime { get; set; } + public UInt16 PulseOut3LowTime { get; set; } + public UInt16 PulseOut4LowTime { get; set; } + public UInt16 PulseOut5LowTime { get; set; } + public UInt16 PulseOut0HighTime { get; set; } + public UInt16 PulseOut1HighTime { get; set; } + public UInt16 PulseOut2HighTime { get; set; } + public UInt16 PulseOut3HighTime { get; set; } + public UInt16 PulseOut4HighTime { get; set; } + public UInt16 PulseOut5HighTime { get; set; } + + public void PerformSolidGreenLed() + { + Console.WriteLine("Solid Green: This is not yet implemented "); + } + + public void PerformSlowFlashingGreenLed() + { + Console.WriteLine("Slow Flashing Green: This is not yet implemented "); + } + + public void PerformFastFlashingGreenLed() + { + Console.WriteLine("Fast Flashing Green: This is not yet implemented "); + } + + public void PerformSolidOrangeLed() + { + Console.WriteLine("Solid Orange: This is not yet implemented "); + } + + public void PerformSlowFlashingOrangeLed() + { + Console.WriteLine("Slow Flashing Orange: This is not yet implemented "); + } + + public void PerformFastFlashingOrangeLed() + { + Console.WriteLine("Fast Flashing Orange: This is not yet implemented "); + } + + public void PerformSolidRedLed() + { + Console.WriteLine("Solid Red: This is not yet implemented "); + } + + public void PerformSlowFlashingRedLed() + { + Console.WriteLine("Slow Flashing Red: This is not yet implemented "); + } + + public void PerformFastFlashingRedLed() + { + Console.WriteLine("Fast Flashing Red: This is not yet implemented "); + } + + public IEnumerable K3InverterIsConnectedToIslandBus + { + get + { + yield return K3Inverter1IsConnectedToIslandBus; + yield return K3Inverter2IsConnectedToIslandBus; + yield return K3Inverter3IsConnectedToIslandBus; + yield return K3Inverter4IsConnectedToIslandBus; + } + } + + private Boolean K3Inverter1IsConnectedToIslandBus => !_Regs.DigitalInput16; + private Boolean K3Inverter2IsConnectedToIslandBus => !_Regs.DigitalInput17; + private Boolean K3Inverter3IsConnectedToIslandBus => !_Regs.DigitalInput18; + private Boolean K3Inverter4IsConnectedToIslandBus => !_Regs.DigitalInput19; + + public Boolean FiWarning => !_Regs.DigitalInput21; + public Boolean FiError => !_Regs.DigitalInput23; + + public Boolean K2ConnectIslandBusToGridBus + { + get => _Regs.Relay23; + set => _Regs.Relay23 = value; + } + + public static implicit operator Amax5070Registers(RelaysRecordAmax d) => d._Regs; + public static implicit operator RelaysRecordAmax(Amax5070Registers d) => new RelaysRecordAmax(d); + +} diff --git a/csharp/App/SodiStoreMax/src/Switch.cs b/csharp/App/SodiStoreMax/src/Switch.cs new file mode 100644 index 000000000..932fdb8df --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Switch.cs @@ -0,0 +1,15 @@ +using InnovEnergy.Lib.Utils; + +namespace InnovEnergy.App.SodiStoreMax; + +public static class Switch +{ + public static TextBlock Open(String name) + { + return TextBlock.AlignCenterHorizontal + ( + " __╱ __ ", + name + ); + } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/System/Controller.cs b/csharp/App/SodiStoreMax/src/System/Controller.cs new file mode 100644 index 000000000..4285236d4 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/System/Controller.cs @@ -0,0 +1,726 @@ +using InnovEnergy.App.SodiStoreMax.Ess; +using InnovEnergy.App.SodiStoreMax.SaliMaxRelays; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertAc; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertDc; +using static InnovEnergy.Lib.Devices.Trumpf.SystemControl.DataTypes.GridType; + +namespace InnovEnergy.App.SodiStoreMax.System; + +public static class Controller +{ + private static Int32 GetSystemState(this StatusRecord r) + { + var relays = r.Relays; + + if (relays is null) + return 101; // Message = "Panic: relay device is not available!", + + var acDcs = r.AcDc; + + if (acDcs.NotAvailable()) + return 102; + + var k4 = acDcs.AllGridTied() ? 0 + : acDcs.AllIsland() ? 1 + : 4; + + var k5 = acDcs.AllDisabled() ? 0 + : acDcs.AllEnabled() ? 1 + : 4; + + if (k4 == 4 || k5 == 4) + return 103; //Message = "Panic: ACDCs have unequal grid types or power stage", + + var nInverters = r.AcDc.Devices.Count; + + var k1 = relays.K1GridBusIsConnectedToGrid ? 1 : 0; + var k2 = relays.K2IslandBusIsConnectedToGridBus ? 1 : 0; + var k3 = relays.K3InverterIsConnectedToIslandBus.Take(nInverters).Any(c => c) ? 1 : 0; + + + // states as defined in states excel sheet + return 1 * k1 + + 2 * k2 + + 4 * k3 + + 8 * k4 + + 16 * k5; + } + + public static Boolean ControlSystemState(this StatusRecord s) + { + s.StateMachine.State = s.GetSystemState(); + + return s.StateMachine.State switch + { + 0 => State0(s), + 1 => State1(s), + 2 => State2(s), + 3 => State3(s), + 4 => State4(s), + 5 => State5(s), + 6 => State6(s), + 7 => State7(s), + 8 => State8(s), + 9 => State9(s), + 10 => State10(s), + 11 => State11(s), + 12 => State12(s), + 13 => State13(s), + 14 => State14(s), + 15 => State15(s), + 16 => State16(s), + 17 => State17(s), + 18 => State18(s), + 19 => State19(s), + 20 => State20(s), + 21 => State21(s), + 22 => State22(s), + 23 => State23(s), + 24 => State24(s), + 25 => State25(s), + 26 => State26(s), + 27 => State27(s), + 28 => State28(s), + 29 => State29(s), + 30 => State30(s), + 31 => State31(s), + + + 101 => State101(s), + 102 => State102(s), + 103 => State103(s), + _ => UnknownState(s) + }; + + } + + private static Boolean NotAvailable(this AcDcDevicesRecord acDcs) + { + return acDcs.SystemControl == null || acDcs.Devices.Count == 0; + } + + private static Boolean State0(StatusRecord s) + { + s.StateMachine.Message = "Ac/Dc are off. Switching to Island Mode."; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 8 + } + + + private static Boolean State1(StatusRecord s) + { + s.StateMachine.Message = "Grid Tied mode active, closing k2"; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.ConnectIslandBusToGrid(); + + return false; + + // => 3 + } + + private static Boolean State2(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 0 + } + + private static Boolean State3(StatusRecord s) + { + s.StateMachine.Message = "K2 closed, Turning on Ac/Dc"; + + s.DcDc.Enable(); + s.AcDc.Enable(); + s.AcDc.EnableGridTieMode(); + s.Relays.ConnectIslandBusToGrid(); + + return false; + + // => 19 + } + + + private static Boolean State4(StatusRecord s) + { + s.StateMachine.Message = "K2 is open, waiting K3 to open"; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return true; + + // => 0 + } + + private static Boolean State5(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => ? + } + + private static Boolean State6(StatusRecord s) + { + s.StateMachine.Message = "Inverters are off, opening K2"; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return true; + + // => 4 + } + + private static Boolean State7(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.ConnectIslandBusToGrid(); + + return false; + + // => ? + } + + private static Boolean State8(StatusRecord s) + { + s.StateMachine.Message = "Ac/Dc are off and in Island Mode."; + + s.DcDc.Enable(); + s.AcDc.Enable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return true; + // => 24 + } + + private static Boolean State9(StatusRecord s) + { + s.StateMachine.Message = "Ac/Dc are disconnected from Island Bus. Switching to GridTie Mode."; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 1 + } + + private static Boolean State10(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 8 + } + + private static Boolean State11(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 9 + } + + private static Boolean State12(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 8 + } + + private static Boolean State13(StatusRecord s) + { + s.StateMachine.Message = "Ac/Dc are off. Waiting for them to disconnect from Island Bus."; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return true; + + // => 9 + } + + private static Boolean State14(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 12 + } + + + private static Boolean State15(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 13 + } + + + private static Boolean State16(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 0 + } + + + private static Boolean State17(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 1 + } + + + private static Boolean State18(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 0 + } + + private static Boolean State19(StatusRecord s) + { + s.StateMachine.Message = "Waiting for Ac/Dc to connect to Island Bus"; + + s.DcDc.Enable(); + s.AcDc.Enable(); + s.AcDc.EnableGridTieMode(); + s.Relays.ConnectIslandBusToGrid(); + + return true; + + // => 23 + } + + + private static Boolean State20(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 4 + } + + private static Boolean State21(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 5 + } + + private static Boolean State22(StatusRecord s) + { + s.StateMachine.Message = "K1 opened, switching inverters off"; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableGridTieMode(); + s.Relays.ConnectIslandBusToGrid(); + + return true; + + // => 6 + } + + private static Boolean State23(StatusRecord s) + { + s.StateMachine.Message = "ESS"; + + s.DcDc.Enable(); + s.AcDc.Enable(); + s.AcDc.EnableGridTieMode(); + s.Relays.ConnectIslandBusToGrid(); + + return true; + + // => 22 + } + + + private static Boolean State24(StatusRecord s) + { + s.StateMachine.Message = "Inverter are on waiting for k3 to close"; + + s.DcDc.Enable(); + s.AcDc.Enable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 28 + } + + private static Boolean State25(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 9 + } + + private static Boolean State26(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 10 + } + + private static Boolean State27(StatusRecord s) + { + s.StateMachine.Message = "K2 open and enable island mode"; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 9 + } + + private static Boolean State28(StatusRecord s) + { + s.StateMachine.Message = "Island Mode"; + + s.DcDc.Enable(); + s.AcDc.Enable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 29 + } + + private static Boolean State29(StatusRecord s) + { + s.StateMachine.Message = "K1 closed, Switching off Inverters and moving to grid tie"; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 13 + } + + private static Boolean State30(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 14 + } + + private static Boolean State31(StatusRecord s) + { + s.StateMachine.Message = ""; + + s.DcDc.Disable(); + s.AcDc.Disable(); + s.AcDc.EnableIslandMode(); + s.Relays.DisconnectIslandBusFromGrid(); + + return false; + + // => 13 + } + + private static Boolean State101(StatusRecord s) + { + s.StateMachine.Message = "Relay device is not available"; + return s.EnableSafeDefaults(); + } + + private static Boolean State102(StatusRecord s) + { + s.StateMachine.Message = "ACDCs not available"; + return s.EnableSafeDefaults(); + } + + private static Boolean State103(StatusRecord s) + { + s.StateMachine.Message = "Panic: ACDCs have unequal grid types or PowerStage"; + return s.EnableSafeDefaults(); + } + + // private static Boolean State104(StatusRecord s) + // { + // s.StateMachine.Message = "Panic: DCDCs not available"; + // return s.EnableSafeDefaults(); + // } + + + private static Boolean UnknownState(StatusRecord s) + { + // "Unknown System State" + return s.EnableSafeDefaults(); + } + + + + private static Boolean AllDisabled(this AcDcDevicesRecord acDcs) + { + return acDcs.Devices.All(d => !d.Control.PowerStageEnable); + } + + private static Boolean AllEnabled(this AcDcDevicesRecord acDcs) + { + return acDcs.Devices.All(d => d.Control.PowerStageEnable); + } + + + private static Boolean AllTheSame(this AcDcDevicesRecord acDcs) + { + return acDcs.Devices + .Select(d => d.Control.PowerStageEnable) + .Distinct() + .Count() == 1; + } + + private static Boolean AllGridTied(this AcDcDevicesRecord acDcs) + { + return acDcs.Devices.All(d => d.Status.ActiveGridType is GridTied380V60Hz) + || acDcs.Devices.All(d => d.Status.ActiveGridType is GridTied400V50Hz) + || acDcs.Devices.All(d => d.Status.ActiveGridType is GridTied480V60Hz); + } + + private static Boolean AllIsland(this AcDcDevicesRecord acDcs) + { + return acDcs.Devices.All(d => d.Status.ActiveGridType is Island400V50Hz) + || acDcs.Devices.All(d => d.Status.ActiveGridType is Island480V60Hz); + } + + private static void ForAll(this IEnumerable ts, Action action) + { + foreach (var t in ts) + action(t); + } + + private static void Disable(this AcDcDevicesRecord acDc) + { + acDc.Devices + .Select(d => d.Control) + .ForAll(c => c.PowerStageEnable = false); + } + + //this is must be deleted + private static void Disable(this DcDcDevicesRecord dcDc) + { + // For Test purpose, The transition from island mode to grid tier and vis versa , may not need to disable Dc/Dc. + // This will keep the Dc link powered. + + // dcDc.Devices + // .Select(d => d.Control) + // .ForAll(c => c.PowerStageEnable = false); + } + + private static void Enable(this AcDcDevicesRecord acDc) + { + acDc.Devices + .Select(d => d.Control) + .ForAll(c => c.PowerStageEnable = true); + } + + private static void Enable(this DcDcDevicesRecord dcDc) + { + dcDc.Devices + .Select(d => d.Control) + .ForAll(c => c.PowerStageEnable = true); + } + + + private static void EnableGridTieMode(this AcDcDevicesRecord acDc) + { + acDc.Devices + .Select(d => d.Control) + .ForAll(c => c.Ac.GridType = GridTied400V50Hz); // TODO: config grid type + } + + + private static void EnableIslandMode(this AcDcDevicesRecord acDc) + { + acDc.Devices + .Select(d => d.Control) + .ForAll(c => c.Ac.GridType = Island400V50Hz); // TODO: config grid type + } + + private static void DisconnectIslandBusFromGrid(this IRelaysRecord? relays) + { + if (relays is not null) + relays.K2ConnectIslandBusToGridBus = false; + } + + private static void ConnectIslandBusToGrid(this IRelaysRecord? relays) + { + if (relays is not null) + relays.K2ConnectIslandBusToGridBus = true; + } + + + private static Boolean EnableSafeDefaults(this StatusRecord s) + { + // After some tests, the safe state is switch off inverter and keep the last state of K2 , Dc/Dc and Grid type to avoid conflict. + + // s.DcDc.Disable(); + s.AcDc.Disable(); // Maybe comment this to avoid opening/closing K3 + // s.AcDc.EnableGridTieMode(); + // s.Relays.DisconnectIslandBusFromGrid(); + return false; + } + + public static DcDcDevicesRecord ResetAlarms(this DcDcDevicesRecord dcDcStatus) + { + var sc = dcDcStatus.SystemControl; + + if (sc is not null) + sc.ResetAlarmsAndWarnings = sc.Alarms.Any(); + + foreach (var d in dcDcStatus.Devices) + d.Control.ResetAlarmsAndWarnings = d.Status.Alarms.Any() || d.Status.Warnings.Any(); + + return dcDcStatus; + } + + public static AcDcDevicesRecord ResetAlarms(this AcDcDevicesRecord acDcStatus) + { + var sc = acDcStatus.SystemControl; + + if (sc is not null) + sc.ResetAlarmsAndWarnings = sc.Alarms.Any() || sc.Warnings.Any(); + + foreach (var d in acDcStatus.Devices) + d.Control.ResetAlarmsAndWarnings = d.Status.Alarms.Any() || d.Status.Warnings.Any(); + + return acDcStatus; + } + +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/System/StateMachine.cs b/csharp/App/SodiStoreMax/src/System/StateMachine.cs new file mode 100644 index 000000000..6e0d498ec --- /dev/null +++ b/csharp/App/SodiStoreMax/src/System/StateMachine.cs @@ -0,0 +1,9 @@ +namespace InnovEnergy.App.SodiStoreMax.System; + +public record StateMachine +{ + public required String Message { get; set; } // TODO: init only + public required Int32 State { get; set; } // TODO: init only + + public static StateMachine Default { get; } = new StateMachine { State = 100, Message = "Unknown State" }; +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/SystemConfig/AcDcConfig.cs b/csharp/App/SodiStoreMax/src/SystemConfig/AcDcConfig.cs new file mode 100644 index 000000000..28b514dc4 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SystemConfig/AcDcConfig.cs @@ -0,0 +1,8 @@ +namespace InnovEnergy.App.SodiStoreMax.SystemConfig; + +public class AcDcConfig +{ + public required Double MaxDcLinkVoltage { get; set; } + public required Double MinDcLinkVoltage { get; set; } + public required Double ReferenceDcLinkVoltage { get; init; } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/SystemConfig/CalibrationChargeType.cs b/csharp/App/SodiStoreMax/src/SystemConfig/CalibrationChargeType.cs new file mode 100644 index 000000000..1b783b808 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SystemConfig/CalibrationChargeType.cs @@ -0,0 +1,8 @@ +namespace InnovEnergy.App.SodiStoreMax.SystemConfig; + +public enum CalibrationChargeType +{ + RepetitivelyEvery, + AdditionallyOnce, + ChargePermanently +} diff --git a/csharp/App/SodiStoreMax/src/SystemConfig/Config.cs b/csharp/App/SodiStoreMax/src/SystemConfig/Config.cs new file mode 100644 index 000000000..110db56cb --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SystemConfig/Config.cs @@ -0,0 +1,272 @@ +using System.Text.Json; +using InnovEnergy.App.SodiStoreMax.Devices; +using InnovEnergy.Lib.Utils; +using static System.Text.Json.JsonSerializer; + +namespace InnovEnergy.App.SodiStoreMax.SystemConfig; + +// shut up trim warnings +#pragma warning disable IL2026 + +public class Config //TODO: let IE choose from config files (Json) and connect to GUI +{ + private static String DefaultConfigFilePath => Path.Combine(Environment.CurrentDirectory, "config.json"); + private static DateTime DefaultDatetime => new(2024, 03, 11, 09, 00, 00); + + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public required Double MinSoc { get; set; } + public required UInt16 CurtailP { get; set; }// in Kw + public required UInt16 PvInstalledPower { get; set; }// in Kw + public required CalibrationChargeType ForceCalibrationChargeState { get; set; } + public required DateTime DayAndTimeForRepetitiveCalibration { get; set; } + public required DateTime DayAndTimeForAdditionalCalibration { get; set; } + public required Boolean DisplayIndividualBatteries { get; set; } + public required Double PConstant { get; set; } + public required Double GridSetPoint { get; set; } + public required Double BatterySelfDischargePower { get; set; } + public required Double HoldSocZone { get; set; } + public required DevicesConfig IslandMode { get; set; } + public required DevicesConfig GridTie { get; set; } + + public required DeviceConfig Devices { get; set; } + public required S3Config? S3 { get; set; } + + private static String? LastSavedData { get; set; } + + #if DEBUG + public static Config Default => new() + { + MinSoc = 20, + CurtailP = 0, + PvInstalledPower = 20, + ForceCalibrationChargeState = CalibrationChargeType.RepetitivelyEvery, + DayAndTimeForRepetitiveCalibration = DefaultDatetime, + DayAndTimeForAdditionalCalibration = DefaultDatetime, + DisplayIndividualBatteries = false, + PConstant = .5, + GridSetPoint = 0, + BatterySelfDischargePower = 200, + HoldSocZone = 1, // TODO: find better name, + IslandMode = new() + { + AcDc = new () + { + MinDcLinkVoltage = 690, + ReferenceDcLinkVoltage = 750, + MaxDcLinkVoltage = 810, + }, + + DcDc = new () + { + UpperDcLinkVoltage = 50, + LowerDcLinkVoltage = 50, + ReferenceDcLinkVoltage = 750, + + MaxBatteryChargingCurrent = 210, + MaxBatteryDischargingCurrent = 210, + MaxDcPower = 10000, + + MaxChargeBatteryVoltage = 57, + MinDischargeBatteryVoltage = 0, + }, + }, + + GridTie = new() + { + AcDc = new () + { + MinDcLinkVoltage = 720, + ReferenceDcLinkVoltage = 750, + MaxDcLinkVoltage = 810, + }, + + DcDc = new () + { + UpperDcLinkVoltage = 50, + LowerDcLinkVoltage = 50, + ReferenceDcLinkVoltage = 750, + + MaxBatteryChargingCurrent = 210, + MaxBatteryDischargingCurrent = 210, + MaxDcPower = 10000, + + MaxChargeBatteryVoltage = 57, + MinDischargeBatteryVoltage = 0, + }, + }, + + Devices = new () + { + RelaysIp = new() { Host = "localhost", Port = 5006, DeviceState = DeviceState.Measured}, + TsRelaysIp = new() { Host = "localhost", Port = 5006, DeviceState = DeviceState.Measured}, + GridMeterIp = new() { Host = "localhost", Port = 5003, DeviceState = DeviceState.Measured}, + PvOnAcGrid = new() { Host = "false" , Port = 0 , DeviceState = DeviceState.Measured}, + LoadOnAcGrid = new() { Host = "false" , Port = 0 , DeviceState = DeviceState.Measured}, + PvOnAcIsland = new() { Host = "true" , Port = 0 , DeviceState = DeviceState.Measured}, + IslandBusLoadMeterIp = new() { Host = "localhost", Port = 5004, DeviceState = DeviceState.Measured}, + TruConvertAcIp = new() { Host = "localhost", Port = 5001, DeviceState = DeviceState.Measured}, + PvOnDc = new() { Host = "localhost", Port = 5005, DeviceState = DeviceState.Measured}, + LoadOnDc = new() { Host = "false" , Port = 0 , DeviceState = DeviceState.Measured}, + TruConvertDcIp = new() { Host = "localhost", Port = 5002, DeviceState = DeviceState.Measured}, + BatteryIp = new() { Host = "localhost", Port = 5007, DeviceState = DeviceState.Measured}, + BatteryNodes = new []{ 2, 3, 4, 5, 6 } + }, + + S3 = new() + { + Bucket = "1-3e5b3069-214a-43ee-8d85-57d72000c19d", + Region = "sos-ch-dk-2", + Provider = "exo.io", + ContentType = "text/plain; charset=utf-8", + Key = "EXO4ec5faf1a7650b79b5722fb5", + Secret = "LUxu1PGEA-POEIckoEyq6bYyz0RnenW6tmqccMKgkHQ" + }, + }; + #else + public static Config Default => new() + { + MinSoc = 20, + CurtailP = 0, + PvInstalledPower = 20, + ForceCalibrationChargeState = CalibrationChargeType.RepetitivelyEvery, + DayAndTimeForRepetitiveCalibration = DefaultDatetime, + DayAndTimeForAdditionalCalibration = DefaultDatetime, + DisplayIndividualBatteries = false, + PConstant = .5, + GridSetPoint = 0, + BatterySelfDischargePower = 200, + HoldSocZone = 1, // TODO: find better name, + IslandMode = new() + { + AcDc = new () + { + MinDcLinkVoltage = 690, + ReferenceDcLinkVoltage = 750, + MaxDcLinkVoltage = 810, + }, + + DcDc = new () + { + UpperDcLinkVoltage = 50, + LowerDcLinkVoltage = 50, + ReferenceDcLinkVoltage = 750, + + MaxBatteryChargingCurrent = 210, + MaxBatteryDischargingCurrent = 210, + MaxDcPower = 10000, + + MaxChargeBatteryVoltage = 57, + MinDischargeBatteryVoltage = 0, + }, + }, + + GridTie = new() + { + AcDc = new () + { + MinDcLinkVoltage = 720, + ReferenceDcLinkVoltage = 750, + MaxDcLinkVoltage = 780, + }, + + DcDc = new () + { + UpperDcLinkVoltage = 20, + LowerDcLinkVoltage = 20, + ReferenceDcLinkVoltage = 750, + + MaxBatteryChargingCurrent = 210, + MaxBatteryDischargingCurrent = 210, + MaxDcPower = 10000, + + MaxChargeBatteryVoltage = 57, + MinDischargeBatteryVoltage = 0, + }, + }, + + + S3 = new() + { + Bucket = "1-3e5b3069-214a-43ee-8d85-57d72000c19d", + Region = "sos-ch-dk-2", + Provider = "exo.io", + Key = "EXObb5a49acb1061781761895e7", + Secret = "sKhln0w8ii3ezZ1SJFF33yeDo8NWR1V4w2H0D4-350I", + ContentType = "text/plain; charset=utf-8" + }, + + Devices = new () + { + RelaysIp = new() { Host = "10.0.1.1", Port = 502, DeviceState = DeviceState.Measured}, + TsRelaysIp = new() { Host = "10.0.1.2", Port = 502, DeviceState = DeviceState.Measured}, + GridMeterIp = new() { Host = "10.0.4.1", Port = 502, DeviceState = DeviceState.Measured}, + PvOnAcGrid = new() { Host = "false" , Port = 0 , DeviceState = DeviceState.Measured}, + LoadOnAcGrid = new() { Host = "true" , Port = 0 , DeviceState = DeviceState.Measured}, + PvOnAcIsland = new() { Host = "false" , Port = 0 , DeviceState = DeviceState.Measured}, + IslandBusLoadMeterIp = new() { Host = "10.0.4.2", Port = 502, DeviceState = DeviceState.Measured}, + TruConvertAcIp = new() { Host = "10.0.2.1", Port = 502, DeviceState = DeviceState.Measured}, + PvOnDc = new() { Host = "10.0.5.1", Port = 502, DeviceState = DeviceState.Measured}, + LoadOnDc = new() { Host = "false" , Port = 0 , DeviceState = DeviceState.Measured}, + TruConvertDcIp = new() { Host = "10.0.3.1", Port = 502, DeviceState = DeviceState.Measured}, + BatteryIp = new() { Host = "localhost", Port = 6855, DeviceState = DeviceState.Measured }, + BatteryNodes = new []{ 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, + }, + }; + #endif + + public void Save(String? path = null) + { + var configFilePath = path ?? DefaultConfigFilePath; + + try + { + var jsonString = Serialize(this, JsonOptions); + + if (LastSavedData == jsonString) + return; + + LastSavedData = jsonString; + + File.WriteAllText(configFilePath, jsonString); + } + catch (Exception e) + { + $"Failed to write config file {configFilePath}\n{e}".WriteLine(); + throw; + } + } + + + public static Config Load(String? path = null) + { + var configFilePath = path ?? DefaultConfigFilePath; + try + { + var jsonString = File.ReadAllText(configFilePath); + return Deserialize(jsonString)!; + } + catch (Exception e) + { + $"Failed to read config file {configFilePath}, using default config\n{e}".WriteLine(); + return Default; + } + } + + + public static async Task LoadAsync(String? path = null) + { + var configFilePath = path ?? DefaultConfigFilePath; + try + { + var jsonString = await File.ReadAllTextAsync(configFilePath); + return Deserialize(jsonString)!; + } + catch (Exception e) + { + Console.WriteLine($"Couldn't read config file {configFilePath}, using default config"); + e.Message.WriteLine(); + return Default; + } + } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/SystemConfig/DcDcConfig.cs b/csharp/App/SodiStoreMax/src/SystemConfig/DcDcConfig.cs new file mode 100644 index 000000000..182f71521 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SystemConfig/DcDcConfig.cs @@ -0,0 +1,15 @@ +namespace InnovEnergy.App.SodiStoreMax.SystemConfig; + +public class DcDcConfig +{ + public required Double LowerDcLinkVoltage { get; set; } + public required Double ReferenceDcLinkVoltage { get; init; } + public required Double UpperDcLinkVoltage { get; set; } + + public required Double MaxBatteryChargingCurrent { get; set; } + public required Double MaxBatteryDischargingCurrent { get; set; } + public required Double MaxDcPower { get; set; } + + public required Double MaxChargeBatteryVoltage { get; set; } + public required Double MinDischargeBatteryVoltage { get; set; } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/SystemConfig/DeviceConfig.cs b/csharp/App/SodiStoreMax/src/SystemConfig/DeviceConfig.cs new file mode 100644 index 000000000..aed893168 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SystemConfig/DeviceConfig.cs @@ -0,0 +1,21 @@ +using InnovEnergy.App.SodiStoreMax.Devices; +using InnovEnergy.Lib.Utils.Net; + +namespace InnovEnergy.App.SodiStoreMax.SystemConfig; + +public class DeviceConfig +{ + public required SalimaxDevice RelaysIp { get; init; } + public required SalimaxDevice TsRelaysIp { get; init; } + public required SalimaxDevice GridMeterIp { get; init; } + public required SalimaxDevice PvOnAcGrid { get; init; } + public required SalimaxDevice LoadOnAcGrid { get; init; } + public required SalimaxDevice PvOnAcIsland { get; init; } + public required SalimaxDevice IslandBusLoadMeterIp { get; init; } + public required SalimaxDevice TruConvertAcIp { get; init; } + public required SalimaxDevice PvOnDc { get; init; } + public required SalimaxDevice LoadOnDc { get; init; } + public required SalimaxDevice TruConvertDcIp { get; init; } + public required SalimaxDevice BatteryIp { get; init; } + public required Int32[] BatteryNodes { get; init; } +} diff --git a/csharp/App/SodiStoreMax/src/SystemConfig/DevicesConfig.cs b/csharp/App/SodiStoreMax/src/SystemConfig/DevicesConfig.cs new file mode 100644 index 000000000..ba049f5cb --- /dev/null +++ b/csharp/App/SodiStoreMax/src/SystemConfig/DevicesConfig.cs @@ -0,0 +1,7 @@ +namespace InnovEnergy.App.SodiStoreMax.SystemConfig; + +public class DevicesConfig +{ + public required AcDcConfig AcDc { get; init; } + public required DcDcConfig DcDc { get; init; } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/Topology.cs b/csharp/App/SodiStoreMax/src/Topology.cs new file mode 100644 index 000000000..5ad0190e1 --- /dev/null +++ b/csharp/App/SodiStoreMax/src/Topology.cs @@ -0,0 +1,530 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using InnovEnergy.App.SodiStoreMax.Devices; +using InnovEnergy.App.SodiStoreMax.Ess; +using InnovEnergy.Lib.Devices.AMPT; +using InnovEnergy.Lib.Devices.BatteryDeligreen; +using InnovEnergy.Lib.Devices.EmuMeter; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertAc; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertDc; +using InnovEnergy.Lib.Units; +using InnovEnergy.Lib.Units.Power; +using InnovEnergy.Lib.Utils; +using Ac3Bus = InnovEnergy.Lib.Units.Composite.Ac3Bus; + +namespace InnovEnergy.App.SodiStoreMax; + +// ┌────┐ ┌────┐ +// │ Pv │ │ Pv │ ┌────┐ +// └────┘ └────┘ │ Pv │ +// V V └────┘ +// V V V +// (b) 0 W (e) 0 W V +// V V (i) 13.2 kW ┌────────────┐ +// V V V │ Battery │ +// ┌─────────┐ ┌──────────┐ ┌────────────┐ ┌─────────┐ V ├────────────┤ +// │ Grid │ │ Grid Bus │ │ Island Bus │ │ AC/DC │ ┌────────┐ ┌───────┐ │ 52.3 V │ +// ├─────────┤ -10.3 kW ├──────────┤ -11.7 kW ├────────────┤ -11.7 kW ├─────────┤ -11.7 kW │ Dc Bus │ 1008 W │ DC/DC │ 1008 W │ 99.1 % │ +// │ -3205 W │<<<<<<<<<<│ 244 V │<<<<<<<<<<│ 244 V │<<<<<<<<<<│ -6646 W │<<<<<<<<<<├────────┤>>>>>>>>>>├───────┤>>>>>>>>>>│ 490 mA │ +// │ -3507 W │ (a) │ 244 V │ (d) │ 244 V │ (g) │ -5071 W │ (h) │ 776 V │ (k) │ 56 V │ (l) │ 250 °C │ +// │ -3605 W │ K1 │ 246 V │ K2 │ 246 V │ K3 └─────────┘ └────────┘ └───────┘ │ 445 A │ +// └─────────┘ └──────────┘ └────────────┘ V │ 0 Warnings │ +// V V V │ 0 Alarms │ +// V V (j) 0 W └────────────┘ +// (c) 1400 W (f) 0 W V +// V V V +// V V ┌──────┐ +// ┌──────┐ ┌──────┐ │ Load │ +// │ Load │ │ Load │ └──────┘ +// └──────┘ └──────┘ + + +// Calculated values: c,d & h +// ========================== +// +// +// AC side +// a + b - c - d = 0 [eq1] +// d + e - f - g = 0 [eq2] +// +// c & d are not measured! +// +// d = f + g - e [eq2] +// c = a + b - d [eq1] +// +// DC side +// h + i - j - k = 0 [eq3] +// +// if Dc load not existing, h = i - k [eq4] + +// k = l assuming no losses in DCDC // this is changed now l is equal total battery power +// j = h + i - k [eq3] + + +public static class Topology +{ + + public static TextBlock CreateTopologyTextBlock(this StatusRecord status) + { + var a = status.GridMeter?.Ac.Power.Active; + var b = status.PvOnAcGrid?.Dc.Power.Value; + var e = status.PvOnAcIsland?.Dc.Power.Value; + var f = status.LoadOnAcIsland?.Ac.Power.Active; + var g = status.AcDc.Dc.Power.Value; + var h = status.AcDcToDcLink?.Power.Value; + var i = status.PvOnDc?.Dc.Power.Value; + var k = status.DcDc.Dc.Link.Power.Value; + var l = status.Battery is not null ? status.Battery.Power : 0; + var j = status.LoadOnDc?.Power.Value; + var d = status.AcGridToAcIsland?.Power.Active; + var c = status.LoadOnAcGrid?.Power.Active; + + ///////////////////////////// + + var grid = status.CreateGridColumn(a); + var gridBus = status.CreateGridBusColumn(b, c, d); + var islandBus = status.CreateIslandBusColumn(e, f, g); + var inverter = status.CreateInverterColumn(h); + var dcBus = status.CreateDcBusColumn(i, j, k); + var dcDc = status.CreateDcDcColumn(l); + var batteries = status.CreateBatteryColumn(); + + return TextBlock.AlignCenterVertical + ( + grid, + gridBus, + islandBus, + inverter, + dcBus, + dcDc, + batteries + ); + } + + private static TextBlock CreateGridColumn(this StatusRecord status, ActivePower? a) + { + // ┌─────────┐ + // │ Grid │ + // ├─────────┤ -10.3 kW + // │ -3205 W │<<<<<<<<<< + // │ -3507 W │ (a) + // │ -3605 W │ K1 + // └─────────┘ + + var gridMeterAc = status.GridMeter?.Ac; + var k1 = status.Relays?.K1GridBusIsConnectedToGrid; + + var gridBox = PhasePowersActive(gridMeterAc).TitleBox("Grid"); + var gridFlow = SwitchedFlow(k1, a, "K1"); + + return TextBlock.AlignCenterVertical(gridBox, gridFlow); + } + + + private static TextBlock CreateGridBusColumn(this StatusRecord status, + ActivePower? b, + ActivePower? c, + ActivePower? d) + { + + // ┌────┐ + // │ Pv │ + // └────┘ + // V + // V + // (b) 0 W + // V + // V + // ┌──────────┐ + // │ Grid Bus │ + // ├──────────┤ -11.7 kW + // │ 244 V │<<<<<<<<<< + // │ 244 V │ (d) + // │ 246 V │ K2 + // └──────────┘ + // V + // V + // (c) 1400 W + // V + // V + // ┌──────┐ + // │ Load │ + // └──────┘ + + + ////////////// top ////////////// + + var pvBox = TextBlock.FromString("PV").Box(); + var pvFlow = Flow.Vertical(b); + + ////////////// center ////////////// + + // on IslandBus show voltages measured by inverter + // on GridBus show voltages measured by grid meter + // ought to be approx the same + + var gridMeterAc = status.GridMeter?.Ac; + var k2 = status.Relays?.K2IslandBusIsConnectedToGridBus; + + var busBox = PhaseVoltages(gridMeterAc).TitleBox("Grid Bus"); + var busFlow = SwitchedFlow(k2, d, "K2"); + + ////////////// bottom ////////////// + + var loadFlow = Flow.Vertical(c); + var loadBox = TextBlock.FromString("Load").Box(); + + ////////////// assemble ////////////// + + return TextBlock.AlignCenterVertical + ( + TextBlock.AlignCenterHorizontal(pvBox, pvFlow, busBox, loadFlow, loadBox), + busFlow + ); + } + + + private static TextBlock CreateIslandBusColumn(this StatusRecord status, + ActivePower? e, + ActivePower? f, + ActivePower? g) + { + + // ┌────┐ + // │ Pv │ + // └────┘ + // V + // V + // (e) 0 W + // V + // V + // ┌────────────┐ + // │ Island Bus │ + // ├────────────┤ -11.7 kW + // │ 244 V │<<<<<<<<<< + // │ 244 V │ (g) + // │ 246 V │ K3 + // └────────────┘ + // V + // V + // (f) 0 W + // V + // V + // ┌──────┐ + // │ Load │ + // └──────┘ + + + ////////////// top ////////////// + + var pvBox = TextBlock.FromString("PV").Box(); + var pvFlow = Flow.Vertical(e); + + ////////////// center ////////////// + + // on IslandBus show voltages measured by inverter + // on GridBus show voltages measured by grid meter + // ought to be approx the same + + var inverterAc = status.AcDc.Ac; + var busBox = PhaseVoltages(inverterAc).TitleBox("Island Bus"); + var busFlow = status.IslandBusToInverterConnection(g); + + ////////////// bottom ////////////// + + var loadFlow = Flow.Vertical(f); + var loadBox = TextBlock.FromString("Load").Box(); + + ////////////// assemble ////////////// + + return TextBlock.AlignCenterVertical + ( + TextBlock.AlignCenterHorizontal(pvBox, pvFlow, busBox, loadFlow, loadBox), + busFlow + ); + } + + + + private static TextBlock CreateInverterColumn(this StatusRecord status, ActivePower? h) + { + // ┌─────────┐ + // │ AC/DC │ + // ├─────────┤ -11.7 kW + // │ -6646 W │<<<<<<<<<< + // │ -5071 W │ (h) + // └─────────┘ + + var inverterBox = status + .AcDc + .Devices + .Select(d => d.Status.Ac.Power.Active) + .Apply(TextBlock.AlignLeft) + .TitleBox("AC/DC"); + + var dcFlow = Flow.Horizontal(h); + + return TextBlock.AlignCenterVertical(inverterBox, dcFlow); + } + + + [SuppressMessage("ReSharper", "PossibleMultipleEnumeration")] + private static TextBlock IslandBusToInverterConnection(this StatusRecord status, ActivePower? g) + { + if (status.Relays is null) + return TextBlock.FromString("????????"); + + var nInverters = status.AcDc.Devices.Count; + + var k3S = status + .Relays + .K3InverterIsConnectedToIslandBus + .Take(nInverters); + + if (k3S.Prepend(true).All(s => s)) // TODO: display when no ACDC present + return Flow.Horizontal(g); + + return Switch.Open("K3"); + } + + private static TextBlock CreateDcDcColumn(this StatusRecord status, ActivePower? p) + { + var dc48Voltage = status.DcDc.Dc.Battery.Voltage.ToDisplayString(); + + var busBox = TextBlock + .AlignLeft(dc48Voltage) + .TitleBox("DC/DC"); + + var busFlow = Flow.Horizontal(p); + + return TextBlock.AlignCenterVertical(busBox, busFlow); + } + + private static TextBlock CreateDcBusColumn(this StatusRecord status, + ActivePower? i, + ActivePower? j, + ActivePower? k) + { + // ┌────┐ + // │ Pv │ + // └────┘ + // V + // V + // (i) 13.2 kW + // V + // V + // ┌────────┐ + // │ Dc Bus │ 1008 W + // ├────────┤>>>>>>>>>> + // │ 776 V │ (k) + // └────────┘ + // V + // V + // (j) 0 W + // V + // V + // ┌──────┐ + // │ Load │ + // └──────┘ + // + + + /////////////////// top /////////////////// + + var mppt = status.PvOnDc; + + var nStrings = mppt is not null + ? "x" + mppt.Strings.Count + : "?"; + + var pvBox = TextBlock.FromString($"PV {nStrings}").Box(); + var pvToBus = Flow.Vertical(i); + + /////////////////// center /////////////////// + + var dcBusVoltage = status.DcDc.Dc.Link.Voltage; + + var dcBusBox = dcBusVoltage + .ToDisplayString() + .Apply(TextBlock.FromString) + .TitleBox("DC Bus "); + + var busFlow = Flow.Horizontal(k); + + /////////////////// bottom /////////////////// + + var busToLoad = Flow.Vertical(j); + var loadBox = TextBlock.FromString("DC Load").Box(); + + ////////////// assemble ////////////// + + return TextBlock.AlignCenterVertical + ( + TextBlock.AlignCenterHorizontal(pvBox, pvToBus, dcBusBox, busToLoad, loadBox), + busFlow + ); + } + + private static TextBlock CreateBatteryColumn(this StatusRecord status) + { + var bat = status.Battery; + if (bat is null) + return TextBlock.AlignLeft("no battery").Box(); + + + var batteryAvgBox = CreateAveragedBatteryBox(bat); + + var batteryBoxes = bat + .Devices + .Select(CreateBatteryBox) + .ToReadOnlyList(); + + + var individualWithAvgBox = TextBlock + .AlignCenterVertical + ( + batteryAvgBox , + batteryBoxes.Any() + ? TextBlock.AlignLeft(batteryBoxes) + : TextBlock.Empty + ); + + return status.Config.DisplayIndividualBatteries ? individualWithAvgBox : batteryAvgBox; + } + + private static TextBlock CreateAveragedBatteryBox(BatteryDeligreenRecords bat) + { + var voltage = bat.Voltage.ToDisplayString(); + var soc = bat.Devices.Any() ? bat.Devices.Average(b => b.BatteryDeligreenDataRecord.Soc).Percent().ToDisplayString() : "0"; // TODO + var current = bat.Current.ToDisplayString(); + var busCurrent = bat.Devices.Any() ? bat.Devices.Sum(b => b.BatteryDeligreenDataRecord.BusCurrent).A().ToDisplayString() : "0"; + var temp = bat.TemperatureCell1.ToDisplayString(); + //var alarms = bat.Alarms.Count + " Alarms"; + //var warnings = bat.Warnings.Count + " Warnings"; + var nBatteries = bat.Devices.Count; + + return TextBlock + .AlignLeft + ( + voltage, + soc, + current, + busCurrent, + temp + ) + .TitleBox($"Battery x{nBatteries}"); + } + + private static TextBlock PhaseVoltages(Ac3Bus? ac) + { + return TextBlock.AlignLeft + ( + ac?.L1.Voltage.ToDisplayString() ?? "???", + ac?.L2.Voltage.ToDisplayString() ?? "???", + ac?.L3.Voltage.ToDisplayString() ?? "???" + ); + } + + private static TextBlock PhasePowersActive(Ac3Bus? ac) + { + return TextBlock.AlignLeft + ( + ac?.L1.Power.Active.ToDisplayString() ?? "???", + ac?.L2.Power.Active.ToDisplayString() ?? "???", + ac?.L3.Power.Active.ToDisplayString() ?? "???" + ); + } + + private static TextBlock CreateBatteryBox(BatteryDeligreenRecord battery, Int32 i) + { + var batteryWarnings = "";// battery.Warnings.Any(); + var batteryAlarms = "";// battery.Alarms.Any(); + + var content = TextBlock.AlignLeft + ( + battery.BatteryDeligreenDataRecord.BusVoltage.ToDisplayString(), + battery.BatteryDeligreenDataRecord.Soc.ToDisplayString(), + battery.BatteryDeligreenDataRecord.BusCurrent.ToDisplayString() + " C/D", + battery.BatteryDeligreenDataRecord.TemperaturesList.PowerTemperature.ToDisplayString(), + battery.BatteryDeligreenDataRecord.BatteryCapacity.ToString(CultureInfo.CurrentCulture) , + batteryWarnings, + batteryAlarms + ); + + var box = content.TitleBox($"Battery {i + 1}"); + var flow = Flow.Horizontal(battery.BatteryDeligreenDataRecord.Power); + + return TextBlock.AlignCenterVertical(flow, box); + } + + + private static TextBlock SwitchedFlow(Boolean? switchClosed, ActivePower? power, String kx) + { + return switchClosed is null ? TextBlock.FromString("??????????") + : !switchClosed.Value ? Switch.Open(kx) + : power is null ? TextBlock.FromString("??????????") + : Flow.Horizontal(power); + } + + //We are fake using the ampt instead of PvOnAc, We dont have a Pv on Ac at the moment and we don't have it classes :TODO + public static AcPowerDevice? CalculateGridBusLoad(EmuMeterRegisters? gridMeter, AmptStatus? pvOnAcGrid, AcPowerDevice? gridBusToIslandBusPower) + { + var a = gridMeter ?.Ac.Power; + var b = pvOnAcGrid is not null? pvOnAcGrid?.Dc.Power.Value: 0; + var d = gridBusToIslandBusPower?.Power; + + if (a is null || b is null || d is null) + return null; + + var c = a + b - d; // [eq1] + + return new AcPowerDevice { Power = c }; + } + + //We are fake using the ampt instead of PvOnAc, We dont have a Pv on Ac at the moment and we don't have it classes :TODO + public static DcPowerDevice? CalculateAcDcToDcLink(AmptStatus? pvOnDc, DcDcDevicesRecord? dcDc, AcDcDevicesRecord acDc) + { + var i = pvOnDc?.Dc.Power; + var k = dcDc?.Dc.Link.Power; // We don't check on the DcDc because this device is mandatory + var g = acDc?.Dc.Power; + + if (i is null || k is null ) + { + return new DcPowerDevice { Power = g }; + } + + var h = -(i - k); // [eq4] + + return new DcPowerDevice { Power = h }; + } + + //We are fake using the ampt instead of PvOnAc, We dont have a Pv on Ac at the moment and we don't have it classes :TODO + public static AcPowerDevice? CalculateGridBusToIslandBusPower(AmptStatus? pvOnAcIsland, EmuMeterRegisters? loadOnAcIsland, AcDcDevicesRecord? acDc) + { + var e = pvOnAcIsland is not null? pvOnAcIsland?.Dc.Power.Value: 0; + var f = loadOnAcIsland is not null? loadOnAcIsland?.Ac.Power : 0; + var g = acDc ?.Ac.Power; // We don't check on the AcDc because this device is mandatory, if this does not exist the system will not start + + if (e is null || f is null || g is null) + return null; + + var d = f + g - e; // [eq2] + + return new AcPowerDevice { Power = d }; + } + + public static DcPowerDevice? CalculateDcLoad(AcDcDevicesRecord? acDc, AmptStatus? pvOnDc, DcDcDevicesRecord? dcDc) + { + var h = acDc?.Dc.Power; // We don't check on the AcDc because this device is mandatory + var i = pvOnDc is not null? pvOnDc?.Dc.Power: 0; + var k = dcDc?.Dc.Link.Power; // We don't check on the DcDc because this device is mandatory + + if (h is null || i is null || k is null) + return null; + + var j = h + i - k; // [eq3] + + return new DcPowerDevice { Power = j}; + } + +} diff --git a/csharp/App/SodiStoreMax/tunnelstoSalimaxX.sh b/csharp/App/SodiStoreMax/tunnelstoSalimaxX.sh new file mode 100755 index 000000000..03b256195 --- /dev/null +++ b/csharp/App/SodiStoreMax/tunnelstoSalimaxX.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +host="ie-entwicklung@$1" + +tunnel() { + name=$1 + ip=$2 + rPort=$3 + lPort=$4 + + echo -n "$name @ $ip mapped to localhost:$lPort " + ssh -nNTL "$lPort:$ip:$rPort" "$host" 2> /dev/null & + + until nc -vz 127.0.0.1 $lPort 2> /dev/null + do + echo -n . + sleep 0.3 + done + + echo "ok" +} + +echo "" + +tunnel "Trumpf Inverter (http) " 10.0.2.1 80 8001 +tunnel "Trumpf DCDC (http) " 10.0.3.1 80 8002 +tunnel "Ext Emu Meter (http) " 10.0.4.1 80 8003 +tunnel "Int Emu Meter (http) " 10.0.4.2 80 8004 +tunnel "AMPT (http) " 10.0.5.1 8080 8005 + +tunnel "Trumpf Inverter (modbus)" 10.0.2.1 502 5001 +tunnel "Trumpf DCDC (modbus) " 10.0.3.1 502 5002 +tunnel "Ext Emu Meter (modbus) " 10.0.4.1 502 5003 +tunnel "Int Emu Meter " 10.0.4.2 502 5004 +tunnel "AMPT (modbus) " 10.0.5.1 502 5005 +tunnel "Adam " 10.0.1.1 502 5006 #for AMAX is 10.0.1.3 +tunnel "Batteries " 127.0.0.1 6855 5007 + +echo +echo "press any key to close the tunnels ..." +read -r -n 1 -s +kill $(jobs -p) +echo "done" + diff --git a/csharp/App/SodiStoreMax/uploadBatteryFw/AF0A.bin b/csharp/App/SodiStoreMax/uploadBatteryFw/AF0A.bin new file mode 100644 index 0000000000000000000000000000000000000000..e5b7b9aba4bd3faaa32acad19314bfac5a4307da GIT binary patch literal 589824 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 J@c);A{{wk90$Bh6 literal 0 HcmV?d00001 diff --git a/csharp/App/SodiStoreMax/uploadBatteryFw/update_firmware.sh b/csharp/App/SodiStoreMax/uploadBatteryFw/update_firmware.sh new file mode 100755 index 000000000..fab8d1174 --- /dev/null +++ b/csharp/App/SodiStoreMax/uploadBatteryFw/update_firmware.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +dotnet_version='net6.0' +salimax_ip="$1" +username='ie-entwicklung' +root_password='Salimax4x25' + +set -e + +ip_addresses=("10.2.3.115" "10.2.3.104" "10.2.4.33" "10.2.4.32" "10.2.4.36" "10.2.4.35" "10.2.4.154" "10.2.4.113" "10.2.4.29") +battery_ids=("2" "3" "4" "5" "6" "7" "8" "9" "10" "11") + + +for ip_address in "${ip_addresses[@]}"; do + scp upload-bms-firmware AF0A.bin "$username"@"$ip_address":/home/"$username" + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S systemctl stop battery.service" + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S apt install python3-pip -y" + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S pip3 install pymodbus" + + for battery in "${battery_ids[@]}"; do + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S python3 upload-bms-firmware ttyUSB0 " "$battery" " AF0A.bin" + done + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S systemctl start battery.service" + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S systemctl rm upload-bms-firmware AF0A.bin" + +echo "Deployed and ran commands on $ip_address" +done + + diff --git a/csharp/App/SodiStoreMax/uploadBatteryFw/upload-bms-firmware b/csharp/App/SodiStoreMax/uploadBatteryFw/upload-bms-firmware new file mode 100755 index 000000000..58d2c804d --- /dev/null +++ b/csharp/App/SodiStoreMax/uploadBatteryFw/upload-bms-firmware @@ -0,0 +1,288 @@ +#!/usr/bin/python2 -u +# coding=utf-8 + +import os +import struct +from time import sleep + +import serial +from os import system +import logging + +from pymodbus.client import ModbusSerialClient as Modbus +from pymodbus.exceptions import ModbusIOException +from pymodbus.pdu import ModbusResponse +from os.path import dirname, abspath +from sys import path, argv, exit + +path.append(dirname(dirname(abspath(__file__)))) + +PAGE_SIZE = 0x100 +HALF_PAGE =int( PAGE_SIZE / 2) +WRITE_ENABLE = [1] +FIRMWARE_VERSION_REGISTER = 1054 + +ERASE_FLASH_REGISTER = 0x2084 +RESET_REGISTER = 0x2087 +logging.basicConfig(level=logging.INFO) + + +# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime +# noinspection PyUnreachableCode +if False: + from typing import List, NoReturn, Iterable, Optional + +def calc_stm32_crc_round(crc, data): + # type: (int, int) -> int + crc = crc ^ data + for _ in range(32): + xor = (crc & 0x80000000) != 0 + crc = (crc & 0x7FFFFFFF) << 1 # clear bit 31 because python ints have "infinite" bits + if xor: + crc = crc ^ 0x04C11DB7 + + return crc + + +def calc_stm32_crc(data): + # type: (Iterable[int]) -> int + crc = 0xFFFFFFFF + + for dw in data: + crc = calc_stm32_crc_round(crc, dw) + + return crc + + +def init_modbus(tty): + # type: (str) -> Modbus + + return Modbus( + port='/dev/' + tty, + method='rtu', + baudrate=115200, + stopbits=1, + bytesize=8, + timeout=0.5, # seconds + parity=serial.PARITY_ODD) + + +def failed(response): + # type: (ModbusResponse) -> bool + + # Todo 'ModbusIOException' object has no attribute 'function_code' + return response.function_code > 0x80 + + +def clear_flash(modbus, slave_address): + # type: (Modbus, int) -> bool + + print ('erasing flash...') + + write_response = modbus.write_registers(address=0x2084, values=[1], slave=slave_address) + + if failed(write_response): + print('erasing flash FAILED') + return False + + flash_countdown = 17 + while flash_countdown > 0: + read_response = modbus.read_holding_registers(address=0x2085, count=1, slave=slave_address) + + if failed(read_response): + print('erasing flash FAILED') + return False + + if read_response.registers[0] != flash_countdown: + flash_countdown = read_response.registers[0] + + msg = str(100 * (16 - flash_countdown) / 16) + '%' + print('\r{0} '.format(msg), end=' ') + + print('done!') + + return True + + +# noinspection PyShadowingBuiltins +def bytes_to_words(bytes): + # type: (str) -> List[int] + return list(struct.unpack('>' + int(len(bytes)/2) * 'H', bytes)) + + +def send_half_page_1(modbus, slave_address, data, page): + # type: (Modbus, int, str, int) -> NoReturn + + first_half = [page] + bytes_to_words(data[:HALF_PAGE]) + write_first_half = modbus.write_registers(0x2000, first_half, slave=slave_address) + + if failed(write_first_half): + raise Exception("Failed to write page " + str(page)) + + +def send_half_page_2(modbus, slave_address, data, page): + # type: (Modbus, int, str, int) -> NoReturn + + registers = bytes_to_words(data[HALF_PAGE:]) + calc_crc(page, data) + WRITE_ENABLE + result = modbus.write_registers(0x2041, registers, slave=slave_address) + + if failed(result): + raise Exception("Failed to write page " + str(page)) + + +def get_fw_name(fw_path): + # type: (str) -> str + return fw_path.split('/')[-1].split('.')[0] + + +def upload_fw(modbus, slave_id, fw_path, fw_name): + # type: (Modbus, int, str, str) -> NoReturn + + with open(fw_path, "rb") as f: + + size = os.fstat(f.fileno()).st_size + n_pages = int(size / PAGE_SIZE) + + print('uploading firmware ' + fw_name + ' to BMS ...') + + for page in range(0, n_pages): + page_data = f.read(PAGE_SIZE) + + msg = "page " + str(page + 1) + '/' + str(n_pages) + ' ' + str(100 * page / n_pages + 1) + '%' + print('\r{0} '.format(msg), end=' ') + + if is_page_empty(page_data): + continue + sleep(0.01) + send_half_page_1(modbus, slave_id, page_data, page) + sleep(0.01) + send_half_page_2(modbus, slave_id, page_data, page) + + +def is_page_empty(page): + # type: (str) -> bool + return page.count(b'\xff') == len(page) + + +def reset_bms(modbus, slave_id): + # type: (Modbus, int) -> bool + + print ('resetting BMS...') + + result = modbus.write_registers(RESET_REGISTER, [1], slave=slave_id) + + # expecting a ModbusIOException (timeout) + # BMS can no longer reply because it is already reset + success = isinstance(result, ModbusIOException) + + if success: + print('done') + else: + print('FAILED to reset battery!') + + return success + + +def calc_crc(page, data): + # type: (int, str) -> List[int] + + crc = calc_stm32_crc([page] + bytes_to_words(data)) + crc_bytes = struct.pack('>L', crc) + + return bytes_to_words(crc_bytes) + + +def identify_battery(modbus, slave_id): + # type: (Modbus, int) -> Optional[str] + print("slave id=",slave_id) + target = 'battery ' + str(slave_id) + ' at ' + '502' + + try: + + print(('contacting ...')) + + response = modbus.read_input_registers(address=FIRMWARE_VERSION_REGISTER, count=1, slave=slave_id) + fw = '{0:0>4X}'.format(response.registers[0]) + + print(('found battery with firmware ' + fw)) + + return fw + + except: + print(('failed to communicate with ')) + return None + + +def print_usage(): + print(('Usage: ' + __file__ + ' ')) + print(('Example: ' + __file__ + ' ttyUSB0 2 A08C.bin')) + + +def parse_cmdline_args(argv): + # type: (List[str]) -> (str, str, str, str) + + def fail_with(msg): + print(msg) + print_usage() + exit(1) + + if len(argv) < 1: + fail_with('missing argument for tty device') + + if len(argv) < 2: + fail_with('missing argument for battery ID') + + if len(argv) < 3: + fail_with('missing argument for firmware') + + return argv[0], int(argv[1]), argv[2], get_fw_name(argv[2]) + + +def verify_firmware(modbus, battery_id, fw_name): + # type: (Modbus, int, str) -> NoReturn + + fw_verify = identify_battery(modbus, battery_id) + + if fw_verify == fw_name: + print('SUCCESS') + else: + print('FAILED to verify uploaded firmware!') + if fw_verify is not None: + print('expected firmware version ' + fw_name + ' but got ' + fw_verify) + + +def wait_for_bms_reboot(): + # type: () -> NoReturn + + # wait 20s for the battery to reboot + + print('waiting for BMS to reboot...') + + for t in range(20, 0, -1): + print('\r{0} '.format(t), end=' ') + sleep(1) + + print('0') + + +def main(argv): + # type: (List[str]) -> NoReturn + + tty, battery_id, fw_path, fw_name = parse_cmdline_args(argv) + with init_modbus(tty) as modbus: + + if identify_battery(modbus, battery_id) is None: + return + + clear_flash(modbus, battery_id) + upload_fw(modbus, battery_id, fw_path, fw_name) + + if not reset_bms(modbus, battery_id): + return + + wait_for_bms_reboot() + + verify_firmware(modbus, battery_id, fw_name) + + +main(argv[1:]) diff --git a/csharp/InnovEnergy.sln b/csharp/InnovEnergy.sln index a334ae2c0..291c6023b 100644 --- a/csharp/InnovEnergy.sln +++ b/csharp/InnovEnergy.sln @@ -97,6 +97,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BatteryDeligreen", "Lib\Dev EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DeligreenBatteryCommunication", "App\DeligreenBatteryCommunication\DeligreenBatteryCommunication.csproj", "{11ED6871-5B7D-462F-8710-B5D85DEC464A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SodiStoreMax", "App\SodiStoreMax\SodiStoreMax.csproj", "{39B83793-49DB-4940-9C25-A7F944607407}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -256,6 +258,10 @@ Global {11ED6871-5B7D-462F-8710-B5D85DEC464A}.Debug|Any CPU.Build.0 = Debug|Any CPU {11ED6871-5B7D-462F-8710-B5D85DEC464A}.Release|Any CPU.ActiveCfg = Release|Any CPU {11ED6871-5B7D-462F-8710-B5D85DEC464A}.Release|Any CPU.Build.0 = Release|Any CPU + {39B83793-49DB-4940-9C25-A7F944607407}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39B83793-49DB-4940-9C25-A7F944607407}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39B83793-49DB-4940-9C25-A7F944607407}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39B83793-49DB-4940-9C25-A7F944607407}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {CF4834CB-91B7-4172-AC13-ECDA8613CD17} = {145597B4-3E30-45E6-9F72-4DD43194539A} @@ -300,5 +306,6 @@ Global {F2967439-A590-4D5E-9208-1B973C83AA1C} = {4931A385-24DC-4E78-BFF4-356F8D6D5183} {1045AC74-D4D8-4581-AAE3-575DF26060E6} = {4931A385-24DC-4E78-BFF4-356F8D6D5183} {11ED6871-5B7D-462F-8710-B5D85DEC464A} = {145597B4-3E30-45E6-9F72-4DD43194539A} + {39B83793-49DB-4940-9C25-A7F944607407} = {145597B4-3E30-45E6-9F72-4DD43194539A} EndGlobalSection EndGlobal diff --git a/csharp/Lib/Devices/Adam6360D/Doc/flowchart_LEDSetting_20241216_ps.odp b/csharp/Lib/Devices/Adam6360D/Doc/flowchart_LEDSetting_20241216_ps.odp new file mode 100644 index 0000000000000000000000000000000000000000..25ad2180228d99e05e4b95bc92a01e22955a02c3 GIT binary patch literal 39170 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 XU$q`x!~fol?DF8bWJ$HX{C@R+1#+At literal 0 HcmV?d00001 diff --git a/csharp/Lib/Devices/BatteryDeligreen/Alarms.cs b/csharp/Lib/Devices/BatteryDeligreen/Alarms.cs new file mode 100644 index 000000000..efb206a46 --- /dev/null +++ b/csharp/Lib/Devices/BatteryDeligreen/Alarms.cs @@ -0,0 +1,21 @@ +using InnovEnergy.Lib.Units; + +namespace InnovEnergy.Lib.Devices.BatteryDeligreen; + + +public class Alarms +{ + public struct CellAlarm + { + public Int32 CellNumber { get; set; } + public String AlarmDescription { get; set; } + } + + private static readonly Dictionary ByteAlarmCodes = new Dictionary + { + { "00", "Normal, no alarm" }, + { "01", "Alarm that analog quantity reaches the lower limit" }, + { "02", "Alarm that analog quantity reaches the upper limit" }, + { "F0", "Other alarms" } + }; +} \ No newline at end of file diff --git a/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenAlarmRecord.cs b/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenAlarmRecord.cs new file mode 100644 index 000000000..7b860cfa1 --- /dev/null +++ b/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenAlarmRecord.cs @@ -0,0 +1,26 @@ +namespace InnovEnergy.Lib.Devices.BatteryDeligreen; + +public class BatteryDeligreenAlarmRecord +{/* + public String FwVersion { get; set; } + + public TemperaturesList TemperaturesList { get; set; } + // public Dc_ Dc { get; set; } + + public BatteryDeligreenAlarmRecord(Voltage busVoltage, Current busCurrent ,String fwVersion, Percent soc, UInt16 numberOfCycles, Double batteryCapacity, Double ratedCapacity, Voltage totalBatteryVoltage, Percent soh, Double residualCapacity, List cellVoltage, TemperaturesList temperaturesList) + { + BusVoltage = busVoltage; + BusCurrent = busCurrent; + FwVersion = fwVersion; + TotalBatteryVoltage = totalBatteryVoltage; + ResidualCapacity = residualCapacity; + BatteryCapacity = batteryCapacity; + Soc = soc; + RatedCapacity = ratedCapacity; + NumberOfCycles = numberOfCycles; + Soh = soh; + CellVoltage = cellVoltage; + TemperaturesList = temperaturesList; + Power = busVoltage * busCurrent; + }*/ +} \ No newline at end of file diff --git a/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenDataRecord.cs b/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenDataRecord.cs new file mode 100644 index 000000000..590214524 --- /dev/null +++ b/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenDataRecord.cs @@ -0,0 +1,54 @@ +using InnovEnergy.Lib.Units.Power; +using static InnovEnergy.Lib.Devices.BatteryDeligreen.Temperatures; + +namespace InnovEnergy.Lib.Devices.BatteryDeligreen; +using InnovEnergy.Lib.Units; + +using Strings = IReadOnlyList; + +public class BatteryDeligreenDataRecord +{ + + // public Strings Warnings => ParseWarnings().OrderBy(w => w).ToList(); + // public Strings Alarms => ParseAlarms() .OrderBy(w => w).ToList(); + + public String FwVersion { get; set; } + public Voltage BusVoltage { get; set; } + public Current BusCurrent { get; set; } + public ActivePower Power { get; set; } + public Voltage TotalBatteryVoltage { get; set; } + public Double ResidualCapacity { get; set; } + public Double BatteryCapacity { get; set; } + public Percent Soc { get; set; } + public Double RatedCapacity { get; set; } + public UInt16 NumberOfCycles { get; set; } + public Percent Soh { get; set; } + public List CellVoltage { get; set; } + public TemperaturesList TemperaturesList { get; set; } + // public Dc_ Dc { get; set; } + + public BatteryDeligreenDataRecord(Voltage busVoltage, Current busCurrent ,String fwVersion, Percent soc, UInt16 numberOfCycles, Double batteryCapacity, Double ratedCapacity, Voltage totalBatteryVoltage, Percent soh, Double residualCapacity, List cellVoltage, TemperaturesList temperaturesList) + { + BusVoltage = busVoltage; + BusCurrent = busCurrent; + FwVersion = fwVersion; + TotalBatteryVoltage = totalBatteryVoltage; + ResidualCapacity = residualCapacity; + BatteryCapacity = batteryCapacity; + Soc = soc; + RatedCapacity = ratedCapacity; + NumberOfCycles = numberOfCycles; + Soh = soh; + CellVoltage = cellVoltage; + TemperaturesList = temperaturesList; + Power = busVoltage * busCurrent; + } + + // public struct Dc_ + // { + // public Voltage Voltage => BusVoltage; + // public Current Current => BusCurrent; + // public ActivePower Power => BusVoltage * BusCurrent; + + // } +} \ No newline at end of file diff --git a/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenDevice.cs b/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenDevice.cs index 2b590d796..5bd7118ff 100644 --- a/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenDevice.cs +++ b/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenDevice.cs @@ -1,40 +1,65 @@ -namespace InnovEnergy.Lib.Devices.BatteryDeligreen; - -using System; using System.IO.Ports; +using InnovEnergy.Lib.Utils; +namespace InnovEnergy.Lib.Devices.BatteryDeligreen; public class BatteryDeligreenDevice { - private const Parity Parity = System.IO.Ports.Parity.None; + private const Parity Parity = System.IO.Ports.Parity.None; private const StopBits StopBits = System.IO.Ports.StopBits.One; - private const Int32 BaudRate = 19200; - private const Int32 DataBits = 8; + private const Int32 BaudRate = 19200; + private const Int32 DataBits = 8; private readonly SerialPort _serialPort; - // Constructor for local serial port connection - public BatteryDeligreenDevice(String tty) - { - _serialPort = new SerialPort(tty, BaudRate, Parity, DataBits, StopBits) - { - ReadTimeout = 1000, // 1 second timeout for reads - WriteTimeout = 1000 // 1 second timeout for writes - }; + public UInt16 SlaveId { get; } - try + // Dynamically construct the frame to send + private const String FrameStart = "7E"; // Starting of the frame + private const String Version = "3230"; // Protocol version + private const String DeviceCode = "3436"; // Device Code + private const String TelemetryFunctionCode = "3432"; + private const String TelcommandFunctionCode = "3434"; + + private static SerialPort _sharedSerialPort; + + private static SerialPort GetSharedPort(string tty) + { + if (_sharedSerialPort == null) { - // Open the serial port - _serialPort.Open(); - Console.WriteLine("Serial port opened successfully."); + _sharedSerialPort = new SerialPort(tty, BaudRate, Parity, DataBits, StopBits) + { + ReadTimeout = 1000, // 1 second timeout for reads + WriteTimeout = 1000 // 1 second timeout for writes + }; + try + { + // Open the shared serial port + _sharedSerialPort.Open(); + Console.WriteLine("Shared Serial Port opened successfully."); + } + catch (Exception e) + { + Console.WriteLine(e); + } } - catch (Exception e) - { - Console.WriteLine(e); - } - + + return _sharedSerialPort; } - + + public static void CloseSharedPort() + { + _sharedSerialPort?.Close(); + Console.WriteLine("Shared Serial Port closed."); + } + + // Constructor for local serial port connection + public BatteryDeligreenDevice(String tty, UInt16 slaveId) + { + SlaveId = slaveId; + _serialPort = GetSharedPort(tty); + } + // Method to send data to the device private void Write(String hexCommand) { @@ -45,7 +70,7 @@ public class BatteryDeligreenDevice // Send the command _serialPort.Write(commandBytes, 0, commandBytes.Length); - Console.WriteLine("Command sent successfully."); + //Console.WriteLine("Write Command sent successfully."); } catch (TimeoutException) { @@ -67,7 +92,7 @@ public class BatteryDeligreenDevice var buffer = new Byte[bufferSize]; var bytesRead = _serialPort.Read(buffer, 0, bufferSize); - Console.WriteLine($"Read {bytesRead} bytes from the device."); + //Console.WriteLine($"Read {bytesRead} bytes from the device."); // Return only the received bytes var responseData = new Byte[bytesRead]; @@ -90,7 +115,7 @@ public class BatteryDeligreenDevice { return BitConverter.ToString(byteArray).Replace("-", "").ToUpper(); } - + // Helper method to convert a hex string to a byte array private static Byte[] HexStringToByteArray(string hex) { @@ -102,6 +127,7 @@ public class BatteryDeligreenDevice { bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); } + return bytes; } @@ -114,29 +140,37 @@ public class BatteryDeligreenDevice Console.WriteLine("Serial port closed."); } } - - // Read telemetry data from the connected device - public async Task ReadTelemetryData() - { - const String frameToSend = "7E3230303034363432453030323030464433370D"; // Example custom frame + // Read telemetry data from the connected device + private async Task ReadTelemetryData(UInt16 batteryId) + { + String frameToSend = batteryId switch + { + 0 => "7E3230303034363432453030323030464433370D", + 1 => "7E3230303134363432453030323031464433350D", + 2 => "7E3230303234363432453030323032464433330D", + 3 => "7E3230303334363432453030323033464433310D", + 4 => "7E3230303434363432453030323034464432460D", + 5 => "7E3230303534363432453030323035464432440D", + 6 => "7E3230303634363432453030323036464432420D", + 7 => "7E3230303734363432453030323037464432390D", + 8 => "7E3230303834363432453030323038464432370D", + 9 => "7E3230303934363432453030323039464432350D", + _ => "0" + }; + // var frameToSend = ConstructFrameToSend(batteryId,TelemetryFunctionCode); + try { // Write the frame to the channel (send it to the device) - await Task.Run(() => Write(frameToSend)); - + Write(frameToSend); // Read the response from the channel (assuming max response size) - var responseBytes = await Task.Run(() => Read(1024)); // Assuming Read can be executed asynchronously + var responseBytes = await ReadFullResponse(168, 64); // Convert the byte array to a hexadecimal string var responseHex = BytesToHexString(responseBytes); - - new TelemetryFrameParser().ParsingTelemetryFrame(responseHex); - - // Parse the ASCII response (you can implement any custom parsing logic) - var responseData = ParseAsciiResponse(responseBytes.ToArray()); - return responseData; + return new TelemetryFrameParser().ParsingTelemetryFrame(responseHex); } catch (Exception ex) { @@ -145,34 +179,83 @@ public class BatteryDeligreenDevice } } - public Byte[] ReadTelecomandData() + private Task ReadFullResponse(Int32 totalBytes, Int32 chunkSize) { - const String frameToSend = "7E3230303034363434453030323030464433350D"; // Example custom frame - - // Write the frame to the channel (send it to the device) - Write(frameToSend); + var responseBuffer = new List(); + while (responseBuffer.Count < totalBytes) + { + // Calculate how many more bytes need to be read + var bytesToRead = Math.Min(chunkSize, totalBytes - responseBuffer.Count); + var chunk = Read(bytesToRead); - // Read the response from the channel (assuming max response size) - var responseBytes = Read(1024); // Adjust this size if needed + if (chunk.Length == 0) + { + throw new TimeoutException("Failed to read the expected number of bytes from the device."); + } - // Parse the ASCII response (you can implement any custom parsing logic) - var responseData = ParseAsciiResponse(responseBytes.ToArray()); + responseBuffer.AddRange(chunk); + } - return responseData; + return Task.FromResult(responseBuffer.ToArray()); } - // Helper method to parse the ASCII response (you can add any parsing logic here) - private static byte[] ParseAsciiResponse(byte[] responseBytes) + private async Task ReadTelecomandData(UInt16 batteryId) { - Console.WriteLine($"Last Timestamp: {DateTime.Now:HH:mm:ss.fff}"); - // Convert the byte array to a hex string for display - var hexResponse = BitConverter.ToString(responseBytes).Replace("-", " "); - //Console.WriteLine($"Response (Hex): {hexResponse}"); - // Implement custom parsing logic if necessary based on the protocol's frame - // For now, we return the raw response bytes - return responseBytes; + var frameToSend = batteryId switch + { + 0 => "7E3230303034363434453030323030464433350D", + 1 => "7E3230303134363434453030323031464433330D", + 2 => "7E3230303234363434453030323032464433310D", + 3 => "7E3230303334363434453030323033464432460D", + 4 => "7E3230303434363434453030323034464432440D", + 5 => "7E3230303534363434453030323035464432420D", + 6 => "7E3230303634363434453030323036464432390D", + 7 => "7E3230303734363434453030323037464432370D", + 8 => "7E3230303834363434453030323038464432350D", + 9 => "7E3230303934363434453030323039464432330D", + _ => "0" + }; + try + { + // Write the frame to the channel (send it to the device) + Write(frameToSend); + // await Task.Delay(delayFrame2); + // Read the response from the channel (assuming max response size) + var responseBytes = await ReadFullResponse(116, 64); // Assuming Read can be executed asynchronously + // Convert the byte array to a hexadecimal string + var responseHex = BytesToHexString(responseBytes); + + var response = new TelecommandFrameParser().ParsingTelecommandFrame(responseHex); + + return new BatteryDeligreenAlarmRecord(); + } + catch (Exception ex) + { + Console.WriteLine($"Error during Telecomnd data retrieval: {ex.Message}"); + throw; + } } -} + public async Task Reads() + { + var dataRecord = ReadTelemetryData(SlaveId).Result; + var alarmRecord = ReadTelecomandData(SlaveId).Result; + await Task.Delay(5); // looks like this is need. A time delay needed between each frame to send to each battery + return dataRecord != null ? new BatteryDeligreenRecord(dataRecord, alarmRecord) : null; + } + private static String ConstructFrameToSend(UInt16 batteryId, String functionCode) + { + // Convert batteryId to a 2-character ASCII string + var batteryIdAscii = $"{(Char)(batteryId / 10 + '0')}{(char)(batteryId % 10 + '0')}"; + var batteryIdHex = string.Concat(batteryIdAscii.Select(c => ((Int32)c).ToString("X2"))); + Console.WriteLine("Battery ID " + batteryIdHex); + + var frameToSend = + FrameStart + Version + batteryIdHex + DeviceCode + functionCode + + "453030323030464433370D"; // Example custom frame with dynamic batteryId + Console.WriteLine(frameToSend); + return frameToSend; + } +} \ No newline at end of file diff --git a/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenDevices.cs b/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenDevices.cs new file mode 100644 index 000000000..c2ec5d6d0 --- /dev/null +++ b/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenDevices.cs @@ -0,0 +1,35 @@ +using InnovEnergy.Lib.Utils; + +namespace InnovEnergy.Lib.Devices.BatteryDeligreen; + +public class BatteryDeligreenDevices +{ + private readonly IReadOnlyList _devices; + + public BatteryDeligreenDevices(IReadOnlyList devices) => _devices = devices; + + public BatteryDeligreenRecords? Read() + { + var records = _devices + .Select(TryRead) + .NotNull() + .ToList(); + + return BatteryDeligreenRecords.FromBatteries(records); + } + + private static BatteryDeligreenRecord? TryRead(BatteryDeligreenDevice d) + { + try + { + return d.Reads().Result; + } + catch (Exception e) + { + Console.WriteLine($"Failed to read Battery node {d.SlaveId}\n{e.Message}"); + // TODO: log + + return null; + } + } +} \ No newline at end of file diff --git a/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenRecord.cs b/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenRecord.cs new file mode 100644 index 000000000..d26c53924 --- /dev/null +++ b/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenRecord.cs @@ -0,0 +1,13 @@ +namespace InnovEnergy.Lib.Devices.BatteryDeligreen; + +public class BatteryDeligreenRecord +{ + public readonly BatteryDeligreenDataRecord BatteryDeligreenDataRecord; + public readonly BatteryDeligreenAlarmRecord BatteryDeligreenAlarmRecord; + + public BatteryDeligreenRecord(BatteryDeligreenDataRecord batteryDeligreenDataRecord, BatteryDeligreenAlarmRecord batteryDeligreenAlarmRecord) + { + BatteryDeligreenDataRecord = batteryDeligreenDataRecord; + BatteryDeligreenAlarmRecord = batteryDeligreenAlarmRecord; + } +} diff --git a/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenRecords.cs b/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenRecords.cs new file mode 100644 index 000000000..3dbcf9245 --- /dev/null +++ b/csharp/Lib/Devices/BatteryDeligreen/BatteryDeligreenRecords.cs @@ -0,0 +1,46 @@ +using InnovEnergy.Lib.Units; +using InnovEnergy.Lib.Units.Composite; +using InnovEnergy.Lib.Units.Power; + +namespace InnovEnergy.Lib.Devices.BatteryDeligreen; + +public class BatteryDeligreenRecords +{ + //public required DcBus Dc { get; init; } + public required Current Current { get; init; } + public required Voltage Voltage { get; init; } + public required Percent Soc { get; init; } + public required Double Soh { get; init; } + public required Percent CurrentMinSoc { get; init; } + public required Temperature TemperatureCell1 { get; init; } + public required Double Power { get; init; } + + // public required Temperature TemperatureCell2 { get; init; } + // public required Temperature TemperatureCell3 { get; init; } + // public required Temperature TemperatureCell4 { get; init; } + // to continue other temperature + + public required IReadOnlyList Devices { get; init; } + + public static BatteryDeligreenRecords? FromBatteries(IReadOnlyList? records) + { + if (records is null || records.Count == 0) + { + Console.WriteLine("FromBatteries: either record is null or empty"); + return null; + } + + return new BatteryDeligreenRecords + { + Devices = records, + Soc = records.Average(r => r.BatteryDeligreenDataRecord.Soc.Value), + Soh = records.Average(r => r.BatteryDeligreenDataRecord.Soh), + CurrentMinSoc = records.Min(r => r.BatteryDeligreenDataRecord.Soc.Value), + TemperatureCell1 = records.Average(b => b.BatteryDeligreenDataRecord.TemperaturesList.CellTemperature1), + Current = records.Sum(r =>r.BatteryDeligreenDataRecord.BusCurrent), + Voltage = records.Average(r =>r.BatteryDeligreenDataRecord.BusVoltage), + Power = records.Sum(r => r.BatteryDeligreenDataRecord.Power), + + }; + } +} \ No newline at end of file diff --git a/csharp/Lib/Devices/BatteryDeligreen/Doc/retrieve Telecommand.py b/csharp/Lib/Devices/BatteryDeligreen/Doc/retrieve Telecommand.py new file mode 100644 index 000000000..617e6f724 --- /dev/null +++ b/csharp/Lib/Devices/BatteryDeligreen/Doc/retrieve Telecommand.py @@ -0,0 +1,480 @@ +import serial +import csv + +TELECOMMAND_FILE_PATH = "Telecommand_Return_Record.csv" + +# Table 3 +CID1_DEVICE_CODES = { + "46": "Lithium iron phosphate battery BMS", +} + +# Table 4 +CID2_COMMAND_CODES = { + "42": "Acquisition of telemetering information", + "44": "Acquisition of telecommand information", + "45": "Telecontrol command", + "47": "Acquisition of teleregulation information", + "49": "Setting of teleregulation information", + "4F": "Acquisition of the communication protocol version number", + "51": "Acquisition of device vendor information", + "4B": "Acquisition of historical data", + "4D": "Acquisition time", + "4E": "Synchronization time", + "A0": "Production calibration", + "A1": "Production setting", + "A2": "Regular recording" +} + +# Table 5 +CID2_RETURN_CODES = { + "00": "Normal", + "01": "VER error", + "02": "CHKSUM error", + "03": "LCHKSUM error", + "04": "CID2 invalid", + "05": "Command format error", + "06": "Data invalid (parameter setting)", + "07": "No data (history)", + "E1": "CID1 invalid", + "E2": "Command execution failure", + "E3": "Device fault", + "E4": "Invalid permissions" + } + + +# Table 12 +BYTE_ALARM_CODES = { + "00": "Normal, no alarm", + "01": "Alarm that analog quantity reaches the lower limit", + "02": "Alarm that analog quantity reaches the upper limit", + "F0": "Other alarms" +} + +# Table 13 +BIT_ALARM_CODES = { + "Alarm event 1": ( + "Voltage sensor fault", + "Temperature sensor fault", + "Current sensor fault", + "Key switch fault", + "Cell voltage dropout fault", + "Charge switch fault", + "Discharge switch fault", + "Current limit switch fault" + ), + "Alarm event 2": ( + "Monomer high voltage alarm", + "Monomer overvoltage protection", + "Monomer low voltage alarm", + "Monomer under voltage protection", + "High voltage alarm for total voltage", + "Overvoltage protection for total voltage", + "Low voltage alarm for total voltage", + "Under voltage protection for total voltage" + ), + "Alarm event 3": ( + "Charge high temperature alarm", + "Charge over temperature protection", + "Charge low temperature alarm", + "Charge under temperature protection", + "Discharge high temperature alarm", + "Discharge over temperature protection", + "Discharge low temperature alarm", + "Discharge under temperature protection" + ), + "Alarm event 4": ( + "Environment high temperature alarm", + "Environment over temperature protection", + "Environment low temperature alarm", + "Environment under temperature protection", + "Power over temperature protection", + "Power high temperature alarm", + "Cell low temperature heating", + "Reservation bit" + ), + "Alarm event 5": ( + "Charge over current alarm", + "Charge over current protection", + "Discharge over current alarm", + "Discharge over current protection", + "Transient over current protection", + "Output short circuit protection", + "Transient over current lockout", + "Output short circuit lockout" + ), + "Alarm event 6": ( + "Charge high voltage protection", + "Intermittent recharge waiting", + "Residual capacity alarm", + "Residual capacity protection", + "Cell low voltage charging prohibition", + "Output reverse polarity protection", + "Output connection fault", + "Inside bit" + ), + "On-off state": ( + "Discharge switch state", + "Charge switch state", + "Current limit switch state", + "Heating switch state", + "Reservation bit", + "Reservation bit", + "Reservation bit", + "Reservation bit" + ), + "Equilibrium state 1": ( + "Cell 01 equilibrium", + "Cell 02 equilibrium", + "Cell 03 equilibrium", + "Cell 04 equilibrium", + "Cell 05 equilibrium", + "Cell 06 equilibrium", + "Cell 07 equilibrium", + "Cell 08 equilibrium" + ), + "Equilibrium state 2": ( + "Cell 09 equilibrium", + "Cell 10 equilibrium", + "Cell 11 equilibrium", + "Cell 12 equilibrium", + "Cell 13 equilibrium", + "Cell 14 equilibrium", + "Cell 15 equilibrium", + "Cell 16 equilibrium" + ), + "System state": ( + "Discharge", + "Charge", + "Floating charge", + "Reservation bit", + "Standby", + "Shutdown", + "Reservation bit", + "Reservation bit" + ), + "Disconnection state 1": ( + "Cell 01 disconnection", + "Cell 02 disconnection", + "Cell 03 disconnection", + "Cell 04 disconnection", + "Cell 05 disconnection", + "Cell 06 disconnection", + "Cell 07 disconnection", + "Cell 08 disconnection" + ), + "Disconnection state 2": ( + "Cell 09 disconnection", + "Cell 10 disconnection", + "Cell 11 disconnection", + "Cell 12 disconnection", + "Cell 13 disconnection", + "Cell 14 disconnection", + "Cell 15 disconnection", + "Cell 16 disconnection" + ), + "Alarm event 7": ( + "Inside bit", + "Inside bit", + "Inside bit", + "Inside bit", + "Automatic charging waiting", + "Manual charging waiting", + "Inside bit", + "Inside bit" + ), + "Alarm event 3": ( + "EEP storage fault", + "RTC error", + "Voltage calibration not performed", + "Current calibration not performed", + "Zero calibration not performed", + "Inside bit", + "Inside bit", + "Inside bit" + ), +} + + +def parse_start_code(frame): + soi = frame[0:1] + if soi == "~": + return "ok!" + else: + raise ValueError(f"Invalid start identifier! ({soi})") + +def parse_version_code(frame): + ver = frame[1:3] + return f"Protocol Version V{ver[0]}.{ver[1]}" + +def parse_address_code(frame): + adr = frame[3:5] + if 0 <= int(adr) <= 15: + return adr + else: + raise ValueError(f"Invalid address: {adr} (out of range 0-15)") + +def parse_device_code(frame): + cid1 = frame[5:7] + return CID1_DEVICE_CODES.get(cid1, "Unknown!") + +def parse_function_code(frame): + cid2 = frame[7:9] + if cid2 in CID2_COMMAND_CODES: + return f"Command -> {CID2_COMMAND_CODES.get(cid2)}" + elif cid2 in CID2_RETURN_CODES: + return f"Return -> {CID2_RETURN_CODES.get(cid2)}" + else: + return f"Unknown CID2: {cid2}" + +def parse_lchksum(length_code): + # implements chapter 3.2.2 of the Protocol Specification + lchksum = int(length_code[0], 16) + # Compute lchksum + d11d10d09d08 = int(length_code[1]) + d07d06d05d04 = int(length_code[2]) + d03d0ld01d00 = int(length_code[3]) + sum = d11d10d09d08 + d07d06d05d04 + d03d0ld01d00 + remainder = sum % 16 + inverted = ~remainder & 0xF + computed_lchksum = (inverted + 1) & 0xF + if computed_lchksum == lchksum: + return "ok!" + else: + raise ValueError(f"Invalid LCHKSUM: {lchksum} (computed: {computed_lchksum})") + +def parse_lenid(length_code): + # implements chapter 3.2.1 of the Protocol Specification + d11d10d09d08 = int(length_code[1]) + d07d06d05d04 = int(length_code[2]) + d03d0ld01d00 = int(length_code[3]) + lenid = d11d10d09d08 << 8 | d07d06d05d04 << 4 | d03d0ld01d00 + return lenid >> 1 + +def parse_length_code(frame): + # implements chapter 3.2 of the Protocol Specification + length_code = frame[9:13] + lchksum = parse_lchksum(length_code) + lenid = parse_lenid(length_code) + return { "LCHKSUM": lchksum, "LENID": lenid } + +def parse_info(frame): + cid2 = frame[7:9] + lenid = parse_lenid(frame[9:13]) + info = frame[13:13+lenid*2] + + if cid2 == '00' and lenid == 49: + return parse_telecommand_return(info) + elif cid2 == '00' and lenid == 75: + return parse_telemetry_return(info) + else: + return info + +def parse_telecommand_return(info_raw, info={}, index=0): + + info["DATA FLAG"] = info_raw[index:index+2] + index += 2 + + info["COMMAND GROUP"] = info_raw[index:index+2] + index += 2 + + num_of_cells = int(info_raw[index:index+2], 16) + info["Number of cells"] = num_of_cells + index += 2 + + for cell in range(info["Number of cells"]): + alarm = BYTE_ALARM_CODES.get(info_raw[index:index+2]) + info[f"Cell {cell +1} alarm"] = alarm + index += 2 + + num_of_temperatures = int(info_raw[index:index+2], 16) + info["Number of temperatures"] = num_of_temperatures + index += 2 + + for sensor in range(4): + alarm = BYTE_ALARM_CODES.get(info_raw[index:index+2]) + info[f"Cell temperature alarm {sensor}"] = alarm + index += 2 + + alarm = BYTE_ALARM_CODES.get(info_raw[index:index+2]) + info["Environment temperature alarm"] = alarm + index += 2 + + alarm = BYTE_ALARM_CODES.get(info_raw[index:index+2]) + info["Power temperature alarm 1"] = alarm + index += 2 + + alarm = BYTE_ALARM_CODES.get(info_raw[index:index+2]) + info["Charge/discharge current alarm"] = alarm + index += 2 + + alarm = BYTE_ALARM_CODES.get(info_raw[index:index+2]) + info["Total battery voltage alarm"] = alarm + index += 2 + + num_custom = int(info_raw[index:index+2], 16) + info["Number of custom alarms"] = num_custom + index += 2 + + alarm = info_raw[index:index+2] + info["Alarm event 1"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Alarm event 2"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Alarm event 3"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Alarm event 4"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Alarm event 5"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Alarm event 6"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["On-off state"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Equilibrium state 1"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Equilibrium state 2"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["System state"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Disconnection state 1"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Disconnection state 2"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Alarm event 7"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Alarm event 8"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Reservation extention 1"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Reservation extention 2"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Reservation extention 3"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Reservation extention 4"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Reservation extention 5"] = alarm + index += 2 + + alarm = info_raw[index:index+2] + info["Reservation extention 6"] = alarm + index += 2 + + save_dict_to_csv(TELECOMMAND_FILE_PATH, info) + return f"Telecommand Return Data saved in ./{TELECOMMAND_FILE_PATH}" + + +def save_dict_to_csv(file_path, data): + with open(file_path, mode='a+', newline='') as csvfile: + csvfile.seek(0) + has_header = csvfile.read(1) != "" + csvfile.seek(0, 2) + writer = csv.DictWriter(csvfile, fieldnames=data.keys()) + if not has_header: + writer.writeheader() + writer.writerow(data) + +def parse_checksum(frame): + """implements section 3.3 of the Protocol Specification""" + chksum = int(frame[-6:-1], 16) + data = frame[1:-5] + # Compute chksum + ascii_sum = sum(ord(char) for char in data) + remainder = ascii_sum % 65536 + inverted = ~remainder & 0xFFFF + computed_chksum = (inverted + 1) & 0xFFFF + # Compare with CHKSUM in frame + if computed_chksum == chksum: + return "ok!" + else: + raise ValueError(f"Invalid CHKSUM: {chksum} (computed: {computed_chksum})") + +def parse_end_code(frame): + eoi = frame[-1] + if eoi == "\r": + return "ok!" + else: + raise ValueError(f"Invalid end identifier! ({eoi})") + +def parse_modbus_ascii_frame(frame, parsed_data = {}): + frame = bytes.fromhex(frame).decode('ascii') + parsed_data["SOI"] = parse_start_code(frame) + parsed_data["VER"] = parse_version_code(frame) + parsed_data["ADR"] = parse_address_code(frame) + parsed_data["CID1"] = parse_device_code(frame) + parsed_data["CID2"] = parse_function_code(frame) + parsed_data["LENGTH"] = parse_length_code(frame) + parsed_data["INFO"] = parse_info(frame) + parsed_data["CHKSUM"] = parse_checksum(frame) + parsed_data["EOI"] = parse_end_code(frame) + return parsed_data + +def send_command(): + + # Define the serial port and baud rate + port = 'COM9' # Replace with your actual port + baudrate = 19200 # Replace with the correct baud rate for your BMS + + # Create the serial connection + try: + with serial.Serial(port, baudrate, timeout=1) as ser: + # Convert the hex string to bytes + command = bytes.fromhex("7E3230303034363434453030323030464433350D") + + # Send the command + ser.write(command) + print("Command sent successfully.") + + # Wait for and read the response + response = ser.read(200) # Adjust the number of bytes to read as needed + if response: + hex_response = response.hex() + print("Response received:", hex_response) + # Process the response to check details + parsed_result = parse_modbus_ascii_frame(hex_response) + for key, value in parsed_result.items(): + print(f"{key}: {value}") + else: + print("No response received.") + except serial.SerialException as e: + print(f"Error opening serial port: {e}") + except Exception as e: + print(f"An unexpected error occurred: {e}") + +if __name__ == "__main__": + send_command() diff --git a/csharp/Lib/Devices/BatteryDeligreen/Doc/retrieve Telemetry.py b/csharp/Lib/Devices/BatteryDeligreen/Doc/retrieve Telemetry.py new file mode 100644 index 000000000..33c38b5d1 --- /dev/null +++ b/csharp/Lib/Devices/BatteryDeligreen/Doc/retrieve Telemetry.py @@ -0,0 +1,277 @@ +import serial + +def check_starting_byte_and_extract_details(response): + # Ensure the response is a valid hex string + if not response or len(response) < 38 + (16 * 8) + 4 + (8 * 14) + 4: # Update minimum length check + print("Response is too short to contain valid data.") + return + + # Extract the first byte and check if it's '7E' + starting_byte = response[:2] + if starting_byte.upper() == "7E": + print(f"Starting byte: {starting_byte} (Hex)") + else: + print(f"Incorrect starting byte: {starting_byte}") + return + + # Extract the next two bytes for the firmware version + version_bytes = response[2:6] + try: + version_ascii = bytes.fromhex(version_bytes).decode('ascii') + print(f"Firmware version: {version_bytes} (Hex), ASCII: {version_ascii}") + except ValueError: + print(f"Failed to decode firmware version from bytes: {version_bytes}") + return + + # Extract the next two bytes for the address + address_bytes = response[6:10] + try: + address_ascii = bytes.fromhex(address_bytes).decode('ascii') + address_decimal = int(address_ascii, 16) + print(f"Device Address: {address_bytes} (Hex), ASCII: {address_ascii}, Decimal: {address_decimal}") + except ValueError: + print(f"Failed to decode device address from bytes: {address_bytes}") + return + + # Extract the next two bytes for CID1 (Device Code) + cid1_bytes = response[10:14] + try: + cid1_ascii = bytes.fromhex(cid1_bytes).decode('ascii') + cid1_decimal = int(cid1_ascii, 16) + print(f"Device Code (CID1): {cid1_bytes} (Hex), ASCII: {cid1_ascii}, Decimal: {cid1_decimal}") + except ValueError: + print(f"Failed to decode device code from bytes: {cid1_bytes}") + + # Extract the next two bytes for the Function Code + function_code_bytes = response[14:18] + try: + function_code_ascii = bytes.fromhex(function_code_bytes).decode('ascii') + function_code_decimal = int(function_code_ascii, 16) + print(f"Function Code: {function_code_bytes} (Hex), ASCII: {function_code_ascii}, Decimal: {function_code_decimal}") + except ValueError: + print(f"Failed to decode function code from bytes: {function_code_bytes}") + + # Extract the next 4 bytes for the Length Code + length_code_bytes = response[18:26] + try: + length_ascii = bytes.fromhex(length_code_bytes).decode('ascii') + length_decimal = int(length_ascii, 16) + print(f"Length Code: {length_code_bytes} (Hex), ASCII: {length_ascii}, Decimal: {length_decimal}") + except ValueError: + print(f"Failed to decode length code from bytes: {length_code_bytes}") + + # Extract the next 2 bytes for the Data Flag + data_flag_bytes = response[26:30] + try: + data_flag_ascii = bytes.fromhex(data_flag_bytes).decode('ascii') + data_flag_decimal = int(data_flag_ascii, 16) + print(f"Data Flag: {data_flag_bytes} (Hex), ASCII: {data_flag_ascii}, Decimal: {data_flag_decimal}") + except ValueError: + print(f"Failed to decode data flag from bytes: {data_flag_bytes}") + + # Extract the next 2 bytes for the Command Group + command_group_bytes = response[30:34] + try: + command_group_ascii = bytes.fromhex(command_group_bytes).decode('ascii') + command_group_decimal = int(command_group_ascii, 16) + print(f"Command Group: {command_group_bytes} (Hex), ASCII: {command_group_ascii}, Decimal: {command_group_decimal}") + except ValueError: + print(f"Failed to decode command group from bytes: {command_group_bytes}") + + # Extract the next 2 bytes for the Number of Cells + num_cells_bytes = response[34:38] + try: + num_cells_ascii = bytes.fromhex(num_cells_bytes).decode('ascii') + num_cells_decimal = int(num_cells_ascii, 16) + print(f"Number of Cells: {num_cells_bytes} (Hex), ASCII: {num_cells_ascii}, Decimal: {num_cells_decimal}") + except ValueError: + print(f"Failed to decode number of cells from bytes: {num_cells_bytes}") + + # Extract and process the voltages for all 16 cells + for cell_index in range(16): + start = 38 + (cell_index * 8) + end = start + 8 + cell_voltage_bytes = response[start:end] + try: + cell_voltage_ascii = bytes.fromhex(cell_voltage_bytes).decode('ascii') + cell_voltage_decimal = int(cell_voltage_ascii, 16) / 1000.0 # Convert to volts + print(f"Voltage of Cell {cell_index + 1}: {cell_voltage_bytes} (Hex), ASCII: {cell_voltage_ascii}, Voltage: {cell_voltage_decimal:.3f} V") + except ValueError: + print(f"Failed to decode Voltage of Cell {cell_index + 1} from bytes: {cell_voltage_bytes}") + + # Extract the number of temperature sensors (4 hex bytes) + num_temp_start = 38 + (16 * 8) + num_temp_end = num_temp_start + 4 + num_temp_bytes = response[num_temp_start:num_temp_end] + try: + num_temp_ascii = bytes.fromhex(num_temp_bytes).decode('ascii') + num_temp_decimal = int(num_temp_ascii, 16) + print(f"Number of Temperature Sensors: {num_temp_bytes} (Hex), ASCII: {num_temp_ascii}, Decimal: {num_temp_decimal}") + except ValueError: + print(f"Failed to decode number of temperature sensors from bytes: {num_temp_bytes}") + + # Extract and process additional temperature and battery information + current_index = num_temp_end + + # Cell Temperature 1 + for temp_index in range(1, 5): + temp_bytes = response[current_index:current_index + 8] + try: + temp_ascii = bytes.fromhex(temp_bytes).decode('ascii') + temp_decimal = (int(temp_ascii, 16)- 2731 )/ 10.0 # Convert to Celsius + print(f"Cell Temperature {temp_index}: {temp_bytes} (Hex), ASCII: {temp_ascii}, Temperature: {temp_decimal:.2f} °C") + except ValueError: + print(f"Failed to decode Cell Temperature {temp_index} from bytes: {temp_bytes}") + current_index += 8 + + # Environment Temperature + env_temp_bytes = response[current_index:current_index + 8] + try: + env_temp_ascii = bytes.fromhex(env_temp_bytes).decode('ascii') + env_temp_decimal = (int(env_temp_ascii, 16) - 2731 )/ 10.0 + print(f"Environment Temperature: {env_temp_bytes} (Hex), ASCII: {env_temp_ascii}, Temperature: {env_temp_decimal:.2f} °C") + except ValueError: + print(f"Failed to decode Environment Temperature from bytes: {env_temp_bytes}") + current_index += 8 + + # Power Temperature + power_temp_bytes = response[current_index:current_index + 8] + try: + power_temp_ascii = bytes.fromhex(power_temp_bytes).decode('ascii') + power_temp_decimal = (int(power_temp_ascii, 16)- 2731 )/ 10.0 + print(f"Power Temperature: {power_temp_bytes} (Hex), ASCII: {power_temp_ascii}, Temperature: {power_temp_decimal:.2f} °C") + except ValueError: + print(f"Failed to decode Power Temperature from bytes: {power_temp_bytes}") + current_index += 8 + + # Charge/Discharge Current + current_bytes = response[current_index:current_index + 8] + try: + current_ascii = bytes.fromhex(current_bytes).decode('ascii') + current_decimal = int(current_ascii, 16) / 100.0 + print(f"Charge/Discharge Current: {current_bytes} (Hex), ASCII: {current_ascii}, Current: {current_decimal:.3f} A") + except ValueError: + print(f"Failed to decode Charge/Discharge Current from bytes: {current_bytes}") + current_index += 8 + + # Total Battery Voltage + total_voltage_bytes = response[current_index:current_index + 8] + try: + total_voltage_ascii = bytes.fromhex(total_voltage_bytes).decode('ascii') + total_voltage_decimal = int(total_voltage_ascii, 16) / 100.0 + print(f"Total Battery Voltage: {total_voltage_bytes} (Hex), ASCII: {total_voltage_ascii}, Voltage: {total_voltage_decimal:.3f} V") + except ValueError: + print(f"Failed to decode Total Battery Voltage from bytes: {total_voltage_bytes}") + current_index += 8 + + # Residual Capacity + residual_capacity_bytes = response[current_index:current_index + 8] + try: + residual_capacity_ascii = bytes.fromhex(residual_capacity_bytes).decode('ascii') + residual_capacity_decimal = int(residual_capacity_ascii, 16) / 100.0 + print(f"Residual Capacity: {residual_capacity_bytes} (Hex), ASCII: {residual_capacity_ascii}, Capacity: {residual_capacity_decimal:.3f} Ah") + except ValueError: + print(f"Failed to decode Residual Capacity from bytes: {residual_capacity_bytes}") + current_index += 8 + + # Custom Number + custom_number_bytes = response[current_index:current_index + 4] + try: + custom_number_ascii = bytes.fromhex(custom_number_bytes).decode('ascii') + custom_number_decimal = int(custom_number_ascii, 16) + print(f"Custom Number: {custom_number_bytes} (Hex), ASCII: {custom_number_ascii}, Decimal: {custom_number_decimal}") + except ValueError: + print(f"Failed to decode Custom Number from bytes: {custom_number_bytes}") + current_index += 4 + + # Battery Capacity + battery_capacity_bytes = response[current_index:current_index + 8] + try: + battery_capacity_ascii = bytes.fromhex(battery_capacity_bytes).decode('ascii') + battery_capacity_decimal = int(battery_capacity_ascii, 16) / 100.0 + print(f"Battery Capacity: {battery_capacity_bytes} (Hex), ASCII: {battery_capacity_ascii}, Capacity: {battery_capacity_decimal:.3f} Ah") + except ValueError: + print(f"Failed to decode Battery Capacity from bytes: {battery_capacity_bytes}") + current_index += 8 + + # SOC + soc_bytes = response[current_index:current_index + 8] + try: + soc_ascii = bytes.fromhex(soc_bytes).decode('ascii') + soc_decimal = int(soc_ascii, 16) / 10.0 + print(f"SOC: {soc_bytes} (Hex), ASCII: {soc_ascii}, SOC: {soc_decimal:.2f}%") + except ValueError: + print(f"Failed to decode SOC from bytes: {soc_bytes}") + current_index += 8 + + # Rated Capacity + rated_capacity_bytes = response[current_index:current_index + 8] + try: + rated_capacity_ascii = bytes.fromhex(rated_capacity_bytes).decode('ascii') + rated_capacity_decimal = int(rated_capacity_ascii, 16) / 100.0 + print(f"Rated Capacity: {rated_capacity_bytes} (Hex), ASCII: {rated_capacity_ascii}, Capacity: {rated_capacity_decimal:.3f} Ah") + except ValueError: + print(f"Failed to decode Rated Capacity from bytes: {rated_capacity_bytes}") + current_index += 8 + + # Number of Cycles + num_cycles_bytes = response[current_index:current_index + 8] + try: + num_cycles_ascii = bytes.fromhex(num_cycles_bytes).decode('ascii') + num_cycles_decimal = int(num_cycles_ascii, 16) + print(f"Number of Cycles: {num_cycles_bytes} (Hex), ASCII: {num_cycles_ascii}, Cycles: {num_cycles_decimal}") + except ValueError: + print(f"Failed to decode Number of Cycles from bytes: {num_cycles_bytes}") + current_index += 8 + + # SOH + soh_bytes = response[current_index:current_index + 8] + try: + soh_ascii = bytes.fromhex(soh_bytes).decode('ascii') + soh_decimal = int(soh_ascii, 16) / 10.0 + print(f"SOH: {soh_bytes} (Hex), ASCII: {soh_ascii}, SOH: {soh_decimal:.2f}%") + except ValueError: + print(f"Failed to decode SOH from bytes: {soh_bytes}") + current_index += 8 + + # bus voltage + bus_bytes = response[current_index:current_index + 8] + try: + bus_ascii = bytes.fromhex(bus_bytes).decode('ascii') + bus_decimal = int(bus_ascii, 16) / 100.0 + print(f"bus voltage: {bus_bytes} (Hex), ASCII: {bus_ascii}, bus voltage: {bus_decimal:.2f}V") + except ValueError: + print(f"Failed to decode bus voltage from bytes: {bus_bytes}") + + +def send_command(): + # Define the serial port and baud rate + port = '/dev/ttyUSB0' # Ensure the full path is correct + baudrate = 19200 # Replace with the correct baud rate for your BMS + + # Create the serial connection + try: + with serial.Serial(port, baudrate, timeout=1) as ser: + # Convert the hex string to bytes + command = bytes.fromhex("7E3230303134363432453030323031464433350D") + + # Send the command + ser.write(command) + print("Command sent successfully.") + + # Wait for and read the response + response = ser.read(200) # Adjust the number of bytes to read as needed + if response: + hex_response = response.hex() + print("Response received:", hex_response) + # Process the response to check details + check_starting_byte_and_extract_details(hex_response) + else: + print("No response received.") + except serial.SerialException as e: + print(f"Error opening serial port: {e}") + except Exception as e: + print(f"An unexpected error occurred: {e}") + +if __name__ == "__main__": + send_command() diff --git a/csharp/Lib/Devices/BatteryDeligreen/TelecommandFrameParser.cs b/csharp/Lib/Devices/BatteryDeligreen/TelecommandFrameParser.cs index f50bf70ec..56f32c610 100644 --- a/csharp/Lib/Devices/BatteryDeligreen/TelecommandFrameParser.cs +++ b/csharp/Lib/Devices/BatteryDeligreen/TelecommandFrameParser.cs @@ -1,6 +1,145 @@ +using System.Globalization; + namespace InnovEnergy.Lib.Devices.BatteryDeligreen; +using System; +using System.Collections.Generic; + public class TelecommandFrameParser { + private static Int32 _currentIndex; + private const Int32 FrameLength = 232; + + public Boolean ParsingTelecommandFrame(String response) + { + _currentIndex = 0; // Reset currentIndex to the start + + if (string.IsNullOrEmpty(response) || response.Length < FrameLength) + { + Console.WriteLine("Response is too short to contain valid data."); + Console.WriteLine(" Fixed Length" + FrameLength); + Console.WriteLine(" response Length" + response.Length); + return false; + } + + // Check starting byte + string startingByte = response.Substring(_currentIndex, 2).ToUpper(); + if (startingByte == "7E") + { + // Console.WriteLine($"Starting byte: {startingByte} (Hex)"); + } + else + { + Console.WriteLine($"Incorrect starting byte: {startingByte}"); + return false; + } + + _currentIndex += 2; + + // Extract firmware version + var versionBytes = response.Substring(_currentIndex, 4); + try + { + var versionAscii = HexToAscii(versionBytes); + // Console.WriteLine($"Firmware version: {versionBytes} (Hex), ASCII: {versionAscii}"); + } + catch (Exception) + { + Console.WriteLine($"Failed to decode firmware version from bytes: {versionBytes}"); + return false; + } + + _currentIndex += 4; + + // Extract and parse other fields + ParseAndPrintHexField(response, "Device Address", 4); + ParseAndPrintHexField(response, "Device Code (CID1)", 4); + ParseAndPrintHexField(response, "Function Code", 4); + ParseAndPrintHexField(response, "Length Code", 8); + ParseAndPrintHexField(response, "Data Flag", 4); + ParseAndPrintHexField(response, "Command Group", 4); + ParseAndPrintHexField(response, "Number of Cells", 4); + ExtractCellAlarm(response); + return true; + } + + -} \ No newline at end of file + private static void ExtractCellAlarm(String response) + { + + Dictionary byteAlarmCodes = new Dictionary + { + { "00", "Normal, no alarm" }, + { "01", "Alarm that analog quantity reaches the lower limit" }, + { "02", "Alarm that analog quantity reaches the upper limit" }, + { "F0", "Other alarms" } + }; + + // Process Alarms for all 16 cells + for (var i = 0; i < 16; i++) + { + var cellAlarm = response.Substring(_currentIndex, 4); + try + { + var alarmAscii = HexToAscii(cellAlarm); + var cellVoltageDecimal = HexToDecimal(alarmAscii); + string alarmMessage = byteAlarmCodes.ContainsKey(alarmAscii) ? byteAlarmCodes[alarmAscii] : "Unknown alarm code"; + + // Console.WriteLine($"Cell {i + 1}: Alarm Code {cellAlarm}, Status: {alarmMessage}"); + + } + catch (Exception) + { + Console.WriteLine($"Failed to decode Voltage of Cell {i + 1} from bytes: {cellAlarm}"); + } + _currentIndex += 4; + } + } + + private static void ParseAndPrintHexField(String response, String fieldName, int length) + { + var hexBytes = response.Substring(_currentIndex, length); + try + { + var asciiValue = HexToAscii(hexBytes); + var decimalValue = int.Parse(asciiValue, NumberStyles.HexNumber); + // Console.WriteLine($"{fieldName}: {hexBytes} (Hex), ASCII: {asciiValue}, Decimal: {decimalValue}"); + } + catch (Exception) + { + Console.WriteLine($"Failed to decode {fieldName} from bytes: {hexBytes}"); + } + _currentIndex += length; + } + private static void ParseAndPrintField(String response, String fieldName, Int32 length, Func conversion, String unit) + { + var fieldBytes = response.Substring(_currentIndex, length); + try + { + var fieldAscii = HexToAscii(fieldBytes); + var fieldDecimal = conversion(HexToDecimal(fieldAscii)); + Console.WriteLine($"{fieldName}: {fieldBytes} (Hex), ASCII: {fieldAscii}, {fieldName}: {fieldDecimal:F3} {unit}"); + } + catch (Exception) + { + Console.WriteLine($"Failed to decode {fieldName} from bytes: {fieldBytes}"); + } + _currentIndex += length; + } + + private static String HexToAscii(String hex) + { + var bytes = new Byte[hex.Length / 2]; + for (var i = 0; i < hex.Length; i += 2) + { + bytes[i / 2] = byte.Parse(hex.Substring(i, 2), NumberStyles.HexNumber); + } + return System.Text.Encoding.ASCII.GetString(bytes); + } + + private static double HexToDecimal(String hex) + { + return int.Parse(hex, NumberStyles.HexNumber); + } +} diff --git a/csharp/Lib/Devices/BatteryDeligreen/TelemetryFrameParser.cs b/csharp/Lib/Devices/BatteryDeligreen/TelemetryFrameParser.cs index 8045c4dc8..bdd2e4218 100644 --- a/csharp/Lib/Devices/BatteryDeligreen/TelemetryFrameParser.cs +++ b/csharp/Lib/Devices/BatteryDeligreen/TelemetryFrameParser.cs @@ -1,48 +1,54 @@ using System.Globalization; +using InnovEnergy.Lib.Units; +using InnovEnergy.Lib.Utils; +using static InnovEnergy.Lib.Devices.BatteryDeligreen.BatteryDeligreenDataRecord; +using static InnovEnergy.Lib.Devices.BatteryDeligreen.Temperatures; namespace InnovEnergy.Lib.Devices.BatteryDeligreen; public class TelemetryFrameParser { private static Int32 _currentIndex; - private const Int32 FrameLenght = 286; + private const Int32 FrameLenght = 336; - public void ParsingTelemetryFrame(String response) + public BatteryDeligreenDataRecord? ParsingTelemetryFrame(String response) { - _currentIndex = 0; // Reset currentIndex to the start if (string.IsNullOrEmpty(response) || response.Length < FrameLenght) { Console.WriteLine("Response is too short to contain valid data."); - return; + Console.WriteLine("length " + response.Length); + return null; } // Check starting byte - string startingByte = response.Substring(_currentIndex, 2).ToUpper(); + var startingByte = response.Substring(_currentIndex, 2).ToUpper(); if (startingByte == "7E") { - Console.WriteLine($"Starting byte: {startingByte} (Hex)"); + //Console.WriteLine($"Starting byte: {startingByte} (Hex)"); } else { Console.WriteLine($"Incorrect starting byte: {startingByte}"); - return; + return null; } _currentIndex += 2; // Extract firmware version var versionBytes = response.Substring(_currentIndex, 4); + var versionAscii = ""; try { - String versionAscii = HexToAscii(versionBytes); - Console.WriteLine($"Firmware version: {versionBytes} (Hex), ASCII: {versionAscii}"); + versionAscii = HexToAscii(versionBytes); + // Console.WriteLine($"Firmware version: {versionBytes} (Hex), ASCII: {versionAscii}"); } catch (Exception) { Console.WriteLine($"Failed to decode firmware version from bytes: {versionBytes}"); - return; + return null; } + _currentIndex += 4; // Extract and parse other fields @@ -54,15 +60,54 @@ public class TelemetryFrameParser ParseAndPrintHexField(response, "Command Group", 4); ParseAndPrintHexField(response, "Number of Cells", 4); + var cellVoltages = ExtractCellVoltage(response); + + // Parse other fields + ParseAndPrintHexField(response, "Number of Temperature Sensors", 4); + var cellTemperature = new List(); + + // Parse cell temperatures + for (var i = 1; i <= 4; i++) + { + cellTemperature.Add(ParseAndPrintTemperatureField(response, $"Cell Temperature {i}")); + } + + // Parse other temperature and battery information + var environmentTemp = ParseAndPrintTemperatureField(response, "Environment Temperature"); + var powerTemp = ParseAndPrintTemperatureField(response, "Power Temperature"); + var current = ParseAndPrintField(response, "Charge/Discharge Current" , 8, value => value / 100.0, "A"); + var totalBatteryVoltage = ParseAndPrintField(response, "Total Battery Voltage" , 8, value => value / 100.0, "V"); + var residualCapacity = ParseAndPrintField(response, "Residual Capacity" , 8, value => value / 100.0, "Ah"); + var customNumber = ParseAndPrintHexField(response, "Custom Number" , 4); + var batteryCapacity = ParseAndPrintField(response, "Battery Capacity" , 8, value => value / 100.0, "Ah"); + var soc = ParseAndPrintField(response, "SOC" , 8, value => value / 10.0, "%"); + var ratedCapacity = ParseAndPrintField(response, "Rated Capacity" , 8, value => value / 100.0, "Ah"); + var numberOfCycle = ParseAndPrintHexField(response, "Number of Cycles" , 8); + var soh = ParseAndPrintField(response, "SOH" , 8, value => value / 10.0, "%"); + var busVoltage = ParseAndPrintField(response, "Bus Voltage" , 8, value => value / 100.0, "V"); + + var temperatures = new TemperaturesList(cellTemperature[0], cellTemperature[1], cellTemperature[2], + cellTemperature[3], environmentTemp, powerTemp); + + var batteryRecord = new BatteryDeligreenDataRecord(busVoltage, current, versionAscii, soc, numberOfCycle, batteryCapacity, ratedCapacity, + totalBatteryVoltage, soh, residualCapacity, cellVoltages, temperatures); + + return batteryRecord; + } + + private static List ExtractCellVoltage(String response) + { + var cellVoltages = new List(); // Process voltages for all 16 cells for (var i = 0; i < 16; i++) { - String cellVoltageBytes = response.Substring(_currentIndex, 8); + var cellVoltageBytes = response.Substring(_currentIndex, 8); try { var cellVoltageAscii = HexToAscii(cellVoltageBytes); var cellVoltageDecimal = HexToDecimal(cellVoltageAscii) / 1000.0; // cell voltage are divided 1000 - Console.WriteLine($"Voltage of Cell {i + 1}: {cellVoltageBytes} (Hex), ASCII: {cellVoltageAscii}, Voltage: {cellVoltageDecimal:F3} V"); + cellVoltages.Add(cellVoltageDecimal); + // Console.WriteLine($"Voltage of Cell {i + 1}: {cellVoltageBytes} (Hex), ASCII: {cellVoltageAscii}, Voltage: {cellVoltageDecimal:F3} V"); } catch (Exception) { @@ -70,77 +115,77 @@ public class TelemetryFrameParser } _currentIndex += 8; } - - // Parse other fields - ParseAndPrintHexField(response, "Number of Temperature Sensors", 4); - - // Parse cell temperatures - for (var i = 1; i <= 4; i++) - { - ParseAndPrintTemperatureField(response, $"Cell Temperature {i}"); - } - - // Parse other temperature and battery information - ParseAndPrintTemperatureField(response, "Environment Temperature"); - ParseAndPrintTemperatureField(response, "Power Temperature"); - ParseAndPrintField(response, "Charge/Discharge Current", 8, value => value / 100.0, "A"); - ParseAndPrintField(response, "Total Battery Voltage", 8, value => value / 100.0, "V"); - ParseAndPrintField(response, "Residual Capacity", 8, value => value / 100.0, "Ah"); - ParseAndPrintHexField(response, "Custom Number", 4); - ParseAndPrintField(response, "Battery Capacity", 8, value => value / 100.0, "Ah"); - ParseAndPrintField(response, "SOC", 8, value => value / 10.0, "%"); - ParseAndPrintField(response, "Rated Capacity", 8, value => value / 100.0, "Ah"); - ParseAndPrintHexField(response, "Number of Cycles", 8); - ParseAndPrintField(response, "SOH", 8, value => value / 10.0, "%"); - ParseAndPrintField(response, "Bus Voltage", 8, value => value / 100.0, "V"); + return cellVoltages; } - private static void ParseAndPrintHexField(String response, String fieldName, int length) + private static UInt16 ParseAndPrintHexField(String response, String fieldName, Int32 length) { var hexBytes = response.Substring(_currentIndex, length); + var decimalValue = 0; try { var asciiValue = HexToAscii(hexBytes); - var decimalValue = int.Parse(asciiValue, NumberStyles.HexNumber); - Console.WriteLine($"{fieldName}: {hexBytes} (Hex), ASCII: {asciiValue}, Decimal: {decimalValue}"); + decimalValue = int.Parse(asciiValue, NumberStyles.HexNumber); + // Console.WriteLine($"{fieldName}: {hexBytes} (Hex), ASCII: {asciiValue}, Decimal: {decimalValue}"); } catch (Exception) { Console.WriteLine($"Failed to decode {fieldName} from bytes: {hexBytes}"); } _currentIndex += length; + return (UInt16)decimalValue; + } - private static void ParseAndPrintTemperatureField(String response, String fieldName) + private static Double ParseAndPrintTemperatureField(String response, String fieldName) { var tempBytes = response.Substring(_currentIndex, 8); + var tempDecimal = 0.0; try { var tempAscii = HexToAscii(tempBytes); - var tempDecimal = (HexToDecimal(tempAscii) - 2731) / 10.0; - Console.WriteLine($"{fieldName}: {tempBytes} (Hex), ASCII: {tempAscii}, Temperature: {tempDecimal:F2} °C"); + tempDecimal = (HexToDecimal(tempAscii) - 2731) / 10.0; + // Console.WriteLine($"{fieldName}: {tempBytes} (Hex), ASCII: {tempAscii}, Temperature: {tempDecimal:F2} °C"); } catch (Exception) { Console.WriteLine($"Failed to decode {fieldName} from bytes: {tempBytes}"); } _currentIndex += 8; + return tempDecimal; } - private static void ParseAndPrintField(String response, String fieldName, Int32 length, Func conversion, String unit) + private static Double ParseAndPrintField(String response, String fieldName, Int32 length, Func conversion, String unit) { var fieldBytes = response.Substring(_currentIndex, length); + var value = 0.0; try { var fieldAscii = HexToAscii(fieldBytes); - var fieldDecimal = conversion(HexToDecimal(fieldAscii)); - Console.WriteLine($"{fieldName}: {fieldBytes} (Hex), ASCII: {fieldAscii}, {fieldName}: {fieldDecimal:F3} {unit}"); + var fieldDouble = 0.0; + + + // Convert from Hex to Integer using Two's Complement logic + Int32 intValue = Convert.ToInt16(fieldAscii, 16); + var bitLength = (length/2) * 4; // Each hex digit is 4 bits + var maxPositiveValue = 1 << (bitLength - 1); // 2^(bitLength-1) + + if (intValue >= maxPositiveValue) + { + intValue -= (1 << bitLength); // Apply two's complement conversion + } + + fieldDouble = conversion(intValue); // Store the converted negative value as string + + //Console.WriteLine($"{fieldName}: {fieldBytes} (Hex), ASCII: {fieldAscii}, {fieldName}: {fieldDouble:F3} {unit}"); + value = fieldDouble; } catch (Exception) { Console.WriteLine($"Failed to decode {fieldName} from bytes: {fieldBytes}"); } _currentIndex += length; + return value; } private static String HexToAscii(String hex) @@ -153,7 +198,7 @@ public class TelemetryFrameParser return System.Text.Encoding.ASCII.GetString(bytes); } - private static double HexToDecimal(String hex) + private static Double HexToDecimal(String hex) { return int.Parse(hex, NumberStyles.HexNumber); } diff --git a/csharp/Lib/Devices/BatteryDeligreen/Temperatures.cs b/csharp/Lib/Devices/BatteryDeligreen/Temperatures.cs new file mode 100644 index 000000000..257f14aca --- /dev/null +++ b/csharp/Lib/Devices/BatteryDeligreen/Temperatures.cs @@ -0,0 +1,26 @@ +using InnovEnergy.Lib.Units; + +namespace InnovEnergy.Lib.Devices.BatteryDeligreen; + +public class Temperatures +{ + public struct TemperaturesList + { + public Temperature CellTemperature1 {get;} + public Temperature CellTemperature2 {get;} + public Temperature CellTemperature3 {get;} + public Temperature CellTemperature4 {get;} + public Temperature EnvironmentTemperature {get;} + public Temperature PowerTemperature {get;} + + public TemperaturesList(Temperature cell1, Temperature cell2, Temperature cell3, Temperature cell4, Temperature environment, Temperature power) + { + CellTemperature1 = cell1; + CellTemperature2 = cell2; + CellTemperature3 = cell3; + CellTemperature4 = cell4; + EnvironmentTemperature = environment; + PowerTemperature = power; + } + } +} \ No newline at end of file