386 lines
19 KiB
Python
386 lines
19 KiB
Python
"""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, mean
|
|
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, ClosureWorkDay,
|
|
MeterReadingSchedule, MeterReadingOccurrence,
|
|
)
|
|
from .planning_service import is_public_holiday
|
|
|
|
|
|
@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()
|
|
closures = CollegeClosure.query.filter(CollegeClosure.start_date <= end.date(), CollegeClosure.end_date >= start.date()).all()
|
|
closure_ids = [row.id for row in closures]
|
|
work_days = {row.work_date for row in ClosureWorkDay.query.filter(ClosureWorkDay.work_date >= start.date(), ClosureWorkDay.work_date < end.date()).all()} if closure_ids else set()
|
|
while d < end.date():
|
|
if meter.housing_unit_id:
|
|
counts["scolaire"] += 1
|
|
else:
|
|
closure = next((row for row in closures if row.start_date <= d <= row.end_date), None)
|
|
if closure:
|
|
kind = (closure.closure_type or "vacances").lower()
|
|
key = "permanence" if d in work_days or "perman" in kind else ("fermeture" if "fermet" in kind else "vacances")
|
|
counts[key] += 1
|
|
elif d.weekday() >= 5 or is_public_holiday(d)[0]:
|
|
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 _metric_value(intervals, metric):
|
|
"""Retourne une métrique contextualisée par répartition temporelle."""
|
|
if not intervals:
|
|
return 0.0
|
|
if metric == "consumption_interval":
|
|
return sum(item.raw_delta for item in intervals)
|
|
if metric == "consumption_per_day":
|
|
return sum(item.raw_delta for item in intervals) / max(sum(item.calendar_days for item in intervals), 1)
|
|
calendar_key = {"school_consumption": "scolaire", "vacation_consumption": "vacances", "closure_consumption": "fermeture"}
|
|
heating_key = {"heating_normal": "NORMAL", "heating_reduced": "REDUCED", "heating_stop": "STOP"}
|
|
key = calendar_key.get(metric)
|
|
if key:
|
|
days = sum((item.context or {}).get("calendar", {}).get(key, 0) for item in intervals)
|
|
else:
|
|
days = sum((item.context or {}).get("heating", {}).get(heating_key.get(metric, ""), 0) for item in intervals)
|
|
total_days = sum(item.calendar_days for item in intervals)
|
|
return sum(item.raw_delta for item in intervals) * days / total_days if total_days else 0.0
|
|
|
|
|
|
def remainder_for_interval(meter, interval):
|
|
children = meter.children.all() if hasattr(meter.children, "all") else list(meter.children)
|
|
if not children:
|
|
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 += mean(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, intervals=None, commit=True):
|
|
if not rule.is_active:
|
|
return None
|
|
intervals = intervals or [interval]
|
|
if rule.period in ("WEEK", "MONTH"):
|
|
grouped = aggregate_intervals(intervals, rule.period)
|
|
bucket = next((row["intervals"] for row in grouped if interval in row["intervals"]), [interval])
|
|
else:
|
|
bucket = [interval]
|
|
value = _metric_value(bucket, rule.metric)
|
|
if rule.context_filter:
|
|
context = {
|
|
"calendar": {key: sum((item.context or {}).get("calendar", {}).get(key, 0) for item in bucket)
|
|
for key in ("scolaire", "vacances", "fermeture", "permanence", "autre")},
|
|
"heating": {key: sum((item.context or {}).get("heating", {}).get(key, 0) for item in bucket)
|
|
for key in ("NORMAL", "REDUCED", "STOP")},
|
|
}
|
|
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
|
|
zero_low = rule.detect_zero_or_low and (
|
|
value == 0 or
|
|
(rule.low_consumption_threshold is not None and value <= rule.low_consumption_threshold)
|
|
)
|
|
if not zero_low and not _matches(value, rule.operator, rule.threshold): return None
|
|
alert_type = "ZERO_OR_LOW_CONSUMPTION" if zero_low else "MANUAL_THRESHOLD"
|
|
open_alert = MeterAlert.query.filter_by(meter_id=rule.meter_id, alert_type=alert_type, metric=rule.metric, period=rule.period, context_key=rule.context_filter, rule_id=rule.id, status="OPEN").first()
|
|
if not open_alert:
|
|
open_alert = MeterAlert(meter_id=rule.meter_id, alert_type=alert_type, level=rule.level, metric=rule.metric, period=rule.period, context_key=rule.context_filter, rule_id=rule.id, observed_value=value, period_start=bucket[0].start_date.date(), period_end=bucket[-1].end_date.date())
|
|
db.session.add(open_alert); db.session.flush()
|
|
else:
|
|
open_alert.observed_value = value
|
|
evidence = MeterAlertEvidence.query.filter_by(alert_id=open_alert.id, reading_start_id=bucket[0].reading_start.id, reading_end_id=bucket[-1].reading_end.id).first()
|
|
if not evidence:
|
|
db.session.add(MeterAlertEvidence(alert_id=open_alert.id, reading_start_id=bucket[0].reading_start.id, reading_end_id=bucket[-1].reading_end.id, observed_value=value))
|
|
if commit: db.session.commit()
|
|
return open_alert
|
|
|
|
|
|
def statistical_anomaly(meter, interval, min_intervals=3, minimum_deviation_percent=30.0, rule=None, commit=True):
|
|
if rule is not None:
|
|
min_intervals = rule.min_comparable_intervals
|
|
minimum_deviation_percent = rule.min_deviation_percent
|
|
history = [i for i in consumption_intervals(meter) if i.end_date < interval.start_date and i.context == interval.context]
|
|
history = [i for i in history if not MeterAlertEvidence.query.filter_by(reading_end_id=i.reading_end.id, excluded_from_baseline=True).first()]
|
|
if len(history) < min_intervals: return None
|
|
reference = median(i.consumption_per_day for i in history)
|
|
if reference == 0 or interval.consumption_per_day <= reference: return None
|
|
deviation = (interval.consumption_per_day - reference) / reference * 100
|
|
if deviation < minimum_deviation_percent:
|
|
return None
|
|
rule_id = rule.id if rule else None
|
|
alert = MeterAlert.query.filter_by(meter_id=meter.id, alert_type="STATISTICAL_ANOMALY", metric="consumption_per_day", period="DAY", context_key="comparable", rule_id=rule_id, status="OPEN").first()
|
|
if not alert:
|
|
alert = MeterAlert(meter_id=meter.id, alert_type="STATISTICAL_ANOMALY", level="WARNING", metric="consumption_per_day", period="DAY", context_key="comparable", rule_id=rule_id)
|
|
db.session.add(alert); db.session.flush()
|
|
alert.observed_value, alert.reference_value, alert.deviation_percent, alert.comparable_count = interval.consumption_per_day, reference, deviation, len(history)
|
|
if not MeterAlertEvidence.query.filter_by(alert_id=alert.id, reading_start_id=interval.reading_start.id, reading_end_id=interval.reading_end.id).first():
|
|
db.session.add(MeterAlertEvidence(alert_id=alert.id, reading_start_id=interval.reading_start.id, reading_end_id=interval.reading_end.id, observed_value=interval.consumption_per_day))
|
|
if commit: db.session.commit()
|
|
return alert
|
|
|
|
|
|
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 create_bill_gas_conversion(*, meter, volume_m3, billed_kwh, valid_from, valid_to=None, commit=True):
|
|
if volume_m3 is None or volume_m3 <= 0:
|
|
raise ValueError("Le volume gaz facturé doit être strictement positif.")
|
|
if billed_kwh is None or billed_kwh < 0:
|
|
raise ValueError("Les kWh facturés ne peuvent pas être négatifs.")
|
|
conversion = GasConversion(meter=meter, coefficient_kwh_per_m3=billed_kwh / volume_m3,
|
|
valid_from=valid_from, valid_to=valid_to, origin="BILL",
|
|
volume_m3=volume_m3, billed_kwh=billed_kwh)
|
|
db.session.add(conversion)
|
|
if commit: db.session.commit()
|
|
return conversion
|
|
|
|
|
|
def close_meter_alert(*, alert, conclusion, comment=None, user_id=None, commit=True):
|
|
allowed = {"CONFIRMED_LEAK", "NORMAL_EXPLAINED", "READING_ERROR", "BAD_THRESHOLD", "OTHER"}
|
|
if conclusion not in allowed:
|
|
raise ValueError("Conclusion d'alerte invalide.")
|
|
alert.status = "CLOSED"
|
|
alert.closed_at = datetime.now(timezone.utc)
|
|
alert.conclusion = conclusion
|
|
alert.comment = comment
|
|
if conclusion in {"CONFIRMED_LEAK", "READING_ERROR"}:
|
|
for evidence in alert.evidence:
|
|
evidence.excluded_from_baseline = True
|
|
if commit: db.session.commit()
|
|
return alert
|
|
|
|
|
|
def investigate_meter_alert(*, alert, comment=None, user_id=None, commit=True):
|
|
"""Passe une alerte ouverte en investigation via le service métier."""
|
|
if alert.status not in {"OPEN", "INVESTIGATING"}:
|
|
raise ValueError("Une alerte clôturée ne peut plus passer en investigation.")
|
|
alert.status = "INVESTIGATING"
|
|
if comment:
|
|
alert.comment = comment
|
|
if commit:
|
|
db.session.commit()
|
|
return alert
|
|
|
|
|
|
def visible_open_meter_alerts(user):
|
|
"""Retourne les alertes visibles dans Ma journée selon le RBAC métier."""
|
|
query = MeterAlert.query.filter(MeterAlert.status.in_(("OPEN", "INVESTIGATING")))
|
|
if user.is_admin():
|
|
return query.order_by(MeterAlert.level.desc(), MeterAlert.opened_at.desc()).all()
|
|
assigned_schedule = db.session.query(MeterReadingSchedule.meter_id).filter(
|
|
MeterReadingSchedule.assigned_to_id == user.id
|
|
)
|
|
assigned_occurrence = db.session.query(MeterReadingOccurrence.schedule_id).filter(
|
|
MeterReadingOccurrence.assigned_to_id == user.id
|
|
)
|
|
occurrence_meter = db.session.query(MeterReadingSchedule.meter_id).filter(
|
|
MeterReadingSchedule.id.in_(assigned_occurrence)
|
|
)
|
|
return query.filter(MeterAlert.meter_id.in_(assigned_schedule.union(occurrence_meter))).order_by(
|
|
MeterAlert.level.desc(), MeterAlert.opened_at.desc()
|
|
).all()
|
|
|
|
|
|
def _intervals_touching_reading(meter, reading):
|
|
"""Retourne uniquement les segments voisins d'un relevé modifié."""
|
|
return [interval for interval in consumption_intervals(meter)
|
|
if interval.reading_start.id == reading.id or interval.reading_end.id == reading.id]
|
|
|
|
|
|
def _invalidate_open_evidence_for(intervals, meter, changed_reading_ids=None):
|
|
"""Neutralise les preuves ouvertes qui ne correspondent plus à un segment valide.
|
|
|
|
Les alertes déjà clôturées restent historiques. Les preuves ouvertes
|
|
obsolètes sont retirées afin qu'une réévaluation puisse recréer une preuve
|
|
valide sans collision ; la correction C1 conserve l'audit de la mesure.
|
|
"""
|
|
changed_ids = set(changed_reading_ids or ())
|
|
changed_ids.update({reading.id for item in intervals for reading in (item.reading_start, item.reading_end)})
|
|
if not changed_ids:
|
|
return []
|
|
affected = []
|
|
alerts = MeterAlert.query.filter_by(meter_id=meter.id).filter(MeterAlert.status == "OPEN").all()
|
|
for alert in alerts:
|
|
invalidated = False
|
|
removed = []
|
|
for evidence in alert.evidence:
|
|
if evidence.reading_start_id in changed_ids or evidence.reading_end_id in changed_ids:
|
|
# Le couple de relevés peut rester valide tout en ayant une
|
|
# valeur différente : sa preuve précédente est alors obsolète.
|
|
# On la retire uniquement d'une alerte encore ouverte ; les
|
|
# alertes clôturées conservent leur audit historique.
|
|
db.session.delete(evidence)
|
|
removed.append(evidence)
|
|
invalidated = True
|
|
db.session.flush()
|
|
remaining = [evidence for evidence in alert.evidence if evidence not in removed]
|
|
if invalidated and not any(not evidence.excluded_from_baseline for evidence in remaining):
|
|
alert.status = "CLOSED"
|
|
alert.closed_at = datetime.now(timezone.utc)
|
|
alert.conclusion = "READING_ERROR"
|
|
alert.comment = "Alerte clôturée automatiquement après correction d'un relevé."
|
|
affected.append(alert)
|
|
return affected
|
|
|
|
|
|
def recalculate_meter_after_reading(*, meter, reading, commit=True):
|
|
"""Analyse le ou les nouveaux segments, sans rescanner l'historique des règles.
|
|
|
|
Le relevé est déjà ajouté à la transaction appelante et doit avoir été
|
|
flushé pour disposer de son identifiant.
|
|
"""
|
|
impacted = _intervals_touching_reading(meter, reading)
|
|
if not impacted:
|
|
if commit:
|
|
db.session.commit()
|
|
return []
|
|
rules = MeterAlertRule.query.filter_by(meter_id=meter.id, is_active=True).all()
|
|
all_intervals = consumption_intervals(meter)
|
|
for rule in rules:
|
|
for interval in impacted:
|
|
evaluate_alert_rule(rule, interval, intervals=all_intervals, commit=False)
|
|
if commit:
|
|
db.session.commit()
|
|
return impacted
|
|
|
|
|
|
def recalculate_meter_after_correction(*, meter, reading, commit=True):
|
|
"""Réévalue seulement les deux segments pouvant dépendre du relevé corrigé."""
|
|
ordered = (MeterReading.query.filter_by(meter_id=meter.id)
|
|
.order_by(MeterReading.reading_date, MeterReading.id).all())
|
|
reading_index = next((index for index, item in enumerate(ordered) if item.id == reading.id), None)
|
|
neighbor_ids = {reading.id}
|
|
if reading_index is not None:
|
|
if reading_index:
|
|
neighbor_ids.add(ordered[reading_index - 1].id)
|
|
if reading_index + 1 < len(ordered):
|
|
neighbor_ids.add(ordered[reading_index + 1].id)
|
|
impacted = _intervals_touching_reading(meter, reading)
|
|
_invalidate_open_evidence_for(impacted, meter, neighbor_ids)
|
|
rules = MeterAlertRule.query.filter_by(meter_id=meter.id, is_active=True).all()
|
|
all_intervals = consumption_intervals(meter)
|
|
for rule in rules:
|
|
for interval in impacted:
|
|
evaluate_alert_rule(rule, interval, intervals=all_intervals, commit=False)
|
|
if commit:
|
|
db.session.commit()
|
|
return impacted
|
|
|
|
|
|
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":
|
|
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
|