feat: Wärmepumpenmodul integrieren
Tests / test (push) Successful in 49s

This commit is contained in:
dh
2026-09-22 14:34:23 +00:00
parent d7308663da
commit 7845c66fd8
14 changed files with 2127 additions and 51 deletions
+1
View File
@@ -24,6 +24,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private const VERBRAUCHER_MODUL_IDS = [ private const VERBRAUCHER_MODUL_IDS = [
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}', '{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
'{15879A4E-D0C2-4495-83DE-46E1E462591E}', '{15879A4E-D0C2-4495-83DE-46E1E462591E}',
'{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}',
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}', '{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}', '{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}',
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}', '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
+1 -1
View File
@@ -16,7 +16,7 @@ weiteren Module sind als Diskussionsentwürfe dokumentiert.
- Wassererwaermer (implementiert) - Wassererwaermer (implementiert)
- Pufferspeicher - Pufferspeicher
- Verbraucher 1-Stufig (implementiert) - Verbraucher 1-Stufig (implementiert)
- Waermepumpe - Waermepumpe (implementiert)
- Ladestation Stand-Alone (implementiert) - Ladestation Stand-Alone (implementiert)
- Ladestation Gateway - Ladestation Gateway
- Easee Gateway - Easee Gateway
+166
View File
@@ -0,0 +1,166 @@
{
"elements": [
{
"type": "ExpansionPanel",
"caption": "Manager und Zeitverhalten",
"expanded": true,
"items": [
{"type": "NumberSpinner", "name": "PrioritaetPV", "caption": "Prioritaet PV", "minimum": 0},
{"type": "NumberSpinner", "name": "PrioritaetPeak", "caption": "Prioritaet Peak", "minimum": 0},
{"type": "NumberSpinner", "name": "Meldeintervall", "caption": "Meldeintervall", "suffix": " s", "minimum": 1},
{"type": "NumberSpinner", "name": "VorgabeTimeout", "caption": "Vorgabe-Timeout", "suffix": " s", "minimum": 1}
]
},
{
"type": "ExpansionPanel",
"caption": "Waermepumpe und Kontakte",
"expanded": true,
"items": [
{
"type": "Select",
"name": "Kontaktart",
"caption": "Kontaktart",
"options": [
{"caption": "Sperre und Erhoehung", "value": 0},
{"caption": "SG Ready", "value": 1}
]
},
{
"type": "SelectVariable",
"name": "Kontakt1VariableID",
"caption": "Kontakt 1 (Sperre / SG Ready 1)",
"validVariableTypes": [0]
},
{
"type": "CheckBox",
"name": "Kontakt1Invertiert",
"caption": "Kontakt 1 invertieren"
},
{
"type": "SelectVariable",
"name": "Kontakt2VariableID",
"caption": "Kontakt 2 (Erhoehung / SG Ready 2)",
"validVariableTypes": [0]
},
{
"type": "CheckBox",
"name": "Kontakt2Invertiert",
"caption": "Kontakt 2 invertieren"
},
{
"type": "NumberSpinner",
"name": "Nennleistung",
"caption": "Nennleistung",
"suffix": " W",
"minimum": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Betriebsrueckmeldung",
"expanded": true,
"items": [
{
"type": "Select",
"name": "Rueckmeldungsart",
"caption": "Rueckmeldungsart",
"options": [
{"caption": "Gemessene Leistung", "value": 0},
{"caption": "Betriebsstatus", "value": 1}
]
},
{
"type": "SelectVariable",
"name": "IstleistungVariableID",
"caption": "Leistung oder Betriebsstatus",
"validVariableTypes": [0, 1, 2]
},
{
"type": "NumberSpinner",
"name": "Laufschwelle",
"caption": "Laufschwelle bei Leistungsmessung",
"suffix": " W",
"minimum": 0,
"digits": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Schutzzeiten",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "Anlaufwartezeit",
"caption": "Anlaufwartezeit",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Wiederholsperre",
"caption": "Wiederholsperre nach erfolglosem Anlauf",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Mindestlaufzeit",
"caption": "Mindestlaufzeit",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Mindestsperrzeit",
"caption": "Mindestsperrzeit",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "MaximaleSperrzeit",
"caption": "Maximale Sperrzeit am Stueck",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "MaximaleSperrzeit24h",
"caption": "Maximale Sperrzeit in 24 Stunden",
"suffix": " s",
"minimum": 0,
"maximum": 86400
}
]
},
{
"type": "ExpansionPanel",
"caption": "Darstellung und Diagnose",
"items": [
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Lokale Einstellungen in der Visualisierung anzeigen"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Diagnoseprotokoll aktivieren"
}
]
}
],
"status": [
{"code": 102, "icon": "active", "caption": "Aktiv"},
{"code": 201, "icon": "error", "caption": "Konfiguration ungueltig"},
{"code": 202, "icon": "error", "caption": "Schalt- oder Rueckmeldefehler"}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}",
"name": "VerbraucherWaermepumpe",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Waermepumpe"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Waermepumpe"
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,8 +1,8 @@
# EMS-Module und Modulentwürfe # EMS-Module und Modulentwürfe
> Manager, Warmwassererwaermer, Batterie, Verbraucher 1-Stufig, Ladestation Stand-Alone, > Manager, Warmwassererwaermer, Batterie, Verbraucher 1-Stufig, Waermepumpe,
> Easee Gateway und Ladestation Gateway sind als installierbare IP-Symcon-Module > Ladestation Stand-Alone, Easee Gateway und Ladestation Gateway sind als
> umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen. > installierbare IP-Symcon-Module umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen.
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb [EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
+76 -34
View File
@@ -1,47 +1,89 @@
# Wärmepumpe # Wärmepumpe
> Status: Diskussionsentwurf. > Status: Implementiert.
Die Kontaktwirkung ist als Sperrkontakte oder SG Ready konfigurierbar. Die Das Modul bindet eine Wärmepumpe über zwei schaltbare Boolean-Ausgänge in den
elektrische Leistung kann fehlen, berechnet oder gemessen werden. Enelix-Manager ein. Die Ausgänge können als getrennte Kontakte für Sperre und
Erhöhung oder als SG-Ready-Eingänge verwendet werden. Die Regelung arbeitet mit
dem Nachrichtenvertrag 4.0 und meldet für PV und Peak ein
zustandsabhängiges Leistungsangebot.
## Zusätzliche Variablen ## Verhalten
| Ident | Typ / Zugriff | Beschreibung | - Im PV-Betrieb kann eine freigegebene Wärmepumpe aus dem Normalzustand in die
Erhöhung geschaltet werden.
- Im Peak-Betrieb kann eine laufende Wärmepumpe gesperrt werden, sofern
Mindestlaufzeit und Sperrgrenzen dies erlauben.
- Schaltvorgänge erfolgen als Break-before-Make: beide Kontakte werden zuerst
deaktiviert, danach wird höchstens ein Kontakt aktiviert.
- Nach einem erfolglosen PV-Anlauf wird die Erhöhung aufgehoben und bis zum
Ablauf der Wiederholsperre nicht erneut angeboten.
- Die maximale Sperrzeit am Stück und innerhalb von 24 Stunden erzwingt eine
Erholungsphase im Normalbetrieb.
- Ohne gültige Manager-Vorgabe fällt die Steuerung in den sicheren
Normalzustand zurück.
## Konfiguration
| Property | Standard | Beschreibung |
| --- | ---: | --- |
| `Kontaktart` | `0` | `0`: Sperre/Erhöhung, `1`: SG Ready |
| `Kontakt1VariableID` | `0` | Schaltbarer Ausgang für Sperre beziehungsweise SG Ready 1 |
| `Kontakt2VariableID` | `0` | Schaltbarer Ausgang für Erhöhung beziehungsweise SG Ready 2 |
| `Kontakt1Invertiert` | `false` | Logik des ersten Ausgangs invertieren |
| `Kontakt2Invertiert` | `false` | Logik des zweiten Ausgangs invertieren |
| `Nennleistung` | `0 W` | Elektrische Nennleistung der Wärmepumpe |
| `Rueckmeldungsart` | `0` | `0`: gemessene Leistung, `1`: Boolean-Betriebsstatus |
| `IstleistungVariableID` | `0` | Leistungsmessung oder Betriebsrückmeldung |
| `Laufschwelle` | `100 W` | Ab dieser Leistung gilt die Wärmepumpe als laufend |
| `Anlaufwartezeit` | `30 s` | Wartezeit auf bestätigten Anlauf |
| `Wiederholsperre` | `300 s` | Pause nach einem erfolglosen Anlauf |
| `Mindestlaufzeit` | `1200 s` | Mindestlaufzeit nach bestätigtem Start |
| `Mindestsperrzeit` | `1200 s` | Mindestdauer einer begonnenen Sperre |
| `MaximaleSperrzeit` | `7200 s` | Maximale zusammenhängende Sperre; `0` deaktiviert |
| `MaximaleSperrzeit24h` | `21600 s` | Maximale Sperrsumme in 24 Stunden; `0` deaktiviert |
Hinzu kommen die gemeinsamen Verbraucher-Properties für Prioritäten,
Meldeintervall, Vorgabe-Timeout, Visualisierung und Logging.
## Variablen
| Ident | Typ | Beschreibung |
| --- | --- | --- | | --- | --- | --- |
| `Waermepumpenzustand` | Integer / Anzeige | `0` unbekannt, `1` aus, `2` normal, `3` erhöht, `4` gesperrt. | | `Waermepumpenzustand` | Integer | `1` normal/aus, `2` normal/laufend, `3` erhöht, `4` gesperrt |
| `SperreAktiv` | Boolean / Anzeige | Aktuelle Sperranforderung. | | `WaermepumpeLaeuft` | Boolean | Bestätigter Betriebsstatus |
| `ErhoehungAktiv` | Boolean / Anzeige | Aktuelle Erhöhungsanforderung. | | `SperreAktiv` | Boolean | Sperrkontakt ist aktiv |
| `SGReadyZustand` | Integer / Anzeige | Bei SG Ready Zustand 0–3. | | `ErhoehungAktiv` | Boolean | Erhöhungskontakt ist aktiv |
| `Laufzeit` | Integer / Anzeige | Kumulierte bestätigte Laufzeit in Sekunden. | | `SGReadyZustand` | Integer | Bitwert der beiden logischen Kontakte |
| `Laufzeit` | Integer | Kumulierte bestätigte Laufzeit in Sekunden |
## Zusätzliche Properties Optionale Diagnosevariablen zeigen unter anderem Ist- und Sollleistung,
Verfügbarkeit, Anlaufstatus, Wiederholsperre, Sperrerholung, verbleibende
Mindestzeiten und die Sperrsumme der letzten 24 Stunden.
| Ident | Typ | Standard / Beschreibung | ## Adaption aus Enelix 1
| --- | --- | --- |
| `Kontaktwirkung` | Auswahl | `Sperrkontakte`; alternativ `SG Ready`. |
| `Kontakt1VariableID` | Integer | `0`; Sperre oder SG-Ready-Eingang 1. |
| `Kontakt2VariableID` | Integer | `0`; Erhöhung oder SG-Ready-Eingang 2. |
| `Kontakt1Invertiert` | Boolean | `false`. |
| `Kontakt2Invertiert` | Boolean | `false`. |
| `Leistungsermittlung` | Auswahl | `Nicht vorhanden`, `Berechnet` oder `Gemessen`; entspricht Quelle 0/1/2. |
| `LeistungsmessungVariableID` | Integer | `0`; nur bei gemessener Leistung. |
| `Zustandskennlinie` | String/JSON | `[]`; bei berechneter Leistung je Zustand Planleistung und Kontaktbelegung. |
| `BetriebsrueckmeldungVariableID` | Integer | `0`; optionale echte Betriebsrückmeldung. |
| `Mindestlaufzeit` | Integer | `0*` s; anlagengerecht festlegen. |
| `Mindeststillstand` | Integer | `0*` s; anlagengerecht festlegen. |
| `MaxSperrzeit` | Integer | `0` s; `0` deaktiviert die EMS-Sperrfunktion. |
Zeit- und wetterabhängiges Sperren beziehungsweise Erhöhen aus dem Altmodul | Bereich | Behandlung in Enelix 2 |
entfällt. | --- | --- |
| Grundzustände Normal, Sperre und Erhöhung | angepasst übernommen |
| Zwei physische Kontakte | angepasst und mit Invertierung sowie Rücklesekontrolle umgesetzt |
| Leistungsangebot an den Manager | neu nach Nachrichtenvertrag 4.0 implementiert |
| Mindestlauf- und Sperrzeiten | neu als ereignisbasierte Schutzlogik implementiert |
| Wetter-, Wolken- und Sonnenaufgangslogik | nicht uebernommen |
| Warmwasser-Schwellwert im Wärmepumpenmodul | nicht uebernommen |
| Fester zyklischer Fünf-Sekunden-Regler | nicht uebernommen; Ereignisse und Einmal-Timer |
## Zustand ## Lizenzierung und Manager
`Waermepumpenzustand`, `SperreAktiv`, `ErhoehungAktiv` beziehungsweise Der Manager erkennt das Modul über die Modul-ID
`SGReadyZustand` als Status, `Laufzeit_s` als Istwert und `{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}`. Es verwendet das bestehende
`Kommunikationsfehler` als Störung. Lizenzkontingent `consumer_single`, das im Portal als einstufiger Verbraucher
mit Wärmepumpe als Beispiel angeboten wird.
## Offene Punkte ## Sicherheitshinweise
- Der Formularbutton „Wassererwärmer über Wärmepumpe betreiben“ bleibt ohne Die beiden Ausgänge müssen unterschiedliche, schaltbare Boolean-Variablen sein.
Regelwirkung, bis Anschluss und Verriegelung gemeinsam definiert sind. Die Rückmeldung ist verpflichtend: entweder eine numerische Leistungsmessung
oder ein Boolean-Betriebsstatus. Bei ungültiger Konfiguration, fehlgeschlagenem
Schalten oder widersprüchlicher Rücklesung setzt das Modul seinen Status auf
Fehler und meldet sich dem Manager als nicht verfügbar.
+190
View File
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class WaermepumpenRegler
{
public const KONTAKTART_SPERRE_ERHOEHUNG = 0;
public const KONTAKTART_SG_READY = 1;
public const RUECKMELDUNG_LEISTUNG = 0;
public const RUECKMELDUNG_BETRIEB = 1;
public const STEUERZUSTAND_NORMAL = 0;
public const STEUERZUSTAND_SPERRE = 1;
public const STEUERZUSTAND_ERHOEHUNG = 2;
/** @return array{Kontakt1: bool, Kontakt2: bool} */
public static function kontaktwerte(int $kontaktart, int $steuerzustand): array
{
self::pruefeKontaktart($kontaktart);
self::pruefeSteuerzustand($steuerzustand);
return [
'Kontakt1' => $steuerzustand === self::STEUERZUSTAND_SPERRE,
'Kontakt2' => $steuerzustand === self::STEUERZUSTAND_ERHOEHUNG,
];
}
/** @param bool|int|float $rueckmeldung */
public static function laeuft($rueckmeldung, int $rueckmeldungsart, float $laufschwelle): bool
{
self::pruefeRueckmeldungsart($rueckmeldungsart);
if ($laufschwelle < 0) {
throw new InvalidArgumentException('Die Laufschwelle darf nicht negativ sein.');
}
if ($rueckmeldungsart === self::RUECKMELDUNG_BETRIEB) {
return (bool) $rueckmeldung;
}
return abs((float) $rueckmeldung) >= $laufschwelle;
}
/** @param bool|int|float $rueckmeldung */
public static function istleistung(
$rueckmeldung,
int $rueckmeldungsart,
int $nennleistung
): float {
self::pruefeRueckmeldungsart($rueckmeldungsart);
self::pruefeNennleistung($nennleistung);
if ($rueckmeldungsart === self::RUECKMELDUNG_BETRIEB) {
return (bool) $rueckmeldung ? (float) $nennleistung : 0.0;
}
return max(0.0, (float) $rueckmeldung);
}
/** @return list<int> */
public static function leistungsangebot(
int $nennleistung,
string $betriebsart,
bool $laeuft,
bool $verfuegbar,
bool $mindestlaufzeitAktiv,
bool $mindestsperrzeitAktiv,
bool $sperrenZulaessig,
bool $startenZulaessig,
bool $sperrerholung
): array {
self::pruefeNennleistung($nennleistung);
if (!in_array($betriebsart, [
Nachrichtenvertrag::BETRIEBSART_PV,
Nachrichtenvertrag::BETRIEBSART_PEAK,
], true)) {
throw new InvalidArgumentException('Die Betriebsart ist ungueltig.');
}
if (!$verfuegbar) {
return [$laeuft ? $nennleistung : 0];
}
if ($sperrerholung || $mindestlaufzeitAktiv) {
return [$laeuft ? $nennleistung : 0];
}
if ($betriebsart === Nachrichtenvertrag::BETRIEBSART_PV) {
return ($laeuft || (!$mindestsperrzeitAktiv && $startenZulaessig))
? [0, $nennleistung]
: [0];
}
return ($laeuft && $sperrenZulaessig)
? [0, $nennleistung]
: [$laeuft ? $nennleistung : 0];
}
public static function restzeit(int $beginn, int $dauer, int $jetzt): int
{
if ($beginn < 0 || $dauer < 0 || $jetzt < 0) {
throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.');
}
if ($beginn === 0 || $dauer === 0) {
return 0;
}
return max(0, $beginn + $dauer - $jetzt);
}
/**
* @param list<array{Von: int, Bis: int}> $historie
* @return list<array{Von: int, Bis: int}>
*/
public static function bereinigeSperrhistorie(array $historie, int $jetzt): array
{
if ($jetzt < 0) {
throw new InvalidArgumentException('Der Zeitpunkt darf nicht negativ sein.');
}
$grenze = $jetzt - 86400;
$ergebnis = [];
foreach ($historie as $intervall) {
$von = (int) ($intervall['Von'] ?? -1);
$bis = (int) ($intervall['Bis'] ?? -1);
if ($von < 0 || $bis < $von || $bis <= $grenze) {
continue;
}
$ergebnis[] = ['Von' => max($von, $grenze), 'Bis' => min($bis, $jetzt)];
}
return $ergebnis;
}
/** @param list<array{Von: int, Bis: int}> $historie */
public static function sperrsekunden24h(
array $historie,
int $jetzt,
?int $aktiverBeginn = null
): int {
$sekunden = 0;
foreach (self::bereinigeSperrhistorie($historie, $jetzt) as $intervall) {
$sekunden += $intervall['Bis'] - $intervall['Von'];
}
if ($aktiverBeginn !== null && $aktiverBeginn > 0) {
$sekunden += max(0, $jetzt - max($aktiverBeginn, $jetzt - 86400));
}
return min(86400, $sekunden);
}
private static function pruefeKontaktart(int $kontaktart): void
{
if (!in_array($kontaktart, [
self::KONTAKTART_SPERRE_ERHOEHUNG,
self::KONTAKTART_SG_READY,
], true)) {
throw new InvalidArgumentException('Die Kontaktart ist ungueltig.');
}
}
private static function pruefeRueckmeldungsart(int $rueckmeldungsart): void
{
if (!in_array($rueckmeldungsart, [
self::RUECKMELDUNG_LEISTUNG,
self::RUECKMELDUNG_BETRIEB,
], true)) {
throw new InvalidArgumentException('Die Rueckmeldungsart ist ungueltig.');
}
}
private static function pruefeSteuerzustand(int $steuerzustand): void
{
if (!in_array($steuerzustand, [
self::STEUERZUSTAND_NORMAL,
self::STEUERZUSTAND_SPERRE,
self::STEUERZUSTAND_ERHOEHUNG,
], true)) {
throw new InvalidArgumentException('Der Steuerzustand ist ungueltig.');
}
}
private static function pruefeNennleistung(int $nennleistung): void
{
if ($nennleistung <= 0) {
throw new InvalidArgumentException('Die Nennleistung muss groesser als 0 sein.');
}
}
}
+5 -13
View File
@@ -8,24 +8,16 @@ use PHPUnit\Framework\TestCase;
final class DokumentationsstrukturTest extends TestCase final class DokumentationsstrukturTest extends TestCase
{ {
/** @return list<array{string}> */ public function testWaermepumpeDokumentiertDenImplementiertenStand(): void
public function modulReadmes(): array
{ {
return [ $pfad = __DIR__ . '/../docs/module/Waermepumpe/README.md';
['Waermepumpe'],
];
}
/** @dataProvider modulReadmes */
public function testJederModulentwurfHatEinReadme(string $modul): void
{
$pfad = __DIR__ . '/../docs/module/' . $modul . '/README.md';
self::assertFileExists($pfad); self::assertFileExists($pfad);
$inhalt = file_get_contents($pfad); $inhalt = file_get_contents($pfad);
self::assertNotFalse($inhalt); self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: Diskussionsentwurf', $inhalt); self::assertStringContainsString('Status: Implementiert', $inhalt);
self::assertStringContainsString('Properties', $inhalt); self::assertStringContainsString('Konfiguration', $inhalt);
self::assertStringContainsString('Adaption aus Enelix 1', $inhalt);
} }
public function testBatterieDokumentiertDenImplementiertenStand(): void public function testBatterieDokumentiertDenImplementiertenStand(): void
+10
View File
@@ -74,6 +74,16 @@ return [
'libs/Nachrichtenvertrag.php', 'libs/Nachrichtenvertrag.php',
], ],
], ],
'Waermepumpe' => [
'file' => __DIR__ . '/modules/Waermepumpe.php',
'paths' => [
'Waermepumpe/*',
'libs/WaermepumpenRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'Warmwassererwaermer' => [ 'Warmwassererwaermer' => [
'file' => __DIR__ . '/modules/Warmwassererwaermer.php', 'file' => __DIR__ . '/modules/Warmwassererwaermer.php',
'paths' => [ 'paths' => [
+140
View File
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$kontakt1 = $test->createActionVariable('WP Kontakt 1');
$kontakt2 = $test->createActionVariable('WP Kontakt 2');
$rueckmeldung = $test->createVariable(0, 'WP läuft', false);
$waermepumpe = $test->createInstance(
'{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}',
'Wärmepumpe',
[
'Kontaktart' => 0,
'Kontakt1VariableID' => $kontakt1,
'Kontakt2VariableID' => $kontakt2,
'Nennleistung' => 5000,
'Rueckmeldungsart' => 1,
'IstleistungVariableID' => $rueckmeldung,
'Anlaufwartezeit' => 1,
'Wiederholsperre' => 3,
'Mindestlaufzeit' => 1,
'Mindestsperrzeit' => 0,
'MaximaleSperrzeit' => 2,
'MaximaleSperrzeit24h' => 60,
'VorgabeTimeout' => 30,
'DiagnosevariablenAnzeigen' => true,
]
);
$manager = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'WP Hilfsmanager',
[
'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode([
['InstanzID' => $waermepumpe, 'Aktiv' => true],
], JSON_THROW_ON_ERROR),
]
);
$senden = static function (?int $leistung, string $betriebsart) use (
$manager,
$waermepumpe
): void {
IPS_RequestAction(
$waermepumpe,
'ManagerdatenEmpfangen',
json_encode([
'Kopf' => [
'Version' => '4.0',
'AbsenderID' => $manager,
'EmpfaengerID' => $waermepumpe,
'Zeitpunkt' => time(),
],
'Betriebsart' => $betriebsart,
'Sollleistung_W' => $leistung,
], JSON_THROW_ON_ERROR)
);
};
$test->runCase('Instanz und Diagnosevariablen werden angelegt', static function (
TestContext $test
) use ($waermepumpe): void {
$test->assertInstanceStatus($waermepumpe);
foreach ([
'Aktiv',
'Waermepumpenzustand',
'WaermepumpeLaeuft',
'SperreAktiv',
'ErhoehungAktiv',
'SGReadyZustand',
'Istleistung',
'Sollleistung',
'AnlaufAusstehend',
'Sperrzeit24h',
] as $ident) {
$test->objectByIdent($ident, $waermepumpe);
}
});
$test->runCase('PV fordert Erhoehung an und bestaetigt den Anlauf', static function (
TestContext $test
) use ($waermepumpe, $kontakt1, $kontakt2, $rueckmeldung, $senden): void {
IPS_RequestAction($waermepumpe, 'Aktiv', true);
$senden(null, 'PV');
$senden(5000, 'PV');
$test->waitUntil(static fn (): bool => GetValue($kontakt2) === true);
$test->assertSame(false, GetValue($kontakt1), 'Kontakt 1 muss im PV-Anlauf aus sein.');
$test->assertSame(true, GetValue(
$test->objectByIdent('AnlaufAusstehend', $waermepumpe)
), 'Der PV-Anlauf muss bis zur Rueckmeldung ausstehend sein.');
$test->waitUntil(static fn (): bool => GetValue($kontakt2) === false, 2500);
$test->waitUntil(static fn (): bool => GetValue(
$test->objectByIdent('WiederholsperreAktiv', $waermepumpe)
) === true);
$test->assertSame(true, GetValue(
$test->objectByIdent('WiederholsperreAktiv', $waermepumpe)
), 'Nach dem fehlgeschlagenen Anlauf muss die Wiederholsperre aktiv sein.');
$test->assertSame(false, GetValue(
$test->objectByIdent('AenderungMoeglich', $waermepumpe)
), 'Waehrend der Wiederholsperre darf keine Aenderung angeboten werden.');
$test->waitUntil(static fn (): bool => GetValue(
$test->objectByIdent('WiederholsperreAktiv', $waermepumpe)
) === false, 4500);
$senden(5000, 'PV');
$test->waitUntil(static fn (): bool => GetValue($kontakt2) === true);
SetValue($rueckmeldung, true);
$test->waitUntil(static fn (): bool => GetValue(
$test->objectByIdent('WaermepumpeLaeuft', $waermepumpe)
) === true);
$test->assertSame(5000, (int) GetValue(
$test->objectByIdent('Istleistung', $waermepumpe)
));
IPS_Sleep(1100);
});
$test->runCase('Peak sperrt und maximale Sperrzeit erzwingt Normalbetrieb', static function (
TestContext $test
) use ($waermepumpe, $kontakt1, $kontakt2, $senden): void {
$senden(null, 'Peak');
$senden(0, 'Peak');
$test->waitUntil(static fn (): bool => GetValue($kontakt1) === true);
$test->assertSame(false, GetValue($kontakt2));
$test->assertSame(true, GetValue(
$test->objectByIdent('SperreAktiv', $waermepumpe)
));
$test->waitUntil(static fn (): bool => GetValue($kontakt1) === false, 2500);
$test->assertSame(true, GetValue(
$test->objectByIdent('SperrerholungAktiv', $waermepumpe)
));
$test->assertSame(false, GetValue(
$test->objectByIdent('SperreAktiv', $waermepumpe)
));
});
};
+2
View File
@@ -65,6 +65,7 @@ final class SymconTestContractTest extends TestCase
'Manager', 'Manager',
'Pufferspeicher', 'Pufferspeicher',
'VerbraucherEinStufig', 'VerbraucherEinStufig',
'Waermepumpe',
'Warmwassererwaermer', 'Warmwassererwaermer',
], ],
$this->resolveAffectedModules(['libs/Nachrichtenvertrag.php']) $this->resolveAffectedModules(['libs/Nachrichtenvertrag.php'])
@@ -90,6 +91,7 @@ final class SymconTestContractTest extends TestCase
'Manager', 'Manager',
'Pufferspeicher', 'Pufferspeicher',
'VerbraucherEinStufig', 'VerbraucherEinStufig',
'Waermepumpe',
'Warmwassererwaermer', 'Warmwassererwaermer',
], ],
$this->resolveAffectedModules(['tests/Symcon/TestContext.php']) $this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class WaermepumpeModulstrukturTest extends TestCase
{
private const MODULPFAD = __DIR__ . '/../Waermepumpe/';
private const MODULID = '{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}';
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
{
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(self::MODULPFAD . $datei);
}
$metadaten = json_decode(
(string) file_get_contents(self::MODULPFAD . 'module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('VerbraucherWaermepumpe', $metadaten['name']);
self::assertSame('Enelix', $metadaten['vendor']);
self::assertSame(['Waermepumpe'], $metadaten['aliases']);
self::assertSame(self::MODULID, $metadaten['id']);
self::assertSame(3, $metadaten['type']);
}
public function testAllePropertiesSindRegistriertUndImFormularEnthalten(): void
{
$basis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
preg_match_all(
'/RegisterProperty(?:Integer|Float|Boolean|String)\(\'([^\']+)\'/',
$basis . $modul,
$treffer
);
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
foreach (array_values(array_unique($treffer[1])) as $property) {
self::assertStringContainsString('"name": "' . $property . '"', $formular, $property);
}
}
public function testVertrag40UndBetriebsartabhaengigesAngebotWerdenVerwendet(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
self::assertStringContainsString('Nachrichtenvertrag::pruefeManagerdaten', $modul);
self::assertStringContainsString('Nachrichtenvertrag::pruefeVerbraucherdaten', $modul);
self::assertStringContainsString("['Betriebsart']", $modul);
self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul);
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
}
public function testRegelungIstEreignisbasiertUndBegrenztSperrzeiten(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('public function MessageSink', $modul);
self::assertStringContainsString('RegisterMessage($neueID, self::VM_UPDATE)', $modul);
foreach ([
'Meldezyklus',
'MeldungVerzoegert',
'Zustandspruefung',
'VorgabeTimeout',
] as $timer) {
self::assertStringContainsString("'" . $timer . "'", $modul);
}
foreach ([
'Mindestlaufzeit',
'Mindestsperrzeit',
'MaximaleSperrzeit',
'MaximaleSperrzeit24h',
'Anlaufwartezeit',
'Wiederholsperre',
] as $property) {
self::assertStringContainsString("'" . $property . "'", $modul);
}
}
public function testKontakteWerdenBreakBeforeMakeGeschaltet(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
$aus = strpos($modul, '$this->setzeKontakt(1, false)');
$ein = strpos($modul, '$this->setzeKontakt(1, true)');
self::assertIsInt($aus);
self::assertIsInt($ein);
self::assertLessThan($ein, $aus);
}
public function testFachUndDiagnosevariablenSindVorhanden(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
foreach ([
'Waermepumpenzustand',
'WaermepumpeLaeuft',
'SperreAktiv',
'ErhoehungAktiv',
'SGReadyZustand',
'Laufzeit',
'AnlaufAusstehend',
'WiederholsperreAktiv',
'SperrerholungAktiv',
'Sperrzeit24h',
] as $ident) {
self::assertStringContainsString("'" . $ident . "'", $modul);
}
self::assertStringContainsString("ReadPropertyBoolean('DiagnosevariablenAnzeigen')", $modul);
self::assertStringContainsString('UnregisterVariable($ident)', $modul);
}
public function testManagerKenntDieModulIDGenauEinmal(): void
{
$manager = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertSame(1, substr_count($manager, self::MODULID));
}
public function testDokumentationBeschreibtImplementiertenStand(): void
{
$dokumentation = (string) file_get_contents(
__DIR__ . '/../docs/module/Waermepumpe/README.md'
);
self::assertStringContainsString('Status: Implementiert', $dokumentation);
self::assertStringContainsString('nicht uebernommen', $dokumentation);
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\WaermepumpenRegler;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class WaermepumpenReglerTest extends TestCase
{
public function testKontaktwerteBildenNormalSperreUndErhoehungAb(): void
{
self::assertSame(
['Kontakt1' => false, 'Kontakt2' => false],
WaermepumpenRegler::kontaktwerte(0, WaermepumpenRegler::STEUERZUSTAND_NORMAL)
);
self::assertSame(
['Kontakt1' => true, 'Kontakt2' => false],
WaermepumpenRegler::kontaktwerte(1, WaermepumpenRegler::STEUERZUSTAND_SPERRE)
);
self::assertSame(
['Kontakt1' => false, 'Kontakt2' => true],
WaermepumpenRegler::kontaktwerte(1, WaermepumpenRegler::STEUERZUSTAND_ERHOEHUNG)
);
}
public function testLeistungUndBetriebsstatusBestimmenLaufUndIstleistung(): void
{
self::assertTrue(WaermepumpenRegler::laeuft(650.0, 0, 100.0));
self::assertFalse(WaermepumpenRegler::laeuft(50.0, 0, 100.0));
self::assertTrue(WaermepumpenRegler::laeuft(true, 1, 100.0));
self::assertSame(650.0, WaermepumpenRegler::istleistung(650.0, 0, 5000));
self::assertSame(5000.0, WaermepumpenRegler::istleistung(true, 1, 5000));
}
public function testPvBietetStartUndErhoehungNurBeiFreigabeAn(): void
{
self::assertSame(
[0, 5000],
WaermepumpenRegler::leistungsangebot(
5000,
Nachrichtenvertrag::BETRIEBSART_PV,
false,
true,
false,
false,
true,
true,
false
)
);
self::assertSame(
[0],
WaermepumpenRegler::leistungsangebot(
5000,
Nachrichtenvertrag::BETRIEBSART_PV,
false,
true,
false,
true,
true,
false,
false
)
);
}
public function testPeakBietetSperreNurBeiLaufenderUndFreigegebenerPumpeAn(): void
{
self::assertSame(
[0, 5000],
WaermepumpenRegler::leistungsangebot(
5000,
Nachrichtenvertrag::BETRIEBSART_PEAK,
true,
true,
false,
false,
true,
true,
false
)
);
self::assertSame(
[5000],
WaermepumpenRegler::leistungsangebot(
5000,
Nachrichtenvertrag::BETRIEBSART_PEAK,
true,
true,
true,
false,
false,
true,
false
)
);
self::assertSame(
[0],
WaermepumpenRegler::leistungsangebot(
5000,
Nachrichtenvertrag::BETRIEBSART_PEAK,
false,
true,
false,
false,
true,
true,
false
)
);
}
public function testSperrerholungHaeltDenBestaetigtenBetrieb(): void
{
self::assertSame(
[5000],
WaermepumpenRegler::leistungsangebot(
5000,
Nachrichtenvertrag::BETRIEBSART_PEAK,
true,
true,
false,
false,
true,
true,
true
)
);
}
public function testRestzeitWirdNieNegativ(): void
{
self::assertSame(20, WaermepumpenRegler::restzeit(100, 30, 110));
self::assertSame(0, WaermepumpenRegler::restzeit(100, 30, 140));
self::assertSame(0, WaermepumpenRegler::restzeit(0, 30, 10));
}
public function testSperrzeit24hSchneidetAlteIntervalleAb(): void
{
$jetzt = 200000;
$historie = [
['Von' => $jetzt - 90000, 'Bis' => $jetzt - 80000],
['Von' => $jetzt - 1000, 'Bis' => $jetzt - 400],
];
self::assertSame(
7400,
WaermepumpenRegler::sperrsekunden24h($historie, $jetzt, $jetzt - 400)
);
}
public function testUngueltigeKontaktartWirdAbgewiesen(): void
{
$this->expectException(InvalidArgumentException::class);
WaermepumpenRegler::kontaktwerte(99, WaermepumpenRegler::STEUERZUSTAND_NORMAL);
}
}