feat(meters): add checkpoint C3 consumption analytics

This commit is contained in:
root 2026-08-24 10:47:01 +00:00
parent ae5223e9b9
commit b30b0e6a2a
8 changed files with 462 additions and 0 deletions

View file

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

View file

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

View 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 == "":
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

View file

@ -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 lalerte.', 'success')
return redirect(url_for('interventions.detail', id=intervention.id))
@planning_bp.route('/meter-rounds')
@login_required
def meter_rounds():

View 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 %}

View 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 %}

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

View 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=""):
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", "")
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