1075 lines
43 KiB
C#
1075 lines
43 KiB
C#
#undef Amax
|
|
#undef GridLimit
|
|
|
|
using System.IO.Compression;
|
|
using System.Reactive.Linq;
|
|
using System.Reactive.Threading.Tasks;
|
|
using System.Text;
|
|
using Flurl.Http;
|
|
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.Devices.Doepke;
|
|
using InnovEnergy.Lib.Protocols.Modbus.Channels;
|
|
using InnovEnergy.Lib.Units;
|
|
using InnovEnergy.Lib.Utils;
|
|
using InnovEnergy.App.SodiStoreMax.DataTypes;
|
|
using Newtonsoft.Json;
|
|
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;
|
|
// ReSharper disable PossibleLossOfFraction
|
|
|
|
#pragma warning disable IL2026
|
|
|
|
namespace InnovEnergy.App.SodiStoreMax;
|
|
|
|
internal static class Program
|
|
{
|
|
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(2);
|
|
|
|
private static readonly IReadOnlyList<Byte> 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 SecondPvOnDc;
|
|
private static readonly Channel PvOnAcGrid;
|
|
private static readonly Channel PvOnAcIsland;
|
|
private static readonly Channel RelaysChannel;
|
|
private static readonly Channel BatteriesChannel;
|
|
private static readonly Channel DoepkeChannel;
|
|
|
|
|
|
//
|
|
private const String SwVersionNumber =" V1.35.220925 beta";
|
|
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; // add this to config file
|
|
private static UInt16 _fileCounter = 0;
|
|
private static SalimaxAlarmState _salimaxAlarmState = SalimaxAlarmState.Green;
|
|
private const String Port = "/dev/ttyUSB0"; // move to a config file
|
|
private static Int32 _failsCounter = 0; // move to a config file
|
|
|
|
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);
|
|
SecondPvOnDc = CreateChannel(d.SecondPvOnDc);
|
|
PvOnAcGrid = CreateChannel(d.PvOnAcGrid);
|
|
PvOnAcIsland = CreateChannel(d.PvOnAcIsland);
|
|
RelaysChannel = CreateChannel(d.RelaysIp);
|
|
BatteriesChannel = CreateChannel(d.BatteryIp);
|
|
DoepkeChannel = CreateChannel(d.DoepkeIp);
|
|
|
|
BatteryNodes = config
|
|
.Devices
|
|
.BatteryNodes
|
|
.Select(n => n.ConvertTo<Byte>())
|
|
.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 secondPvOnDcDevice = new AmptDevices(SecondPvOnDc);
|
|
var pvOnAcGridDevice = new AmptDevices(PvOnAcGrid);
|
|
var pvOnAcIslandDevice = new AmptDevices(PvOnAcIsland);
|
|
var doepkeDevice = new DoepkeDevice(DoepkeChannel);
|
|
|
|
|
|
#if Amax
|
|
var saliMaxRelaysDevice = new RelaysDeviceAmax(RelaysChannel);
|
|
#else
|
|
var saliMaxRelaysDevice = new RelaysDevice(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 loadOnAcIsland = acIslandLoadMeter.Read();
|
|
var gridMeter = gridMeterDevice.Read();
|
|
var firstPvOnDc = pvOnDcDevice.Read();
|
|
var secondPvOnDc = secondPvOnDcDevice.Read();
|
|
var battery = batteryDevices.Read();
|
|
var pvOnAcGrid = pvOnAcGridDevice.Read();
|
|
var pvOnAcIsland = pvOnAcIslandDevice.Read();
|
|
var doepek = doepkeDevice.Read();
|
|
|
|
IReadOnlyList<AmptStatus?> pvOnDc = new List<AmptStatus?> { firstPvOnDc, secondPvOnDc };
|
|
|
|
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};
|
|
|
|
|
|
return new StatusRecord
|
|
{
|
|
AcDc = acDc,
|
|
DcDc = dcDc,
|
|
Battery = battery,
|
|
Relays = relays,
|
|
GridMeter = gridMeter,
|
|
PvOnAcGrid = pvOnAcGrid,
|
|
PvOnAcIsland = pvOnAcIsland,
|
|
PvOnDc = pvOnDc,
|
|
AcGridToAcIsland = gridBusToIslandBus,
|
|
AcDcToDcLink = acDcToDcLink,
|
|
LoadOnAcGrid = gridBusLoad,
|
|
LoadOnAcIsland = loadOnAcIsland,
|
|
LoadOnDc = dcLoad,
|
|
Doepke = doepek,
|
|
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
|
|
((RelaysDevice)saliMaxRelaysDevice).Write((RelaysRecord)r.Relays);
|
|
#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();
|
|
SendSalimaxStateAlarm(GetSalimaxStateAlarm(record), record); // to improve
|
|
|
|
record.ControlConstants();
|
|
record.ControlSystemState();
|
|
|
|
record.AcDc.SystemControl.ApplyAcDcDefaultSettings();
|
|
record.DcDc.SystemControl.ApplyDcDcDefaultSettings();
|
|
|
|
// Retries Control
|
|
//Console.WriteLine(" Fails Counter = " + _failsCounter);
|
|
/* if (record.StateMachine.State is not (28 or 23) )
|
|
{
|
|
// add a case 1 > RCD fail
|
|
// add a case 2 > overload
|
|
if (_failsCounter > 60) // 2 min
|
|
{
|
|
Console.WriteLine(" Fails retries reached threshold");
|
|
record.EnableSafeDefaults();
|
|
return record;
|
|
}
|
|
_failsCounter++;
|
|
}
|
|
else
|
|
{
|
|
_failsCounter = 0;
|
|
}
|
|
//record.ControlPvPower(record.Config.CurtailP, record.Config.PvInstalledPower);
|
|
*/
|
|
var essControl = record.ControlEss().WriteLine();
|
|
|
|
record.EssControl = essControl;
|
|
|
|
DistributePower(record, essControl);
|
|
|
|
//record.PerformLed();
|
|
|
|
WriteControl(record);
|
|
record.Battery.Eoc.WriteLine(" Total EOC");
|
|
|
|
$"{record.StateMachine.State}: {record.StateMachine.Message}".WriteLine();
|
|
$"{DateTime.Now.Round(UpdateInterval).ToUnixTime()} : {DateTime.Now.Round(UpdateInterval):dd/MM/yyyy HH:mm:ss} : {SwVersionNumber}".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;
|
|
|
|
//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);
|
|
}
|
|
|
|
//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<AlarmOrWarning>();
|
|
var warningList = new List<AlarmOrWarning>();
|
|
|
|
if (record.Battery is { MonomerHighVoltageAlarm: true })
|
|
{
|
|
warningList.Add(new AlarmOrWarning
|
|
{
|
|
Date = DateTime.Now.ToString("yyyy-MM-dd"),
|
|
Time = DateTime.Now.ToString("HH:mm:ss"),
|
|
CreatedBy = "Charging Battery System",
|
|
Description = "dynCCL Active: Max Battery Charging is 10 * N"
|
|
});
|
|
}
|
|
if (record.Battery is { MonomerLowVoltageAlarm: true })
|
|
{
|
|
warningList.Add(new AlarmOrWarning
|
|
{
|
|
Date = DateTime.Now.ToString("yyyy-MM-dd"),
|
|
Time = DateTime.Now.ToString("HH:mm:ss"),
|
|
CreatedBy = "Battery System",
|
|
Description = "dynDCL Active: Max Battery Discharging is 10 * N"
|
|
});
|
|
}
|
|
|
|
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
|
|
});
|
|
}
|
|
|
|
alarmList.AddRange(record.AcDc.Alarms
|
|
.Select(alarm => new AlarmOrWarning
|
|
{ Date = DateTime.Now.ToString("yyyy-MM-dd"),
|
|
Time = DateTime.Now.ToString("HH:mm:ss"),
|
|
CreatedBy = "AcDc",
|
|
Description = alarm.ToString() }));
|
|
|
|
alarmList.AddRange(record.DcDc.Alarms
|
|
.Select(alarm => new AlarmOrWarning
|
|
{ Date = DateTime.Now.ToString("yyyy-MM-dd"),
|
|
Time = DateTime.Now.ToString("HH:mm:ss"),
|
|
CreatedBy = "DcDc",
|
|
Description = alarm.ToString() }));
|
|
|
|
warningList.AddRange(record.AcDc.Warnings
|
|
.Select(warning => new AlarmOrWarning
|
|
{ Date = DateTime.Now.ToString("yyyy-MM-dd"),
|
|
Time = DateTime.Now.ToString("HH:mm:ss"),
|
|
CreatedBy = "AcDc",
|
|
Description = warning.ToString() }));
|
|
|
|
warningList.AddRange(record.DcDc.Warnings
|
|
.Select(warning => 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
|
|
|
|
var installationId = GetInstallationId(s3Bucket ?? string.Empty);
|
|
|
|
var returnedStatus = new StatusMessage
|
|
{
|
|
InstallationId = installationId,
|
|
Product = 3,
|
|
Status = _salimaxAlarmState,
|
|
Type = MessageType.AlarmOrWarning,
|
|
Alarms = alarmList,
|
|
Warnings = warningList
|
|
};
|
|
|
|
return returnedStatus;
|
|
}
|
|
|
|
private static Int32 GetInstallationId(String s3Bucket)
|
|
{
|
|
var part = s3Bucket.Split('-').FirstOrDefault();
|
|
return int.TryParse(part, out var id) ? id : 0; // is 0 a default safe value? check with Marios
|
|
}
|
|
|
|
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 devicesConfig = r.AcDc.Devices.All(d => d.Control.Ac.GridType == GridType.GridTied400V50Hz) ? configFile.GridTie : configFile.IslandMode; // TODO if any of the grid tie mode
|
|
|
|
Double maxBatteryChargingCurrentLiveByDcDc ; //used with deligreenBattery for limiting charging
|
|
Double maxBatteryDischargingCurrentLivebyDcDc; //used with deligreenBattery for limiting discharging
|
|
var dcCount = r.DcDc.Devices.Count;
|
|
|
|
if (r.Battery != null)
|
|
{
|
|
// Deligreen upper current limitation dynCCL
|
|
if ( dcCount != 0 && r.Battery.MonomerHighVoltageAlarm )
|
|
{
|
|
maxBatteryChargingCurrentLiveByDcDc = 20.0 / dcCount ; // Max charging current is 10 A * Number of batteries
|
|
Console.WriteLine("dynCCL Active: Max Charging current for one Battery is "+ maxBatteryChargingCurrentLiveByDcDc * dcCount); // multiply by dcCount for display purpose
|
|
}
|
|
else
|
|
{
|
|
maxBatteryChargingCurrentLiveByDcDc = devicesConfig.DcDc.MaxBatteryChargingCurrent/r.Battery.Devices.Count;
|
|
}
|
|
|
|
// Deligreen lower current limitation dynDCL
|
|
if ( dcCount != 0 && r.Battery.MonomerLowVoltageAlarm )
|
|
{
|
|
maxBatteryDischargingCurrentLivebyDcDc = 20.0 / dcCount ; // Max discharging current is 10 A * Number of batteries
|
|
Console.WriteLine("dynDCL Active: Max disCharging current for one Battery is "+ maxBatteryDischargingCurrentLivebyDcDc * dcCount); // multiply by dcCount for display purpose
|
|
}
|
|
else
|
|
{
|
|
maxBatteryDischargingCurrentLivebyDcDc = devicesConfig.DcDc.MaxBatteryDischargingCurrent/r.Battery.Devices.Count;
|
|
}
|
|
|
|
maxBatteryChargingCurrentLiveByDcDc = (maxBatteryChargingCurrentLiveByDcDc * r.Battery.AvailableChBatteries); // Adapt the current to the amount of available battery
|
|
maxBatteryDischargingCurrentLivebyDcDc = (maxBatteryDischargingCurrentLivebyDcDc * r.Battery.AvailableDischBatteries); // Adapt the current to the amount of available battery
|
|
|
|
(r.Battery?.AvailableChBatteries)?.WriteLine(" Available Charging Batteries");
|
|
(r.Battery?.AvailableDischBatteries)?.WriteLine(" Available Discharging Batteries");
|
|
|
|
(r.Battery?.ChargeModeBatteries)?.WriteLine(" Batteries on charge mode");
|
|
(r.Battery?.DischargeModeBatteries)?.WriteLine(" Batteries on discharge mode");
|
|
|
|
maxBatteryChargingCurrentLiveByDcDc.WriteLine(" maxBatteryChargingCurrentLive by DcDC");
|
|
maxBatteryDischargingCurrentLivebyDcDc.WriteLine(" maxBatteryDischargingCurrentLive by DcDc");
|
|
|
|
}
|
|
else
|
|
{
|
|
maxBatteryChargingCurrentLiveByDcDc = 0;
|
|
maxBatteryDischargingCurrentLivebyDcDc = 0;
|
|
}
|
|
|
|
// This random value 5 is applied when the current is limited to 0. The 0 limitations will create an oscillation between discharging and charging
|
|
if (maxBatteryChargingCurrentLiveByDcDc == 0)
|
|
{
|
|
maxBatteryChargingCurrentLiveByDcDc = 5 / dcCount;
|
|
}
|
|
else if (maxBatteryDischargingCurrentLivebyDcDc == 0)
|
|
{
|
|
maxBatteryDischargingCurrentLivebyDcDc = 5 / dcCount;
|
|
}
|
|
|
|
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 = maxBatteryChargingCurrentLiveByDcDc);
|
|
dcDevices.ForEach(d => d.Control.CurrentControl.MaxBatteryDischargingCurrent = maxBatteryDischargingCurrentLivebyDcDc);
|
|
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 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 void InsertIntoJson(Dictionary<String, Object> jsonDict, String[] keys, String value)
|
|
{
|
|
|
|
var currentDict = jsonDict;
|
|
for (Int16 i = 1; i < keys.Length; i++) // Start at 1 to skip empty root
|
|
{
|
|
var key = keys[i];
|
|
if (!currentDict.ContainsKey(key))
|
|
{
|
|
currentDict[key] = new Dictionary<String, Object>();
|
|
}
|
|
|
|
if (i == keys.Length - 1) // Last key, store the value
|
|
{
|
|
|
|
if (!value.Contains(",") && double.TryParse(value, out Double doubleValue)) // Try to parse value as a number
|
|
{
|
|
currentDict[key] = Math.Round(doubleValue, 2); // Round to 2 decimal places
|
|
|
|
}
|
|
else
|
|
{
|
|
currentDict[key] = value; // Store as string if not a number
|
|
}
|
|
}
|
|
else
|
|
{
|
|
currentDict = (Dictionary<String, Object>)currentDict[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async Task<Boolean> UploadCsv(StatusRecord status, DateTime timeStamp)
|
|
{
|
|
var csv = status.ToCsv();
|
|
var jsonData = new Dictionary<String, Object>();
|
|
|
|
foreach (var line in csv.Split('\n'))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
|
|
|
var parts = line.Split(';');
|
|
var keyPath = parts[0];
|
|
var value = parts[1];
|
|
var unit = parts.Length > 2 ? parts[2].Trim() : "";
|
|
//Console.WriteLine(line);
|
|
// Console.WriteLine($"Key: {keyPath}, Value: {value}, Unit: {unit}");
|
|
InsertIntoJson(jsonData, keyPath.Split('/'), value);
|
|
}
|
|
|
|
var jsonOutput = JsonConvert.SerializeObject(jsonData, Formatting.None);
|
|
jsonOutput.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<Boolean> ConcatinatingAndCompressingFiles(DateTime timeStamp, S3Config s3Config)
|
|
{
|
|
if (_fileCounter >= NbrOfFileToConcatenate)
|
|
{
|
|
_fileCounter = 0;
|
|
|
|
var logFileConcatenator = new LogFileConcatenator();
|
|
|
|
var s3Path = timeStamp.ToUnixTime() + ".json";
|
|
var jsonToSend = logFileConcatenator.ConcatenateFiles(NbrOfFileToConcatenate);
|
|
var request = s3Config.CreatePutRequest(s3Path);
|
|
|
|
//Use this for no compression
|
|
var compressedBytes = CompresseBytes(jsonToSend);
|
|
|
|
// Encode the compressed byte array as a Base64 string
|
|
var 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();
|
|
return false;
|
|
}
|
|
|
|
Console.WriteLine("----------------------------------------Sending Heartbit----------------------------------------");
|
|
|
|
Heartbit();
|
|
}
|
|
_fileCounter++;
|
|
|
|
return true;
|
|
}*/
|
|
|
|
private static async Task<Boolean> ConcatinatingAndCompressingFiles(DateTime timeStamp, S3Config s3Config)
|
|
{
|
|
if (_fileCounter >= NbrOfFileToConcatenate)
|
|
{
|
|
_fileCounter = 0;
|
|
|
|
var logFileConcatenator = new LogFileConcatenator();
|
|
var jsontoSend = logFileConcatenator.ConcatenateFiles(NbrOfFileToConcatenate);
|
|
|
|
var fileNameWithoutExtension = timeStamp.ToUnixTime().ToString(); // used for both S3 and local
|
|
var s3Path = fileNameWithoutExtension + ".json";
|
|
|
|
var request = s3Config.CreatePutRequest(s3Path);
|
|
|
|
var compressedBytes = CompresseBytes(jsontoSend);
|
|
var base64String = Convert.ToBase64String(compressedBytes);
|
|
var stringContent = new StringContent(base64String, Encoding.UTF8, "application/base64");
|
|
|
|
var uploadSucceeded = false;
|
|
|
|
try
|
|
{
|
|
var response = await request.PutAsync(stringContent);
|
|
|
|
if (response.StatusCode != 200)
|
|
{
|
|
Console.WriteLine("ERROR: PUT");
|
|
var error = await response.GetStringAsync();
|
|
Console.WriteLine(error);
|
|
|
|
await SaveToLocalCompressedFallback(compressedBytes, fileNameWithoutExtension);
|
|
Heartbit();
|
|
return false;
|
|
}
|
|
|
|
uploadSucceeded = true;
|
|
Console.WriteLine("✅ File uploaded to S3 successfully.");
|
|
|
|
Console.WriteLine("----------------------------------------Sending Heartbit----------------------------------------");
|
|
Heartbit();
|
|
|
|
await ResendLocalFailedFilesAsync(s3Config); // retry any pending failed files
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine("Upload exception: " + ex.Message);
|
|
|
|
if (!uploadSucceeded)
|
|
{
|
|
await SaveToLocalCompressedFallback(compressedBytes, fileNameWithoutExtension);
|
|
}
|
|
|
|
Heartbit();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
_fileCounter++;
|
|
return true;
|
|
}
|
|
|
|
private static async Task SaveToLocalCompressedFallback(Byte[] compressedData, String fileNameWithoutExtension)
|
|
{
|
|
try
|
|
{
|
|
var fallbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "FailedUploads");
|
|
Directory.CreateDirectory(fallbackDir);
|
|
|
|
var fileName = fileNameWithoutExtension + ".json"; // Save as .json, but still compressed
|
|
var fullPath = Path.Combine(fallbackDir, fileName);
|
|
|
|
await File.WriteAllBytesAsync(fullPath, compressedData); // Compressed data
|
|
Console.WriteLine($"Saved compressed failed upload to: {fullPath}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine("Failed to save compressed file locally: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
private static async Task ResendLocalFailedFilesAsync(S3Config s3Config)
|
|
{
|
|
var fallbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "FailedUploads");
|
|
|
|
if (!Directory.Exists(fallbackDir))
|
|
return;
|
|
|
|
var files = Directory.GetFiles(fallbackDir, "*.json");
|
|
|
|
foreach (var filePath in files)
|
|
{
|
|
var fileName = Path.GetFileName(filePath); // e.g., "1720023600.json"
|
|
var s3Key = fileName; // same name for S3
|
|
|
|
try
|
|
{
|
|
byte[] compressedBytes = await File.ReadAllBytesAsync(filePath);
|
|
var base64String = Convert.ToBase64String(compressedBytes);
|
|
var stringContent = new StringContent(base64String, Encoding.UTF8, "application/base64");
|
|
|
|
var request = s3Config.CreatePutRequest(s3Key);
|
|
var response = await request.PutAsync(stringContent);
|
|
|
|
if (response.StatusCode == 200)
|
|
{
|
|
File.Delete(filePath);
|
|
Console.WriteLine($"✅ Successfully resent and deleted: {fileName}");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine($"❌ Failed to resend {fileName}, status: {response.StatusCode}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"⚠️ Exception while resending {fileName}: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void Heartbit()
|
|
{
|
|
var s3Bucket = Config.Load().S3?.Bucket;
|
|
var tryParse = int.TryParse(s3Bucket?.Split("-")[0], out var installationId);
|
|
|
|
if (tryParse)
|
|
{
|
|
var returnedStatus = new StatusMessage
|
|
{
|
|
InstallationId = installationId,
|
|
Product = 3, // Salimax is always 0
|
|
Status = _salimaxAlarmState,
|
|
Type = MessageType.Heartbit,
|
|
};
|
|
if (s3Bucket != null)
|
|
RabbitMqManager.InformMiddleware(returnedStatus);
|
|
}
|
|
}
|
|
|
|
private static Byte[] CompresseBytes(String jsonToSend)
|
|
{
|
|
//Compress JSON 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.json", CompressionLevel.SmallestSize); // Add JSON data to the ZIP archive
|
|
using (var entryStream = entry.Open())
|
|
using (var writer = new StreamWriter(entryStream))
|
|
{
|
|
writer.Write(jsonToSend);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
var 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;
|
|
}
|
|
if (config.CalibrationDischargeState == CalibrationDischargeType.RepetitivelyEvery)
|
|
{
|
|
status.Config.DownDayAndTimeForRepetitiveCalibration = config.CalibrationDischargeDate;
|
|
}
|
|
else if (config.CalibrationDischargeState == CalibrationDischargeType.AdditionallyOnce)
|
|
{
|
|
status.Config.DownDayAndTimeForAdditionalCalibration = config.CalibrationDischargeDate;
|
|
}
|
|
}
|
|
} |