diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py index eba7b17..5b01445 100644 --- a/app_new/core/models/__init__.py +++ b/app_new/core/models/__init__.py @@ -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', diff --git a/app_new/core/models/planning.py b/app_new/core/models/planning.py index cde63b5..39e24a4 100644 --- a/app_new/core/models/planning.py +++ b/app_new/core/models/planning.py @@ -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" diff --git a/app_new/core/services/meter_analytics.py b/app_new/core/services/meter_analytics.py new file mode 100644 index 0000000..f06defc --- /dev/null +++ b/app_new/core/services/meter_analytics.py @@ -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 diff --git a/app_new/planning/meter_readings.py b/app_new/planning/meter_readings.py index cfba39b..b2a637f 100644 --- a/app_new/planning/meter_readings.py +++ b/app_new/planning/meter_readings.py @@ -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/') +@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//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(): diff --git a/app_new/planning/templates/planning/meter_analytics.html b/app_new/planning/templates/planning/meter_analytics.html new file mode 100644 index 0000000..86591c1 --- /dev/null +++ b/app_new/planning/templates/planning/meter_analytics.html @@ -0,0 +1,5 @@ +{% extends 'base.html' %} +{% block title %}Analyse — {{ meter.name }}{% endblock %} +{% block content %}

Analyse — {{ meter.name }}

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

+
{% for item in intervals|reverse %}{% else %}{% endfor %}
DuAuIndex débutIndex finDeltaPar jourContexte
{{ item.start_date.strftime('%d/%m/%Y') }}{{ item.end_date.strftime('%d/%m/%Y') }}{{ item.reading_start.value }} {{ meter.unit }}{{ item.reading_end.value }} {{ meter.unit }}{{ '%.2f'|format(item.raw_delta) }}{{ '%.2f'|format(item.consumption_per_day) }}{{ item.context.calendar }}
Pas assez de relevés exploitables pour calculer un intervalle.
+Retour à la surveillance
{% endblock %} diff --git a/app_new/planning/templates/planning/meter_monitoring.html b/app_new/planning/templates/planning/meter_monitoring.html new file mode 100644 index 0000000..c51e79f --- /dev/null +++ b/app_new/planning/templates/planning/meter_monitoring.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block title %}Surveillance des compteurs{% endblock %} +{% block content %} +
+

Surveillance des compteurs

+
+
Compteurs
+ {% for meter in meters %}{% else %}{% endfor %} +
CompteurDerniers intervallesAction
{{ meter.name }}{{ meter.unit }} · {{ meter.usage or 'Usage non renseigné' }}{{ meter.readings.count() }}Analyser
Aucun compteur actif.
+
Alertes ouvertes
    {% for alert in alerts %}
  • {{ 'Critique' if alert.level == 'CRITICAL' else 'Avertissement' }} {{ alert.meter.name }}{{ alert.alert_type }} · {{ alert.observed_value }}
  • {% else %}
  • Aucune alerte ouverte.
  • {% endfor %}
+
+
+{% endblock %} diff --git a/migrations/versions/p1e2f3g4h5i6_meter_c3_analytics.py b/migrations/versions/p1e2f3g4h5i6_meter_c3_analytics.py new file mode 100644 index 0000000..daf0c50 --- /dev/null +++ b/migrations/versions/p1e2f3g4h5i6_meter_c3_analytics.py @@ -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) diff --git a/tests/integration/test_meter_checkpoint_c3.py b/tests/integration/test_meter_checkpoint_c3.py new file mode 100644 index 0000000..9cbc85e --- /dev/null +++ b/tests/integration/test_meter_checkpoint_c3.py @@ -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