Coverage for backend/django/Economics/results/services/lifecycle/runs.py: 100%
70 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
1"""Result-run reuse, classification, and stale-state transitions.
3This module owns status decisions for persisted result runs. It compares stored
4fingerprints with current source fingerprints, annotates reuse/stale events, and
5must not calculate new economics metrics or materialize result lines.
6"""
8from __future__ import annotations
10from django.db import transaction
11from django.utils import timezone
13from core.validation import flowsheet_context, get_current_flowsheet
14from Economics.results.models import EconomicsResultRun
16from Economics.studies.models import EconomicsStudy
17from Economics.studies.services.baseline_access import stable_flowsheet_id
19from Economics.shared.choices import ResultDependencyType, ResultRunStatus
20from Economics.results.services.lifecycle.fingerprints import (
21 DependencyFingerprint,
22 build_dependency_fingerprints,
23 _fingerprint_map,
24 _stored_dependency_map,
25 promote_legacy_schedule_source_version,
26 schedule_source_version,
27)
30class ResultRunClassification:
31 """Stable result-run classification labels exposed through serializers."""
33 CURRENT = "current"
34 STALE = "stale"
35 REUSABLE = "reusable"
39def classify_result_run(result_run: EconomicsResultRun) -> str:
40 """Classify a persisted run against the study's current dependency state."""
41 context = get_current_flowsheet() or {}
42 user = context.get("user")
43 active_flowsheet_id = context.get("flowsheet")
44 result_flowsheet_id = stable_flowsheet_id(result_run.study)
45 if user is not None and active_flowsheet_id != result_flowsheet_id:
46 # Result-run classification must compare dependencies from the run's
47 # owning flowsheet. This matters when a target study uses a readable
48 # cross-flowsheet baseline: the target request context would otherwise
49 # hide the baseline study's dependencies and make a current run look
50 # stale. Re-entering the run flowsheet keeps the normal access-control
51 # managers in play; it does not broaden what the user can read.
52 with flowsheet_context(result_flowsheet_id, user):
53 return classify_result_run(result_run)
54 dependencies = list(result_run.dependencies.order_by("dependency_type", "dependency_key"))
55 schedule_dependency = next(
56 (
57 dependency
58 for dependency in dependencies
59 if dependency.dependency_type == ResultDependencyType.SCHEDULE
60 ),
61 None,
62 )
63 current_schedule_version = schedule_source_version(result_run.study)
64 persisted_schedule_fingerprint = ""
65 if (
66 schedule_dependency is not None
67 and current_schedule_version
68 and schedule_dependency.source_version == current_schedule_version
69 ):
70 persisted_schedule_fingerprint = schedule_dependency.fingerprint_value
71 current_schedule_version = promote_legacy_schedule_source_version(
72 result_run.study,
73 verified_source_version=current_schedule_version,
74 )
75 current_map = _fingerprint_map(
76 build_dependency_fingerprints(
77 result_run.study,
78 persisted_schedule_fingerprint=persisted_schedule_fingerprint,
79 schedule_version=current_schedule_version,
80 )
81 )
82 run_map = _stored_dependency_map(result_run, dependencies=dependencies)
83 if run_map == current_map:
84 if result_run.status == ResultRunStatus.CURRENT:
85 return ResultRunClassification.CURRENT
86 return ResultRunClassification.REUSABLE
87 return ResultRunClassification.STALE
90def mark_result_runs_stale_for_study(
91 *,
92 study: EconomicsStudy,
93 reason: str,
94 requires_solve: bool = False,
95) -> int:
96 """Mark current runs stale with diagnostic context, without calculating new results."""
97 with transaction.atomic():
98 runs = list(
99 EconomicsResultRun.objects.select_for_update().filter(
100 flowsheet_state=study.flowsheet_state,
101 study=study,
102 status=ResultRunStatus.CURRENT,
103 )
104 )
105 for run in runs:
106 _mark_run_stale(run=run, reason=reason, requires_solve=requires_solve)
107 return len(runs)
110def _matching_result_run(
111 *,
112 study: EconomicsStudy,
113 fingerprints: list[DependencyFingerprint],
114) -> EconomicsResultRun | None:
115 """Return the newest current/stale run whose stored dependencies still match."""
116 current_map = _fingerprint_map(fingerprints)
117 for status in (ResultRunStatus.CURRENT, ResultRunStatus.STALE):
118 for result_run in EconomicsResultRun.objects.filter(
119 flowsheet_state=study.flowsheet_state,
120 study=study,
121 status=status,
122 ).order_by("-created_at"):
123 if _stored_dependency_map(result_run) == current_map:
124 return result_run
125 return None
128def _mark_nonmatching_current_runs_stale(
129 *,
130 study: EconomicsStudy,
131 fingerprints: list[DependencyFingerprint],
132 reason: str,
133) -> None:
134 """Mark only current runs stale when their dependency map no longer matches."""
135 current_map = _fingerprint_map(fingerprints)
136 for result_run in EconomicsResultRun.objects.select_for_update().filter(
137 flowsheet_state=study.flowsheet_state,
138 study=study,
139 status=ResultRunStatus.CURRENT,
140 ):
141 if _stored_dependency_map(result_run) != current_map:
142 _mark_run_stale(run=result_run, reason=reason, requires_solve=False)
145def _mark_run_stale(*, run: EconomicsResultRun, reason: str, requires_solve: bool) -> None:
146 """Append a stale event without discarding existing warning diagnostics."""
147 payload = dict(run.warning_payload or {})
148 stale_events = list(payload.get("stale_events", []))
149 stale_events.append(
150 {
151 "reason": reason,
152 "requires_solve": requires_solve,
153 "marked_at": timezone.now().isoformat(),
154 "previous_status": run.status,
155 }
156 )
157 payload["stale_events"] = stale_events
158 payload["latest_stale_reason"] = reason
159 payload["requires_solve"] = requires_solve
160 run.status = ResultRunStatus.STALE
161 run.warning_payload = payload
162 run.save(update_fields=["status", "warning_payload"])
165def _annotate_reused_run(*, result_run: EconomicsResultRun, reason: str, duration_ms: int) -> None:
166 """Record reuse diagnostics after a matching run is promoted or refreshed."""
167 payload = dict(result_run.warning_payload or {})
168 reuse_events = list(payload.get("reuse_events", []))
169 reuse_events.append(
170 {
171 "reason": reason,
172 "reused_at": timezone.now().isoformat(),
173 "duration_ms": duration_ms,
174 }
175 )
176 payload["reuse_events"] = reuse_events
177 result_run.warning_payload = payload
178 result_run.save(update_fields=["warning_payload"])