Verbrauchermodule und betriebsartabhängige Angebote ergänzen
Tests / test (push) Successful in 42s

This commit is contained in:
dh
2026-09-21 18:46:17 +00:00
parent 7b6d1b2f07
commit 3c71626b5f
46 changed files with 4513 additions and 311 deletions
+12 -2
View File
@@ -18,7 +18,9 @@ final class EinStufigRegler
int $istleistung,
bool $verfuegbar,
bool $aenderungMoeglich,
bool $mindestlaufzeitErzwungen
bool $mindestlaufzeitErzwungen,
bool $peakbetrieb,
bool $peakSperreBeiMindestlaufzeitAnbieten = true
): array {
if ($nennleistung <= 0) {
throw new InvalidArgumentException('Die Nennleistung muss groesser als 0 sein.');
@@ -27,9 +29,17 @@ final class EinStufigRegler
throw new InvalidArgumentException('Die Istleistung muss 0 oder der Nennleistung entsprechen.');
}
if (!$verfuegbar || !$aenderungMoeglich || $mindestlaufzeitErzwungen) {
if (!$verfuegbar || !$aenderungMoeglich) {
return [$istleistung];
}
if ($mindestlaufzeitErzwungen) {
return $peakbetrieb && $peakSperreBeiMindestlaufzeitAnbieten
? [0, $nennleistung]
: [$nennleistung];
}
if ($peakbetrieb) {
return [0];
}
return [0, $nennleistung];
}
+208
View File
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use RuntimeException;
final class LadestationAdapter
{
public const GO_E_ALT = 1;
public const GO_E_GEMINI = 2;
public const SMART_ME_PICO = 3;
/**
* @param array<string, string> $konfiguration
* @return array<string, mixed>
*/
public static function statusAnfrage(int $geraetetyp, array $konfiguration): array
{
if ($geraetetyp === self::GO_E_ALT) {
return self::anfrage('GET', self::lokaleBasisUrl($konfiguration) . '/mqtt?payload=');
}
if ($geraetetyp === self::GO_E_GEMINI) {
return self::anfrage('GET', self::lokaleBasisUrl($konfiguration) . '/api/status');
}
if ($geraetetyp === self::SMART_ME_PICO) {
$id = self::pflichtwert($konfiguration, 'GeraeteID');
return self::anfrage(
'GET',
'https://api.smart-me.com/pico/charging/' . rawurlencode($id),
$konfiguration
);
}
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
}
/**
* @param array<string, string> $konfiguration
* @return list<array<string, mixed>>
*/
public static function steuerAnfragen(
int $geraetetyp,
array $konfiguration,
int $ladestrom
): array {
if ($ladestrom < 0 || $ladestrom > 32 || ($ladestrom > 0 && $ladestrom < 6)) {
throw new InvalidArgumentException('Ladestrom muss 0 oder 6 bis 32 A betragen.');
}
if ($geraetetyp === self::GO_E_ALT) {
$basis = self::lokaleBasisUrl($konfiguration) . '/mqtt?payload=';
if ($ladestrom === 0) {
return [self::anfrage('GET', $basis . 'alw=0')];
}
return [
self::anfrage('GET', $basis . 'alw=1'),
self::anfrage('GET', $basis . 'amp=' . $ladestrom),
];
}
if ($geraetetyp === self::GO_E_GEMINI) {
$basis = self::lokaleBasisUrl($konfiguration) . '/api/set?';
if ($ladestrom === 0) {
return [self::anfrage('GET', $basis . 'frc=1&sua=false')];
}
return [
self::anfrage('GET', $basis . 'frc=2'),
self::anfrage('GET', $basis . 'amp=' . $ladestrom),
];
}
if ($geraetetyp === self::SMART_ME_PICO) {
$seriennummer = self::pflichtwert($konfiguration, 'Seriennummer');
return [self::anfrage(
'POST',
'https://api.smart-me.com/pico/loadmanagementgroup/current/'
. rawurlencode($seriennummer)
. '?current=' . ($ladestrom * 1000),
$konfiguration
)];
}
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
}
/**
* @return array{Rohstatus: int, Istleistung_W: float}
*/
public static function dekodiereStatus(int $geraetetyp, string $antwort): array
{
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new RuntimeException('Die Geraeteantwort ist kein JSON-Objekt.');
}
if ($geraetetyp === self::GO_E_ALT || $geraetetyp === self::GO_E_GEMINI) {
if (!isset($daten['car'], $daten['nrg'])
|| !is_numeric($daten['car'])
|| !is_array($daten['nrg'])
|| !isset($daten['nrg'][11])
|| !is_numeric($daten['nrg'][11])
) {
throw new RuntimeException('Die go-e-Antwort enthaelt nicht alle Statusfelder.');
}
$faktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0;
return [
'Rohstatus' => (int) $daten['car'],
'Istleistung_W' => (float) $daten['nrg'][11] * $faktor,
];
}
if ($geraetetyp === self::SMART_ME_PICO) {
if (!isset($daten['State'], $daten['ActiveChargingPower'])
|| !is_numeric($daten['State'])
|| !is_numeric($daten['ActiveChargingPower'])
) {
throw new RuntimeException('Die Pico-Antwort enthaelt nicht alle Statusfelder.');
}
return [
'Rohstatus' => (int) $daten['State'],
'Istleistung_W' => round((float) $daten['ActiveChargingPower'] * 1000.0),
];
}
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
}
/**
* @param array<string, string> $konfiguration
* @return array<string, mixed>
*/
private static function anfrage(
string $methode,
string $url,
array $konfiguration = []
): array {
$anfrage = ['Methode' => $methode, 'URL' => $url];
if (str_starts_with($url, 'https://api.smart-me.com/')) {
$anfrage['Benutzername'] = self::pflichtwert($konfiguration, 'Benutzername');
$anfrage['Passwort'] = self::pflichtwert($konfiguration, 'Passwort');
}
return $anfrage;
}
/** @param array<string, string> $konfiguration */
private static function lokaleBasisUrl(array $konfiguration): string
{
$adresse = trim(self::pflichtwert($konfiguration, 'Geraeteadresse'));
if (str_contains($adresse, '/') || str_contains($adresse, '?')) {
throw new InvalidArgumentException('Geraeteadresse muss ein Hostname oder eine IP-Adresse sein.');
}
return 'http://' . $adresse;
}
/** @param array<string, string> $konfiguration */
private static function pflichtwert(array $konfiguration, string $name): string
{
$wert = trim($konfiguration[$name] ?? '');
if ($wert === '') {
throw new InvalidArgumentException($name . ' darf nicht leer sein.');
}
return $wert;
}
}
final class LadestationTransport
{
/** @var callable(array<string, mixed>): string */
private $anfrageAusfuehren;
/** @param callable(array<string, mixed>): string $anfrageAusfuehren */
public function __construct(callable $anfrageAusfuehren)
{
$this->anfrageAusfuehren = $anfrageAusfuehren;
}
/** @param array<string, string> $konfiguration */
public function statusLesen(int $geraetetyp, array $konfiguration): string
{
return ($this->anfrageAusfuehren)(
LadestationAdapter::statusAnfrage($geraetetyp, $konfiguration)
);
}
/** @param array<string, string> $konfiguration */
public function ladestromSetzen(
int $geraetetyp,
array $konfiguration,
int $ladestrom
): void {
foreach (LadestationAdapter::steuerAnfragen($geraetetyp, $konfiguration, $ladestrom) as $anfrage) {
($this->anfrageAusfuehren)($anfrage);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class LadestationRegler
{
public const PHASEN_UNBEKANNT = 0;
public const PHASEN_EIN = 1;
public const PHASEN_DREI = 3;
/** @var list<int> */
private const LEISTUNGEN_EINPHASIG = [
1450, 1690, 1930, 2170, 2410, 2650, 2890, 3130, 3370,
3610, 3850, 4090, 4330, 4570, 4810, 5050, 5290, 5530,
5770, 6010, 6250, 6490, 6730, 6970, 7210, 7450, 7690,
];
/** @var list<int> */
private const LEISTUNGEN_DREIPHASIG = [
4150, 4850, 5550, 6250, 6950, 7600, 8300, 9000, 9700,
10300, 11000, 11750, 12450, 13150, 13850, 14550, 15250,
15900, 16600, 17300, 18000, 18700, 19400, 20100, 20800,
21500, 22000,
];
/**
* Die Schwelle, die Voll-Erkennung und der Zuschlag von 2.5 A stammen
* aus der Enelix-1-Ladestation und bleiben bewusst kompatibel.
*
* @param array{Rohstatus: int, Istleistung_W: float} $rohstatus
* @return array<string, int|float|bool>
*/
public static function normalisiereStatus(array $rohstatus, bool $warVerbunden): array
{
$verbunden = $rohstatus['Rohstatus'] !== 1;
$leistung = max(0.0, $rohstatus['Istleistung_W']);
$phasen = self::PHASEN_UNBEKANNT;
$ladestrom = 0.0;
$geladen = false;
if ($verbunden) {
$phasen = $leistung > 7500.0 ? self::PHASEN_DREI : self::PHASEN_EIN;
$nenner = $phasen === self::PHASEN_EIN ? 230.0 : 1.71 * 400.0;
$ladestrom = $leistung / $nenner;
$ermittelterMaximalstrom = 2.5 + $ladestrom;
$geladen = $warVerbunden && $ermittelterMaximalstrom < 6.0;
}
if (!$verbunden) {
$fahrzeugstatus = 1;
} elseif ($geladen) {
$fahrzeugstatus = 4;
} elseif ($leistung > 0.0) {
$fahrzeugstatus = 3;
} else {
$fahrzeugstatus = 2;
}
return [
'FahrzeugVerbunden' => $verbunden,
'FahrzeugGeladen' => $geladen,
'Fahrzeugstatus' => $fahrzeugstatus,
'Phasenzahl' => $phasen,
'Istleistung_W' => $leistung,
'Ladestrom_A' => $ladestrom,
];
}
/** @return list<int> */
public static function leistungsangebot(
bool $aktiv,
bool $ladefreigabe,
bool $verbunden,
bool $geladen,
int $phasenzahl,
int $maximalerLadestrom,
bool $solarladen,
bool $peakbetrieb
): array {
if (!$aktiv || !$ladefreigabe || !$verbunden || $geladen) {
return [0];
}
$stufen = self::leistungsstufen($phasenzahl, $maximalerLadestrom);
if ($stufen === []) {
return [0];
}
if ($solarladen) {
return $peakbetrieb ? [0] : array_merge([0], $stufen);
}
if ($peakbetrieb) {
return array_merge([0], $stufen);
}
return [$stufen[count($stufen) - 1]];
}
public static function stromFuerLeistung(int $leistung, int $phasenzahl): int
{
if ($leistung <= 0) {
return 0;
}
$stufen = self::stufenFuerPhasen($phasenzahl);
if ($leistung < $stufen[0]) {
return 0;
}
$strom = 6;
foreach ($stufen as $index => $schwelle) {
if ($leistung < $schwelle) {
break;
}
$strom = 6 + $index;
}
return min(32, $strom);
}
/** @return list<int> */
private static function leistungsstufen(int $phasenzahl, int $maximalerLadestrom): array
{
if ($maximalerLadestrom < 6 || $maximalerLadestrom > 32) {
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
}
return array_slice(self::stufenFuerPhasen($phasenzahl), 0, $maximalerLadestrom - 5);
}
/** @return list<int> */
private static function stufenFuerPhasen(int $phasenzahl): array
{
if ($phasenzahl === self::PHASEN_EIN) {
return self::LEISTUNGEN_EINPHASIG;
}
if ($phasenzahl === self::PHASEN_DREI) {
return self::LEISTUNGEN_DREIPHASIG;
}
throw new InvalidArgumentException('Phasenzahl muss 1 oder 3 sein.');
}
}
+2 -2
View File
@@ -16,8 +16,8 @@ use InvalidArgumentException;
final class ManagerRegler
{
public const BETRIEBSART_INAKTIV = 'Inaktiv';
public const BETRIEBSART_PV = 'PV';
public const BETRIEBSART_PEAK = 'Peak';
public const BETRIEBSART_PV = Nachrichtenvertrag::BETRIEBSART_PV;
public const BETRIEBSART_PEAK = Nachrichtenvertrag::BETRIEBSART_PEAK;
/**
* @param array<int, float|int> $monatsgrenzen Monatsnummer 1 bis 12 als Schluessel
+22 -4
View File
@@ -8,7 +8,10 @@ use InvalidArgumentException;
final class Nachrichtenvertrag
{
public const VERSION = '3.0';
public const VERSION = '4.0';
public const BETRIEBSART_PV = 'PV';
public const BETRIEBSART_PEAK = 'Peak';
public const LEISTUNGSQUELLE_NICHT_VORHANDEN = 0;
public const LEISTUNGSQUELLE_BERECHNET = 1;
@@ -19,11 +22,12 @@ final class Nachrichtenvertrag
*/
public static function pruefeManagerdaten(array $daten): void
{
self::pruefeSchluessel($daten, ['Kopf', 'Sollleistung_W'], 'Managerdaten');
self::pruefeSchluessel($daten, ['Kopf', 'Betriebsart', 'Sollleistung_W'], 'Managerdaten');
self::pruefeKopf($daten['Kopf']);
self::pruefeBetriebsart($daten['Betriebsart']);
if (!is_int($daten['Sollleistung_W'])) {
throw new InvalidArgumentException('Sollleistung_W muss eine Ganzzahl sein.');
if ($daten['Sollleistung_W'] !== null && !is_int($daten['Sollleistung_W'])) {
throw new InvalidArgumentException('Sollleistung_W muss eine Ganzzahl oder null sein.');
}
}
@@ -36,6 +40,7 @@ final class Nachrichtenvertrag
$daten,
[
'Kopf',
'Betriebsart',
'PrioritaetPV',
'PrioritaetPeak',
'Leistungswerte_W',
@@ -49,6 +54,7 @@ final class Nachrichtenvertrag
);
self::pruefeKopf($daten['Kopf']);
self::pruefeBetriebsart($daten['Betriebsart']);
self::pruefePrioritaet($daten['PrioritaetPV'], 'PrioritaetPV');
self::pruefePrioritaet($daten['PrioritaetPeak'], 'PrioritaetPeak');
self::pruefeLeistungswerte($daten['Leistungswerte_W']);
@@ -93,6 +99,18 @@ final class Nachrichtenvertrag
}
}
/** @param mixed $betriebsart */
private static function pruefeBetriebsart($betriebsart): void
{
if (!is_string($betriebsart) || !in_array(
$betriebsart,
[self::BETRIEBSART_PV, self::BETRIEBSART_PEAK],
true
)) {
throw new InvalidArgumentException('Betriebsart muss PV oder Peak sein.');
}
}
/**
* @param mixed $prioritaet
*/
+214
View File
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class PufferspeicherRegler
{
public const MINIMUM_AUS = 0;
public const MINIMUM_STATISCH = 1;
public const MINIMUM_DIFFERENZ = 2;
/**
* @return list<array{Stufe: int, Leistung_W: int, KontaktID: int}>
*/
public static function dekodiereLeistungsstufen(string $json): array
{
$werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($werte) || !self::istListe($werte)) {
throw new InvalidArgumentException('LeistungsStufen muss eine JSON-Liste sein.');
}
$ergebnis = [];
$leistungen = [];
$kontakte = [];
foreach ($werte as $index => $eintrag) {
if (!is_array($eintrag)) {
throw new InvalidArgumentException('Jede Leistungsstufe muss ein Objekt sein.');
}
$stufe = $eintrag['Stufe'] ?? $index + 1;
$leistung = $eintrag['Leistung'] ?? null;
$kontakt = $eintrag['Schaltkontakt_Stufe'] ?? null;
if (!is_int($stufe) || $stufe <= 0) {
throw new InvalidArgumentException('Stufe muss eine positive Ganzzahl sein.');
}
if (!is_int($leistung) || $leistung <= 0) {
throw new InvalidArgumentException('Leistung muss eine positive Ganzzahl sein.');
}
if (!is_int($kontakt) || $kontakt <= 0) {
throw new InvalidArgumentException('Schaltkontakt_Stufe muss eine positive Variablen-ID sein.');
}
if (isset($leistungen[$leistung])) {
throw new InvalidArgumentException('Leistungen muessen eindeutig sein.');
}
if (isset($kontakte[$kontakt])) {
throw new InvalidArgumentException('Schaltkontakte duerfen nur einer Stufe zugeordnet sein.');
}
$leistungen[$leistung] = true;
$kontakte[$kontakt] = true;
$ergebnis[] = [
'Stufe' => $stufe,
'Leistung_W' => $leistung,
'KontaktID' => $kontakt,
];
}
usort(
$ergebnis,
static fn (array $links, array $rechts): int => $links['Leistung_W'] <=> $rechts['Leistung_W']
);
return $ergebnis;
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
* @return list<int>
*/
public static function leistungswerte(array $stufen): array
{
return array_merge([0], array_column($stufen, 'Leistung_W'));
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
*/
public static function stufeFuerLeistung(array $stufen, int $leistung): int
{
if ($leistung === 0) {
return 0;
}
foreach ($stufen as $stufe) {
if ($stufe['Leistung_W'] === $leistung) {
return $stufe['Stufe'];
}
}
throw new InvalidArgumentException('Die Sollleistung entspricht keiner konfigurierten Leistungsstufe.');
}
public static function solltemperatur(
float $aussentemperatur,
float $fusspunkt,
float $steigung,
float $minimum,
float $maximum
): float {
if ($steigung < 0.0) {
throw new InvalidArgumentException('HeizkurvenSteigung darf nicht negativ sein.');
}
if ($minimum > $maximum) {
throw new InvalidArgumentException('Heizkurvenminimum darf nicht groesser als das Maximum sein.');
}
$soll = $fusspunkt + $steigung * (20.0 - $aussentemperatur);
return min($maximum, max($minimum, $soll));
}
public static function einschaltschwelle(
float $solltemperatur,
float $hysterese,
int $minimumModus,
float $statischeMindesttemperatur,
float $mindesttemperaturDifferenz
): float {
if ($hysterese <= 0.0) {
throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.');
}
if ($minimumModus === self::MINIMUM_AUS) {
return $solltemperatur - $hysterese;
}
if ($minimumModus === self::MINIMUM_STATISCH) {
return min($solltemperatur, $statischeMindesttemperatur);
}
if ($minimumModus === self::MINIMUM_DIFFERENZ) {
if ($mindesttemperaturDifferenz < 0.0) {
throw new InvalidArgumentException('MindesttemperaturDifferenz darf nicht negativ sein.');
}
return $solltemperatur - $mindesttemperaturDifferenz;
}
throw new InvalidArgumentException('MindesttemperaturModus muss 0, 1 oder 2 sein.');
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
* @return list<int>
*/
public static function leistungsangebot(
bool $aktiv,
bool $peakbetrieb,
bool $temperaturenGueltig,
float $puffertemperatur,
float $einschaltschwelle,
array $stufen
): array {
if (!$aktiv || $peakbetrieb || !$temperaturenGueltig || $stufen === []) {
return [0];
}
if ($puffertemperatur < $einschaltschwelle) {
return self::leistungswerte($stufen);
}
return [0];
}
public static function fusspunktAusWaermepumpenwert(
float $waermepumpenSolltemperatur,
float $aussentemperatur,
float $steigung
): float {
if ($steigung < 0.0) {
throw new InvalidArgumentException('HeizkurvenSteigung darf nicht negativ sein.');
}
return $waermepumpenSolltemperatur - $steigung * (20.0 - $aussentemperatur);
}
public static function pt1(float $bisher, float $eingang, int $deltaSekunden, int $zeitkonstante): float
{
if ($deltaSekunden <= 0 || $zeitkonstante <= 0) {
throw new InvalidArgumentException('PT1-Zeiten muessen groesser als 0 sein.');
}
$alpha = $deltaSekunden / ($zeitkonstante + $deltaSekunden);
return $bisher + $alpha * ($eingang - $bisher);
}
public static function restzeitBisLastwechsel(
int $letzterLastwechsel,
int $jetzt,
int $sperrzeitSekunden
): int {
if ($sperrzeitSekunden < 0) {
throw new InvalidArgumentException('LastwechselSperrzeit darf nicht negativ sein.');
}
if ($letzterLastwechsel <= 0) {
return 0;
}
return max(0, $sperrzeitSekunden - max(0, $jetzt - $letzterLastwechsel));
}
public static function energieKWh(float $leistungW, int $dauerSekunden): float
{
if ($dauerSekunden < 0) {
throw new InvalidArgumentException('Dauer darf nicht negativ sein.');
}
return $leistungW * $dauerSekunden / 3600000.0;
}
/** @param array<mixed> $wert */
private static function istListe(array $wert): bool
{
return $wert === [] || array_keys($wert) === range(0, count($wert) - 1);
}
}
+34
View File
@@ -78,6 +78,40 @@ final class WarmwasserRegler
return array_merge([0], array_column($stufen, 'Leistung_W'));
}
/**
* @param list<int> $leistungswerte
* @return list<int>
*/
public static function leistungsangebot(
bool $peakbetrieb,
float $temperatur,
float $mindesttemperatur,
float $maximaltemperatur,
float $hysterese,
bool $stufeAktiv,
array $leistungswerte
): array {
if ($hysterese < 0.0 || $leistungswerte === []) {
throw new InvalidArgumentException('Hysterese und Leistungswerte sind ungueltig.');
}
$mindestladung = $temperatur < $mindesttemperatur
|| ($temperatur < $mindesttemperatur + $hysterese && $stufeAktiv);
if ($peakbetrieb) {
return $mindestladung ? $leistungswerte : [0];
}
if ($mindestladung) {
return [max($leistungswerte)];
}
if ($temperatur < $maximaltemperatur - $hysterese
|| ($temperatur < $maximaltemperatur && $stufeAktiv)
) {
return $leistungswerte;
}
return [0];
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
*/