fix(meters): complete checkpoint C3 analytics

This commit is contained in:
root 2026-08-24 11:14:06 +00:00
parent 3f883c5494
commit 57860a0870
9 changed files with 192 additions and 20 deletions

View file

@ -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)

View file

@ -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,37 +160,56 @@ 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)
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 == "":
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

View file

@ -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/<int:meter_id>')
@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/<int:alert_id>/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)

View file

@ -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,
)

View file

@ -1,5 +1,12 @@
{% extends 'base.html' %}
{% block title %}Analyse — {{ meter.name }}{% endblock %}
{% block content %}<div class="container py-3"><h1 class="h3">Analyse — {{ meter.name }}</h1><p class="text-muted">Les valeurs brutes restent les relevés physiques en {{ meter.unit }}. Les intervalles ignorent explicitement les resets et les ruptures de compteur.</p>
<div class="row g-3 mb-3"><div class="col-lg-6"><div class="card"><div class="card-header">Index</div><div class="card-body"><canvas id="meterIndexChart" height="180" aria-label="Courbe des index"></canvas></div></div></div><div class="col-lg-6"><div class="card"><div class="card-header">Consommation par intervalle / jour</div><div class="card-body"><canvas id="meterConsumptionChart" height="180" aria-label="Consommation par intervalle et par jour"></canvas></div></div></div></div>
<div class="table-responsive"><table class="table table-sm"><thead><tr><th>Du</th><th>Au</th><th>Index début</th><th>Index fin</th><th>Delta</th><th>Par jour</th><th>Contexte</th></tr></thead><tbody>{% for item in intervals|reverse %}<tr><td>{{ item.start_date.strftime('%d/%m/%Y') }}</td><td>{{ item.end_date.strftime('%d/%m/%Y') }}</td><td>{{ item.reading_start.value }} {{ meter.unit }}</td><td>{{ item.reading_end.value }} {{ meter.unit }}</td><td>{{ '%.2f'|format(item.raw_delta) }}</td><td>{{ '%.2f'|format(item.consumption_per_day) }}</td><td>{{ item.context.calendar }}</td></tr>{% else %}<tr><td colspan="7">Pas assez de relevés exploitables pour calculer un intervalle.</td></tr>{% endfor %}</tbody></table></div>
<a class="btn btn-outline-secondary" href="{{ url_for('planning.meter_monitoring') }}">Retour à la surveillance</a></div>{% endblock %}
<a class="btn btn-outline-secondary" href="{{ url_for('planning.meter_monitoring') }}">Retour à la surveillance</a></div>
<script>
(function(){const rows=[{% for i in intervals %}{d:'{{ i.end_date.strftime('%d/%m') }}',start:{{ i.reading_start.value }},end:{{ i.reading_end.value }},delta:{{ i.raw_delta }},day:{{ i.consumption_per_day }}},{% endfor %}];
function draw(id, key, color){const c=document.getElementById(id),x=c.getContext('2d'),w=c.width=c.clientWidth*2,h=c.height=c.clientHeight*2;x.scale(2,2);const ww=c.clientWidth,hh=c.clientHeight,pad=18,max=Math.max(...rows.map(r=>r[key]),1),step=(ww-pad*2)/Math.max(rows.length-1,1);x.strokeStyle=color;x.lineWidth=2;x.beginPath();rows.forEach((r,n)=>{const px=pad+n*step,py=hh-pad-(r[key]/max)*(hh-pad*2);n?x.lineTo(px,py):x.moveTo(px,py)});x.stroke();x.fillStyle='#495057';x.font='11px sans-serif';rows.forEach((r,n)=>{if(n%Math.max(1,Math.floor(rows.length/6))===0)x.fillText(r.d,pad+n*step-10,hh-2)});}
if(rows.length){draw('meterIndexChart','end','#0d6efd');draw('meterConsumptionChart','delta','#198754');}
})();
</script>{% endblock %}

View file

@ -3,6 +3,7 @@
{% block content %}
<div class="container py-3">
<h1 class="h3">Surveillance des compteurs</h1>
<div class="row g-3 mb-3"><div class="col-md-4"><div class="card"><div class="card-body"><strong>Alertes critiques</strong><div class="fs-3">{{ alerts|selectattr('level','equalto','CRITICAL')|list|length }}</div></div></div></div><div class="col-md-4"><div class="card"><div class="card-body"><strong>Avertissements</strong><div class="fs-3">{{ alerts|selectattr('level','equalto','WARNING')|list|length }}</div></div></div></div><div class="col-md-4"><div class="card"><div class="card-body"><strong>Compteurs actifs</strong><div class="fs-3">{{ meters|length }}</div></div></div></div></div>
<div class="row g-3">
<div class="col-lg-8"><div class="card"><div class="card-header">Compteurs</div><div class="table-responsive"><table class="table mb-0"><thead><tr><th>Compteur</th><th>Derniers intervalles</th><th>Action</th></tr></thead><tbody>
{% for meter in meters %}<tr><td>{{ meter.name }}<small class="d-block text-muted">{{ meter.unit }} · {{ meter.usage or 'Usage non renseigné' }}</small></td><td>{{ meter.readings.count() }}</td><td><a class="btn btn-sm btn-outline-primary" href="{{ url_for('planning.meter_analytics', meter_id=meter.id) }}">Analyser</a></td></tr>{% else %}<tr><td colspan="3">Aucun compteur actif.</td></tr>{% endfor %}

View file

@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block title %}Ma journée{% endblock %}
{% block content %}
{% if informational_alerts %}<div class="alert alert-warning" role="status"><strong>Informations à examiner</strong><ul class="mb-0">{% for alert in informational_alerts %}<li>{{ alert.meter.name }} — {{ 'Critique' if alert.level == 'CRITICAL' else 'Avertissement' }}{% if alert.intervention_id %} — Intervention créée : #{{ alert.intervention_id }}{% endif %}</li>{% endfor %}</ul><small>Ces alertes n'occupent aucun créneau ; une intervention liée constitue le travail planifiable.</small></div>{% endif %}
<div class="container-fluid py-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>

View file

@ -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")

View file

@ -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", "")
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", "")
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