Merge remote-tracking branch 'origin/dev' into feat/service-de-scoring

This commit is contained in:
Dorian
2026-09-18 15:06:41 +02:00
32 changed files with 4851 additions and 197 deletions
+18
View File
@@ -0,0 +1,18 @@
sonar.projectKey=ProjetPiscine_EnerVision
sonar.organization=groupe3-ener-vision
sonar.sourceEncoding=UTF-8
# Dossier contenant le code source
sonar.sources=apps/frontend/src,apps/backend/app
sonar.tests=apps/backend/tests
# Liste des fichiers et dossiers à exclure de l'analyse
# Liste des fichiers et dossiers à exclure de l'analyse
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json
# Chemin vers le rapport de couverture de code
# Fichier généré par Vitest
# Chemin vers le rapport de couverture de code
# Fichier généré par Vitest
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
sonar.python.coverage.reportPaths=apps/backend/cov.info
+1 -3
View File
@@ -21,8 +21,6 @@ export const routes: Routes = [
path: 'sites/:siteId',
canActivate: [authGuard],
loadComponent: () =>
import('./features/sites/site-detail-placeholder/site-detail-placeholder').then(
(m) => m.SiteDetailPlaceholder,
),
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
},
];
@@ -0,0 +1,50 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { ReadingsService } from './readings.service';
import { environment } from '../../../environments/environment';
describe('ReadingsService', () => {
let service: ReadingsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(ReadingsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it("demande l'historique du site avec la fenêtre temporelle donnée", () => {
let result: unknown;
service
.getHistory('SITE001', '2026-09-16T00:00:00Z', '2026-09-17T00:00:00Z')
.subscribe((r) => (result = r));
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET',
);
expect(req.request.params.get('site_id')).toBe('SITE001');
expect(req.request.params.get('start')).toBe('2026-09-16T00:00:00Z');
expect(req.request.params.get('end')).toBe('2026-09-17T00:00:00Z');
req.flush([{ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }]);
expect((result as unknown[]).length).toBe(1);
});
it('ne pose pas de paramètres start/end quand ils sont omis', () => {
service.getHistory('SITE001').subscribe();
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET',
);
expect(req.request.params.has('start')).toBe(false);
expect(req.request.params.has('end')).toBe(false);
req.flush([]);
});
});
@@ -0,0 +1,20 @@
import { Service, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Reading } from '../../shared/models/reading.model';
@Service()
export class ReadingsService {
private http = inject(HttpClient);
getHistory(siteId: string, start?: string, end?: string) {
let params = new HttpParams().set('site_id', siteId);
if (start) {
params = params.set('start', start);
}
if (end) {
params = params.set('end', end);
}
return this.http.get<Reading[]>(`${environment.apiUrl}/readings`, { params });
}
}
@@ -38,4 +38,50 @@ describe('SitesService', () => {
expect((result as { site_id: string }[])[0].site_id).toBe('SITE001');
});
it('appelle le bon endpoint et retourne un site', () => {
let result: unknown;
service.getSite('SITE001').subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001`);
expect(req.request.method).toBe('GET');
req.flush({
site_id: 'SITE001',
site_name: 'Site 1',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
});
expect((result as { site_id: string }).site_id).toBe('SITE001');
});
it('appelle le bon endpoint et retourne la mesure courante du site', () => {
let result: unknown;
service.getCurrent('SITE001').subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`);
expect(req.request.method).toBe('GET');
req.flush({
timestamp: '2026-09-17T10:00:00Z',
site_id: 'SITE001',
site_type: 'industriel',
consumption_kw: 120,
consumption_kwh: null,
voltage_v: null,
current_a: null,
power_factor: null,
temperature_celsius: 22,
humidity_percent: 55,
null_reasons: ['electrical_sensor_failure'],
data_quality: 'partial',
});
expect((result as { null_reasons: string[] }).null_reasons).toEqual([
'electrical_sensor_failure',
]);
});
});
@@ -2,6 +2,7 @@ import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Site } from '../../shared/models/site.model';
import { SiteCurrent } from '../../shared/models/site-current.model';
@Service()
export class SitesService {
@@ -10,4 +11,12 @@ export class SitesService {
getSites() {
return this.http.get<Site[]>(`${environment.apiUrl}/sites`);
}
getSite(siteId: string) {
return this.http.get<Site>(`${environment.apiUrl}/sites/${siteId}`);
}
getCurrent(siteId: string) {
return this.http.get<SiteCurrent>(`${environment.apiUrl}/sites/${siteId}/current`);
}
}
@@ -1,19 +0,0 @@
<div class="site-detail-placeholder">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
<span>/</span>
<a routerLink="/sites">Sites</a>
</nav>
<header class="site-detail-placeholder__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="site-detail-placeholder__logo" />
</a>
<h1>Site {{ siteId() }}</h1>
</header>
<ev-card>
<p>Le détail de ce site est à venir (voir issue #51).</p>
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
</ev-card>
</div>
@@ -1,28 +0,0 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 640px;
margin: 0 auto;
}
.site-detail-placeholder__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 1.5rem;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
}
.site-detail-placeholder__logo {
font-size: 1.3rem;
}
ev-card p {
margin: 0 0 0.75rem;
}
@@ -1,42 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { SiteDetailPlaceholder } from './site-detail-placeholder';
describe('SiteDetailPlaceholder', () => {
it("affiche l'identifiant du site depuis la route", () => {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
TestBed.configureTestingModule({
imports: [SiteDetailPlaceholder],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
],
});
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('SITE001');
});
it('met à jour l\'affichage quand le paramètre change sans recréer le composant', () => {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
TestBed.configureTestingModule({
imports: [SiteDetailPlaceholder],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
],
});
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
fixture.detectChanges();
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('SITE002');
expect(fixture.nativeElement.textContent).not.toContain('SITE001');
});
});
@@ -1,19 +0,0 @@
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { map } from 'rxjs';
import { Card } from '../../../shared/components/ui/card/card';
import { Brand } from '../../../shared/components/ui/brand/brand';
@Component({
selector: 'app-site-detail-placeholder',
standalone: true,
imports: [RouterLink, Card, Brand],
templateUrl: './site-detail-placeholder.html',
styleUrl: './site-detail-placeholder.scss',
})
export class SiteDetailPlaceholder {
private route = inject(ActivatedRoute);
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId'))));
}
@@ -0,0 +1,85 @@
<div class="site-detail">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
<span>/</span>
<a routerLink="/sites">Sites</a>
</nav>
<header class="site-detail__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="site-detail__logo" />
</a>
<div>
<h1>{{ site()?.site_name ?? siteId() }}</h1>
@if (site(); as s) {
<p class="site-detail__subtitle">
{{ s.site_type }} · {{ s.location || 'Localisation inconnue' }}
</p>
}
</div>
<div class="site-detail__badges">
@if (site(); as s) {
<ev-badge [tone]="badgeToneForStatus(s.status)">{{ s.status ?? '-' }}</ev-badge>
}
@if (hasMeasurement() && qualityLabel(); as label) {
<ev-badge [tone]="qualityTone()">{{ label }}</ev-badge>
}
</div>
</header>
@if (error(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (site(); as s) {
@if (hasMeasurement()) {
<section class="overview">
<ev-card class="card card--gauge">
<span class="card__label">Consommation vs capacité</span>
@let consumption = consumptionKw();
@if (consumption !== null) {
<app-consumption-gauge [consumption]="consumption" [capacity]="s.capacity_kw ?? 0" />
<span class="card__value">
{{ consumptionLabel() }} / {{ s.capacity_kw ?? '-' }} kW
</span>
} @else {
<p class="card__unavailable">
Consommation indisponible
<span class="metric__reason">({{ consumptionReason() }})</span>
</p>
}
</ev-card>
<ev-card class="metrics-card">
<span class="card__label">Mesure instantanée</span>
<dl class="metrics-grid">
@for (metric of metrics(); track metric.key) {
<div class="metric">
<dt>{{ metric.label }}</dt>
@if (metric.value !== null) {
<dd>{{ metric.value }}</dd>
} @else {
<dd class="metric__unavailable">
Indisponible
<span class="metric__reason">({{ metric.reason }})</span>
</dd>
}
</div>
}
</dl>
</ev-card>
</section>
@if (history().length > 0) {
<section class="chart-section">
<h2>Historique de consommation</h2>
<app-reading-history-chart [readings]="history()" />
</section>
}
} @else {
<ev-alert severity="warning" class="banner-empty">{{ noMeasurementMessage }}</ev-alert>
}
}
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
</div>
@@ -0,0 +1,119 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.site-detail__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.site-detail__logo {
font-size: 1.3rem;
}
.site-detail__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.site-detail__badges {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.banner-error {
display: block;
margin: 0 0 1.5rem;
}
.banner-empty {
display: block;
margin: 0 0 1.5rem;
}
.overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
margin-bottom: 2.5rem;
}
.card {
padding: 1.25rem;
gap: 0.35rem;
}
.card--gauge {
align-items: center;
text-align: center;
}
.card__label {
font-size: 0.8rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.card__value {
font-size: 1.6rem;
font-weight: 700;
}
.card__unavailable {
color: var(--color-text-muted);
margin: 0;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem 1.5rem;
margin: 0.5rem 0 0;
}
.metric {
dt {
font-size: 0.75rem;
color: var(--color-text-muted);
}
dd {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}
}
.metric__unavailable {
color: var(--color-text-muted);
font-weight: 400;
}
.metric__reason {
font-size: 0.8rem;
}
h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 1rem;
}
.chart-section {
margin-bottom: 2rem;
}
@@ -0,0 +1,281 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { BehaviorSubject, of, throwError } from 'rxjs';
import { SiteDetail } from './site-detail';
import { SitesService } from '../../../core/services/sites.service';
import { ReadingsService } from '../../../core/services/readings.service';
const SITE = {
site_id: 'SITE001',
site_name: 'Site 1',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
};
const CURRENT_COMPLET = {
timestamp: '2026-09-17T10:00:00Z',
site_id: 'SITE001',
site_type: 'industriel',
consumption_kw: 120,
consumption_kwh: null,
voltage_v: 230,
current_a: 12,
power_factor: 0.95,
temperature_celsius: 22,
humidity_percent: 55,
null_reasons: [] as string[],
data_quality: 'good' as const,
};
const SANS_MESURE = {
...CURRENT_COMPLET,
timestamp: null,
consumption_kw: null,
voltage_v: null,
current_a: null,
power_factor: null,
temperature_celsius: null,
humidity_percent: null,
data_quality: 'critical' as const,
};
const LECTURE = {
reading_id: 1,
site_id: 'SITE001',
timestamp: '2026-09-17T09:00:00Z',
source: 'api_history' as const,
consumption_kw: 118,
consumption_kwh: null,
consumption_euros: null,
voltage_v: 230,
current_a: 12,
power_factor: 0.95,
temperature_celsius: 22,
humidity_percent: 55,
solar_irradiance_wm2: null,
is_working_hours: true,
data_quality: 'good' as const,
null_reasons: null,
imputed_values: null,
imputation_method: null,
};
function setup(
siteId: string,
sitesMock: Partial<SitesService>,
readingsMock: Partial<ReadingsService>,
) {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
TestBed.configureTestingModule({
imports: [SiteDetail],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
{ provide: SitesService, useValue: sitesMock },
{ provide: ReadingsService, useValue: readingsMock },
],
});
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
}
describe('SiteDetail', () => {
it('charge le site, la mesure courante et son historique au démarrage', () => {
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.site()?.site_id).toBe('SITE001');
expect(fixture.componentInstance.current()?.consumption_kw).toBe(120);
expect(fixture.componentInstance.history().length).toBe(1);
expect(fixture.componentInstance.error()).toBeNull();
});
it("signale l'indisponibilité quand un des appels échoue", () => {
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.site()).toBeNull();
});
it('efface les données du site précédent quand le chargement du suivant échoue', () => {
const getSite = vi
.fn()
.mockReturnValueOnce(of(SITE))
.mockReturnValueOnce(throwError(() => new Error('404')));
const { fixture, paramMap } = setup(
'SITE001',
{ getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) },
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.site()?.site_id).toBe('SITE001');
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
fixture.detectChanges();
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.site()).toBeNull();
expect(fixture.componentInstance.current()).toBeNull();
expect(fixture.componentInstance.history()).toEqual([]);
expect(fixture.nativeElement.textContent).not.toContain('Site 1');
});
it('interroge le site et sa mesure courante en parallèle', () => {
const getSite = vi.fn().mockReturnValue(of(SITE));
const getCurrent = vi.fn().mockReturnValue(of(CURRENT_COMPLET));
const { fixture } = setup(
'SITE001',
{ getSite, getCurrent },
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
expect(getSite).toHaveBeenCalledWith('SITE001');
expect(getCurrent).toHaveBeenCalledWith('SITE001');
});
it('signale la panne du capteur de consommation au lieu de tracer une jauge à zéro', () => {
const sansConsommation = {
...CURRENT_COMPLET,
consumption_kw: null,
null_reasons: ['consumption_sensor_failure'],
data_quality: 'partial' as const,
};
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(sansConsommation)),
},
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.consumptionKw()).toBeNull();
expect(fixture.componentInstance.consumptionReason()).toBe('capteur de consommation en panne');
expect(fixture.nativeElement.querySelector('app-consumption-gauge')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Consommation indisponible');
});
it('trace la jauge pour une consommation nulle réellement mesurée', () => {
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of({ ...CURRENT_COMPLET, consumption_kw: 0 })),
},
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.consumptionLabel()).toBe('0.0 kW');
expect(fixture.nativeElement.querySelector('app-consumption-gauge')).not.toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('Consommation indisponible');
});
it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => {
const partielle = {
...CURRENT_COMPLET,
voltage_v: null,
current_a: null,
power_factor: null,
null_reasons: ['electrical_sensor_failure'],
data_quality: 'partial' as const,
};
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(partielle)),
},
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
const tension = fixture.componentInstance.metrics().find((m) => m.key === 'voltage_v');
expect(tension?.value).toBeNull();
expect(tension?.reason).toBe('capteur électrique en panne');
const texte = fixture.nativeElement.textContent;
expect(texte).toContain('Indisponible');
expect(texte).toContain('capteur électrique en panne');
expect(texte).toContain('Données partielles');
});
it('recharge les données quand le paramètre de route siteId change', () => {
const getSite = vi.fn().mockReturnValue(of(SITE));
const { fixture, paramMap } = setup(
'SITE001',
{ getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) },
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
fixture.detectChanges();
expect(getSite).toHaveBeenCalledWith('SITE002');
});
it("ancre la fenêtre d'historique sur la dernière mesure connue plutôt que sur l'horloge", () => {
const getHistory = vi.fn().mockReturnValue(of([]));
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory },
);
fixture.detectChanges();
expect(getHistory).toHaveBeenCalledWith(
'SITE001',
'2026-09-16T10:00:00.000Z',
'2026-09-17T10:00:00Z',
);
});
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
const getHistory = vi.fn().mockReturnValue(of([]));
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(SANS_MESURE)),
},
{ getHistory },
);
fixture.detectChanges();
expect(getHistory).not.toHaveBeenCalled();
expect(fixture.componentInstance.hasMeasurement()).toBe(false);
expect(fixture.nativeElement.textContent).toContain('Aucune mesure remontée pour ce site.');
});
});
@@ -0,0 +1,213 @@
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, EMPTY, filter, forkJoin, map, Observable, of, switchMap } from 'rxjs';
import { SitesService } from '../../../core/services/sites.service';
import { ReadingsService } from '../../../core/services/readings.service';
import { Site } from '../../../shared/models/site.model';
import { Reading, ReadingDataQuality } from '../../../shared/models/reading.model';
import { SiteCurrent } from '../../../shared/models/site-current.model';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
import { Brand } from '../../../shared/components/ui/brand/brand';
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000;
const TON_PAR_STATUT: Record<string, BadgeTone> = {
actif: 'success',
maintenance: 'warning',
hors_service: 'danger',
};
const TON_PAR_QUALITE: Record<ReadingDataQuality, BadgeTone> = {
good: 'success',
partial: 'warning',
degraded: 'danger',
critical: 'critical',
};
const LIBELLE_PAR_QUALITE: Record<ReadingDataQuality, string> = {
good: 'Données complètes',
partial: 'Données partielles',
degraded: 'Données dégradées',
critical: 'Données critiques',
};
type MetricKey =
| 'consumption_kw'
| 'voltage_v'
| 'current_a'
| 'power_factor'
| 'temperature_celsius'
| 'humidity_percent';
interface MetricDef {
key: MetricKey;
label: string;
format: (value: number) => string;
}
const CONSUMPTION_DEF: MetricDef = {
key: 'consumption_kw',
label: 'Consommation',
format: (v) => `${v.toFixed(1)} kW`,
};
const METRIC_DEFS: MetricDef[] = [
CONSUMPTION_DEF,
{ key: 'voltage_v', label: 'Tension', format: (v) => `${v.toFixed(1)} V` },
{ key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` },
{ key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) },
{ key: 'temperature_celsius', label: 'Température', format: (v) => `${v.toFixed(1)} °C` },
{ key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` },
];
// Contrainte : miroir de RAISON_VERS_CAPTEUR et CHAMPS_PAR_CAPTEUR (backend, services/sensor.py) ;
// `null_reasons` porte le code de panne du capteur, jamais le nom du champ resté vide.
const RAISONS_PAR_CHAMP: Record<MetricKey, string[]> = {
consumption_kw: ['consumption_sensor_failure', 'network_loss'],
voltage_v: ['electrical_sensor_failure', 'network_loss'],
current_a: ['electrical_sensor_failure', 'network_loss'],
power_factor: ['electrical_sensor_failure', 'network_loss'],
temperature_celsius: ['temperature_sensor_failure', 'network_loss'],
humidity_percent: ['humidity_sensor_failure', 'network_loss'],
};
const LIBELLE_PAR_RAISON: Record<string, string> = {
consumption_sensor_failure: 'capteur de consommation en panne',
electrical_sensor_failure: 'capteur électrique en panne',
temperature_sensor_failure: 'capteur de température en panne',
humidity_sensor_failure: "capteur d'humidité en panne",
network_loss: 'perte réseau',
};
export interface MetricView {
key: MetricKey;
label: string;
value: string | null;
reason: string;
}
@Component({
selector: 'app-site-detail',
standalone: true,
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
templateUrl: './site-detail.html',
styleUrl: './site-detail.scss',
})
export class SiteDetail {
private route = inject(ActivatedRoute);
private sitesService = inject(SitesService);
private readingsService = inject(ReadingsService);
private destroyRef = inject(DestroyRef);
readonly noMeasurementMessage = NO_MEASUREMENT_MESSAGE;
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? '')));
site = signal<Site | null>(null);
current = signal<SiteCurrent | null>(null);
history = signal<Reading[]>([]);
error = signal<string | null>(null);
hasMeasurement = computed(() => this.current()?.timestamp != null);
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
consumptionLabel = computed(() => {
const kw = this.consumptionKw();
return kw != null ? CONSUMPTION_DEF.format(kw) : null;
});
consumptionReason = computed(() => this.reasonFor('consumption_kw', this.current()));
qualityLabel = computed(() => {
const quality = this.current()?.data_quality;
return quality ? LIBELLE_PAR_QUALITE[quality] : null;
});
qualityTone = computed<BadgeTone>(() => {
const quality = this.current()?.data_quality;
return quality ? TON_PAR_QUALITE[quality] : 'neutral';
});
metrics = computed<MetricView[]>(() => {
const current = this.current();
return METRIC_DEFS.map((def) => {
const valeur = current ? current[def.key] : null;
return {
key: def.key,
label: def.label,
value: valeur != null ? def.format(valeur) : null,
reason: valeur == null ? this.reasonFor(def.key, current) : '',
};
});
});
constructor() {
toObservable(this.siteId)
.pipe(
filter((siteId): siteId is string => !!siteId),
// Piège : switchMap sur le flux externe annule le chargement en cours dès qu'un
// nouveau siteId arrive, sinon une réponse en retard peut écraser le site affiché.
switchMap((siteId) => this.load(siteId)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((result) => {
this.error.set(null);
this.site.set(result.site);
this.current.set(result.current);
this.history.set(result.history);
});
}
badgeToneForStatus(status: string | null): BadgeTone {
return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral';
}
private load(siteId: string) {
return forkJoin({
site: this.sitesService.getSite(siteId),
current: this.sitesService.getCurrent(siteId),
}).pipe(
switchMap(({ site, current }) =>
this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))),
),
catchError(() => this.reportUnavailable()),
);
}
private loadHistory(siteId: string, current: SiteCurrent): Observable<Reading[]> {
// Piège : le jeu de données s'arrête bien avant « maintenant » ; ancrer la fenêtre sur la
// dernière mesure connue plutôt que sur l'horloge évite un historique systématiquement vide.
const end = current.timestamp;
if (end === null) {
return of([]);
}
const start = new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString();
return this.readingsService.getHistory(siteId, start, end);
}
private reasonFor(field: MetricKey, current: SiteCurrent | null): string {
const raisons = RAISONS_PAR_CHAMP[field];
const trouvees = (current?.null_reasons ?? [])
.filter((raison) => raisons.includes(raison))
.map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison);
return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue';
}
// Piège : vider les signaux avec l'erreur, sinon la page garde le site précédemment chargé
// sous le bandeau et laisse lire les chiffres de A en croyant regarder B.
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
this.site.set(null);
this.current.set(null);
this.history.set([]);
return EMPTY;
}
}
@@ -0,0 +1 @@
<canvas #canvas></canvas>
@@ -0,0 +1,4 @@
:host {
display: block;
height: 260px;
}
@@ -0,0 +1,101 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { Chart } from 'chart.js';
import { ReadingHistoryChart } from './reading-history-chart';
vi.mock('chart.js', () => {
class ChartMock {
static instances: ChartMock[] = [];
static register = vi.fn();
update = vi.fn();
destroy = vi.fn();
data: { labels?: unknown[]; datasets: Record<string, unknown>[] } = { datasets: [{}] };
constructor(_canvas: unknown, config?: { data?: ChartMock['data'] }) {
if (config?.data) {
this.data = config.data;
}
ChartMock.instances.push(this);
}
}
return { Chart: ChartMock, registerables: [] };
});
type ChartDouble = {
destroy: ReturnType<typeof vi.fn>;
data: { labels?: unknown[]; datasets: Record<string, unknown>[] };
};
function lastChart(): ChartDouble | undefined {
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
}
const READING = {
reading_id: 1,
site_id: 'S1',
timestamp: '2026-09-17T10:00:00Z',
source: 'api_history' as const,
consumption_kw: 42,
consumption_kwh: null,
consumption_euros: null,
voltage_v: null,
current_a: null,
power_factor: null,
temperature_celsius: null,
humidity_percent: null,
solar_irradiance_wm2: null,
is_working_hours: null,
data_quality: 'good' as const,
null_reasons: null,
imputed_values: null,
imputation_method: null,
};
describe('ReadingHistoryChart', () => {
it('se crée sans erreur avec une liste de lectures valide', () => {
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
const fixture = TestBed.createComponent(ReadingHistoryChart);
fixture.componentRef.setInput('readings', [READING]);
expect(() => fixture.detectChanges()).not.toThrow();
});
it('met à jour le graphique quand les lectures changent après initialisation', () => {
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
const fixture = TestBed.createComponent(ReadingHistoryChart);
fixture.componentRef.setInput('readings', [READING]);
fixture.detectChanges();
fixture.componentRef.setInput('readings', [
{ ...READING, reading_id: 2, consumption_kw: 60, data_quality: 'critical' as const },
]);
fixture.detectChanges();
expect(() => fixture.detectChanges()).not.toThrow();
});
it("trace du plus ancien au plus récent, quel que soit l'ordre reçu de l'API", () => {
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
const fixture = TestBed.createComponent(ReadingHistoryChart);
// L'API trie en timestamp décroissant : le composant doit rétablir la chronologie.
fixture.componentRef.setInput('readings', [
{ ...READING, reading_id: 2, timestamp: '2026-09-17T11:00:00Z', consumption_kw: 60 },
{ ...READING, reading_id: 1, timestamp: '2026-09-17T10:00:00Z', consumption_kw: 42 },
]);
fixture.detectChanges();
const chart = lastChart();
expect(chart?.data.labels).toEqual(['2026-09-17T10:00:00Z', '2026-09-17T11:00:00Z']);
expect(chart?.data.datasets[0]['data']).toEqual([42, 60]);
});
it('détruit le graphique quand le composant est détruit', () => {
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
const fixture = TestBed.createComponent(ReadingHistoryChart);
fixture.componentRef.setInput('readings', [READING]);
fixture.detectChanges();
const chart = lastChart();
fixture.destroy();
expect(chart?.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,95 @@
import {
Component,
ElementRef,
ViewChild,
input,
effect,
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { Chart, registerables } from 'chart.js';
import { Reading, ReadingDataQuality } from '../../models/reading.model';
Chart.register(...registerables);
const QUALITY_COLORS: Record<ReadingDataQuality, string> = {
good: '#3b82f6',
partial: '#f9a825',
degraded: '#ef6c00',
critical: '#c62828',
};
const UNKNOWN_QUALITY_COLOR = '#9ca3af';
interface ChartSeries {
labels: string[];
values: number[];
colors: string[];
}
// Piège : l'API renvoie les lectures du plus récent au plus ancien (ReadingRepository.list_history
// trie en timestamp desc) ; sans ce tri l'axe des abscisses se lirait à rebours.
function toSeries(readings: Reading[]): ChartSeries {
const ordered = [...readings].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
return {
labels: ordered.map((r) => r.timestamp),
values: ordered.map((r) => r.consumption_kw ?? 0),
colors: ordered.map((r) =>
r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR,
),
};
}
@Component({
selector: 'app-reading-history-chart',
standalone: true,
templateUrl: './reading-history-chart.html',
styleUrl: './reading-history-chart.scss',
})
export class ReadingHistoryChart implements AfterViewInit, OnDestroy {
readings = input.required<Reading[]>();
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
private chart?: Chart<'line'>;
constructor() {
effect(() => {
const series = toSeries(this.readings());
if (this.chart) {
this.chart.data.labels = series.labels;
this.chart.data.datasets[0].data = series.values;
this.chart.data.datasets[0].pointBackgroundColor = series.colors;
this.chart.update('none');
}
});
}
ngAfterViewInit(): void {
const series = toSeries(this.readings());
this.chart = new Chart(this.canvasRef.nativeElement, {
type: 'line',
data: {
labels: series.labels,
datasets: [
{
data: series.values,
borderColor: '#3b82f6',
pointBackgroundColor: series.colors,
tension: 0.25,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Consommation (kW)' } },
},
},
});
}
ngOnDestroy(): void {
this.chart?.destroy();
}
}
@@ -0,0 +1,23 @@
export type ReadingSource = 'csv' | 'api_current' | 'api_history';
export type ReadingDataQuality = 'good' | 'partial' | 'degraded' | 'critical';
export interface Reading {
reading_id: number;
site_id: string;
timestamp: string;
source: ReadingSource;
consumption_kw: number | null;
consumption_kwh: number | null;
consumption_euros: string | null;
voltage_v: number | null;
current_a: number | null;
power_factor: number | null;
temperature_celsius: number | null;
humidity_percent: number | null;
solar_irradiance_wm2: number | null;
is_working_hours: boolean | null;
data_quality: ReadingDataQuality | null;
null_reasons: string[] | null;
imputed_values: Record<string, unknown> | null;
imputation_method: string | null;
}
@@ -0,0 +1,16 @@
import { ReadingDataQuality } from './reading.model';
export interface SiteCurrent {
timestamp: string | null;
site_id: string;
site_type: string;
consumption_kw: number | null;
consumption_kwh: number | null;
voltage_v: number | null;
current_a: number | null;
power_factor: number | null;
temperature_celsius: number | null;
humidity_percent: number | null;
null_reasons: string[];
data_quality: ReadingDataQuality;
}
@@ -1,5 +1,5 @@
export const environment = {
production: false,
apiUrl: '/api/v1',
useMockFixtures: true, // a passer a false une fois le backend prêt
useMockFixtures: false,
};