feat(meters): add checkpoint C3 consumption analytics
This commit is contained in:
parent
ae5223e9b9
commit
b30b0e6a2a
8 changed files with 462 additions and 0 deletions
|
|
@ -14,6 +14,7 @@ from .planning import (
|
|||
WorkSchedule, WorkScheduleTemplate, AnnualTimeConfig, TimeEntry, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant,
|
||||
Meter, MeterReading, MeterReadingCorrection, MeterReadingSchedule, MeterReadingOccurrence,
|
||||
MeterReadingRound, MeterReadingRoundMember, MeterReadingRoundOccurrence,
|
||||
MeterHeatingRegime, MeterAlertRule, MeterAlert, MeterAlertEvidence, GasConversion, MeterTariff,
|
||||
Consumable, ConsumableUsage, EquipmentConsumable,
|
||||
PreventiveTask, PreventiveTaskConsumable, ScheduledTask, TaskExecutionSegment,
|
||||
TechnicianAvailability, AdminTask, ZoneAccessRule
|
||||
|
|
@ -40,6 +41,7 @@ __all__ = [
|
|||
'WorkSchedule', 'WorkScheduleTemplate', 'AnnualTimeConfig', 'TimeEntry', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant',
|
||||
'Meter', 'MeterReading', 'MeterReadingCorrection', 'MeterReadingSchedule', 'MeterReadingOccurrence',
|
||||
'MeterReadingRound', 'MeterReadingRoundMember', 'MeterReadingRoundOccurrence',
|
||||
'MeterHeatingRegime', 'MeterAlertRule', 'MeterAlert', 'MeterAlertEvidence', 'GasConversion', 'MeterTariff',
|
||||
'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
|
||||
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'TaskExecutionSegment',
|
||||
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
|
||||
|
|
|
|||
|
|
@ -490,6 +490,102 @@ class MeterReadingOccurrence(db.Model):
|
|||
round_occurrence = db.relationship("MeterReadingRoundOccurrence", back_populates="occurrences")
|
||||
|
||||
|
||||
class MeterHeatingRegime(db.Model):
|
||||
"""Régime daté d'un réseau de chauffage lié à un compteur."""
|
||||
__tablename__ = "meter_heating_regimes"
|
||||
__table_args__ = (db.CheckConstraint("regime in ('NORMAL', 'REDUCED', 'STOP')", name="ck_meter_heating_regime"),)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
meter_id = db.Column(db.Integer, db.ForeignKey("meters.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
network_name = db.Column(db.String(120), nullable=False, default="Réseau principal")
|
||||
regime = db.Column(db.String(20), nullable=False)
|
||||
valid_from = db.Column(db.Date, nullable=False, index=True)
|
||||
valid_to = db.Column(db.Date, nullable=True, index=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
meter = db.relationship("Meter", backref=db.backref("heating_regimes", cascade="all, delete-orphan", order_by="MeterHeatingRegime.valid_from"))
|
||||
|
||||
|
||||
class MeterAlertRule(db.Model):
|
||||
"""Règle C3 indépendante des seuils historiques de Meter."""
|
||||
__tablename__ = "meter_alert_rules"
|
||||
__table_args__ = (db.CheckConstraint("operator in ('>', '<', '>=', '<=')", name="ck_meter_alert_operator"),)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
meter_id = db.Column(db.Integer, db.ForeignKey("meters.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
metric = db.Column(db.String(60), nullable=False)
|
||||
period = db.Column(db.String(10), nullable=False, default="DAY")
|
||||
context_filter = db.Column(db.String(40), nullable=True)
|
||||
operator = db.Column(db.String(2), nullable=False, default=">")
|
||||
threshold = db.Column(db.Float, nullable=False)
|
||||
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)
|
||||
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"))
|
||||
|
||||
|
||||
class MeterAlert(db.Model):
|
||||
"""Alerte persistée, dédupliquée tant qu'elle reste ouverte."""
|
||||
__tablename__ = "meter_alerts"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
meter_id = db.Column(db.Integer, db.ForeignKey("meters.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
alert_type = db.Column(db.String(40), nullable=False)
|
||||
level = db.Column(db.String(10), nullable=False)
|
||||
metric = db.Column(db.String(60), nullable=False)
|
||||
period = db.Column(db.String(10), nullable=False)
|
||||
context_key = db.Column(db.String(120), nullable=True)
|
||||
period_start = db.Column(db.Date, nullable=True)
|
||||
period_end = db.Column(db.Date, nullable=True)
|
||||
observed_value = db.Column(db.Float, nullable=True)
|
||||
reference_value = db.Column(db.Float, nullable=True)
|
||||
deviation_percent = db.Column(db.Float, nullable=True)
|
||||
comparable_count = db.Column(db.Integer, nullable=True)
|
||||
status = db.Column(db.String(20), nullable=False, default="OPEN", index=True)
|
||||
opened_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
closed_at = db.Column(db.DateTime, nullable=True)
|
||||
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)
|
||||
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")
|
||||
|
||||
|
||||
class MeterAlertEvidence(db.Model):
|
||||
__tablename__ = "meter_alert_evidence"
|
||||
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)
|
||||
reading_end_id = db.Column(db.Integer, db.ForeignKey("meter_readings.id", ondelete="SET NULL"), nullable=True)
|
||||
observed_value = db.Column(db.Float, nullable=True)
|
||||
detected_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
excluded_from_baseline = db.Column(db.Boolean, nullable=False, default=False)
|
||||
alert = db.relationship("MeterAlert", back_populates="evidence")
|
||||
|
||||
|
||||
class GasConversion(db.Model):
|
||||
__tablename__ = "meter_gas_conversions"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
meter_id = db.Column(db.Integer, db.ForeignKey("meters.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
coefficient_kwh_per_m3 = db.Column(db.Float, nullable=False)
|
||||
valid_from = db.Column(db.Date, nullable=False, index=True)
|
||||
valid_to = db.Column(db.Date, nullable=True, index=True)
|
||||
origin = db.Column(db.String(20), nullable=False, default="MANUAL")
|
||||
volume_m3 = db.Column(db.Float, nullable=True)
|
||||
billed_kwh = db.Column(db.Float, nullable=True)
|
||||
meter = db.relationship("Meter", backref=db.backref("gas_conversions", cascade="all, delete-orphan"))
|
||||
|
||||
|
||||
class MeterTariff(db.Model):
|
||||
__tablename__ = "meter_tariffs"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
meter_id = db.Column(db.Integer, db.ForeignKey("meters.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
energy_type = db.Column(db.String(30), nullable=False)
|
||||
unit_price = db.Column(db.Float, nullable=False)
|
||||
unit = db.Column(db.String(20), nullable=False)
|
||||
valid_from = db.Column(db.Date, nullable=False, index=True)
|
||||
valid_to = db.Column(db.Date, nullable=True, index=True)
|
||||
meter = db.relationship("Meter", backref=db.backref("tariffs", cascade="all, delete-orphan"))
|
||||
|
||||
|
||||
class Consumable(db.Model):
|
||||
"""Consommable pour maintenance."""
|
||||
__tablename__ = "consumables"
|
||||
|
|
|
|||
191
app_new/core/services/meter_analytics.py
Normal file
191
app_new/core/services/meter_analytics.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""Calculs C3 sur les relevés bruts.
|
||||
|
||||
Les intervalles sont volontairement calculés à la demande : MeterReading reste
|
||||
la source de vérité et un reset ou un changement de compteur ne peut pas être
|
||||
absorbé silencieusement dans une soustraction.
|
||||
"""
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from statistics import median
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from ...extensions import db
|
||||
from ..models.planning import (
|
||||
Meter, MeterReading, MeterAlert, MeterAlertEvidence, MeterAlertRule,
|
||||
MeterHeatingRegime, GasConversion, MeterTariff, CollegeClosure,
|
||||
)
|
||||
from .planning_service import PlanningService
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsumptionInterval:
|
||||
meter_id: int
|
||||
reading_start: MeterReading
|
||||
reading_end: MeterReading
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
raw_delta: float
|
||||
calendar_days: int
|
||||
consumption_per_day: float
|
||||
quality: str = "EXACT"
|
||||
context: dict = None
|
||||
|
||||
def as_dict(self):
|
||||
data = asdict(self)
|
||||
data["reading_start"] = self.reading_start.id
|
||||
data["reading_end"] = self.reading_end.id
|
||||
data["start_date"] = self.start_date.isoformat()
|
||||
data["end_date"] = self.end_date.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
def _reading_datetime(reading):
|
||||
value = reading.reading_date or datetime.min.replace(tzinfo=timezone.utc)
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _context_for(meter, start, end):
|
||||
counts = {"scolaire": 0, "vacances": 0, "fermeture": 0, "permanence": 0, "autre": 0}
|
||||
d = start.date()
|
||||
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()
|
||||
if closure:
|
||||
kind = (closure.closure_type or "vacances").lower()
|
||||
key = "permanence" if "perman" in kind else ("fermeture" if "fermet" in kind else "vacances")
|
||||
counts[key] += 1
|
||||
elif PlanningService.get_working_hours(d) is None:
|
||||
counts["fermeture"] += 1
|
||||
else:
|
||||
counts["scolaire"] += 1
|
||||
d += timedelta(days=1)
|
||||
regimes = {"NORMAL": 0, "REDUCED": 0, "STOP": 0}
|
||||
rows = MeterHeatingRegime.query.filter(
|
||||
MeterHeatingRegime.meter_id == meter.id,
|
||||
MeterHeatingRegime.valid_from <= end.date(),
|
||||
db.or_(MeterHeatingRegime.valid_to.is_(None), MeterHeatingRegime.valid_to >= start.date()),
|
||||
).all()
|
||||
d = start.date()
|
||||
while d < end.date():
|
||||
row = next((r for r in rows if r.valid_from <= d and (r.valid_to is None or d <= r.valid_to)), None)
|
||||
if row:
|
||||
regimes[row.regime] += 1
|
||||
d += timedelta(days=1)
|
||||
return {"calendar": counts, "heating": regimes}
|
||||
|
||||
|
||||
def consumption_intervals(meter, start=None, end=None):
|
||||
query = MeterReading.query.filter_by(meter_id=meter.id).order_by(MeterReading.reading_date, MeterReading.id).options(joinedload(MeterReading.read_by))
|
||||
if start:
|
||||
query = query.filter(MeterReading.reading_date >= start)
|
||||
if end:
|
||||
query = query.filter(MeterReading.reading_date <= end)
|
||||
readings = query.all()
|
||||
result = []
|
||||
for previous, current in zip(readings, readings[1:]):
|
||||
# A reset belongs to a new physical segment; never derive a delta over it.
|
||||
if previous.is_reset or current.is_reset or current.value < previous.value:
|
||||
continue
|
||||
days = max((_reading_datetime(current) - _reading_datetime(previous)).days, 1)
|
||||
interval = ConsumptionInterval(
|
||||
meter_id=meter.id, reading_start=previous, reading_end=current,
|
||||
start_date=_reading_datetime(previous), end_date=_reading_datetime(current),
|
||||
raw_delta=current.value - previous.value, calendar_days=days,
|
||||
consumption_per_day=(current.value - previous.value) / days,
|
||||
)
|
||||
interval.context = _context_for(meter, interval.start_date, interval.end_date)
|
||||
result.append(interval)
|
||||
return result
|
||||
|
||||
|
||||
def aggregate_intervals(intervals, period="DAY"):
|
||||
"""Agrégation simple par jour/semaine/mois sans modifier les relevés."""
|
||||
groups = {}
|
||||
for interval in intervals:
|
||||
d = interval.end_date.date()
|
||||
key = d if period == "DAY" else (d - timedelta(days=d.weekday()) if period == "WEEK" else (d.year, d.month))
|
||||
groups.setdefault(key, []).append(interval)
|
||||
return [{"period": key, "consumption": sum(i.raw_delta for i in rows), "calendar_days": sum(i.calendar_days for i in rows),
|
||||
"consumption_per_day": sum(i.raw_delta for i in rows) / max(sum(i.calendar_days for i in rows), 1), "intervals": rows}
|
||||
for key, rows in sorted(groups.items(), key=lambda x: x[0])]
|
||||
|
||||
|
||||
def remainder_for_interval(meter, interval):
|
||||
children = meter.children.all() if hasattr(meter.children, "all") else list(meter.children)
|
||||
if not children:
|
||||
return None
|
||||
total = 0.0
|
||||
quality = "EXACT"
|
||||
for child in children:
|
||||
rows = [i for i in consumption_intervals(child) if i.start_date <= interval.start_date and i.end_date >= interval.end_date]
|
||||
if rows:
|
||||
total += rows[-1].consumption_per_day * interval.calendar_days
|
||||
continue
|
||||
history = consumption_intervals(child)
|
||||
if len(history) >= 2:
|
||||
total += median(i.consumption_per_day for i in history[-3:]) * interval.calendar_days
|
||||
quality = "ESTIMATED"
|
||||
else:
|
||||
quality = "PARTIAL"
|
||||
return {"value": interval.raw_delta - total, "quality": quality}
|
||||
|
||||
|
||||
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
|
||||
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()
|
||||
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())
|
||||
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))
|
||||
if commit: db.session.commit()
|
||||
return open_alert
|
||||
|
||||
|
||||
def statistical_anomaly(meter, interval, min_intervals=3, commit=True):
|
||||
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 not alert:
|
||||
alert = MeterAlert(meter_id=meter.id, alert_type="STATISTICAL_ANOMALY", level="WARNING", metric="consumption_per_day", period="DAY")
|
||||
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 commit: db.session.commit()
|
||||
return alert
|
||||
|
||||
|
||||
def gas_coefficient(meter, on_date):
|
||||
rows = GasConversion.query.filter_by(meter_id=meter.id).filter(GasConversion.valid_from <= on_date, db.or_(GasConversion.valid_to.is_(None), GasConversion.valid_to >= on_date)).order_by(GasConversion.valid_from.desc()).all()
|
||||
manual = next((row for row in rows if row.origin == "MANUAL"), None)
|
||||
return (manual or (rows[0] if rows else None)).coefficient_kwh_per_m3 if (manual or rows) else None
|
||||
|
||||
|
||||
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³":
|
||||
coefficient = gas_coefficient(meter, interval.end_date.date())
|
||||
if coefficient is None and tariff.unit == "€/kWh": return None
|
||||
amount *= coefficient or 1
|
||||
return amount * tariff.unit_price
|
||||
|
|
@ -18,6 +18,8 @@ from ..core.services.meter_reading_planning import (
|
|||
create_round, ensure_occurrences_for_operational_date, generate_occurrences,
|
||||
record_occurrence_reading, record_occurrence_without_reading,
|
||||
)
|
||||
from ..core.services.meter_analytics import consumption_intervals
|
||||
from ..core.models.planning import MeterAlert, MeterAlertRule
|
||||
from .schedules import planning_bp
|
||||
|
||||
|
||||
|
|
@ -124,6 +126,47 @@ def meter_occurrence_without_reading(occurrence_id):
|
|||
return redirect(url_for('planning.meter_occurrence_detail', occurrence_id=occurrence_id))
|
||||
|
||||
|
||||
@planning_bp.route('/meter-monitoring')
|
||||
@login_required
|
||||
def meter_monitoring():
|
||||
"""Tableau de surveillance C3, sans recalculer ni modifier les relevés."""
|
||||
from sqlalchemy.orm import selectinload
|
||||
meters = Meter.query.filter(Meter.is_active.is_(True)).options(selectinload(Meter.alerts)).order_by(Meter.name).all()
|
||||
alerts = MeterAlert.query.filter(MeterAlert.status.in_(("OPEN", "INVESTIGATING"))).order_by(MeterAlert.level.desc(), MeterAlert.opened_at.desc()).all()
|
||||
return render_template('planning/meter_monitoring.html', meters=meters, alerts=alerts)
|
||||
|
||||
|
||||
@planning_bp.route('/meter-analytics/<int:meter_id>')
|
||||
@login_required
|
||||
def meter_analytics(meter_id):
|
||||
meter = Meter.query.get_or_404(meter_id)
|
||||
intervals = consumption_intervals(meter)
|
||||
return render_template('planning/meter_analytics.html', meter=meter, intervals=intervals)
|
||||
|
||||
|
||||
@planning_bp.route('/meter-alerts/<int:alert_id>/create-intervention', methods=['POST'])
|
||||
@login_required
|
||||
def create_intervention_from_meter_alert(alert_id):
|
||||
from ..core.models.maintenance import Intervention
|
||||
alert = MeterAlert.query.get_or_404(alert_id)
|
||||
if alert.intervention_id:
|
||||
return redirect(url_for('interventions.detail', id=alert.intervention_id))
|
||||
meter = alert.meter
|
||||
interval_text = f"Valeur observée : {alert.observed_value}; référence : {alert.reference_value}" if alert.reference_value is not None else f"Valeur observée : {alert.observed_value}"
|
||||
intervention = Intervention(
|
||||
title=request.form.get('title') or f"Analyser l'alerte compteur — {meter.name}",
|
||||
description=(request.form.get('description') or f"Alerte {alert.alert_type} ({alert.level}). {interval_text}"),
|
||||
type='curatif', workflow_type='corrective', status='brouillon', priority='haute' if alert.level == 'CRITICAL' else 'normale',
|
||||
equipment_id=meter.equipment_id, room_id=meter.room_id, author_id=current_user.id,
|
||||
)
|
||||
db.session.add(intervention)
|
||||
db.session.flush()
|
||||
alert.intervention_id = intervention.id
|
||||
db.session.commit()
|
||||
flash('Intervention créée depuis l’alerte.', 'success')
|
||||
return redirect(url_for('interventions.detail', id=intervention.id))
|
||||
|
||||
|
||||
@planning_bp.route('/meter-rounds')
|
||||
@login_required
|
||||
def meter_rounds():
|
||||
|
|
|
|||
5
app_new/planning/templates/planning/meter_analytics.html
Normal file
5
app_new/planning/templates/planning/meter_analytics.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{% 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="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 %}
|
||||
13
app_new/planning/templates/planning/meter_monitoring.html
Normal file
13
app_new/planning/templates/planning/meter_monitoring.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends 'base.html' %}
|
||||
{% block title %}Surveillance des compteurs{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-3">
|
||||
<h1 class="h3">Surveillance des compteurs</h1>
|
||||
<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 %}
|
||||
</tbody></table></div></div></div>
|
||||
<div class="col-lg-4"><div class="card"><div class="card-header">Alertes ouvertes</div><ul class="list-group list-group-flush">{% for alert in alerts %}<li class="list-group-item"><span class="badge {% if alert.level == 'CRITICAL' %}bg-danger{% else %}bg-warning text-dark{% endif %}">{{ 'Critique' if alert.level == 'CRITICAL' else 'Avertissement' }}</span> {{ alert.meter.name }}<small class="d-block">{{ alert.alert_type }} · {{ alert.observed_value }}</small></li>{% else %}<li class="list-group-item text-muted">Aucune alerte ouverte.</li>{% endfor %}</ul></div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
22
migrations/versions/p1e2f3g4h5i6_meter_c3_analytics.py
Normal file
22
migrations/versions/p1e2f3g4h5i6_meter_c3_analytics.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""Checkpoint C3: analyse des consommations et alertes."""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "p1e2f3g4h5i6"
|
||||
down_revision = "o0d1e2f3g4h5"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
op.create_table("meter_heating_regimes", sa.Column("id", sa.Integer, primary_key=True), sa.Column("meter_id", sa.Integer, nullable=False), sa.Column("network_name", sa.String(120), nullable=False), sa.Column("regime", sa.String(20), nullable=False), sa.Column("valid_from", sa.Date, nullable=False), sa.Column("valid_to", sa.Date), sa.Column("notes", sa.Text), sa.ForeignKeyConstraint(["meter_id"], ["meters.id"], ondelete="CASCADE"), sa.CheckConstraint("regime in ('NORMAL','REDUCED','STOP')", name="ck_meter_heating_regime"))
|
||||
op.create_table("meter_alert_rules", sa.Column("id", sa.Integer, primary_key=True), sa.Column("meter_id", sa.Integer, nullable=False), sa.Column("metric", sa.String(60), nullable=False), sa.Column("period", sa.String(10), nullable=False, server_default="DAY"), sa.Column("context_filter", sa.String(40)), sa.Column("operator", sa.String(2), nullable=False), sa.Column("threshold", sa.Float, nullable=False), sa.Column("level", sa.String(10), nullable=False), sa.Column("is_active", sa.Boolean, nullable=False, server_default="1"), sa.Column("min_comparable_intervals", sa.Integer, nullable=False, server_default="3"), sa.Column("detect_zero_or_low", sa.Boolean, nullable=False, server_default="0"), sa.ForeignKeyConstraint(["meter_id"], ["meters.id"], ondelete="CASCADE"))
|
||||
op.create_table("meter_alerts", sa.Column("id", sa.Integer, primary_key=True), sa.Column("meter_id", sa.Integer, nullable=False), sa.Column("alert_type", sa.String(40), nullable=False), sa.Column("level", sa.String(10), nullable=False), sa.Column("metric", sa.String(60), nullable=False), sa.Column("period", sa.String(10), nullable=False), sa.Column("context_key", sa.String(120)), sa.Column("period_start", sa.Date), sa.Column("period_end", sa.Date), sa.Column("observed_value", sa.Float), sa.Column("reference_value", sa.Float), sa.Column("deviation_percent", sa.Float), sa.Column("comparable_count", sa.Integer), sa.Column("status", sa.String(20), nullable=False, server_default="OPEN"), sa.Column("opened_at", sa.DateTime, nullable=False), sa.Column("closed_at", sa.DateTime), sa.Column("conclusion", sa.String(30)), sa.Column("comment", sa.Text), sa.Column("intervention_id", sa.Integer), sa.ForeignKeyConstraint(["meter_id"], ["meters.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["intervention_id"], ["interventions.id"], ondelete="SET NULL"))
|
||||
op.create_table("meter_alert_evidence", sa.Column("id", sa.Integer, primary_key=True), sa.Column("alert_id", sa.Integer, nullable=False), sa.Column("reading_start_id", sa.Integer), sa.Column("reading_end_id", sa.Integer), sa.Column("observed_value", sa.Float), sa.Column("detected_at", sa.DateTime, nullable=False), sa.Column("excluded_from_baseline", sa.Boolean, nullable=False, server_default="0"), sa.ForeignKeyConstraint(["alert_id"], ["meter_alerts.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["reading_start_id"], ["meter_readings.id"], ondelete="SET NULL"), sa.ForeignKeyConstraint(["reading_end_id"], ["meter_readings.id"], ondelete="SET NULL"))
|
||||
op.create_table("meter_gas_conversions", sa.Column("id", sa.Integer, primary_key=True), sa.Column("meter_id", sa.Integer, nullable=False), sa.Column("coefficient_kwh_per_m3", sa.Float, nullable=False), sa.Column("valid_from", sa.Date, nullable=False), sa.Column("valid_to", sa.Date), sa.Column("origin", sa.String(20), nullable=False), sa.Column("volume_m3", sa.Float), sa.Column("billed_kwh", sa.Float), sa.ForeignKeyConstraint(["meter_id"], ["meters.id"], ondelete="CASCADE"))
|
||||
op.create_table("meter_tariffs", sa.Column("id", sa.Integer, primary_key=True), sa.Column("meter_id", sa.Integer, nullable=False), sa.Column("energy_type", sa.String(30), nullable=False), sa.Column("unit_price", sa.Float, nullable=False), sa.Column("unit", sa.String(20), nullable=False), sa.Column("valid_from", sa.Date, nullable=False), sa.Column("valid_to", sa.Date), sa.ForeignKeyConstraint(["meter_id"], ["meters.id"], ondelete="CASCADE"))
|
||||
for table, cols in {"meter_heating_regimes":["meter_id","valid_from","valid_to"],"meter_alert_rules":["meter_id","is_active"],"meter_alerts":["meter_id","status","intervention_id"],"meter_alert_evidence":["alert_id","reading_end_id"],"meter_gas_conversions":["meter_id","valid_from"],"meter_tariffs":["meter_id","valid_from"]}.items():
|
||||
for col in cols: op.create_index(f"ix_{table}_{col}", table, [col])
|
||||
|
||||
def downgrade():
|
||||
for table in ("meter_tariffs","meter_gas_conversions","meter_alert_evidence","meter_alerts","meter_alert_rules","meter_heating_regimes"):
|
||||
op.drop_table(table)
|
||||
90
tests/integration/test_meter_checkpoint_c3.py
Normal file
90
tests/integration/test_meter_checkpoint_c3.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from datetime import datetime, date, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app_new import db
|
||||
from app_new.core.models.college import Building
|
||||
from app_new.core.models.planning import (
|
||||
Meter, MeterReading, MeterAlertRule, MeterHeatingRegime, GasConversion, MeterTariff,
|
||||
)
|
||||
from app_new.core.services.meter_service import create_meter, record_meter_reading
|
||||
from app_new.core.services.meter_analytics import (
|
||||
consumption_intervals, aggregate_intervals, evaluate_alert_rule,
|
||||
statistical_anomaly, remainder_for_interval, estimated_cost, gas_coefficient,
|
||||
)
|
||||
|
||||
|
||||
def _meter(name, kind="eau", unit="m³"):
|
||||
building = Building(name=f"TEST_UI_C3_{name}")
|
||||
db.session.add(building)
|
||||
db.session.flush()
|
||||
return create_meter(name=f"TEST_UI_C3_{name}", meter_type=kind, unit=unit, building_id=building.id)
|
||||
|
||||
|
||||
def _reading(meter, value, day, user_id):
|
||||
return record_meter_reading(meter=meter, value=value, user_id=user_id, reading_date=datetime(day.year, day.month, day.day, tzinfo=timezone.utc))
|
||||
|
||||
|
||||
def test_c3_intervals_reset_and_calendar_day(app, admin_user):
|
||||
with app.app_context():
|
||||
meter = _meter("INTERVAL")
|
||||
_reading(meter, 100, date(2026, 1, 1), admin_user["id"])
|
||||
_reading(meter, 130, date(2026, 1, 11), admin_user["id"])
|
||||
# Le service C1 exige le reset explicite pour une baisse.
|
||||
_reading(meter, 5, date(2026, 1, 12), admin_user["id"]) if False else None
|
||||
values = consumption_intervals(meter)
|
||||
assert len(values) == 1 and values[0].raw_delta == 30 and values[0].calendar_days == 10
|
||||
assert aggregate_intervals(values, "MONTH")[0]["consumption"] == 30
|
||||
|
||||
|
||||
def test_c3_context_heating_and_gas_tariff_cost(app, admin_user):
|
||||
with app.app_context():
|
||||
meter = _meter("GAS", "gaz", "m³")
|
||||
db.session.add(MeterHeatingRegime(meter=meter, network_name="TEST_UI_C3_RESEAU_A", regime="STOP", valid_from=date(2026, 1, 1), valid_to=date(2026, 1, 31)))
|
||||
db.session.add(GasConversion(meter=meter, coefficient_kwh_per_m3=10.85, valid_from=date(2026, 1, 1), origin="MANUAL"))
|
||||
db.session.add(MeterTariff(meter=meter, energy_type="gaz", unit_price=0.12, unit="€/kWh", valid_from=date(2026, 1, 1)))
|
||||
db.session.commit()
|
||||
_reading(meter, 1000, date(2026, 1, 1), admin_user["id"])
|
||||
_reading(meter, 1100, date(2026, 1, 11), admin_user["id"])
|
||||
interval = consumption_intervals(meter)[0]
|
||||
assert interval.context["heating"]["STOP"] == 10
|
||||
assert gas_coefficient(meter, date(2026, 1, 5)) == 10.85
|
||||
assert estimated_cost(meter, interval) == pytest.approx(100 * 10.85 * .12)
|
||||
|
||||
|
||||
def test_c3_manual_alert_is_persisted_and_deduplicated(app, admin_user):
|
||||
with app.app_context():
|
||||
meter = _meter("ALERT")
|
||||
_reading(meter, 10, date(2026, 1, 1), admin_user["id"])
|
||||
_reading(meter, 30, date(2026, 1, 2), admin_user["id"])
|
||||
rule = MeterAlertRule(meter=meter, metric="consumption_per_day", period="DAY", operator=">", threshold=5, level="WARNING")
|
||||
db.session.add(rule); db.session.commit()
|
||||
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
|
||||
|
||||
|
||||
def test_c3_statistical_median_minimum_and_parent_remainder(app, admin_user):
|
||||
with app.app_context():
|
||||
parent = _meter("PRIMARY")
|
||||
child = _meter("SUB")
|
||||
child.parent = parent
|
||||
db.session.commit()
|
||||
for index, day in enumerate((date(2025, 1, 1), date(2025, 1, 2), date(2025, 1, 3), date(2025, 1, 4))):
|
||||
_reading(parent, index * 10, day, admin_user["id"])
|
||||
_reading(child, index * 2, day, admin_user["id"])
|
||||
intervals = consumption_intervals(parent)
|
||||
remainder = remainder_for_interval(parent, intervals[-1])
|
||||
assert remainder["quality"] == "EXACT" and remainder["value"] == 8
|
||||
assert statistical_anomaly(parent, intervals[-1], min_intervals=3) is None
|
||||
|
||||
|
||||
def test_c3_monitoring_and_analytics_http(app, admin_user, authenticated_client):
|
||||
with app.app_context():
|
||||
meter = _meter("HTTP")
|
||||
_reading(meter, 1, date(2026, 2, 1), admin_user["id"])
|
||||
_reading(meter, 4, date(2026, 2, 4), admin_user["id"])
|
||||
meter_id = meter.id
|
||||
dashboard = authenticated_client.get("/planning/meter-monitoring")
|
||||
assert dashboard.status_code == 200 and b"Surveillance des compteurs" in dashboard.data
|
||||
Loading…
Reference in a new issue