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

1"""Dependency fingerprint contracts for Economics presentation result runs. 

2 

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""" 

8 

9from __future__ import annotations 

10 

11import hashlib 

12import json 

13import uuid 

14from typing import Any 

15 

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 

21 

22from Economics.costing.models import ( 

23 

24 CapitalCostLine, 

25 

26 CostCurve, 

27 

28 CostDriver, 

29 

30 CostableItem, 

31 

32 EquipmentMapping, 

33 

34 OperatingCostLine, 

35 

36) 

37 

38from Economics.reference_data.models import CostIndexSeries, CostIndexValue 

39 

40from Economics.results.models import EconomicsResultDependency, EconomicsResultLine, EconomicsResultRun 

41 

42from Economics.settings_profiles.models import EconomicsSettingsProfile 

43from Economics.settings_profiles.services.settings_profiles import get_settings_profile 

44 

45from Economics.studies.models import EconomicsStudy 

46from Economics.studies.services.baseline_access import resolve_baseline_study 

47 

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 

61 

62 

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} 

91 

92 

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 

111 

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 

126 

127 @property 

128 def identity(self) -> tuple[str, str]: 

129 return (self.dependency_type, self.dependency_key) 

130 

131 

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. 

139 

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) 

163 

164 

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 ] 

182 

183 

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 ] 

232 

233 

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)] 

238 

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 ] 

263 

264 

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 } 

282 

283 

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 ) 

311 

312 

313def _current_baseline_result_run(study: EconomicsStudy) -> EconomicsResultRun | None: 

314 """Find the selected baseline study's current result, even when it is cross-flowsheet. 

315 

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 ) 

332 

333 

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 

354 

355 

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 

359 

360 return classify_result_run(run) 

361 

362 

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.""" 

370 

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 ] 

383 

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 ] 

398 

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 

423 

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] 

437 

438 

439def schedule_source_version(study: EconomicsStudy) -> str: 

440 """Return a compact version for scenario inputs and immutable solved rows.""" 

441 

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()}" 

511 

512 

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. 

519 

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 """ 

524 

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 

575 

576 

577def _persisted_schedule_source_version(scenario: Scenario) -> str: 

578 """Format the persisted scenario generation used by schedule dependencies.""" 

579 

580 return f"schedule-v3:{scenario.pk}:{scenario.economics_schedule_revision}" 

581 

582 

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}" 

587 

588 

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.""" 

596 

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 ) 

612 

613 

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 } 

644 

645 

646def _composite_schedule_source_payload(plan) -> list[dict[str, Any]]: 

647 """Return child scenario payloads consumed by a composite schedule plan.""" 

648 

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 ] 

656 

657 

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 ] 

674 

675 

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 ] 

694 

695 

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 ] 

714 

715 

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 } 

740 

741 

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 

769 

770 

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 

810 

811 

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 

847 

848 

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 } 

869 

870 

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 

906 

907 

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 

927 

928 

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 } 

960 

961 

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 ] 

997 

998 

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 

1006 

1007 

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 } 

1035 

1036 

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 

1095 

1096 

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 

1151 

1152 

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 ) 

1181 

1182 

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 ) 

1205 

1206 

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()}" 

1210 

1211 

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=(",", ":")) 

1215 

1216 

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} 

1220 

1221 

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 } 

1234 

1235 

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 }