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 000000000..776df481c Binary files /dev/null and b/csharp/App/SodiStoreMax/Doc/States_Table.xlsx differ diff --git a/csharp/App/SodiStoreMax/Doc/TransitionToGridTied.graphml b/csharp/App/SodiStoreMax/Doc/TransitionToGridTied.graphml new file mode 100644 index 000000000..b73645cf6 --- /dev/null +++ b/csharp/App/SodiStoreMax/Doc/TransitionToGridTied.graphml @@ -0,0 +1,501 @@ + + + + + + + + + + + + + + + + + + + + + + + + 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 000000000..e5b7b9aba Binary files /dev/null and b/csharp/App/SodiStoreMax/uploadBatteryFw/AF0A.bin differ 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 000000000..25ad21802 Binary files /dev/null and b/csharp/Lib/Devices/Adam6360D/Doc/flowchart_LEDSetting_20241216_ps.odp differ 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