1, 'NetzbezugEnergieVariableID' => 9, 'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60, 'Lastspitzenmodus' => 1, 'Lastspitzengrenze' => 25000.0, 'EinspeisebegrenzungAktiv' => true, 'Einspeisegrenze' => 25000.0]; $m['V4BezugszaehlerQuellen'] = [ ['VariableID' => 6, 'ElternID' => 99, 'Ident' => 'Energy_0', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'], ['VariableID' => 7, 'ElternID' => 99, 'Ident' => 'Energy_1', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'], ]; $a = [['ID' => 'ev', 'Nennkapazitaet_kWh' => 161.44, 'Nutzkapazitaet_kWh' => 161.44, 'MaxLadeleistung_kW' => 39.0, 'MaxEntladeleistung_kW' => 30.0, 'SOCVariableID' => 2, 'LeistungVariableID' => 5]]; $c = [['InstanzID' => 42, 'LadezustandVariableID' => 2, 'MaxLadeleistungVariableID' => 3, 'MaxEntladeleistungVariableID' => 4, 'IstleistungVariableID' => 5, 'ReserveLadezustand' => 15.0, 'MindestLadezustand' => 3.0, 'MesswertMaxAlter' => 30, 'Verfuegbar' => true, 'Batteriemanagement' => 2, 'EmpfangenAm' => $now]]; $read = static function (int $id) use ($now): array { return ['value' => [1 => -5332.0, 2 => 19.0, 3 => 39000.0, 4 => 35000.0, 6 => 3992.203, 7 => 0.0][$id], 'updated' => $now, 'parentID' => 99, 'ident' => [6 => 'Energy_0', 7 => 'Energy_1'][$id] ?? 'other']; }; $check = static function (bool $ok): void { if (!$ok) { throw new RuntimeException('Assertion failed'); } }; $fail = static function (callable $fn): void { try { $fn(); } catch (InvalidArgumentException $e) { return; } throw new RuntimeException('Expected invalid-input rejection'); }; $cases = []; $build = static function ($mm = null, $aa = null, $cc = null, $r = null, $e = []) use ($m, $a, $c, $read, $now): array { return NetzfahrplanV4Betriebsdaten::erstellen($mm ?? $m, $aa ?? $a, $cc ?? $c, $r ?? $read, $e, $now); }; $cases['native_mapping_and_asymmetric_limits'] = static function () use ($build, $check): void { $x = $build()['operation']; $b = $x['batteries'][0]; $check($x['gridW'] === -5332.0 && $b['capacityKwh'] === 161.44 && $b['minSocPercent'] === 15.0 && $b['maxChargeW'] === 39000.0 && $b['maxDischargeW'] === 30000.0); }; $cases['manager_cap_not_paid_peak'] = static function () use ($build, $check): void { $x = $build()['operation']; $check($x['limits']['importW'] === 25000.0 && (array) $x['measuredPeaks'] === [] && $x['quarterPast'] === null); }; $cases['empty_maps_are_objects'] = static function () use ($build, $check): void { $x = json_decode(json_encode($build()['operation'])); $check(is_object($x->measuredPeaks) && is_object($x->limits->managerMonthLimitsW)); }; $cases['zero_limits_not_unlimited'] = static function () use ($build, $m, $check): void { $m['Einspeisegrenze'] = 0.0; $m['Lastspitzengrenze'] = 0.0; $x = $build($m)['operation']; $check($x['limits']['importW'] === 0.0 && $x['limits']['exportW'] === 0.0); }; $cases['disabled_limits_are_null'] = static function () use ($build, $m, $check): void { $m['EinspeisebegrenzungAktiv'] = false; $m['Lastspitzenmodus'] = 0; $x = $build($m)['operation']; $check($x['limits']['importW'] === null && $x['limits']['exportW'] === null); }; $cases['all_monthly_limits'] = static function () use ($build, $m, $check, $fail): void { $m['Lastspitzenmodus'] = 2; $m['Monatsgrenzen'] = []; for ($i = 1; $i <= 12; $i++) { $m['Monatsgrenzen'][] = ['MonatIndex' => $i, 'Grenze_W' => $i * 1000]; } $x = $build($m)['operation']; $check($x['limits']['managerMonthLimitsW']->{'10'} === 10000.0); array_pop($m['Monatsgrenzen']); $fail(static fn() => $build($m)); }; $cases['no_unconfirmed_soc_capacity'] = static function () use ($build, $a, $m, $fail, $check): void { $a[0]['Nutzkapazitaet_kWh'] = 140.; $fail(static fn() => $build(null, $a)); $m['BatterieOptionen'] = ['ev' => ['SOCKapazitaet_kWh' => 161.44]]; $check($build($m, $a)['operation']['batteries'][0]['capacityKwh'] === 161.44); }; $cases['no_duplicate_controller'] = static function () use ($build, $a, $fail): void { $a[] = array_replace($a[0], ['ID' => 'other']); $fail(static fn() => $build(null, $a)); }; $cases['no_ambiguous_mapping'] = static function () use ($build, $c, $fail): void { $c[] = array_replace($c[0], ['InstanzID' => 43]); $fail(static fn() => $build(null, null, $c)); }; $cases['mismatched_power_source'] = static function () use ($build, $c, $fail): void { $c[0]['IstleistungVariableID'] = 100; $fail(static fn() => $build(null, null, $c)); }; $cases['stale_measurements_fail'] = static function () use ($build, $read, $now, $fail): void { $r = static function ($id) use ($read, $now) { $v = $read($id); $v['updated'] = $now - 61; return $v; }; $fail(static fn() => $build(null, null, null, $r)); }; $cases['stale_consumer_cache_fails'] = static function () use ($build, $c, $now, $fail): void { $c[0]['EmpfangenAm'] = $now - 61; $fail(static fn() => $build(null, null, $c)); }; $cases['soc_below_reserve_is_not_fabricated'] = static function () use ($build, $read, $check): void { $r = static function ($id) use ($read) { $v = $read($id); if ($id === 2) { $v['value'] = 5.; } return $v; }; $b = $build(null, null, null, $r)['operation']['batteries'][0]; $check($b['socPercent'] === 5.0 && $b['minSocPercent'] === 15.0 && $b['physicalMinSocPercent'] === 3.0 && $b['recoveryAllowed'] === true && $b['dischargeBlocked'] === true); }; $cases['unavailable_asset_no_power'] = static function () use ($build, $c, $check): void { $c[0]['Verfuegbar'] = false; $x = $build(null, null, $c)['operation']['batteries'][0]; $check($x['maxChargeW'] === 0.0 && $x['maxDischargeW'] === 0.0); }; $cases['hysteresis_not_silently_ignored'] = static function () use ($build, $c, $check): void { $c[0]['HystereseAktiv'] = true; $x = $build(null, null, $c); $check($x['operation']['batteries'][0]['dischargeBlocked'] === true && $x['operation']['batteries'][0]['maxDischargeW'] === 30000.0 && count($x['warnings']) > 1); }; $cases['grid_charge_explicit_opt_in'] = static function () use ($build, $m, $check): void { $check($build()['operation']['batteries'][0]['gridCharging'] === false); $m['NetzladenErlaubt'] = true; $check($build($m)['operation']['batteries'][0]['gridCharging'] === true); }; $ev = ['version' => 1, 'meterId' => \Belevo\EnelixEMS\NetzfahrplanV4Bezugszaehler::identitaet($m['V4BezugszaehlerQuellen']), 'measuredAt' => gmdate('c', $now), 'measuredPeaks' => ['2026-10' => ['kw' => 18.4, 'source' => 'meter_month_register']], 'quarterPast' => ['start' => '2026-10-01T12:00:00Z', 'measuredSeconds' => 300, 'importKwh' => 0.5]]; $cases['actual_meter_evidence_used'] = static function () use ($build, $ev, $check): void { $x = $build(null, null, null, null, $ev); $check($x['status'] === 'ready_shadow' && $x['operation']['measuredPeaks']->{'2026-10'}['kw'] === 18.4 && $x['operation']['quarterPast']['importKwh'] === 0.5); }; $cases['stale_quarter_not_extrapolated'] = static function () use ($build, $ev, $now, $check): void { $ev['measuredAt'] = gmdate('c', $now - 1); $x = $build(null, null, null, null, $ev); $check($x['operation']['quarterPast'] === null && count($x['warnings']) > 0); }; $cases['wrong_meter_evidence_fails'] = static function () use ($build, $ev, $fail): void { $ev['meterId'] = 'symcon:100'; $fail(static fn() => $build(null, null, null, null, $ev)); }; $cases['planned_peak_not_evidence'] = static function () use ($build, $ev, $fail): void { $ev['measuredPeaks']['2026-10']['source'] = 'manager_cap'; $fail(static fn() => $build(null, null, null, null, $ev)); }; $cases['deterministic_retry_event_id'] = static function () use ($build, $check): void { $check($build()['operation']['eventId'] === $build()['operation']['eventId']); }; $cases['legacy_meter_identity_not_accepted'] = static function () use ($build, $ev, $fail): void { $ev['meterId'] = 'symcon:9'; $fail(static fn() => $build(null, null, null, null, $ev)); }; $cases['missing_v4_sources_never_use_legacy'] = static function () use ($build, $m, $fail): void { unset($m['V4BezugszaehlerQuellen']); $fail(static fn() => $build($m)); }; $cases['counter_snapshot_is_not_billing_evidence'] = static function () use ($build, $check): void { $v = $build(); $check($v['bezugszaehler']['totalKwh'] === 3992.203 && !$v['bezugszaehler']['billingEvidence'] && (array) $v['operation']['measuredPeaks'] === []); }; $passed = []; foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; } return $passed; } }