fix(meters): close checkpoint C3 analytics workflows
This commit is contained in:
parent
c6b89b20f9
commit
b20c5a1216
6 changed files with 279 additions and 16 deletions
|
|
@ -15,6 +15,7 @@ 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
|
||||
|
||||
|
|
@ -171,11 +172,19 @@ def evaluate_alert_rule(rule, interval, intervals=None, commit=True):
|
|||
bucket = [interval]
|
||||
value = _metric_value(bucket, rule.metric)
|
||||
if rule.context_filter:
|
||||
context = interval.context or {}
|
||||
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 rule.low_consumption_threshold is not None and value <= rule.low_consumption_threshold
|
||||
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()
|
||||
|
|
@ -249,6 +258,111 @@ def close_meter_alert(*, alert, conclusion, comment=None, user_id=None, commit=T
|
|||
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
|
||||
|
|
|
|||
|
|
@ -126,6 +126,9 @@ def record_meter_reading(*, meter, value, user_id, reading_date=None, notes=None
|
|||
meter.initial_value = value
|
||||
meter.last_maintenance_value = value
|
||||
db.session.add(reading)
|
||||
db.session.flush()
|
||||
from .meter_analytics import recalculate_meter_after_reading
|
||||
recalculate_meter_after_reading(meter=meter, reading=reading, commit=False)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return reading
|
||||
|
|
@ -155,6 +158,9 @@ def correct_meter_reading(*, reading, new_value, user_id, reason, commit=True):
|
|||
if latest is not None:
|
||||
meter.current_value = latest.value
|
||||
meter.last_reading_date = latest.reading_date.date()
|
||||
db.session.flush()
|
||||
from .meter_analytics import recalculate_meter_after_correction
|
||||
recalculate_meter_after_correction(meter=meter, reading=reading, commit=False)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return reading
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Routes C2 : règles, échéances et tournées de relevés."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
|
|
@ -11,7 +11,8 @@ from werkzeug.utils import secure_filename
|
|||
|
||||
from ..extensions import db
|
||||
from ..core.models.planning import (
|
||||
Meter, MeterReadingOccurrence, MeterReadingRound, MeterReadingSchedule,
|
||||
Meter, MeterReading, MeterReadingOccurrence, MeterReadingRound, MeterReadingSchedule,
|
||||
GasConversion,
|
||||
)
|
||||
from ..core.models.user import User
|
||||
from ..core.services.meter_reading_planning import (
|
||||
|
|
@ -19,7 +20,7 @@ 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.services.meter_analytics import consumption_intervals, visible_open_meter_alerts, close_meter_alert
|
||||
from ..core.models.planning import MeterAlert, MeterAlertRule
|
||||
from .schedules import planning_bp
|
||||
|
||||
|
|
@ -133,9 +134,54 @@ def meter_occurrence_without_reading(occurrence_id):
|
|||
def meter_monitoring():
|
||||
"""Tableau de surveillance C3, sans recalculer ni modifier les relevés."""
|
||||
from sqlalchemy.orm import selectinload
|
||||
period_end = date.today()
|
||||
period_start = period_end - timedelta(days=29)
|
||||
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)
|
||||
alerts = visible_open_meter_alerts(current_user)
|
||||
summaries = {"eau": {"raw": 0.0}, "gaz": {"raw": 0.0, "kwh": 0.0}, "electricite": {"raw": 0.0}}
|
||||
insufficient = []
|
||||
meter_ids = [meter.id for meter in meters]
|
||||
readings_by_meter = {meter_id: [] for meter_id in meter_ids}
|
||||
readings = (MeterReading.query.filter(MeterReading.meter_id.in_(meter_ids))
|
||||
.order_by(MeterReading.meter_id, MeterReading.reading_date, MeterReading.id).all()) if meter_ids else []
|
||||
for reading in readings:
|
||||
readings_by_meter[reading.meter_id].append(reading)
|
||||
conversions_by_meter = {meter_id: [] for meter_id in meter_ids}
|
||||
for conversion in GasConversion.query.filter(GasConversion.meter_id.in_(meter_ids)).all() if meter_ids else []:
|
||||
conversions_by_meter[conversion.meter_id].append(conversion)
|
||||
reading_counts = {meter_id: len(rows) for meter_id, rows in readings_by_meter.items()}
|
||||
for meter in meters:
|
||||
rows = readings_by_meter[meter.id]
|
||||
intervals = []
|
||||
for previous, current in zip(rows, rows[1:]):
|
||||
if current.reading_date.date() < period_start or current.reading_date.date() > period_end:
|
||||
continue
|
||||
if previous.is_reset or current.is_reset or current.value < previous.value:
|
||||
continue
|
||||
intervals.append((current.reading_date.date(), current.value - previous.value))
|
||||
rules = [rule for rule in meter.alert_rules if rule.is_active and rule.metric in ("consumption_per_day", "consumption_interval")]
|
||||
if rules and any(len(intervals) < rule.min_comparable_intervals for rule in rules):
|
||||
insufficient.append(meter)
|
||||
bucket = summaries.get(meter.meter_type)
|
||||
if not bucket:
|
||||
continue
|
||||
bucket["raw"] += sum(value for _, value in intervals)
|
||||
if meter.meter_type == "gaz":
|
||||
for on_date, value in intervals:
|
||||
applicable = [conversion for conversion in conversions_by_meter[meter.id]
|
||||
if conversion.valid_from <= on_date and
|
||||
(conversion.valid_to is None or conversion.valid_to >= on_date)]
|
||||
manual = next((conversion for conversion in sorted(applicable, key=lambda item: item.valid_from, reverse=True)
|
||||
if conversion.origin == "MANUAL"), None)
|
||||
coefficient = (manual or max(applicable, key=lambda item: item.valid_from) if applicable else None)
|
||||
coefficient = coefficient.coefficient_kwh_per_m3 if coefficient else None
|
||||
if coefficient is not None:
|
||||
bucket["kwh"] += value * coefficient
|
||||
return render_template(
|
||||
'planning/meter_monitoring.html', meters=meters, alerts=alerts,
|
||||
period_start=period_start, period_end=period_end, summaries=summaries,
|
||||
insufficient_meters=insufficient, reading_counts=reading_counts,
|
||||
)
|
||||
|
||||
|
||||
@planning_bp.route('/meter-analytics/<int:meter_id>')
|
||||
|
|
@ -171,6 +217,30 @@ def create_intervention_from_meter_alert(alert_id):
|
|||
return redirect(url_for('interventions.detail', id=intervention.id))
|
||||
|
||||
|
||||
@planning_bp.route('/meter-alerts/<int:alert_id>/close', methods=['POST'])
|
||||
@login_required
|
||||
@permission_required('planning.manage')
|
||||
def close_meter_alert_route(alert_id):
|
||||
alert = MeterAlert.query.get_or_404(alert_id)
|
||||
action = request.form.get('status', 'CLOSED').upper()
|
||||
if action == 'INVESTIGATING':
|
||||
alert.status = 'INVESTIGATING'
|
||||
alert.comment = request.form.get('comment') or alert.comment
|
||||
db.session.commit()
|
||||
flash('Alerte passée en investigation.', 'success')
|
||||
else:
|
||||
try:
|
||||
close_meter_alert(
|
||||
alert=alert, conclusion=request.form.get('conclusion', 'OTHER'),
|
||||
comment=request.form.get('comment'), user_id=current_user.id,
|
||||
)
|
||||
flash('Alerte clôturée.', 'success')
|
||||
except ValueError as exc:
|
||||
db.session.rollback()
|
||||
flash(str(exc), 'danger')
|
||||
return redirect(url_for('planning.meter_monitoring'))
|
||||
|
||||
|
||||
@planning_bp.route('/meter-rounds')
|
||||
@login_required
|
||||
def meter_rounds():
|
||||
|
|
|
|||
|
|
@ -85,8 +85,8 @@ def my_day():
|
|||
if item.status in ('à replanifier', 'proposé', 'conflit')
|
||||
}
|
||||
execution_states = {}
|
||||
from ..core.models.planning import MeterAlert
|
||||
informational_alerts = MeterAlert.query.filter(MeterAlert.status.in_(("OPEN", "INVESTIGATING"))).order_by(MeterAlert.level.desc()).all()
|
||||
from ..core.services.meter_analytics import visible_open_meter_alerts
|
||||
informational_alerts = visible_open_meter_alerts(current_user)
|
||||
for task in ScheduledTask.query.filter(ScheduledTask.scheduled_date == target_date).all():
|
||||
execution_states[task.id] = {
|
||||
'status': task.status,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
{% block content %}
|
||||
<div class="container py-3">
|
||||
<h1 class="h3">Surveillance des compteurs</h1>
|
||||
<div class="row g-3 mb-3"><div class="col-md-4"><div class="card"><div class="card-body"><strong>Alertes critiques</strong><div class="fs-3">{{ alerts|selectattr('level','equalto','CRITICAL')|list|length }}</div></div></div></div><div class="col-md-4"><div class="card"><div class="card-body"><strong>Avertissements</strong><div class="fs-3">{{ alerts|selectattr('level','equalto','WARNING')|list|length }}</div></div></div></div><div class="col-md-4"><div class="card"><div class="card-body"><strong>Compteurs actifs</strong><div class="fs-3">{{ meters|length }}</div></div></div></div></div>
|
||||
<p class="text-muted">Synthèses informatives du {{ period_start.strftime('%d/%m/%Y') }} au {{ period_end.strftime('%d/%m/%Y') }}.</p>
|
||||
<div class="row g-3 mb-3"><div class="col-md-3"><div class="card"><div class="card-body"><strong>Alertes critiques</strong><div class="fs-3">{{ alerts|selectattr('level','equalto','CRITICAL')|list|length }}</div></div></div></div><div class="col-md-3"><div class="card"><div class="card-body"><strong>Avertissements</strong><div class="fs-3">{{ alerts|selectattr('level','equalto','WARNING')|list|length }}</div></div></div></div><div class="col-md-3"><div class="card"><div class="card-body"><strong>Compteurs actifs</strong><div class="fs-3">{{ meters|length }}</div></div></div></div><div class="col-md-3"><div class="card"><div class="card-body"><strong>Données insuffisantes</strong><div class="fs-3">{{ insufficient_meters|length }}</div></div></div></div></div>
|
||||
<div class="row g-3 mb-3"><div class="col-md-4"><div class="card"><div class="card-body"><strong>Eau</strong><div>{{ '%.2f'|format(summaries.eau.raw) }} m³</div></div></div></div><div class="col-md-4"><div class="card"><div class="card-body"><strong>Gaz</strong><div>{{ '%.2f'|format(summaries.gaz.raw) }} m³</div><div>{% if summaries.gaz.kwh %}{{ '%.2f'|format(summaries.gaz.kwh) }} kWh convertis{% else %}kWh non calculés{% endif %}</div></div></div></div><div class="col-md-4"><div class="card"><div class="card-body"><strong>Électricité</strong><div>{{ '%.2f'|format(summaries.electricite.raw) }} kWh</div></div></div></div></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-8"><div class="card"><div class="card-header">Compteurs</div><div class="table-responsive"><table class="table mb-0"><thead><tr><th>Compteur</th><th>Derniers intervalles</th><th>Action</th></tr></thead><tbody>
|
||||
{% for meter in meters %}<tr><td>{{ meter.name }}<small class="d-block text-muted">{{ meter.unit }} · {{ meter.usage or 'Usage non renseigné' }}</small></td><td>{{ meter.readings.count() }}</td><td><a class="btn btn-sm btn-outline-primary" href="{{ url_for('planning.meter_analytics', meter_id=meter.id) }}">Analyser</a></td></tr>{% else %}<tr><td colspan="3">Aucun compteur actif.</td></tr>{% endfor %}
|
||||
{% 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>{{ reading_counts.get(meter.id, 0) }}</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>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ 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,
|
||||
Meter, MeterReading, MeterReadingCorrection, MeterAlert, MeterAlertEvidence,
|
||||
MeterAlertRule, MeterHeatingRegime, GasConversion, MeterTariff,
|
||||
)
|
||||
from app_new.core.services.meter_service import create_meter, record_meter_reading
|
||||
from app_new.core.services.meter_service import create_meter, record_meter_reading, correct_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,
|
||||
|
|
@ -135,15 +136,85 @@ def test_c3_alert_closure_excludes_only_relevant_baselines(app, admin_user):
|
|||
|
||||
def test_c3_performance_measurement(app, admin_user, authenticated_client):
|
||||
with app.app_context():
|
||||
first_meter_id = None
|
||||
for index in range(4):
|
||||
meter = _meter(f"PERF_{index}")
|
||||
first_meter_id = first_meter_id or meter.id
|
||||
for offset in range(12):
|
||||
_reading(meter, offset * 5, date(2025, 1, 1) + __import__('datetime').timedelta(days=offset), admin_user["id"])
|
||||
perf_meter = db.session.get(Meter, first_meter_id)
|
||||
perf_intervals = consumption_intervals(perf_meter)
|
||||
perf_rule = MeterAlertRule(meter=perf_meter, metric="consumption_interval", period="DAY", operator=">", threshold=1)
|
||||
db.session.add(perf_rule)
|
||||
db.session.commit()
|
||||
count = {"value": 0}
|
||||
from sqlalchemy import event
|
||||
def before_cursor(*args): count["value"] += 1
|
||||
def measure(label, callback):
|
||||
count["value"] = 0
|
||||
started = perf_counter()
|
||||
callback()
|
||||
elapsed = perf_counter() - started
|
||||
print(f"C3_CLOSE_PERF {label}_seconds={elapsed:.4f} {label}_queries={count['value']}")
|
||||
return elapsed, count["value"]
|
||||
event.listen(db.engine, "before_cursor_execute", before_cursor)
|
||||
started = perf_counter(); response = authenticated_client.get("/planning/meter-monitoring"); elapsed = perf_counter() - started
|
||||
dashboard_time, dashboard_queries = measure("dashboard", lambda: authenticated_client.get("/planning/meter-monitoring"))
|
||||
analytics_time, analytics_queries = measure("analytics", lambda: authenticated_client.get(f"/planning/meter-analytics/{first_meter_id}"))
|
||||
alert_time, alert_queries = measure("alerts", lambda: evaluate_alert_rule(perf_rule, perf_intervals[-1], intervals=perf_intervals))
|
||||
day_time, day_queries = measure("my_day", lambda: authenticated_client.get("/planning/my-day"))
|
||||
event.remove(db.engine, "before_cursor_execute", before_cursor)
|
||||
print(f"C3_PERF dashboard_seconds={elapsed:.4f} dashboard_queries={count['value']}")
|
||||
assert response.status_code == 200
|
||||
assert dashboard_time >= 0 and analytics_time >= 0 and alert_time >= 0 and day_time >= 0
|
||||
|
||||
|
||||
def test_c3_new_reading_runs_targeted_alert_analysis(app, admin_user):
|
||||
with app.app_context():
|
||||
meter = _meter("AUTO_READING")
|
||||
_reading(meter, 0, date(2026, 3, 1), admin_user["id"])
|
||||
_reading(meter, 1, date(2026, 3, 2), admin_user["id"])
|
||||
rule = MeterAlertRule(meter=meter, metric="consumption_per_day", period="DAY", operator=">", threshold=5)
|
||||
db.session.add(rule)
|
||||
db.session.commit()
|
||||
reading = _reading(meter, 10, date(2026, 3, 3), admin_user["id"])
|
||||
alert = MeterAlert.query.filter_by(meter_id=meter.id, rule_id=rule.id, status="OPEN").one()
|
||||
assert alert.evidence and alert.evidence[0].reading_end_id == reading.id
|
||||
assert MeterAlertEvidence.query.filter_by(alert_id=alert.id).count() == 1
|
||||
|
||||
|
||||
def test_c3_correction_recalculates_neighbors_and_closes_false_alert(app, admin_user):
|
||||
with app.app_context():
|
||||
meter = _meter("AUTO_CORRECTION")
|
||||
_reading(meter, 0, date(2026, 4, 1), admin_user["id"])
|
||||
_reading(meter, 1, date(2026, 4, 2), admin_user["id"])
|
||||
rule = MeterAlertRule(meter=meter, metric="consumption_per_day", period="DAY", operator=">", threshold=5)
|
||||
db.session.add(rule)
|
||||
db.session.commit()
|
||||
high = _reading(meter, 20, date(2026, 4, 3), admin_user["id"])
|
||||
alert = MeterAlert.query.filter_by(meter_id=meter.id, rule_id=rule.id, status="OPEN").one()
|
||||
correct_meter_reading(reading=high, new_value=3, user_id=admin_user["id"], reason="TEST_UI_C3_CLOSE correction")
|
||||
db.session.expire_all()
|
||||
refreshed = db.session.get(MeterAlert, alert.id)
|
||||
assert MeterReadingCorrection.query.filter_by(reading_id=high.id).count() == 1
|
||||
assert refreshed.status == "CLOSED"
|
||||
assert all(evidence.excluded_from_baseline for evidence in refreshed.evidence)
|
||||
|
||||
|
||||
def test_c3_alert_close_route_and_dashboard_summaries(app, admin_user, authenticated_client):
|
||||
with app.app_context():
|
||||
water = _meter("CLOSE_HTTP_WATER", "eau", "m³")
|
||||
_reading(water, 0, date.today(), admin_user["id"])
|
||||
_reading(water, 12, date.today(), admin_user["id"])
|
||||
rule = MeterAlertRule(meter=water, metric="consumption_interval", period="DAY", operator=">", threshold=1)
|
||||
db.session.add(rule)
|
||||
db.session.commit()
|
||||
alert = evaluate_alert_rule(rule, consumption_intervals(water)[0])
|
||||
alert_id = alert.id
|
||||
response = authenticated_client.post(
|
||||
f"/planning/meter-alerts/{alert_id}/close",
|
||||
data={"status": "CLOSED", "conclusion": "BAD_THRESHOLD", "comment": "TEST_UI_C3_CLOSE"},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
with app.app_context():
|
||||
assert db.session.get(MeterAlert, alert_id).status == "CLOSED"
|
||||
dashboard = authenticated_client.get("/planning/meter-monitoring")
|
||||
assert dashboard.status_code == 200
|
||||
assert b"Eau" in dashboard.data and b"m" in dashboard.data
|
||||
|
|
|
|||
Loading…
Reference in a new issue