RELAIS : mon avertissement de reconstruction sur un réordonnancement est devenu FAUX, et
il est corrigé. `+etm16` compare la TABLE que les relais produisent (deriveStages), plus la
liste par index. Le miroir Dart suit, y compris le détail qui compte : les ThingIds d'une
combinaison se comparent comme un ENSEMBLE — collectés dans l'ordre de déclaration, fermer
{K1,K2} ou {K2,K1} est le même geste électrique. Mais l'appariement palier par palier
reste positionnel, ce qui préserve le seul cas où l'ordre signifie encore quelque chose :
deux relais de MÊME puissance, où il décide quel contact sert ce palier.
Conséquence pour l'écran : réordonner des contacteurs de puissances distinctes n'avertit
plus de rien. Avertir sur un geste devenu gratuit serait aussi trompeur que de se taire sur
un geste coûteux.
PALIERS : l'app ne dérive plus la combinatoire pour l'affichage — la box les publie
(mechanism.stagesW). Vérifié sur appareil : [0, 500, 1000, 1500, 2000, 2500, 3000, 3500].
deriveStages reste côté app pour la seule annonce d'impact, qui se fait avant l'écriture.
Tant que le brouillon n'a pas touché aux contacteurs, c'est la table PUBLIÉE qui s'affiche ;
dès qu'il y touche elle devient périmée, et l'écran montre une prévision annoncée comme
telle plutôt qu'une table qui décrit l'état d'avant.
COMPTEUR ET SONDE : ils ne « vont pas arriver », ils SONT au schéma de .75
(o:meterThingId, o:sensorThingId). Le grisage dérivé les a donc activés seul, sans nouvelle
version de l'app — exactement ce qui était promis. Mais un bloc actif sur un placeholder
mort est pire qu'un bloc grisé : les deux sont désormais câblés sur un vrai sélecteur,
filtré par INTERFACE (smartmeter, temperaturesensor) et jamais par nom de plugin.
Ni l'un ni l'autre n'entre dans sameHardware() : les rattacher ne reconstruit rien. Ni dans
le contrôle d'unicité : deux charges peuvent partager une sonde, un compteur divisionnaire
en couvrir plusieurs — le sélecteur ne grise donc rien ici, contrairement aux contacteurs.
Et la mesure sert à VÉRIFIER, jamais à décider : elle n'entre pas dans le budget, la charge
étant déjà comptée dans le racine dont elle n'est qu'une décomposition.
COMPTEURS CUMULÉS : la base purgée, les valeurs sont saines (55 730 / 50 / 6 327 / 62 007
kWh) et la tuile « compteur box invalide » a disparu d'elle-même. Le plafond de
plausibilité reste : un zéro réel s'affiche « 0,0 kWh », jamais « — ».
hasNeverRun était déjà traité comme un état distinct — « l'absence de cycle est dite pour
elle-même, jamais rendue par un âge de zéro ».
Vérifié sur appareil contre .75 : compteur racine relu, « à configurer » vide, deux bornes
listées (Simulated wallbox + Terra AC Charger), stagesW de la box, les deux champs
writable, 5/5 sections de l'écran SG-Ready. Tests 98/98, analyze 0 erreur.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
308 lines
12 KiB
Dart
308 lines
12 KiB
Dart
/// Miroir client de `EnergyArbitrator::sameHardware()` — pour **annoncer avant
|
|
/// d'enregistrer**, jamais pour décider à la place de la box.
|
|
///
|
|
/// ## Ce que la distinction change vraiment
|
|
///
|
|
/// `SetLoadConfig` remplace l'ensemble des charges, mais le moteur ne reconstruit pas
|
|
/// tout : il compare chaque charge à celle qui a servi à bâtir l'adaptateur en place
|
|
/// (`rebuildLoadAdapters()`, ECS-412). Deux issues, très différentes pour l'installation :
|
|
///
|
|
/// - **mise à jour en place** — `priority`, `needs` : l'adaptateur garde son palier
|
|
/// courant et ses verrous. Rien ne bouge sur le matériel.
|
|
/// - **reconstruction** — tout ce que [sameHardware] compare : l'ancien adaptateur passe
|
|
/// par `applySafeState()` (**contacts ouverts, `force`**), le nouveau relit l'état réel,
|
|
/// et ses verrous se **réarment à froid** pour leur durée configurée (ECS-412).
|
|
///
|
|
/// Sur un ballon à `minOnS` de 60 s c'est anecdotique. Sur une PAC à `minStateHoldS` de
|
|
/// 900 s, l'installateur qui corrige une ligne se retrouve avec une machine figée un quart
|
|
/// d'heure — et rien ne le lui aura dit. D'où cet écran d'annonce.
|
|
///
|
|
/// ## Les relais se comparent par leur TABLE, pas par leur liste — depuis `+etm16`
|
|
///
|
|
/// La comparaison était indexée : réordonner `relays[]` sans rien changer d'autre passait
|
|
/// pour un changement matériel et coûtait une reconstruction complète. Signalé au moteur,
|
|
/// **corrigé côté moteur** — `sameHardware()` compare désormais
|
|
/// `RelayRouter::deriveStages(relays)`, c'est-à-dire la table de paliers que les relais
|
|
/// PRODUISENT, et non la liste qui la décrit.
|
|
///
|
|
/// Ce que ça change, et pourquoi un ensemble n'aurait pas suffi :
|
|
///
|
|
/// - **réordonner des relais de puissances distinctes** ne change pas la table → aucune
|
|
/// reconstruction. Vérifié au banc : le journal dit « 2 INCHANGÉE(S) », aucun état sûr,
|
|
/// aucune commutation ;
|
|
/// - **échanger deux relais de MÊME puissance** change le contact qui sert ce palier — à
|
|
/// somme égale la table retient la première combinaison rencontrée. Autre contacteur qui
|
|
/// s'use, autre résistance qui chauffe. Un ensemble ne le verrait pas ; la table, si.
|
|
///
|
|
/// [deriveStages] ci-dessous est le **miroir** de l'implémentation moteur. Elle existe
|
|
/// parce que l'annonce d'impact se fait côté app, avant l'écriture — mais elle ne sert
|
|
/// jamais à AFFICHER les paliers : la box les publie elle-même en télémétrie
|
|
/// (`mechanism.stagesW`), et c'est cette valeur-là qui fait foi.
|
|
library;
|
|
|
|
import '../models/load_config_entry.dart';
|
|
|
|
/// Ce qu'un enregistrement va provoquer pour une charge donnée.
|
|
enum LoadChangeImpact {
|
|
/// Rien ne part pour cette charge.
|
|
none,
|
|
|
|
/// Mise à jour en place : rang, besoins. Verrous et palier courant préservés.
|
|
inPlace,
|
|
|
|
/// Reconstruction de l'adaptateur : état sûr, relecture, verrous réarmés à froid.
|
|
rebuild,
|
|
|
|
/// La charge entre dans l'arbitrage (`enabled` faux → vrai, ou charge nouvelle).
|
|
created,
|
|
|
|
/// La charge en sort (`enabled` vrai → faux, ou charge retirée) : elle passe par
|
|
/// `applySafeState()` avant destruction (ECS-413).
|
|
removed,
|
|
}
|
|
|
|
/// Verdict d'impact pour une charge, avec de quoi le formuler à l'écran.
|
|
class LoadImpact {
|
|
final String loadId;
|
|
final String label;
|
|
final LoadChangeImpact impact;
|
|
|
|
/// Champs matériels qui ont changé (`relays`, `sgReady`, `minOnS`, …). Vide hors
|
|
/// reconstruction. Sert à dire **ce qui** a déclenché, pas seulement qu'il y a eu.
|
|
final List<String> changedFields;
|
|
|
|
/// Durée du verrou qui va se réarmer, en secondes, si elle est connue.
|
|
///
|
|
/// `minStateHoldS` pour une charge `sg-ready`, `max(minOnS, minOffS)` pour un
|
|
/// routeur de relais. `null` quand le mécanisme n'en déclare pas.
|
|
final int? rearmSeconds;
|
|
|
|
const LoadImpact({
|
|
required this.loadId,
|
|
required this.label,
|
|
required this.impact,
|
|
this.changedFields = const [],
|
|
this.rearmSeconds,
|
|
});
|
|
|
|
/// Vrai si l'installateur doit être averti avant que ça parte.
|
|
bool get needsWarning =>
|
|
impact == LoadChangeImpact.rebuild || impact == LoadChangeImpact.removed;
|
|
}
|
|
|
|
/// Table de paliers produite par une liste de relais — **miroir** de
|
|
/// `RelayRouter::deriveStages()`.
|
|
///
|
|
/// Toutes les sommes de sous-ensembles, dédupliquées par puissance, triées, 0 inclus
|
|
/// (sous-ensemble vide). À puissance égale on garde la **première** combinaison
|
|
/// rencontrée : c'est ce qui rend l'ordre de déclaration significatif dans ce cas, et dans
|
|
/// celui-là seulement.
|
|
///
|
|
/// \return les paliers et, pour chacun, les Things à fermer.
|
|
({List<int> levels, List<List<String>> mapping}) deriveStages(
|
|
List<LoadRelay> relays) {
|
|
var n = relays.length;
|
|
if (n > kMaxRelays) n = kMaxRelays;
|
|
|
|
final parPuissance = <int, List<String>>{};
|
|
for (var masque = 0; masque < (1 << n); masque++) {
|
|
var somme = 0;
|
|
final ensemble = <String>[];
|
|
for (var i = 0; i < n; i++) {
|
|
if (masque & (1 << i) != 0) {
|
|
somme += relays[i].powerW;
|
|
ensemble.add(normalizeThingId(relays[i].thingId));
|
|
}
|
|
}
|
|
parPuissance.putIfAbsent(somme, () => ensemble);
|
|
}
|
|
|
|
final niveaux = parPuissance.keys.toList()..sort();
|
|
return (
|
|
levels: niveaux,
|
|
mapping: [for (final k in niveaux) parPuissance[k]!],
|
|
);
|
|
}
|
|
|
|
/// Plafond du moteur : au-delà, la combinatoire n'est plus calculée.
|
|
const int kMaxRelays = 16;
|
|
|
|
/// Miroir **exact** de `EnergyArbitrator::sameHardware()` (energyarbitrator.cpp).
|
|
///
|
|
/// Toute divergence ferait mentir l'annonce — dans un sens comme dans l'autre. Les champs
|
|
/// comparés, dans l'ordre du moteur : `adapter`, `mode`, `minOnS`, `minOffS`, `maxPowerW`,
|
|
/// `powerLevels`, la charge utile `sgReady` entière, puis la **table** que `relays[]`
|
|
/// produit — [deriveStages], et non la liste elle-même.
|
|
///
|
|
/// \return vrai si le moteur gardera l'adaptateur en place.
|
|
bool sameHardware(LoadConfigEntry a, LoadConfigEntry b) =>
|
|
hardwareDiff(a, b).isEmpty;
|
|
|
|
/// Les champs matériels qui diffèrent. Vide ⇔ [sameHardware].
|
|
///
|
|
/// Rendre la liste plutôt qu'un booléen permet de dire *ce qui* impose la reconstruction :
|
|
/// « vous avez changé l'ordre des contacteurs » est actionnable, « la charge sera
|
|
/// reconstruite » ne l'est pas.
|
|
List<String> hardwareDiff(LoadConfigEntry a, LoadConfigEntry b) {
|
|
final diff = <String>[];
|
|
|
|
if (a.adapter != b.adapter) diff.add('adapter');
|
|
if (a.mode != b.mode) diff.add('mode');
|
|
if (a.minOnS != b.minOnS) diff.add('minOnS');
|
|
if (a.minOffS != b.minOffS) diff.add('minOffS');
|
|
if (a.maxPowerW != b.maxPowerW) diff.add('maxPowerW');
|
|
if (!_sameIntList(a.powerLevelsInt, b.powerLevelsInt)) diff.add('powerLevels');
|
|
if (!_sameSgReady(a, b)) diff.add('sgReady');
|
|
|
|
// On compare la TABLE que les relais produisent, comme le moteur depuis `+etm16` —
|
|
// pas la liste qui la décrit.
|
|
final ta = deriveStages(a.relays);
|
|
final tb = deriveStages(b.relays);
|
|
if (!_sameIntList(ta.levels, tb.levels) ||
|
|
!_sameMapping(ta.mapping, tb.mapping)) {
|
|
diff.add('relays');
|
|
}
|
|
|
|
return diff;
|
|
}
|
|
|
|
/// Impact d'un enregistrement, charge par charge.
|
|
///
|
|
/// [avant] est l'état de la box (celui de `GetLoadConfig`), [apres] ce qui va partir.
|
|
/// Les charges sont appariées par `id` — c'est la clé du moteur.
|
|
List<LoadImpact> impactOfSave({
|
|
required List<LoadConfigEntry> avant,
|
|
required List<LoadConfigEntry> apres,
|
|
}) {
|
|
final parAvant = {for (final e in avant) e.id: e};
|
|
final parApres = {for (final e in apres) e.id: e};
|
|
final out = <LoadImpact>[];
|
|
|
|
for (final e in apres) {
|
|
final old = parAvant[e.id];
|
|
|
|
if (old == null) {
|
|
out.add(LoadImpact(
|
|
loadId: e.id, label: e.label, impact: LoadChangeImpact.created));
|
|
continue;
|
|
}
|
|
if (!old.enabled && e.enabled) {
|
|
out.add(LoadImpact(
|
|
loadId: e.id, label: e.label, impact: LoadChangeImpact.created));
|
|
continue;
|
|
}
|
|
if (old.enabled && !e.enabled) {
|
|
out.add(LoadImpact(
|
|
loadId: e.id, label: e.label, impact: LoadChangeImpact.removed));
|
|
continue;
|
|
}
|
|
if (!e.enabled) {
|
|
// Désactivée avant comme après : aucun adaptateur n'existe, rien à reconstruire.
|
|
out.add(LoadImpact(
|
|
loadId: e.id, label: e.label, impact: LoadChangeImpact.none));
|
|
continue;
|
|
}
|
|
|
|
final diff = hardwareDiff(old, e);
|
|
if (diff.isNotEmpty) {
|
|
out.add(LoadImpact(
|
|
loadId: e.id,
|
|
label: e.label,
|
|
impact: LoadChangeImpact.rebuild,
|
|
changedFields: diff,
|
|
rearmSeconds: _rearmSecondsOf(e),
|
|
));
|
|
continue;
|
|
}
|
|
|
|
// `label` et `domain` ne sont PAS dans la comparaison du moteur : ils ne déclenchent
|
|
// ni reconstruction ni mise à jour en place — l'adaptateur est réutilisé tel quel.
|
|
// Seuls `priority` et `needs` provoquent un updateSoftConfig().
|
|
final soft = old.priority != e.priority ||
|
|
old.dailyDeadline != e.dailyDeadline ||
|
|
old.minEnergyWhPerDay != e.minEnergyWhPerDay;
|
|
out.add(LoadImpact(
|
|
loadId: e.id,
|
|
label: e.label,
|
|
impact: soft ? LoadChangeImpact.inPlace : LoadChangeImpact.none,
|
|
));
|
|
}
|
|
|
|
for (final e in avant) {
|
|
if (parApres.containsKey(e.id)) continue;
|
|
out.add(LoadImpact(
|
|
loadId: e.id,
|
|
label: e.label,
|
|
impact: e.enabled ? LoadChangeImpact.removed : LoadChangeImpact.none,
|
|
));
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/// Durée du verrou qui se réarmera après reconstruction, si le mécanisme en déclare une.
|
|
int? _rearmSecondsOf(LoadConfigEntry e) {
|
|
if (e.isSgReady) {
|
|
final h = e.minStateHoldS;
|
|
return h > 0 ? h : null;
|
|
}
|
|
final m = e.minOnS > e.minOffS ? e.minOnS : e.minOffS;
|
|
return m > 0 ? m : null;
|
|
}
|
|
|
|
bool _sameIntList(List<int> a, List<int> b) {
|
|
if (a.length != b.length) return false;
|
|
for (var i = 0; i < a.length; i++) {
|
|
if (a[i] != b[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// Compare la charge utile `sg-ready` comme le moteur : `minStateHoldS`, le NOMBRE
|
|
/// d'états, puis chaque état index par index (`state`, `relays`, `estimatedPowerW`).
|
|
bool _sameSgReady(LoadConfigEntry a, LoadConfigEntry b) {
|
|
if (a.minStateHoldS != b.minStateHoldS) return false;
|
|
final sa = a.sgReadyStates, sb = b.sgReadyStates;
|
|
if (sa.length != sb.length) return false;
|
|
for (var i = 0; i < sa.length; i++) {
|
|
if (sa[i].state != sb[i].state) return false;
|
|
if (!_sameStringList(
|
|
sa[i].relays.map(normalizeThingId).toList(),
|
|
sb[i].relays.map(normalizeThingId).toList())) {
|
|
return false;
|
|
}
|
|
// Le moteur compare en qFuzzyCompare : une égalité stricte de doubles suffit ici,
|
|
// les deux valeurs venant du même aller-retour JSON.
|
|
if (sa[i].estimatedPowerW != sb[i].estimatedPowerW) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// Compare deux affectations palier → contacts, **position par position**, chaque
|
|
/// combinaison étant comparée comme un **ENSEMBLE**.
|
|
///
|
|
/// Miroir de `RelayStageTable::operator==`. Les ThingIds d'une combinaison sont collectés
|
|
/// dans l'ordre de déclaration des relais : fermer {K1,K2} et fermer {K2,K1} produit deux
|
|
/// listes différentes pour un geste électrique identique. Les comparer comme des listes
|
|
/// ferait reconstruire la charge sur un simple réordonnancement — le défaut même que cette
|
|
/// table corrige.
|
|
///
|
|
/// L'ordre reste significatif là où il l'est vraiment : dans le **choix** de la combinaison
|
|
/// retenue à somme égale, que `levels` et l'appariement position par position capturent.
|
|
bool _sameMapping(List<List<String>> a, List<List<String>> b) {
|
|
if (a.length != b.length) return false;
|
|
for (var i = 0; i < a.length; i++) {
|
|
if (a[i].length != b[i].length) return false;
|
|
if (!a[i].toSet().containsAll(b[i])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool _sameStringList(List<String> a, List<String> b) {
|
|
if (a.length != b.length) return false;
|
|
for (var i = 0; i < a.length; i++) {
|
|
if (a[i] != b[i]) return false;
|
|
}
|
|
return true;
|
|
}
|