feat(3g): les valeurs de borne sont du RUNTIME — deux natures derrière un même mot
MINIMUM. « Plancher » désigne désormais deux choses qu'il ne faut surtout pas ranger ensemble : - le plancher DÉCLARÉ d'une charge modulable — propriété de l'appareil, saisie une fois, stable, avec un champ de configuration à venir ; - le minimum d'une BORNE — max(borne, voiture), qui dépend du véhicule branché et change avec lui. Il n'aura jamais de champ de configuration : il n'y a rien à y régler. Les rendre modifiables de la même façon ferait croire qu'on peut régler ce qui se constate. Le bloc « plancher » ne vit que dans la section etmvariableload, ce qui écarte les bornes structurellement — elles ne sont pas des LoadConfig et n'ouvrent pas cet écran — et son libellé le dit désormais explicitement. PHASES. Le plancher effectif d'une borne dépend du nombre de phases visé : ~1,4 kW en monophasé, ~4,1 kW en triphasé, pour le MÊME courant. La valeur est affichée telle que la box la donne, sans conversion ni recalcul côté app. Si l'app convertissait, elle devrait deviner laquelle des deux règles s'applique — et se tromperait un jour. C'est exactement le miroir qui vient d'être retiré pour la combinatoire des paliers ; on ne va pas en réintroduire un. PARTAGE. Le budget ne se divise pas entre bornes : aujourd'hui chacune reçoit l'enveloppe entière et ce sont le compteur puis la protection de surcharge qui rattrapent ; après 3g une seule démarrera. Avec deux bornes maintenant déclarées sur le banc, le laisser implicite invitait à déduire un partage qui n'existe ni avant ni après. Une note l'énonce dès qu'il y en a plus d'une. Le bloc de runtime d'une borne reste MUET tant que la recharge n'est pas arbitrée — la box ne publie rien pour elle, et c'est la bonne réponse, pas un trou. Il affichera phases, courant, minimum et présence du véhicule le jour où ils arriveront, en lecture seule. Tests 105/105, analyze 0 erreur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
This commit is contained in:
parent
ab2a27bd30
commit
449c6ac50a
@ -1772,6 +1772,20 @@ class _Aide extends StatelessWidget {
|
||||
/// `mechanism.minPowerW` est **en télémétrie** avant d'être configurable. On l'affiche
|
||||
/// donc en lecture, ce qui donne la valeur réellement en vigueur même quand le réglage,
|
||||
/// lui, n'est pas encore ouvert.
|
||||
///
|
||||
/// ## Ce plancher-ci n'est PAS celui d'une borne de recharge
|
||||
///
|
||||
/// Même mot, deux natures. Ici, une **propriété déclarée de l'appareil** : la consigne
|
||||
/// non nulle la plus basse qu'il accepte, saisie une fois et stable.
|
||||
///
|
||||
/// Le minimum d'une borne, lui, vaut `max(borne, voiture)` et **dépend du véhicule
|
||||
/// branché** — il change quand la voiture change. Il dépend aussi du nombre de phases
|
||||
/// visé, de l'ordre de 1,4 kW en monophasé contre 4,1 kW en triphasé. C'est du **runtime**,
|
||||
/// il se constate et ne se règle pas ; il s'affiche donc sur la carte de la borne, en
|
||||
/// lecture, tel que la box le donne — sans conversion côté app.
|
||||
///
|
||||
/// Ce bloc ne vit que dans la section `etmvariableload`, ce qui l'écarte structurellement
|
||||
/// des bornes : elles ne sont pas des `LoadConfig` et n'ouvrent pas cet écran.
|
||||
class _Plancher extends StatelessWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final LoadConfigProvider provider;
|
||||
@ -1799,8 +1813,9 @@ class _Plancher extends StatelessWidget {
|
||||
return _Carte(
|
||||
titre: 'Plancher de modulation',
|
||||
badge: support.isWritable ? 'enregistré · $champ' : 'pas encore supporté',
|
||||
sousTitre: 'La consigne non nulle la plus basse que l\'appareil accepte — en '
|
||||
'dessous, il vaut mieux ne rien commander du tout.',
|
||||
sousTitre: 'La consigne non nulle la plus basse que CET appareil accepte — en '
|
||||
'dessous, il vaut mieux ne rien commander du tout. Propriété déclarée, stable : '
|
||||
'à ne pas confondre avec le minimum d\'une borne, qui suit le véhicule branché.',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@ -747,6 +747,28 @@ class BorneSection extends StatelessWidget {
|
||||
letterSpacing: 0.2,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
const SizedBox(height: 8),
|
||||
// Le budget n'est PAS divisé entre les bornes, et le dire évite une déduction
|
||||
// fausse dès qu'il y en a deux à l'écran.
|
||||
if (bornes.length > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(11),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.solar.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
child: Text(
|
||||
'Deux bornes déclarées — le budget ne se partage pas entre elles. '
|
||||
'Aujourd\'hui chacune reçoit l\'enveloppe entière, et ce sont le compteur '
|
||||
'puis la protection de surcharge qui rattrapent. Une seule démarrera '
|
||||
'quand l\'arbitrage prendra la recharge en charge.',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final b in bornes)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
@ -787,6 +809,9 @@ class BorneSection extends StatelessWidget {
|
||||
'bord.',
|
||||
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
|
||||
),
|
||||
// Les valeurs de RUNTIME, quand la box en publie. Read-only par nature :
|
||||
// elles décrivent l'instant, elles ne se règlent pas.
|
||||
_RuntimeBorne(thingId: b.id),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -826,3 +851,67 @@ class _FlecheRang extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Ce que la box publie d'une borne **à l'instant** — jamais un réglage.
|
||||
///
|
||||
/// ## Deux natures derrière un même mot
|
||||
///
|
||||
/// Le « minimum » d'une borne n'est pas le **plancher déclaré** d'une charge modulable.
|
||||
/// Celui-là est une propriété de l'appareil, saisie une fois ; celui-ci vaut
|
||||
/// `max(borne, voiture)` et **dépend de la voiture branchée** — il change quand on change
|
||||
/// de véhicule. Les ranger au même endroit, ou les rendre modifiables de la même façon,
|
||||
/// ferait croire qu'on peut régler ce qui se constate.
|
||||
///
|
||||
/// De même, le plancher effectif dépend du **nombre de phases visé** — de l'ordre de
|
||||
/// 1,4 kW en monophasé contre 4,1 kW en triphasé. La valeur publiée correspond à ce nombre
|
||||
/// de phases, pas au courant du moment. On l'affiche **telle que la box la donne** : la
|
||||
/// recalculer ou la convertir côté app est précisément le genre de miroir qui finit par
|
||||
/// diverger, et on en a déjà eu un avec la combinatoire des paliers.
|
||||
///
|
||||
/// Tant que la recharge n'est pas entrée dans l'arbitrage, la box ne publie rien pour la
|
||||
/// borne et ce bloc reste muet — ce qui est la bonne réponse, pas un trou.
|
||||
class _RuntimeBorne extends StatelessWidget {
|
||||
final String thingId;
|
||||
|
||||
const _RuntimeBorne({required this.thingId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final live = context.watch<LoadTelemetryProvider>().entryFor(thingId);
|
||||
final mech = live?.mechanism;
|
||||
if (mech == null) return const SizedBox.shrink();
|
||||
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
final lignes = <String>[];
|
||||
if (mech.phaseCount != null) lignes.add('${mech.phaseCount} phase(s)');
|
||||
if (mech.currentA != null) {
|
||||
lignes.add('${mech.currentA!.toStringAsFixed(1)} A');
|
||||
}
|
||||
if (mech.minPowerW != null) {
|
||||
lignes.add('minimum ${mech.minPowerW!.round()} W');
|
||||
}
|
||||
if (mech.pluggedIn != null) {
|
||||
lignes.add(mech.pluggedIn! ? 'véhicule branché' : 'aucun véhicule');
|
||||
}
|
||||
if (lignes.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.sensors_rounded, size: 13, color: muted),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${lignes.join(' · ')} — mesuré, pas réglé : le minimum d\'une borne vaut '
|
||||
'max(borne, voiture) et suit le véhicule branché.',
|
||||
style: TextStyle(fontSize: 11, height: 1.3, color: muted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
102
test/ev_runtime_test.dart
Normal file
102
test/ev_runtime_test.dart
Normal file
@ -0,0 +1,102 @@
|
||||
/// Les valeurs de runtime d'une borne — **constatées, jamais réglées**.
|
||||
///
|
||||
/// ## Le piège est un mot, pas un calcul
|
||||
///
|
||||
/// « Minimum » désigne deux choses de natures différentes :
|
||||
///
|
||||
/// - le **plancher déclaré** d'une charge modulable : une propriété de l'appareil, saisie
|
||||
/// une fois, stable — elle se configure ;
|
||||
/// - le **minimum d'une borne** : `max(borne, voiture)`, qui dépend du véhicule branché et
|
||||
/// change avec lui. Il dépend aussi du nombre de phases visé — de l'ordre de 1,4 kW en
|
||||
/// monophasé contre 4,1 kW en triphasé.
|
||||
///
|
||||
/// Les ranger au même endroit, ou les rendre modifiables de la même façon, ferait croire
|
||||
/// qu'on peut régler ce qui se constate. Ces tests figent la frontière.
|
||||
///
|
||||
/// ## Et la valeur s'affiche telle que la box la donne
|
||||
///
|
||||
/// Pas de conversion côté app entre ampères et watts, pas de recalcul selon les phases :
|
||||
/// c'est exactement le genre de miroir qui finit par diverger, et le lot précédent en a
|
||||
/// déjà retiré un — la combinatoire des paliers, désormais publiée par la box.
|
||||
library;
|
||||
|
||||
import 'package:etm_powersync_app/models/load_telemetry.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
LoadTelemetryEntry _borne(Map<String, dynamic> mech) =>
|
||||
LoadTelemetryEntry.fromJson({
|
||||
'loadId': '{88160e45-7df2-48cc-9639-f0d15ae6e382}',
|
||||
'allocatedW': 0.0,
|
||||
'available': true,
|
||||
'decision': {'code': 'SURPLUS_INSUFFICIENT', 'params': {'budgetW': 0}},
|
||||
'mechanism': mech,
|
||||
});
|
||||
|
||||
void main() {
|
||||
group('le minimum d\'une borne est du RUNTIME', () {
|
||||
test('il est lu tel quel, sans conversion ni recalcul', () {
|
||||
// Monophasé : la box publie ~1 400 W. L'app ne dérive pas cette valeur d'un courant
|
||||
// et d'un nombre de phases — elle la prend.
|
||||
final mono = _borne({
|
||||
'kind': 'evcharger',
|
||||
'phaseCount': 1,
|
||||
'currentA': 6.0,
|
||||
'minPowerW': 1400.0,
|
||||
'pluggedIn': true,
|
||||
});
|
||||
expect(mono.mechanism!.minPowerW, 1400.0);
|
||||
expect(mono.mechanism!.phaseCount, 1);
|
||||
|
||||
// Triphasé : ~4 100 W pour le MÊME courant. Si l'app convertissait elle-même, elle
|
||||
// devrait deviner laquelle des deux règles s'applique — et se tromperait un jour.
|
||||
final tri = _borne({
|
||||
'kind': 'evcharger',
|
||||
'phaseCount': 3,
|
||||
'currentA': 6.0,
|
||||
'minPowerW': 4100.0,
|
||||
'pluggedIn': true,
|
||||
});
|
||||
expect(tri.mechanism!.minPowerW, 4100.0);
|
||||
expect(tri.mechanism!.currentA, mono.mechanism!.currentA,
|
||||
reason: 'même courant, minimum différent : la conversion n\'appartient pas '
|
||||
'à l\'app');
|
||||
});
|
||||
|
||||
test('sans véhicule branché, la valeur peut disparaître — et ce n\'est pas 0', () {
|
||||
final vide = _borne({'kind': 'evcharger', 'pluggedIn': false});
|
||||
expect(vide.mechanism!.minPowerW, isNull,
|
||||
reason: 'le minimum dépend de la voiture : sans voiture, il n\'y a pas de '
|
||||
'valeur, et zéro serait un mensonge');
|
||||
expect(vide.mechanism!.pluggedIn, isFalse);
|
||||
});
|
||||
|
||||
test('rien n\'est publié tant que la recharge n\'est pas arbitrée', () {
|
||||
// Aujourd'hui la borne n'est pas dans loads[] : le bloc de runtime reste muet, ce
|
||||
// qui est la bonne réponse et non un trou.
|
||||
final sansMecanisme = LoadTelemetryEntry.fromJson({
|
||||
'loadId': 'x',
|
||||
'allocatedW': 0.0,
|
||||
'available': true,
|
||||
'decision': {'code': 'X', 'params': <String, dynamic>{}},
|
||||
});
|
||||
expect(sansMecanisme.mechanism, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('le plancher DÉCLARÉ d\'une charge modulable est un autre objet', () {
|
||||
// Même clé de télémétrie, autre nature : ici c'est une propriété de l'appareil, et
|
||||
// elle a un champ de configuration correspondant (à venir au §12). Le minimum d'une
|
||||
// borne n'en aura jamais — il n'y a rien à y régler.
|
||||
final modulable = LoadTelemetryEntry.fromJson({
|
||||
'loadId': 'ecs-modbus',
|
||||
'allocatedW': 1200.0,
|
||||
'available': true,
|
||||
'decision': {'code': 'SURPLUS_SETPOINT', 'params': {'budgetW': 1500}},
|
||||
'mechanism': {'kind': 'variable', 'maxPowerW': 3000.0, 'minPowerW': 300.0},
|
||||
});
|
||||
expect(modulable.mechanism!.minPowerW, 300.0);
|
||||
expect(modulable.mechanism!.phaseCount, isNull,
|
||||
reason: 'une charge modulable n\'a pas de nombre de phases : c\'est bien '
|
||||
'l\'absence de ce champ qui distingue les deux natures dans la charge utile');
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user