Coverage for backend/django/Economics/results/services/lifecycle/fingerprints.py: 95%
294 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"""Dependency fingerprint contracts for Economics presentation result runs.
3Fingerprints are the audit contract that decides whether a stored result run is
4current, reusable, or stale. This module should only describe source inputs and
5persist dependency rows; it must not calculate financial metrics or write result
6lines.
7"""
9from __future__ import annotations
11import hashlib
12import json
13import uuid
14from typing import Any
16from django.core.cache import cache
17from django.db import models, transaction
18from django.contrib.postgres.aggregates import StringAgg
19from django.db.models.functions import Cast, JSONObject, MD5
20from pydantic import field_serializer
22from Economics.costing.models import (
24 CapitalCostLine,
26 CostCurve,
28 CostDriver,
30 CostableItem,
32 EquipmentMapping,
34 OperatingCostLine,
36)
38from Economics.reference_data.models import CostIndexSeries, CostIndexValue
40from Economics.results.models import EconomicsResultDependency, EconomicsResultLine, EconomicsResultRun
42from Economics.settings_profiles.models import EconomicsSettingsProfile
43from Economics.settings_profiles.services.settings_profiles import get_settings_profile
45from Economics.studies.models import EconomicsStudy
46from Economics.studies.services.baseline_access import resolve_baseline_study
48from Economics.shared.choices import EconomicsBaselineMode, EconomicsScheduleMode, ResultDependencyType, ResultRunStatus
49from Economics.formulas.engine.core import FORMULA_AUDIT_SCHEMA_VERSION
50from Economics.results.services.lifecycle.common import EconomicsContract, get_assumptions, version
51from Economics.results.services.financial_metrics.metric_catalog import FinancialMetricKey, required_financial_metric_spec
52from Economics.scheduling.composite.plan_services import saved_schedule_plan_payload
53from Economics.scheduling.services import schedule_preview
54from Economics.shared.payloads import json_ready
55from core.auxiliary.models import PropertyInfo
56from core.auxiliary.models.Scenario import Scenario
57from core.auxiliary.models.DataCell import DataCell
58from core.auxiliary.models.DataColumn import DataColumn
59from core.auxiliary.models.DataRow import DataRow
60from core.auxiliary.models.Solution import Solution
63FINGERPRINT_ALGORITHM = "sha256"
64FINGERPRINT_PREFIX = "sha256:"
65FORMULA_REGISTRY_VERSION = "2026-06-15.depreciation-tax-model"
66FORMULA_SEMANTIC_FINGERPRINTS = {
67 "cost_curve": "expression_text parsed by constrained AST-to-SymPy evaluator",
68 "production_schedule": "selected schedule mode, scenario timing, schedule inputs, and solved row outputs",
69 "generated_capital_line": "cost_curve * capital_index_factor * optional_lang_factor * contingency_factor",
70 "generated_unit_capex_subtotal": "sum included generated unit-operation capital line formulas",
71 "custom_capital_line": "fixed literal or custom_capex_percentage_basis * basis_percent / 100",
72 "custom_capex_percentage_basis": "generated_unit_capex_subtotal only",
73 "custom_capital_total": "sum included custom capital line formulas",
74 "peak_demand_capacity": "sum included capital line peak-demand capacity in kW",
75 "electrical_upgrade_capex": "peak_demand_capacity * electrical_upgrade_rate_amount",
76 "operating_line": "basis_quantity * annualization_factor * rate_amount",
77 "annual_operating_expense": "sum included non-revenue operating-line formulas",
78 "annual_operating_revenue": "sum included output-revenue operating-line formulas",
79 "default_rate_derived_steam": "fuel_price_nzd_per_gj * steam_energy_gj_per_t / (boiler_efficiency_percent / 100)",
80 "process_energy_contribution": "direct annualized energy quantity or operating-line annual basis converted to MWh",
81 "annual_profit": "target_annual_revenue - target_annual_opex",
82 "annual_savings": "baseline_annual_opex - target_annual_opex",
83 "annual_net_benefit": "annual_savings + target_annual_revenue",
84 "annual_depreciation": "sum included depreciable capital-line bases less residual value over straight-line equipment life",
85 "depreciation_tax_shield": "annual_depreciation * tax_rate",
86 "after_tax_annual_cash_flow": "annual_net_benefit * (1 - tax_rate) + depreciation_tax_shield",
87 "incremental_capex": "target_capex - baseline_capex",
88 "roi_percent": "((cash_flow_basis * lifetime + residual_value - incremental_capex) / incremental_capex) * 100",
89 "cash_flow_rows": "year 0 negative incremental capex, operating years after-tax annual cash-flow basis, final year residual uplift",
90}
93class DependencyFingerprint(EconomicsContract):
94 """Deterministic fingerprint plus source-row links for one lifecycle input."""
95 dependency_type: str
96 dependency_key: str
97 fingerprint_value: str
98 fingerprint_basis: str
99 source_label: str = ""
100 source_row_key: str = ""
101 source_version: str = ""
102 source_settings_profile: EconomicsSettingsProfile | None = None
103 source_costable_item: CostableItem | None = None
104 source_cost_curve: CostCurve | None = None
105 source_capital_line: CapitalCostLine | None = None
106 source_operating_line: OperatingCostLine | None = None
107 source_index_series: CostIndexSeries | None = None
108 source_index_value: CostIndexValue | None = None
109 source_scenario: Any | None = None
110 source_property_info: Any | None = None
112 @field_serializer(
113 "source_settings_profile",
114 "source_costable_item",
115 "source_cost_curve",
116 "source_capital_line",
117 "source_operating_line",
118 "source_index_series",
119 "source_index_value",
120 "source_scenario",
121 "source_property_info",
122 when_used="json",
123 )
124 def serialize_source_model(self, value):
125 return value.pk if isinstance(value, models.Model) else value
127 @property
128 def identity(self) -> tuple[str, str]:
129 return (self.dependency_type, self.dependency_key)
132def build_dependency_fingerprints(
133 study: EconomicsStudy,
134 *,
135 persisted_schedule_fingerprint: str = "",
136 schedule_version: str | None = None,
137) -> list[DependencyFingerprint]:
138 """Collect source-readable fingerprints for all v1 presentation result inputs.
140 Generated capital lines must be synchronized by the orchestrator before this
141 runs, so persisted generated rows participate in the same audit contract as
142 manually entered rows.
143 """
144 fingerprints: list[DependencyFingerprint] = []
145 fingerprints.extend(_formula_fingerprints())
146 fingerprints.extend(_assumption_fingerprints(study))
147 fingerprints.extend(_baseline_fingerprints(study))
148 fingerprints.extend(
149 _schedule_fingerprints(
150 study,
151 persisted_fingerprint_value=persisted_schedule_fingerprint,
152 source_version=schedule_version,
153 )
154 )
155 fingerprints.extend(_costable_item_fingerprints(study))
156 fingerprints.extend(_cost_driver_fingerprints(study))
157 fingerprints.extend(_equipment_mapping_fingerprints(study))
158 fingerprints.extend(_capital_line_fingerprints(study))
159 fingerprints.extend(_operating_line_fingerprints(study))
160 fingerprints.extend(_cost_curve_fingerprints(study))
161 fingerprints.extend(_index_data_fingerprints(study))
162 return sorted(fingerprints, key=lambda fingerprint: fingerprint.identity)
165def _formula_fingerprints() -> list[DependencyFingerprint]:
166 """Fingerprint formula semantics that are not represented by database rows."""
167 return [
168 _dependency(
169 dependency_type=ResultDependencyType.ASSUMPTIONS,
170 dependency_key="formula:registry",
171 payload={
172 "formula_registry_version": FORMULA_REGISTRY_VERSION,
173 "formula_audit_schema_version": FORMULA_AUDIT_SCHEMA_VERSION,
174 "semantic_fingerprints": FORMULA_SEMANTIC_FINGERPRINTS,
175 },
176 fingerprint_basis="economics_formula_registry.semantic_fingerprints",
177 source_label="Economics formula registry",
178 source_row_key="formula:registry",
179 source_version=FORMULA_REGISTRY_VERSION,
180 )
181 ]
184def _assumption_fingerprints(study: EconomicsStudy) -> list[DependencyFingerprint]:
185 """Fingerprint study assumptions or an explicit missing-assumptions marker."""
186 assumptions = get_assumptions(study)
187 if assumptions is None:
188 return [
189 _dependency(
190 dependency_type=ResultDependencyType.ASSUMPTIONS,
191 dependency_key=f"assumptions:missing:{study.pk}",
192 payload={"study_id": study.pk, "assumptions": None},
193 fingerprint_basis="economics_assumptions.missing",
194 source_label="Missing economics assumptions",
195 source_row_key=f"study:{study.pk}:assumptions",
196 )
197 ]
198 return [
199 _dependency(
200 dependency_type=ResultDependencyType.ASSUMPTIONS,
201 dependency_key=f"assumptions:{assumptions.pk}",
202 payload={
203 "id": assumptions.pk,
204 "currency": assumptions.currency,
205 "location": assumptions.location,
206 "basis_date": assumptions.basis_date,
207 "discount_rate_percent": assumptions.discount_rate_percent,
208 "project_lifetime_years": assumptions.project_lifetime_years,
209 "inflation_method": assumptions.inflation_method,
210 "annual_operating_hours": assumptions.annual_operating_hours,
211 "tax_rate_percent": assumptions.tax_rate_percent,
212 "depreciation_enabled": assumptions.depreciation_enabled,
213 "default_depreciation_life_years": assumptions.default_depreciation_life_years,
214 "default_depreciation_salvage_percent": assumptions.default_depreciation_salvage_percent,
215 "contingency_percent": assumptions.contingency_percent,
216 "electrical_upgrade_rate_amount": assumptions.electrical_upgrade_rate_amount,
217 "electrical_upgrade_rate_unit": assumptions.electrical_upgrade_rate_unit,
218 "default_lang_factor": assumptions.default_lang_factor,
219 "capital_index_series_id": assumptions.capital_index_series_id,
220 "operating_index_series_id": assumptions.operating_index_series_id,
221 "default_rate_overrides": assumptions.default_rate_overrides,
222 "notes": assumptions.notes,
223 "updated_at": assumptions.updated_at,
224 },
225 fingerprint_basis="economics_assumptions.fields",
226 source_label=f"Assumptions for {study.name}",
227 source_row_key=f"assumptions:{assumptions.pk}",
228 source_version=version(assumptions.updated_at),
229 source_settings_profile=assumptions,
230 )
231 ]
234def _baseline_fingerprints(study: EconomicsStudy) -> list[DependencyFingerprint]:
235 """Fingerprint the selected baseline contract for the target study."""
236 if study.baseline_mode == EconomicsBaselineMode.STUDY:
237 return [_study_baseline_fingerprint(study)]
239 baseline = get_assumptions(study)
240 if baseline is None:
241 return [
242 _dependency(
243 dependency_type=ResultDependencyType.BASELINE,
244 dependency_key=f"baseline:missing:{study.pk}",
245 payload={"study_id": study.pk, "mode": EconomicsBaselineMode.MANUAL, "baseline": None},
246 fingerprint_basis="economics_baseline.missing",
247 source_label="Missing economics baseline",
248 source_row_key=f"study:{study.pk}:baseline",
249 )
250 ]
251 return [
252 _dependency(
253 dependency_type=ResultDependencyType.BASELINE,
254 dependency_key=f"baseline:{baseline.pk}",
255 payload=_baseline_payload(study=study, baseline=baseline),
256 fingerprint_basis="economics_baseline.manual_fields_and_inherited_study_assumptions",
257 source_label=f"Baseline for {study.name}",
258 source_row_key=f"baseline:{baseline.pk}",
259 source_version=version(baseline.updated_at),
260 source_settings_profile=baseline,
261 )
262 ]
265def _baseline_payload(*, study: EconomicsStudy, baseline: EconomicsSettingsProfile) -> dict[str, Any]:
266 """Include manual baseline fields and inherited assumptions that affect calculations."""
267 return {
268 "id": baseline.pk,
269 "mode": EconomicsBaselineMode.MANUAL,
270 "manual_capex": baseline.manual_capex,
271 "manual_annual_opex": baseline.manual_annual_opex,
272 "annual_heat_basis_mode": baseline.annual_heat_basis_mode,
273 "manual_annual_heat_basis": baseline.manual_annual_heat_basis,
274 "manual_annual_heat_basis_unit": baseline.manual_annual_heat_basis_unit,
275 "average_power_input": baseline.average_power_input,
276 "average_power_unit": baseline.average_power_unit,
277 "residual_value": baseline.residual_value,
278 "notes": baseline.baseline_notes,
279 "inherited_study_assumptions": _manual_baseline_inherited_assumptions(study),
280 "updated_at": baseline.updated_at,
281 }
284def _study_baseline_fingerprint(study: EconomicsStudy) -> DependencyFingerprint:
285 """Fingerprint the selected baseline study result values used by study mode."""
286 baseline_study = resolve_baseline_study(study)
287 run = None if baseline_study is None else _current_baseline_result_run(baseline_study)
288 payload = {
289 "study_id": study.pk,
290 "mode": EconomicsBaselineMode.STUDY,
291 "baseline_study_lineage_id": (
292 str(study.baseline_study_lineage_id)
293 if study.baseline_study_lineage_id is not None
294 else None
295 ),
296 "baseline_flowsheet_id": study.baseline_flowsheet_id,
297 "baseline_result_run_id": run.pk if run is not None else None,
298 "baseline_result_completed_at": run.completed_at if run is not None else None,
299 "baseline_result_classification": _baseline_result_run_classification(run) if run is not None else None,
300 "baseline_result_lines": _baseline_result_line_payload(run) if run is not None else {},
301 }
302 return _dependency(
303 dependency_type=ResultDependencyType.BASELINE,
304 dependency_key=f"baseline:study:{study.pk}",
305 payload=payload,
306 fingerprint_basis="economics_baseline.study_mode_current_result_metrics",
307 source_label=f"Study baseline for {study.name}",
308 source_row_key=f"study:{study.pk}:baseline-study",
309 source_version=version(run.completed_at) if run is not None and run.completed_at is not None else "",
310 )
313def _current_baseline_result_run(study: EconomicsStudy) -> EconomicsResultRun | None:
314 """Find the selected baseline study's current result, even when it is cross-flowsheet.
316 API selection validates read access before a cross-flowsheet baseline study
317 can be stored. Fingerprinting runs under the target study context, so the
318 request-scoped manager would otherwise hide the baseline study's own result
319 rows. The explicit filters keep the read pinned to that selected baseline.
320 """
321 return (
322 # Use the unscoped manager because target-study recalculation runs
323 # under the target flowsheet context; explicit filters pin the baseline.
324 EconomicsResultRun._base_manager.filter(
325 flowsheet_state=study.flowsheet_state,
326 study=study,
327 status=ResultRunStatus.CURRENT,
328 )
329 .order_by("-created_at", "-pk")
330 .first()
331 )
334def _baseline_result_line_payload(run: EconomicsResultRun) -> dict[str, Any]:
335 """Fingerprint only the result metrics consumed by study-baseline financial calculations."""
336 rows = {}
337 # Use the unscoped manager for cross-flowsheet baseline runs, then pin the
338 # payload to the exact run, owning flowsheet, and consumed metric row keys.
339 for line in EconomicsResultLine._base_manager.filter(
340 flowsheet_state=run.flowsheet_state,
341 result_run=run,
342 group="financial_metrics",
343 row_key__in=(
344 required_financial_metric_spec(FinancialMetricKey.CAPEX).row_key,
345 required_financial_metric_spec(FinancialMetricKey.ANNUAL_OPEX).row_key,
346 ),
347 ):
348 rows[line.row_key] = {
349 "amount": line.amount,
350 "unit": line.unit,
351 "warning_payload": line.warning_payload,
352 }
353 return rows
356def _baseline_result_run_classification(run: EconomicsResultRun) -> str:
357 """Mirror the financial-metrics gate that rejects stale study-baseline runs."""
358 from Economics.results.services.lifecycle.runs import classify_result_run
360 return classify_result_run(run)
363def _schedule_fingerprints(
364 study: EconomicsStudy,
365 *,
366 persisted_fingerprint_value: str = "",
367 source_version: str | None = None,
368) -> list[DependencyFingerprint]:
369 """Fingerprint the study production-schedule selection and source data."""
371 scenario = study.schedule_scenario
372 compact_source_version = (
373 schedule_source_version(study) if source_version is None else source_version
374 )
375 if persisted_fingerprint_value and compact_source_version:
376 return [
377 _schedule_dependency(
378 study=study,
379 fingerprint_value=persisted_fingerprint_value,
380 source_version=compact_source_version,
381 )
382 ]
384 cache_key = _schedule_fingerprint_cache_key(
385 study=study,
386 source_version=compact_source_version,
387 )
388 if cache_key:
389 cached_fingerprint = cache.get(cache_key)
390 if cached_fingerprint: 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true
391 return [
392 _schedule_dependency(
393 study=study,
394 fingerprint_value=str(cached_fingerprint),
395 source_version=compact_source_version,
396 )
397 ]
399 payload = {
400 "study_id": study.pk,
401 "schedule_mode": study.schedule_mode,
402 "schedule_scenario_id": study.schedule_scenario_id,
403 "preview": schedule_preview(study).__dict__,
404 }
405 source_label = "Steady-state schedule"
406 source_row_key = f"study:{study.pk}:schedule"
407 source_version = compact_source_version
408 sources = {}
409 if study.schedule_mode == EconomicsScheduleMode.COMPOSITE:
410 plan_payload = saved_schedule_plan_payload(study)
411 plan = getattr(study, "schedule_plan", None)
412 payload["composite_schedule"] = plan_payload.model_dump(mode="json")
413 payload["source_scenarios"] = _composite_schedule_source_payload(plan)
414 source_label = "Composite production schedule"
415 source_row_key = f"schedule_plan:{plan.pk if plan is not None else 'missing'}"
416 source_version = version(getattr(plan, "updated_at", None))
417 if study.schedule_mode == EconomicsScheduleMode.SCENARIO and scenario is not None:
418 payload["scenario"] = _schedule_scenario_payload(scenario)
419 source_label = scenario.displayName or "Production schedule"
420 source_row_key = f"schedule_scenario:{scenario.pk}"
421 source_version = compact_source_version or version(scenario.created_at)
422 sources["source_scenario"] = scenario
424 dependency = _dependency(
425 dependency_type=ResultDependencyType.SCHEDULE,
426 dependency_key=f"schedule:{study.pk}",
427 payload=payload,
428 fingerprint_basis="economics_study.production_schedule",
429 source_label=source_label,
430 source_row_key=source_row_key,
431 source_version=source_version,
432 **sources,
433 )
434 if cache_key:
435 cache.set(cache_key, dependency.fingerprint_value, timeout=86400)
436 return [dependency]
439def schedule_source_version(study: EconomicsStudy) -> str:
440 """Return a compact version for scenario inputs and immutable solved rows."""
442 if study.schedule_mode != EconomicsScheduleMode.SCENARIO or study.schedule_scenario_id is None:
443 return ""
444 scenario = study.schedule_scenario
445 if scenario.economics_schedule_revision is not None:
446 return _persisted_schedule_source_version(scenario)
447 rows = list(
448 DataRow.objects.filter(flowsheet_state=scenario.flowsheet_state, scenario=scenario)
449 .order_by("index", "pk")
450 .values_list("pk", "index", "created_at")
451 )
452 row_ids = [row_id for row_id, _index, _created_at in rows]
453 columns = list(
454 DataColumn.objects.filter(flowsheet_state=scenario.flowsheet_state, scenario=scenario)
455 .order_by("created_at", "pk")
456 .values_list("pk", "name", "value", "property_value_id", "created_at")
457 )
458 cells = (
459 list(
460 DataCell.objects.filter(data_row_id__in=row_ids)
461 .order_by("data_row__index", "data_column__created_at", "pk")
462 .values_list("pk", "data_row_id", "data_column_id", "value", "created_at")
463 )
464 if row_ids
465 else []
466 )
467 solution_version = Solution.objects.filter(
468 flowsheet_state=scenario.flowsheet_state,
469 scenario=scenario,
470 solve_index__isnull=False,
471 ).aggregate(
472 signature=MD5(
473 StringAgg(
474 Cast(
475 JSONObject(
476 id="pk",
477 property_value_id="property_id",
478 property_info_id="property__property_id",
479 solve_index="solve_index",
480 values="values",
481 ),
482 output_field=models.TextField(),
483 ),
484 delimiter="\x1e",
485 ordering=("solve_index", "property_id", "pk"),
486 default="",
487 )
488 ),
489 )
490 profile = get_settings_profile(study)
491 payload = {
492 "study_id": study.pk,
493 "schedule_mode": study.schedule_mode,
494 "scenario_id": scenario.pk,
495 "scenario_fields": {
496 "name": scenario.displayName,
497 "state_name": scenario.state_name,
498 "mss_time_series_enabled": scenario.mss_time_series_enabled,
499 "mss_time_series_interval": scenario.mss_time_series_interval,
500 "mss_time_series_unit": scenario.mss_time_series_unit,
501 "mss_input_mode": scenario.mss_input_mode,
502 "created_at": scenario.created_at,
503 },
504 "annual_operating_hours": profile.annual_operating_hours if profile is not None else None,
505 "rows": rows,
506 "columns": columns,
507 "cells": cells,
508 "solutions": solution_version,
509 }
510 return f"schedule-v2:{hashlib.sha256(_canonical_json(payload).encode('utf-8')).hexdigest()}"
513def promote_legacy_schedule_source_version(
514 study: EconomicsStudy,
515 *,
516 verified_source_version: str,
517) -> str:
518 """Promote a verified legacy hash to the persisted O(1) schedule generation.
520 Only dependencies that matched the freshly computed legacy source hash are
521 promoted. This preserves current/stale classification across deployment
522 without trusting a migration-time snapshot of potentially changing solves.
523 """
525 scenario = study.schedule_scenario
526 if scenario is None or not verified_source_version.startswith("schedule-v2:"):
527 return verified_source_version
528 with transaction.atomic():
529 locked_scenario = Scenario.objects.select_for_update().get(pk=scenario.pk)
530 dependency_ids_to_promote: list[int] = []
531 if locked_scenario.economics_schedule_revision is None: 531 ↛ 562line 531 didn't jump to line 562 because the condition on line 531 was always true
532 # A scenario can back multiple studies. Verify every study's current
533 # legacy hash before switching the shared scenario to v3; otherwise
534 # the first study read would strand valid dependencies from the
535 # remaining studies on an unverifiable v2 source version.
536 legacy_studies = (
537 EconomicsStudy.objects.filter(
538 result_runs__dependencies__dependency_type=ResultDependencyType.SCHEDULE,
539 result_runs__dependencies__source_scenario=locked_scenario,
540 result_runs__dependencies__source_version__startswith="schedule-v2:",
541 )
542 .select_related("schedule_scenario", "settings_profile")
543 .distinct()
544 )
545 for legacy_study in legacy_studies:
546 legacy_source_version = (
547 verified_source_version
548 if legacy_study.pk == study.pk
549 else schedule_source_version(legacy_study)
550 )
551 dependency_ids_to_promote.extend(
552 EconomicsResultDependency.objects.filter(
553 result_run__study=legacy_study,
554 dependency_type=ResultDependencyType.SCHEDULE,
555 source_scenario=locked_scenario,
556 source_version=legacy_source_version,
557 ).values_list("pk", flat=True)
558 )
559 locked_scenario.economics_schedule_revision = uuid.uuid4()
560 locked_scenario.save(update_fields=["economics_schedule_revision"])
561 else:
562 dependency_ids_to_promote.extend(
563 EconomicsResultDependency.objects.filter(
564 dependency_type=ResultDependencyType.SCHEDULE,
565 source_scenario=locked_scenario,
566 source_version=verified_source_version,
567 ).values_list("pk", flat=True)
568 )
569 persisted_source_version = _persisted_schedule_source_version(locked_scenario)
570 EconomicsResultDependency.objects.filter(
571 pk__in=dependency_ids_to_promote,
572 ).update(source_version=persisted_source_version)
573 scenario.economics_schedule_revision = locked_scenario.economics_schedule_revision
574 return persisted_source_version
577def _persisted_schedule_source_version(scenario: Scenario) -> str:
578 """Format the persisted scenario generation used by schedule dependencies."""
580 return f"schedule-v3:{scenario.pk}:{scenario.economics_schedule_revision}"
583def _schedule_fingerprint_cache_key(*, study: EconomicsStudy, source_version: str) -> str:
584 if not source_version:
585 return ""
586 return f"economics:schedule-fingerprint:{study.pk}:{source_version}"
589def _schedule_dependency(
590 *,
591 study: EconomicsStudy,
592 fingerprint_value: str,
593 source_version: str,
594) -> DependencyFingerprint:
595 """Recreate dependency metadata around an exact persisted schedule hash."""
597 scenario = study.schedule_scenario
598 return DependencyFingerprint(
599 dependency_type=ResultDependencyType.SCHEDULE,
600 dependency_key=f"schedule:{study.pk}",
601 fingerprint_value=fingerprint_value,
602 fingerprint_basis="economics_study.production_schedule",
603 source_label=(
604 (scenario.displayName or "Production schedule")
605 if scenario is not None
606 else "Steady-state schedule"
607 ),
608 source_row_key=f"schedule_scenario:{scenario.pk}" if scenario is not None else f"study:{study.pk}:schedule",
609 source_version=source_version,
610 source_scenario=scenario,
611 )
614def _schedule_scenario_payload(scenario) -> dict[str, Any]:
615 rows = list(
616 DataRow.objects.filter(
617 flowsheet_state=scenario.flowsheet_state,
618 scenario=scenario,
619 ).order_by("index", "pk")
620 )
621 row_ids = [row.pk for row in rows]
622 row_index_by_id = {row.pk: row.index for row in rows}
623 return {
624 "id": scenario.pk,
625 "name": scenario.displayName,
626 "state_name": scenario.state_name,
627 "mss_time_series_enabled": scenario.mss_time_series_enabled,
628 "mss_time_series_interval": scenario.mss_time_series_interval,
629 "mss_time_series_unit": scenario.mss_time_series_unit,
630 "mss_input_mode": scenario.mss_input_mode,
631 "created_at": scenario.created_at,
632 "rows": [
633 {
634 "id": row.pk,
635 "index": row.index,
636 "created_at": row.created_at,
637 }
638 for row in rows
639 ],
640 "input_columns": _schedule_input_column_payload(scenario),
641 "input_values": _schedule_input_value_payload(row_ids=row_ids, row_index_by_id=row_index_by_id),
642 "solution_values": _schedule_solution_payload(scenario),
643 }
646def _composite_schedule_source_payload(plan) -> list[dict[str, Any]]:
647 """Return child scenario payloads consumed by a composite schedule plan."""
649 if plan is None: 649 ↛ 650line 649 didn't jump to line 650 because the condition on line 649 was never true
650 return []
651 return [
652 _schedule_scenario_payload(rule.source_scenario)
653 for rule in plan.rules.select_related("source_scenario").order_by("sort_order", "pk")
654 if rule.source_scenario_id is not None
655 ]
658def _schedule_input_column_payload(scenario) -> list[dict[str, Any]]:
659 return [
660 {
661 "id": column.pk,
662 "name": column.name,
663 "property_value_id": column.property_value_id,
664 "property_info_id": column.property_value.property_id if column.property_value_id else None,
665 "created_at": column.created_at,
666 }
667 for column in DataColumn.objects.filter(
668 flowsheet_state=scenario.flowsheet_state,
669 scenario=scenario,
670 )
671 .select_related("property_value")
672 .order_by("created_at", "pk")
673 ]
676def _schedule_input_value_payload(
677 *,
678 row_ids: list[int],
679 row_index_by_id: dict[int, int],
680) -> list[dict[str, Any]]:
681 if not row_ids: 681 ↛ 682line 681 didn't jump to line 682 because the condition on line 681 was never true
682 return []
683 return [
684 {
685 "id": cell.pk,
686 "row_index": row_index_by_id.get(cell.data_row_id),
687 "data_column_id": cell.data_column_id,
688 "value": cell.value,
689 "created_at": cell.created_at,
690 }
691 for cell in DataCell.objects.filter(data_row_id__in=row_ids)
692 .order_by("data_row__index", "data_column__created_at", "pk")
693 ]
696def _schedule_solution_payload(scenario) -> list[dict[str, Any]]:
697 return [
698 {
699 "id": solution.pk,
700 "property_value_id": solution.property_id,
701 "property_info_id": solution.property.property_id if solution.property_id else None,
702 "solve_index": solution.solve_index,
703 "values": solution.values,
704 "created_at": solution.created_at,
705 }
706 for solution in Solution.objects.filter(
707 flowsheet_state=scenario.flowsheet_state,
708 scenario=scenario,
709 solve_index__isnull=False,
710 )
711 .select_related("property")
712 .order_by("solve_index", "property_id", "pk")
713 ]
716def _manual_baseline_inherited_assumptions(study: EconomicsStudy) -> dict[str, Any] | None:
717 """Return study assumptions that affect manual-baseline financial metrics."""
718 assumptions = get_assumptions(study)
719 if assumptions is None: 719 ↛ 720line 719 didn't jump to line 720 because the condition on line 719 was never true
720 return None
721 return {
722 "currency": assumptions.currency,
723 "basis_date": assumptions.basis_date,
724 "discount_rate_percent": assumptions.discount_rate_percent,
725 "project_lifetime_years": assumptions.project_lifetime_years,
726 "inflation_method": assumptions.inflation_method,
727 "annual_operating_hours": assumptions.annual_operating_hours,
728 "tax_rate_percent": assumptions.tax_rate_percent,
729 "depreciation_enabled": assumptions.depreciation_enabled,
730 "default_depreciation_life_years": assumptions.default_depreciation_life_years,
731 "default_depreciation_salvage_percent": assumptions.default_depreciation_salvage_percent,
732 "contingency_percent": assumptions.contingency_percent,
733 "electrical_upgrade_rate_amount": assumptions.electrical_upgrade_rate_amount,
734 "electrical_upgrade_rate_unit": assumptions.electrical_upgrade_rate_unit,
735 "default_lang_factor": assumptions.default_lang_factor,
736 "capital_index_series_id": assumptions.capital_index_series_id,
737 "operating_index_series_id": assumptions.operating_index_series_id,
738 "default_rate_overrides": assumptions.default_rate_overrides,
739 }
742def _costable_item_fingerprints(study: EconomicsStudy) -> list[DependencyFingerprint]:
743 """Fingerprint costable items that define the result-row equipment scope."""
744 fingerprints = []
745 for item in study.costable_items.select_related("simulation_object").order_by("pk"):
746 fingerprints.append(
747 _dependency(
748 dependency_type=ResultDependencyType.COSTABLE_ITEM,
749 dependency_key=f"costable_item:{item.pk}",
750 payload={
751 "id": item.pk,
752 "item_type": item.item_type,
753 "simulation_object_id": item.simulation_object_id,
754 "simulation_object_type": item.simulation_object.objectType if item.simulation_object_id else None,
755 "name": item.name,
756 "included": item.included,
757 "manual": item.manual,
758 "notes": item.notes,
759 "updated_at": item.updated_at,
760 },
761 fingerprint_basis="costable_item.fields",
762 source_label=item.name,
763 source_row_key=f"costable_item:{item.pk}",
764 source_version=version(item.updated_at),
765 source_costable_item=item,
766 )
767 )
768 return fingerprints
771def _cost_driver_fingerprints(study: EconomicsStudy) -> list[DependencyFingerprint]:
772 """Fingerprint cost drivers and their resolved property values."""
773 fingerprints = []
774 drivers = (
775 CostDriver.objects.filter(flowsheet_state=study.flowsheet_state, costable_item__study=study)
776 .select_related("costable_item", "property_info", "manual_property_info")
777 .prefetch_related("property_info__values", "manual_property_info__values")
778 .order_by("pk")
779 )
780 for driver in drivers:
781 source_property = driver.property_info or driver.manual_property_info
782 fingerprints.append(
783 _dependency(
784 dependency_type=ResultDependencyType.PROPERTY,
785 dependency_key=f"cost_driver:{driver.pk}",
786 payload={
787 "id": driver.pk,
788 "costable_item_id": driver.costable_item_id,
789 "source": driver.source,
790 "property_info_id": driver.property_info_id,
791 "manual_property_info_id": driver.manual_property_info_id,
792 "sizing_mode": driver.sizing_mode,
793 "canonical_unit": driver.canonical_unit,
794 "design_value": driver.design_value,
795 "unresolved_reason_code": driver.unresolved_reason_code,
796 "warning_payload": driver.warning_payload,
797 "property": _property_value_payload(driver.property_info),
798 "manual_property": _property_value_payload(driver.manual_property_info),
799 "updated_at": driver.updated_at,
800 },
801 fingerprint_basis="cost_driver.fields_and_property_value",
802 source_label=f"{driver.costable_item.name} cost driver",
803 source_row_key=f"cost_driver:{driver.pk}",
804 source_version=version(driver.updated_at),
805 source_costable_item=driver.costable_item,
806 source_property_info=source_property,
807 )
808 )
809 return fingerprints
812def _equipment_mapping_fingerprints(study: EconomicsStudy) -> list[DependencyFingerprint]:
813 """Fingerprint equipment-to-curve mappings used by generated capital lines."""
814 fingerprints = []
815 mappings = (
816 EquipmentMapping.objects.filter(flowsheet_state=study.flowsheet_state, costable_item__study=study)
817 .select_related("costable_item", "cost_curve")
818 .order_by("pk")
819 )
820 for mapping in mappings:
821 fingerprints.append(
822 _dependency(
823 dependency_type=ResultDependencyType.COSTABLE_ITEM,
824 dependency_key=f"equipment_mapping:{mapping.pk}",
825 payload={
826 "id": mapping.pk,
827 "costable_item_id": mapping.costable_item_id,
828 "cost_curve_id": mapping.cost_curve_id,
829 "equipment_category": mapping.equipment_category,
830 "equipment_subtype": mapping.equipment_subtype,
831 "cost_basis": mapping.cost_basis,
832 "install_factor_profile": mapping.install_factor_profile,
833 "install_factor": mapping.install_factor,
834 "use_study_lang_factor": mapping.use_study_lang_factor,
835 "applicability_notes": mapping.applicability_notes,
836 "updated_at": mapping.updated_at,
837 },
838 fingerprint_basis="equipment_mapping.fields",
839 source_label=f"{mapping.costable_item.name} equipment mapping",
840 source_row_key=f"equipment_mapping:{mapping.pk}",
841 source_version=version(mapping.updated_at),
842 source_costable_item=mapping.costable_item,
843 source_cost_curve=mapping.cost_curve,
844 )
845 )
846 return fingerprints
849def _property_value_payload(property_info) -> dict[str, Any] | None:
850 """Serialize the first property value used by a property-backed cost driver."""
851 if property_info is None:
852 return None
853 prefetched_values = getattr(property_info, "_prefetched_objects_cache", {}).get("values")
854 value = min(prefetched_values, key=lambda item: item.pk) if prefetched_values else None
855 if prefetched_values is None: 855 ↛ 856line 855 didn't jump to line 856 because the condition on line 855 was never true
856 value = property_info.values.order_by("pk").first()
857 return {
858 "id": property_info.pk,
859 "key": property_info.key,
860 "display_name": property_info.displayName,
861 "unit": property_info.unit,
862 "unit_type": property_info.unitType,
863 "value_id": value.pk if value is not None else None,
864 "value": value.value if value is not None else None,
865 "display_value": value.displayValue if value is not None else None,
866 "enabled": value.enabled if value is not None else None,
867 "formula": value.formula if value is not None else None,
868 }
871def _capital_line_fingerprints(study: EconomicsStudy) -> list[DependencyFingerprint]:
872 """Fingerprint included and excluded capital source rows for the study."""
873 fingerprints = []
874 lines = list(study.capital_lines.select_related("costable_item", "cost_curve").order_by("pk"))
875 property_ids = {
876 driver_input.get("property_info")
877 for line in lines
878 for driver_input in (line.driver_inputs.values() if isinstance(line.driver_inputs, dict) else ())
879 if isinstance(driver_input, dict)
880 and driver_input.get("source") == "property"
881 and driver_input.get("property_info") is not None
882 }
883 properties = {
884 property_info.pk: property_info
885 for property_info in PropertyInfo.objects.filter(
886 flowsheet_state=study.flowsheet_state,
887 pk__in=property_ids,
888 ).prefetch_related("values")
889 }
890 for line in lines:
891 fingerprints.append(
892 _dependency(
893 dependency_type=ResultDependencyType.CAPITAL_LINE,
894 dependency_key=f"capital_line:{line.pk}",
895 payload=_capital_line_payload(line, properties=properties),
896 fingerprint_basis="capital_cost_line.fields",
897 source_label=line.label,
898 source_row_key=f"capital_line:{line.pk}",
899 source_version=version(line.updated_at),
900 source_costable_item=line.costable_item,
901 source_cost_curve=line.cost_curve,
902 source_capital_line=line,
903 )
904 )
905 return fingerprints
908def _operating_line_fingerprints(study: EconomicsStudy) -> list[DependencyFingerprint]:
909 """Fingerprint operating source rows, including pricing and resource metadata."""
910 fingerprints = []
911 for line in study.operating_lines.select_related("costable_item", "source_property_info").order_by("pk"):
912 fingerprints.append(
913 _dependency(
914 dependency_type=ResultDependencyType.OPERATING_LINE,
915 dependency_key=f"operating_line:{line.pk}",
916 payload=_operating_line_payload(line),
917 fingerprint_basis="operating_cost_line.fields",
918 source_label=line.label,
919 source_row_key=f"operating_line:{line.pk}",
920 source_version=version(line.updated_at),
921 source_costable_item=line.costable_item,
922 source_property_info=line.source_property_info,
923 source_operating_line=line,
924 )
925 )
926 return fingerprints
929def _capital_line_payload(
930 line: CapitalCostLine,
931 *,
932 properties: dict[int, PropertyInfo] | None = None,
933) -> dict[str, Any]:
934 """Serialize capital-line fields that can affect result materialization."""
935 return {
936 "id": line.pk,
937 "study_id": line.study_id,
938 "costable_item_id": line.costable_item_id,
939 "cost_curve_id": line.cost_curve_id,
940 "label": line.label,
941 "line_type": line.line_type,
942 "calculation_basis": line.calculation_basis,
943 "amount": line.amount,
944 "basis_percent": line.basis_percent,
945 "depreciation_mode": line.depreciation_mode,
946 "depreciation_life_years": line.depreciation_life_years,
947 "depreciation_salvage_percent": line.depreciation_salvage_percent,
948 "peak_demand_kw": line.peak_demand_kw,
949 "minimum_peak_demand_kw": line.minimum_peak_demand_kw,
950 "currency": line.currency,
951 "included": line.included,
952 "manual": line.manual,
953 "source": line.source,
954 "confidence": line.confidence,
955 "warning_payload": line.warning_payload,
956 "driver_inputs": line.driver_inputs,
957 "driver_input_property_values": _driver_input_property_values(line, properties=properties),
958 "updated_at": line.updated_at,
959 }
962def _driver_input_property_values(
963 line: CapitalCostLine,
964 *,
965 properties: dict[int, PropertyInfo] | None = None,
966) -> list[dict[str, Any]]:
967 """Return values for properties referenced by generated-line driver inputs."""
968 if not isinstance(line.driver_inputs, dict): 968 ↛ 969line 968 didn't jump to line 969 because the condition on line 968 was never true
969 return []
970 property_ids = sorted(
971 {
972 driver_input.get("property_info")
973 for driver_input in line.driver_inputs.values()
974 if isinstance(driver_input, dict)
975 and driver_input.get("source") == "property"
976 and driver_input.get("property_info") is not None
977 }
978 )
979 if not property_ids:
980 return []
981 if properties is None: 981 ↛ 982line 981 didn't jump to line 982 because the condition on line 981 was never true
982 properties = {
983 property_info.pk: property_info
984 for property_info in PropertyInfo.objects.filter(
985 flowsheet_state=line.flowsheet_state,
986 pk__in=property_ids,
987 ).prefetch_related("values")
988 }
989 return [
990 {
991 "property_info_id": property_id,
992 "unit": properties[property_id].unit if property_id in properties else "",
993 "value": _first_property_value(properties[property_id]) if property_id in properties else None,
994 }
995 for property_id in property_ids
996 ]
999def _first_property_value(property_info: PropertyInfo):
1000 """Return the same first stored value as PropertyInfo.get_value without another query."""
1001 prefetched_values = getattr(property_info, "_prefetched_objects_cache", {}).get("values")
1002 if prefetched_values is None: 1002 ↛ 1003line 1002 didn't jump to line 1003 because the condition on line 1002 was never true
1003 return property_info.get_value()
1004 value = min(prefetched_values, key=lambda item: item.pk) if prefetched_values else None
1005 return value.value if value is not None else None
1008def _operating_line_payload(line: OperatingCostLine) -> dict[str, Any]:
1009 """Serialize operating-line fields that can affect costs or resource grouping."""
1010 return {
1011 "id": line.pk,
1012 "study_id": line.study_id,
1013 "costable_item_id": line.costable_item_id,
1014 "label": line.label,
1015 "line_type": line.line_type,
1016 "category": line.category,
1017 "currency": line.currency,
1018 "basis_quantity": line.basis_quantity,
1019 "basis_unit": line.basis_unit,
1020 "basis_quantity_source": line.basis_quantity_source,
1021 "rate_amount": line.rate_amount,
1022 "rate_unit": line.rate_unit,
1023 "rate_type": line.rate_type,
1024 "rate_source_mode": line.rate_source_mode,
1025 "calculation_method": line.calculation_method,
1026 "source_property_info_id": line.source_property_info_id,
1027 "source_default_rate_id": line.source_default_rate_id,
1028 "outlet_stream_disposition": line.outlet_stream_disposition,
1029 "included": line.included,
1030 "manual": line.manual,
1031 "source": line.source,
1032 "warning_payload": line.warning_payload,
1033 "updated_at": line.updated_at,
1034 }
1037def _cost_curve_fingerprints(study: EconomicsStudy) -> list[DependencyFingerprint]:
1038 """Fingerprint only cost curves referenced by this study's capital setup."""
1039 curve_ids = set(
1040 study.capital_lines.filter(cost_curve__isnull=False).values_list("cost_curve_id", flat=True)
1041 )
1042 costable_item_ids = study.costable_items.values_list("pk", flat=True)
1043 curve_ids.update(
1044 EquipmentMapping.objects.filter(
1045 flowsheet_state=study.flowsheet_state,
1046 costable_item_id__in=costable_item_ids,
1047 cost_curve__isnull=False,
1048 ).values_list("cost_curve_id", flat=True)
1049 )
1050 fingerprints = []
1051 for curve in CostCurve._base_manager.filter(pk__in=curve_ids).order_by("pk"):
1052 fingerprints.append(
1053 _dependency(
1054 dependency_type=ResultDependencyType.COST_CURVE,
1055 dependency_key=f"cost_curve:{curve.pk}",
1056 payload={
1057 "id": curve.pk,
1058 "curve_key": curve.curve_key,
1059 "name": curve.name,
1060 "equipment_category": curve.equipment_category,
1061 "equipment_subtype": curve.equipment_subtype,
1062 "cost_basis": curve.cost_basis,
1063 "evaluation_kind": curve.evaluation_kind,
1064 "output_unit": curve.output_unit,
1065 "expression_text": curve.expression_text,
1066 "required_driver_specs": curve.required_driver_specs,
1067 "discrete_variants": curve.discrete_variants,
1068 "valid_min": curve.valid_min,
1069 "valid_max": curve.valid_max,
1070 "valid_range_note": curve.valid_range_note,
1071 "currency": curve.currency,
1072 "basis_date": curve.basis_date,
1073 "basis_index_name": curve.basis_index_name,
1074 "basis_index_value": curve.basis_index_value,
1075 "source_document_title": curve.source_document_title,
1076 "source_page": curve.source_page,
1077 "source_figure": curve.source_figure,
1078 "source_data_origin": curve.source_data_origin,
1079 "source_range_precision": curve.source_range_precision,
1080 "source_license_status": curve.source_license_status,
1081 "source_reference": curve.source_reference,
1082 "source_note": curve.source_note,
1083 "applicability_warning": curve.applicability_warning,
1084 "active": curve.active,
1085 "updated_at": curve.updated_at,
1086 },
1087 fingerprint_basis="cost_curve.fields",
1088 source_label=curve.name,
1089 source_row_key=f"cost_curve:{curve.curve_key}",
1090 source_version=version(curve.updated_at),
1091 source_cost_curve=curve,
1092 )
1093 )
1094 return fingerprints
1097def _index_data_fingerprints(study: EconomicsStudy) -> list[DependencyFingerprint]:
1098 """Fingerprint selected index series and values used for capital escalation."""
1099 assumptions = get_assumptions(study)
1100 if assumptions is None:
1101 return []
1102 series_ids = {assumptions.capital_index_series_id, assumptions.operating_index_series_id}
1103 series_ids.discard(None)
1104 fingerprints = []
1105 for series in CostIndexSeries.objects.filter(pk__in=series_ids).order_by("pk"):
1106 values = list(series.values.order_by("period_date", "pk"))
1107 fingerprints.append(
1108 _dependency(
1109 dependency_type=ResultDependencyType.INDEX_SERIES,
1110 dependency_key=f"index_series:{series.pk}",
1111 payload={
1112 "id": series.pk,
1113 "key": series.key,
1114 "name": series.name,
1115 "provider": series.provider,
1116 "source_series_id": series.source_series_id,
1117 "frequency": series.frequency,
1118 "unit": series.unit,
1119 "index_basis": series.index_basis,
1120 "source_url": series.source_url,
1121 "release_title": series.release_title,
1122 "source_asset_filename": series.source_asset_filename,
1123 "source_asset_file_id": series.source_asset_file_id,
1124 "source_parent_id": series.source_parent_id,
1125 "latest_imported_period": series.latest_imported_period,
1126 "updated_at": series.updated_at,
1127 "values": [_index_value_payload(value) for value in values],
1128 },
1129 fingerprint_basis="cost_index_series.fields_and_values",
1130 source_label=series.name,
1131 source_row_key=f"index_series:{series.key}",
1132 source_version=version(series.updated_at),
1133 source_index_series=series,
1134 )
1135 )
1136 for value in values:
1137 fingerprints.append(
1138 _dependency(
1139 dependency_type=ResultDependencyType.INDEX_VALUE,
1140 dependency_key=f"index_value:{value.pk}",
1141 payload=_index_value_payload(value),
1142 fingerprint_basis="cost_index_value.fields",
1143 source_label=f"{series.name} {value.period}",
1144 source_row_key=f"index_value:{series.key}:{value.period}",
1145 source_version=value.period,
1146 source_index_series=series,
1147 source_index_value=value,
1148 )
1149 )
1150 return fingerprints
1153def _create_dependencies(*, result_run: EconomicsResultRun, fingerprints: list[DependencyFingerprint]) -> None:
1154 """Persist fingerprint contracts as auditable dependency rows for a result run."""
1155 EconomicsResultDependency.objects.bulk_create(
1156 [
1157 EconomicsResultDependency(
1158 flowsheet_state=result_run.flowsheet_state,
1159 result_run=result_run,
1160 dependency_type=fingerprint.dependency_type,
1161 dependency_key=fingerprint.dependency_key,
1162 fingerprint_value=fingerprint.fingerprint_value,
1163 fingerprint_algorithm=FINGERPRINT_ALGORITHM,
1164 fingerprint_basis=fingerprint.fingerprint_basis,
1165 source_label=fingerprint.source_label,
1166 source_row_key=fingerprint.source_row_key,
1167 source_version=fingerprint.source_version,
1168 source_settings_profile=fingerprint.source_settings_profile,
1169 source_costable_item=fingerprint.source_costable_item,
1170 source_cost_curve=fingerprint.source_cost_curve,
1171 source_capital_line=fingerprint.source_capital_line,
1172 source_operating_line=fingerprint.source_operating_line,
1173 source_index_series=fingerprint.source_index_series,
1174 source_index_value=fingerprint.source_index_value,
1175 source_scenario=fingerprint.source_scenario,
1176 source_property_info=fingerprint.source_property_info,
1177 )
1178 for fingerprint in fingerprints
1179 ]
1180 )
1183def _dependency(
1184 *,
1185 dependency_type: str,
1186 dependency_key: str,
1187 payload: dict[str, Any],
1188 fingerprint_basis: str,
1189 source_label: str = "",
1190 source_row_key: str = "",
1191 source_version: str = "",
1192 **sources,
1193) -> DependencyFingerprint:
1194 """Build one dependency fingerprint from an already-normalized source payload."""
1195 return DependencyFingerprint(
1196 dependency_type=dependency_type,
1197 dependency_key=dependency_key,
1198 fingerprint_value=_fingerprint_payload(payload),
1199 fingerprint_basis=fingerprint_basis,
1200 source_label=source_label,
1201 source_row_key=source_row_key,
1202 source_version=source_version,
1203 **sources,
1204 )
1207def _fingerprint_payload(payload: dict[str, Any]) -> str:
1208 """Hash a normalized dependency payload using the persisted algorithm label."""
1209 return f"{FINGERPRINT_PREFIX}{hashlib.sha256(_canonical_json(payload).encode('utf-8')).hexdigest()}"
1212def _canonical_json(payload: dict[str, Any]) -> str:
1213 """Return canonical JSON so semantically identical payloads hash identically."""
1214 return json.dumps(json_ready(payload), sort_keys=True, separators=(",", ":"))
1217def _fingerprint_map(fingerprints: list[DependencyFingerprint]) -> dict[tuple[str, str], str]:
1218 """Return the comparable identity-to-hash map for current fingerprints."""
1219 return {fingerprint.identity: fingerprint.fingerprint_value for fingerprint in fingerprints}
1222def _stored_dependency_map(
1223 result_run: EconomicsResultRun,
1224 *,
1225 dependencies=None,
1226) -> dict[tuple[str, str], str]:
1227 """Return the comparable identity-to-hash map persisted for a result run."""
1228 if dependencies is None:
1229 dependencies = result_run.dependencies.order_by("dependency_type", "dependency_key")
1230 return {
1231 (dependency.dependency_type, dependency.dependency_key): dependency.fingerprint_value
1232 for dependency in dependencies
1233 }
1236def _index_value_payload(value: CostIndexValue) -> dict[str, Any]:
1237 """Serialize one index value row for both series and row-level fingerprints."""
1238 return {
1239 "id": value.pk,
1240 "series_id": value.series_id,
1241 "period": value.period,
1242 "period_date": value.period_date,
1243 "value": value.value,
1244 "status": value.status,
1245 "source_asset_filename": value.source_asset_filename,
1246 "source_series_reference": value.source_series_reference,
1247 "source_period": value.source_period,
1248 "source_units": value.source_units,
1249 "source_subject": value.source_subject,
1250 "source_group": value.source_group,
1251 "source_series_title_1": value.source_series_title_1,
1252 }