From 57860a08700f349cf3b1e6ce3bc90916cdc8a7f4 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 24 Aug 2026 11:14:06 +0000 Subject: [PATCH] fix(meters): complete checkpoint C3 analytics --- app_new/core/models/planning.py | 4 + app_new/core/services/meter_analytics.py | 105 +++++++++++++++--- app_new/planning/meter_readings.py | 4 + app_new/planning/schedules.py | 3 + .../templates/planning/meter_analytics.html | 9 +- .../templates/planning/meter_monitoring.html | 1 + app_new/templates/planning/my_day.html | 1 + .../q2f3g4h5i6j7_meter_c3_completion.py | 24 ++++ tests/integration/test_meter_checkpoint_c3.py | 61 +++++++++- 9 files changed, 192 insertions(+), 20 deletions(-) create mode 100644 migrations/versions/q2f3g4h5i6j7_meter_c3_completion.py diff --git a/app_new/core/models/planning.py b/app_new/core/models/planning.py index 39e24a4..573198c 100644 --- a/app_new/core/models/planning.py +++ b/app_new/core/models/planning.py @@ -518,6 +518,8 @@ class MeterAlertRule(db.Model): level = db.Column(db.String(10), nullable=False, default="WARNING") is_active = db.Column(db.Boolean, nullable=False, default=True, index=True) min_comparable_intervals = db.Column(db.Integer, nullable=False, default=3) + min_deviation_percent = db.Column(db.Float, nullable=False, default=30.0) + low_consumption_threshold = db.Column(db.Float, nullable=True) detect_zero_or_low = db.Column(db.Boolean, nullable=False, default=False) meter = db.relationship("Meter", backref=db.backref("alert_rules", cascade="all, delete-orphan")) @@ -544,6 +546,7 @@ class MeterAlert(db.Model): conclusion = db.Column(db.String(30), nullable=True) comment = db.Column(db.Text, nullable=True) intervention_id = db.Column(db.Integer, db.ForeignKey("interventions.id", ondelete="SET NULL"), nullable=True, index=True) + rule_id = db.Column(db.Integer, db.ForeignKey("meter_alert_rules.id", ondelete="SET NULL"), nullable=True, index=True) meter = db.relationship("Meter", backref=db.backref("alerts", cascade="all, delete-orphan")) intervention = db.relationship("Intervention", backref=db.backref("meter_alerts", lazy="dynamic")) evidence = db.relationship("MeterAlertEvidence", back_populates="alert", cascade="all, delete-orphan") @@ -551,6 +554,7 @@ class MeterAlert(db.Model): class MeterAlertEvidence(db.Model): __tablename__ = "meter_alert_evidence" + __table_args__ = (db.UniqueConstraint("alert_id", "reading_start_id", "reading_end_id", name="uq_meter_alert_evidence_interval"),) id = db.Column(db.Integer, primary_key=True) alert_id = db.Column(db.Integer, db.ForeignKey("meter_alerts.id", ondelete="CASCADE"), nullable=False, index=True) reading_start_id = db.Column(db.Integer, db.ForeignKey("meter_readings.id", ondelete="SET NULL"), nullable=True) diff --git a/app_new/core/services/meter_analytics.py b/app_new/core/services/meter_analytics.py index f06defc..3e8fa2d 100644 --- a/app_new/core/services/meter_analytics.py +++ b/app_new/core/services/meter_analytics.py @@ -6,7 +6,7 @@ absorbé silencieusement dans une soustraction. """ from dataclasses import dataclass, asdict from datetime import date, datetime, timedelta, timezone -from statistics import median +from statistics import median, mean from typing import Optional from sqlalchemy.orm import joinedload @@ -14,9 +14,9 @@ from sqlalchemy.orm import joinedload from ...extensions import db from ..models.planning import ( Meter, MeterReading, MeterAlert, MeterAlertEvidence, MeterAlertRule, - MeterHeatingRegime, GasConversion, MeterTariff, CollegeClosure, + MeterHeatingRegime, GasConversion, MeterTariff, CollegeClosure, ClosureWorkDay, ) -from .planning_service import PlanningService +from .planning_service import is_public_holiday @dataclass @@ -49,16 +49,19 @@ def _reading_datetime(reading): def _context_for(meter, start, end): counts = {"scolaire": 0, "vacances": 0, "fermeture": 0, "permanence": 0, "autre": 0} d = start.date() + closures = CollegeClosure.query.filter(CollegeClosure.start_date <= end.date(), CollegeClosure.end_date >= start.date()).all() + closure_ids = [row.id for row in closures] + work_days = {row.work_date for row in ClosureWorkDay.query.filter(ClosureWorkDay.work_date >= start.date(), ClosureWorkDay.work_date < end.date()).all()} if closure_ids else set() while d < end.date(): if meter.housing_unit_id: counts["scolaire"] += 1 else: - closure = CollegeClosure.query.filter(CollegeClosure.start_date <= d, CollegeClosure.end_date >= d).first() + closure = next((row for row in closures if row.start_date <= d <= row.end_date), None) if closure: kind = (closure.closure_type or "vacances").lower() - key = "permanence" if "perman" in kind else ("fermeture" if "fermet" in kind else "vacances") + key = "permanence" if d in work_days or "perman" in kind else ("fermeture" if "fermet" in kind else "vacances") counts[key] += 1 - elif PlanningService.get_working_hours(d) is None: + elif d.weekday() >= 5 or is_public_holiday(d)[0]: counts["fermeture"] += 1 else: counts["scolaire"] += 1 @@ -114,6 +117,25 @@ def aggregate_intervals(intervals, period="DAY"): for key, rows in sorted(groups.items(), key=lambda x: x[0])] +def _metric_value(intervals, metric): + """Retourne une métrique contextualisée par répartition temporelle.""" + if not intervals: + return 0.0 + if metric == "consumption_interval": + return sum(item.raw_delta for item in intervals) + if metric == "consumption_per_day": + return sum(item.raw_delta for item in intervals) / max(sum(item.calendar_days for item in intervals), 1) + calendar_key = {"school_consumption": "scolaire", "vacation_consumption": "vacances", "closure_consumption": "fermeture"} + heating_key = {"heating_normal": "NORMAL", "heating_reduced": "REDUCED", "heating_stop": "STOP"} + key = calendar_key.get(metric) + if key: + days = sum((item.context or {}).get("calendar", {}).get(key, 0) for item in intervals) + else: + days = sum((item.context or {}).get("heating", {}).get(heating_key.get(metric, ""), 0) for item in intervals) + total_days = sum(item.calendar_days for item in intervals) + return sum(item.raw_delta for item in intervals) * days / total_days if total_days else 0.0 + + def remainder_for_interval(meter, interval): children = meter.children.all() if hasattr(meter.children, "all") else list(meter.children) if not children: @@ -127,7 +149,7 @@ def remainder_for_interval(meter, interval): continue history = consumption_intervals(child) if len(history) >= 2: - total += median(i.consumption_per_day for i in history[-3:]) * interval.calendar_days + total += mean(i.consumption_per_day for i in history[-3:]) * interval.calendar_days quality = "ESTIMATED" else: quality = "PARTIAL" @@ -138,38 +160,57 @@ def _matches(value, operator, threshold): return {">": value > threshold, "<": value < threshold, ">=": value >= threshold, "<=": value <= threshold}[operator] -def evaluate_alert_rule(rule, interval, commit=True): - value = interval.raw_delta if rule.metric == "consumption_interval" else interval.consumption_per_day +def evaluate_alert_rule(rule, interval, intervals=None, commit=True): + if not rule.is_active: + return None + intervals = intervals or [interval] + if rule.period in ("WEEK", "MONTH"): + grouped = aggregate_intervals(intervals, rule.period) + bucket = next((row["intervals"] for row in grouped if interval in row["intervals"]), [interval]) + else: + bucket = [interval] + value = _metric_value(bucket, rule.metric) if rule.context_filter: context = interval.context or {} if rule.context_filter in ("NORMAL", "REDUCED", "STOP"): if not (context.get("heating", {}).get(rule.context_filter) or 0): return None elif not (context.get("calendar", {}).get(rule.context_filter) or 0): return None - if not _matches(value, rule.operator, rule.threshold): return None - open_alert = MeterAlert.query.filter_by(meter_id=rule.meter_id, alert_type="MANUAL_THRESHOLD", metric=rule.metric, period=rule.period, status="OPEN").first() + zero_low = rule.detect_zero_or_low and rule.low_consumption_threshold is not None and value <= rule.low_consumption_threshold + if not zero_low and not _matches(value, rule.operator, rule.threshold): return None + alert_type = "ZERO_OR_LOW_CONSUMPTION" if zero_low else "MANUAL_THRESHOLD" + open_alert = MeterAlert.query.filter_by(meter_id=rule.meter_id, alert_type=alert_type, metric=rule.metric, period=rule.period, context_key=rule.context_filter, rule_id=rule.id, status="OPEN").first() if not open_alert: - open_alert = MeterAlert(meter_id=rule.meter_id, alert_type="MANUAL_THRESHOLD", level=rule.level, metric=rule.metric, period=rule.period, observed_value=value, period_start=interval.start_date.date(), period_end=interval.end_date.date()) + open_alert = MeterAlert(meter_id=rule.meter_id, alert_type=alert_type, level=rule.level, metric=rule.metric, period=rule.period, context_key=rule.context_filter, rule_id=rule.id, observed_value=value, period_start=bucket[0].start_date.date(), period_end=bucket[-1].end_date.date()) db.session.add(open_alert); db.session.flush() else: open_alert.observed_value = value - db.session.add(MeterAlertEvidence(alert_id=open_alert.id, reading_start_id=interval.reading_start.id, reading_end_id=interval.reading_end.id, observed_value=value)) + evidence = MeterAlertEvidence.query.filter_by(alert_id=open_alert.id, reading_start_id=bucket[0].reading_start.id, reading_end_id=bucket[-1].reading_end.id).first() + if not evidence: + db.session.add(MeterAlertEvidence(alert_id=open_alert.id, reading_start_id=bucket[0].reading_start.id, reading_end_id=bucket[-1].reading_end.id, observed_value=value)) if commit: db.session.commit() return open_alert -def statistical_anomaly(meter, interval, min_intervals=3, commit=True): +def statistical_anomaly(meter, interval, min_intervals=3, minimum_deviation_percent=30.0, rule=None, commit=True): + if rule is not None: + min_intervals = rule.min_comparable_intervals + minimum_deviation_percent = rule.min_deviation_percent history = [i for i in consumption_intervals(meter) if i.end_date < interval.start_date and i.context == interval.context] history = [i for i in history if not MeterAlertEvidence.query.filter_by(reading_end_id=i.reading_end.id, excluded_from_baseline=True).first()] if len(history) < min_intervals: return None reference = median(i.consumption_per_day for i in history) if reference == 0 or interval.consumption_per_day <= reference: return None deviation = (interval.consumption_per_day - reference) / reference * 100 - alert = MeterAlert.query.filter_by(meter_id=meter.id, alert_type="STATISTICAL_ANOMALY", metric="consumption_per_day", status="OPEN").first() + if deviation < minimum_deviation_percent: + return None + rule_id = rule.id if rule else None + alert = MeterAlert.query.filter_by(meter_id=meter.id, alert_type="STATISTICAL_ANOMALY", metric="consumption_per_day", period="DAY", context_key="comparable", rule_id=rule_id, status="OPEN").first() if not alert: - alert = MeterAlert(meter_id=meter.id, alert_type="STATISTICAL_ANOMALY", level="WARNING", metric="consumption_per_day", period="DAY") + alert = MeterAlert(meter_id=meter.id, alert_type="STATISTICAL_ANOMALY", level="WARNING", metric="consumption_per_day", period="DAY", context_key="comparable", rule_id=rule_id) db.session.add(alert); db.session.flush() alert.observed_value, alert.reference_value, alert.deviation_percent, alert.comparable_count = interval.consumption_per_day, reference, deviation, len(history) - db.session.add(MeterAlertEvidence(alert_id=alert.id, reading_start_id=interval.reading_start.id, reading_end_id=interval.reading_end.id, observed_value=interval.consumption_per_day)) + if not MeterAlertEvidence.query.filter_by(alert_id=alert.id, reading_start_id=interval.reading_start.id, reading_end_id=interval.reading_end.id).first(): + db.session.add(MeterAlertEvidence(alert_id=alert.id, reading_start_id=interval.reading_start.id, reading_end_id=interval.reading_end.id, observed_value=interval.consumption_per_day)) if commit: db.session.commit() return alert @@ -180,11 +221,39 @@ def gas_coefficient(meter, on_date): return (manual or (rows[0] if rows else None)).coefficient_kwh_per_m3 if (manual or rows) else None +def create_bill_gas_conversion(*, meter, volume_m3, billed_kwh, valid_from, valid_to=None, commit=True): + if volume_m3 is None or volume_m3 <= 0: + raise ValueError("Le volume gaz facturé doit être strictement positif.") + if billed_kwh is None or billed_kwh < 0: + raise ValueError("Les kWh facturés ne peuvent pas être négatifs.") + conversion = GasConversion(meter=meter, coefficient_kwh_per_m3=billed_kwh / volume_m3, + valid_from=valid_from, valid_to=valid_to, origin="BILL", + volume_m3=volume_m3, billed_kwh=billed_kwh) + db.session.add(conversion) + if commit: db.session.commit() + return conversion + + +def close_meter_alert(*, alert, conclusion, comment=None, user_id=None, commit=True): + allowed = {"CONFIRMED_LEAK", "NORMAL_EXPLAINED", "READING_ERROR", "BAD_THRESHOLD", "OTHER"} + if conclusion not in allowed: + raise ValueError("Conclusion d'alerte invalide.") + alert.status = "CLOSED" + alert.closed_at = datetime.now(timezone.utc) + alert.conclusion = conclusion + alert.comment = comment + if conclusion in {"CONFIRMED_LEAK", "READING_ERROR"}: + for evidence in alert.evidence: + evidence.excluded_from_baseline = True + if commit: db.session.commit() + return alert + + def estimated_cost(meter, interval): tariff = MeterTariff.query.filter_by(meter_id=meter.id).filter(MeterTariff.valid_from <= interval.end_date.date(), db.or_(MeterTariff.valid_to.is_(None), MeterTariff.valid_to >= interval.end_date.date())).order_by(MeterTariff.valid_from.desc()).first() if not tariff: return None amount = interval.raw_delta - if meter.meter_type == "gaz" or meter.unit == "m³": + if meter.meter_type == "gaz": coefficient = gas_coefficient(meter, interval.end_date.date()) if coefficient is None and tariff.unit == "€/kWh": return None amount *= coefficient or 1 diff --git a/app_new/planning/meter_readings.py b/app_new/planning/meter_readings.py index b2a637f..162dd5e 100644 --- a/app_new/planning/meter_readings.py +++ b/app_new/planning/meter_readings.py @@ -6,6 +6,7 @@ from uuid import uuid4 from flask import abort, current_app, flash, jsonify, redirect, render_template, request, url_for from flask_login import current_user, login_required +from ..core.authorization import permission_required from werkzeug.utils import secure_filename from ..extensions import db @@ -128,6 +129,7 @@ def meter_occurrence_without_reading(occurrence_id): @planning_bp.route('/meter-monitoring') @login_required +@permission_required('planning.view') def meter_monitoring(): """Tableau de surveillance C3, sans recalculer ni modifier les relevés.""" from sqlalchemy.orm import selectinload @@ -138,6 +140,7 @@ def meter_monitoring(): @planning_bp.route('/meter-analytics/') @login_required +@permission_required('planning.view') def meter_analytics(meter_id): meter = Meter.query.get_or_404(meter_id) intervals = consumption_intervals(meter) @@ -146,6 +149,7 @@ def meter_analytics(meter_id): @planning_bp.route('/meter-alerts//create-intervention', methods=['POST']) @login_required +@permission_required('intervention.create') def create_intervention_from_meter_alert(alert_id): from ..core.models.maintenance import Intervention alert = MeterAlert.query.get_or_404(alert_id) diff --git a/app_new/planning/schedules.py b/app_new/planning/schedules.py index 4175e65..894c32f 100644 --- a/app_new/planning/schedules.py +++ b/app_new/planning/schedules.py @@ -85,6 +85,8 @@ def my_day(): if item.status in ('à replanifier', 'proposé', 'conflit') } execution_states = {} + from ..core.models.planning import MeterAlert + informational_alerts = MeterAlert.query.filter(MeterAlert.status.in_(("OPEN", "INVESTIGATING"))).order_by(MeterAlert.level.desc()).all() for task in ScheduledTask.query.filter(ScheduledTask.scheduled_date == target_date).all(): execution_states[task.id] = { 'status': task.status, @@ -101,6 +103,7 @@ def my_day(): hours_configured=bool(windows), alternatives=alternatives, execution_states=execution_states, + informational_alerts=informational_alerts, ) diff --git a/app_new/planning/templates/planning/meter_analytics.html b/app_new/planning/templates/planning/meter_analytics.html index 86591c1..570a75d 100644 --- a/app_new/planning/templates/planning/meter_analytics.html +++ b/app_new/planning/templates/planning/meter_analytics.html @@ -1,5 +1,12 @@ {% extends 'base.html' %} {% block title %}Analyse — {{ meter.name }}{% endblock %} {% block content %}

Analyse — {{ meter.name }}

Les valeurs brutes restent les relevés physiques en {{ meter.unit }}. Les intervalles ignorent explicitement les resets et les ruptures de compteur.

+
Index
Consommation par intervalle / jour
{% for item in intervals|reverse %}{% else %}{% endfor %}
DuAuIndex débutIndex finDeltaPar jourContexte
{{ item.start_date.strftime('%d/%m/%Y') }}{{ item.end_date.strftime('%d/%m/%Y') }}{{ item.reading_start.value }} {{ meter.unit }}{{ item.reading_end.value }} {{ meter.unit }}{{ '%.2f'|format(item.raw_delta) }}{{ '%.2f'|format(item.consumption_per_day) }}{{ item.context.calendar }}
Pas assez de relevés exploitables pour calculer un intervalle.
-Retour à la surveillance
{% endblock %} +Retour à la surveillance +{% endblock %} diff --git a/app_new/planning/templates/planning/meter_monitoring.html b/app_new/planning/templates/planning/meter_monitoring.html index c51e79f..a077f27 100644 --- a/app_new/planning/templates/planning/meter_monitoring.html +++ b/app_new/planning/templates/planning/meter_monitoring.html @@ -3,6 +3,7 @@ {% block content %}

Surveillance des compteurs

+
Alertes critiques
{{ alerts|selectattr('level','equalto','CRITICAL')|list|length }}
Avertissements
{{ alerts|selectattr('level','equalto','WARNING')|list|length }}
Compteurs actifs
{{ meters|length }}
Compteurs
{% for meter in meters %}{% else %}{% endfor %} diff --git a/app_new/templates/planning/my_day.html b/app_new/templates/planning/my_day.html index f0c6b5b..e93ac7b 100644 --- a/app_new/templates/planning/my_day.html +++ b/app_new/templates/planning/my_day.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block title %}Ma journée{% endblock %} {% block content %} +{% if informational_alerts %}
Informations à examiner
    {% for alert in informational_alerts %}
  • {{ alert.meter.name }} — {{ 'Critique' if alert.level == 'CRITICAL' else 'Avertissement' }}{% if alert.intervention_id %} — Intervention créée : #{{ alert.intervention_id }}{% endif %}
  • {% endfor %}
Ces alertes n'occupent aucun créneau ; une intervention liée constitue le travail planifiable.
{% endif %}
diff --git a/migrations/versions/q2f3g4h5i6j7_meter_c3_completion.py b/migrations/versions/q2f3g4h5i6j7_meter_c3_completion.py new file mode 100644 index 0000000..e7ee0e7 --- /dev/null +++ b/migrations/versions/q2f3g4h5i6j7_meter_c3_completion.py @@ -0,0 +1,24 @@ +"""C3 completion: alert thresholds, rule linkage and evidence idempotence.""" +from alembic import op +import sqlalchemy as sa + +revision = "q2f3g4h5i6j7" +down_revision = "p1e2f3g4h5i6" +branch_labels = None +depends_on = None + +def upgrade(): + op.add_column("meter_alert_rules", sa.Column("min_deviation_percent", sa.Float(), nullable=False, server_default="30")) + op.add_column("meter_alert_rules", sa.Column("low_consumption_threshold", sa.Float(), nullable=True)) + op.add_column("meter_alerts", sa.Column("rule_id", sa.Integer(), nullable=True)) + op.create_foreign_key("fk_meter_alerts_rule_id", "meter_alerts", "meter_alert_rules", ["rule_id"], ["id"], ondelete="SET NULL") + op.create_index("ix_meter_alerts_rule_id", "meter_alerts", ["rule_id"]) + op.create_unique_constraint("uq_meter_alert_evidence_interval", "meter_alert_evidence", ["alert_id", "reading_start_id", "reading_end_id"]) + +def downgrade(): + op.drop_constraint("uq_meter_alert_evidence_interval", "meter_alert_evidence", type_="unique") + op.drop_index("ix_meter_alerts_rule_id", table_name="meter_alerts") + op.drop_constraint("fk_meter_alerts_rule_id", "meter_alerts", type_="foreignkey") + op.drop_column("meter_alerts", "rule_id") + op.drop_column("meter_alert_rules", "low_consumption_threshold") + op.drop_column("meter_alert_rules", "min_deviation_percent") diff --git a/tests/integration/test_meter_checkpoint_c3.py b/tests/integration/test_meter_checkpoint_c3.py index 9cbc85e..0689605 100644 --- a/tests/integration/test_meter_checkpoint_c3.py +++ b/tests/integration/test_meter_checkpoint_c3.py @@ -1,4 +1,5 @@ from datetime import datetime, date, timezone +from time import perf_counter import pytest @@ -11,6 +12,7 @@ from app_new.core.services.meter_service import create_meter, record_meter_readi from app_new.core.services.meter_analytics import ( consumption_intervals, aggregate_intervals, evaluate_alert_rule, statistical_anomaly, remainder_for_interval, estimated_cost, gas_coefficient, + create_bill_gas_conversion, close_meter_alert, ) @@ -62,7 +64,7 @@ def test_c3_manual_alert_is_persisted_and_deduplicated(app, admin_user): interval = consumption_intervals(meter)[0] first = evaluate_alert_rule(rule, interval) second = evaluate_alert_rule(rule, interval) - assert first.id == second.id and len(first.evidence) == 2 + assert first.id == second.id and len(first.evidence) == 1 def test_c3_statistical_median_minimum_and_parent_remainder(app, admin_user): @@ -88,3 +90,60 @@ def test_c3_monitoring_and_analytics_http(app, admin_user, authenticated_client) meter_id = meter.id dashboard = authenticated_client.get("/planning/meter-monitoring") assert dashboard.status_code == 200 and b"Surveillance des compteurs" in dashboard.data + + +def test_c3_water_never_uses_gas_conversion_and_bill_conversion(app, admin_user): + with app.app_context(): + water = _meter("WATER_COST", "eau", "m³") + db.session.add(MeterTariff(meter=water, energy_type="eau", unit_price=4.20, unit="€/m³", valid_from=date(2026, 1, 1))) + _reading(water, 20, date(2026, 1, 1), admin_user["id"]); _reading(water, 40, date(2026, 1, 2), admin_user["id"]) + db.session.commit() + assert estimated_cost(water, consumption_intervals(water)[0]) == pytest.approx(84) + gas = _meter("BILL", "gaz", "m³") + conversion = create_bill_gas_conversion(meter=gas, volume_m3=1000, billed_kwh=10850, valid_from=date(2026, 1, 1)) + assert conversion.coefficient_kwh_per_m3 == pytest.approx(10.85) + + +def test_c3_alert_periods_and_zero_low_are_real(app, admin_user): + with app.app_context(): + meter = _meter("PERIODS") + days = [date(2026, 1, 1) + __import__('datetime').timedelta(days=i) for i in range(8)] + for i, day in enumerate(days): _reading(meter, i * 10, day, admin_user["id"]) + intervals = consumption_intervals(meter) + for period in ("DAY", "WEEK", "MONTH"): + rule = MeterAlertRule(meter=meter, metric="consumption_interval", period=period, operator=">=", threshold=1, level="WARNING") + db.session.add(rule); db.session.commit() + assert evaluate_alert_rule(rule, intervals[-1], intervals=intervals) is not None + inactive = MeterAlertRule(meter=meter, metric="consumption_interval", period="MONTH", operator=">", threshold=1, is_active=False) + db.session.add(inactive); db.session.commit() + assert evaluate_alert_rule(inactive, intervals[-1], intervals=intervals) is None + low = MeterAlertRule(meter=meter, metric="consumption_per_day", period="DAY", operator=">", threshold=999, detect_zero_or_low=True, low_consumption_threshold=11) + db.session.add(low); db.session.commit() + assert evaluate_alert_rule(low, intervals[-1], intervals=intervals) is not None + + +def test_c3_alert_closure_excludes_only_relevant_baselines(app, admin_user): + with app.app_context(): + meter = _meter("CLOSE") + _reading(meter, 0, date(2026, 1, 1), admin_user["id"]); _reading(meter, 10, date(2026, 1, 2), admin_user["id"]) + rule = MeterAlertRule(meter=meter, metric="consumption_interval", period="DAY", operator=">", threshold=1) + db.session.add(rule); db.session.commit() + alert = evaluate_alert_rule(rule, consumption_intervals(meter)[0]) + close_meter_alert(alert=alert, conclusion="READING_ERROR", comment="TEST_UI_C3 erreur") + assert alert.status == "CLOSED" and alert.evidence[0].excluded_from_baseline is True + + +def test_c3_performance_measurement(app, admin_user, authenticated_client): + with app.app_context(): + for index in range(4): + meter = _meter(f"PERF_{index}") + for offset in range(12): + _reading(meter, offset * 5, date(2025, 1, 1) + __import__('datetime').timedelta(days=offset), admin_user["id"]) + count = {"value": 0} + from sqlalchemy import event + def before_cursor(*args): count["value"] += 1 + event.listen(db.engine, "before_cursor_execute", before_cursor) + started = perf_counter(); response = authenticated_client.get("/planning/meter-monitoring"); elapsed = perf_counter() - started + event.remove(db.engine, "before_cursor_execute", before_cursor) + print(f"C3_PERF dashboard_seconds={elapsed:.4f} dashboard_queries={count['value']}") + assert response.status_code == 200
CompteurDerniers intervallesAction
{{ meter.name }}{{ meter.unit }} · {{ meter.usage or 'Usage non renseigné' }}{{ meter.readings.count() }}Analyser
Aucun compteur actif.