fix(meters): complete checkpoint C3 analytics
This commit is contained in:
parent
3f883c5494
commit
57860a0870
9 changed files with 192 additions and 20 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
24
migrations/versions/q2f3g4h5i6j7_meter_c3_completion.py
Normal file
24
migrations/versions/q2f3g4h5i6j7_meter_c3_completion.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue