Coverage for backend/django/Economics/costing/cost_curves/driver_specs.py: 85%

239 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-22 05:22 +0000

1"""Typed driver-input and variant contracts for economics cost curves.""" 

2 

3from __future__ import annotations 

4 

5from decimal import Decimal 

6import re 

7from typing import Any, Iterable, Literal, TypeAlias 

8 

9from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator, model_validator 

10 

11from Economics.costing.cost_curves.evaluation import normalize_economics_unit_notation 

12from Economics.costing.cost_curves.unit_options import cost_curve_input_unit_options 

13 

14UnitOptionPayload: TypeAlias = dict[str, str] 

15CostCurveDriverSpecPayload: TypeAlias = dict[str, str | bool | list[str] | None] 

16CostCurveDriverSpecReadPayload: TypeAlias = dict[ 

17 str, str | bool | list[str] | list[UnitOptionPayload] | None 

18] 

19CapitalCostDriverInputPayload: TypeAlias = dict[str, str | int | None] 

20CapitalCostDriverInputsPayload: TypeAlias = dict[str, CapitalCostDriverInputPayload] 

21CostCurveDiscreteVariantPayload: TypeAlias = dict[str, str | dict[str, str]] 

22 

23 

24CostCurveDriverRole = Literal["formula_input", "discrete_selector"] 

25CostCurveDriverSource = Literal["property", "manual"] 

26CapitalScheduleAggregateFunction = Literal["", "min", "max", "mean", "percentile"] 

27 

28 

29class CostCurveDriverSpec(BaseModel): 

30 """One declared input needed to size a cost curve. 

31 

32 Cost curves still store this in JSON so templates and user-authored curves 

33 can remain declarative, but all reads/writes are normalized through this 

34 model before the JSON reaches the API or generated capital-line payloads. 

35 """ 

36 

37 model_config = ConfigDict(frozen=True, extra="forbid") 

38 

39 key: str 

40 label: str 

41 unit: str 

42 role: CostCurveDriverRole 

43 variable_symbol: str = "" 

44 required: bool = True 

45 primary: bool = False 

46 valid_min: str = "" 

47 valid_max: str = "" 

48 valid_range_note: str = "" 

49 soft_maximum_adjustment_percent: str = "" 

50 default_manual_value: str = "" 

51 source_options: tuple[CostCurveDriverSource, ...] = Field( 

52 default=("property", "manual"), 

53 min_length=1, 

54 ) 

55 

56 @field_validator("key", "label", "unit") 

57 @classmethod 

58 def _required_text(cls, value: str) -> str: 

59 value = str(value or "").strip() 

60 if not value: 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true

61 raise ValueError("Required driver spec fields cannot be blank.") 

62 return value 

63 

64 @field_validator( 

65 "variable_symbol", 

66 "valid_min", 

67 "valid_max", 

68 "valid_range_note", 

69 "soft_maximum_adjustment_percent", 

70 "default_manual_value", 

71 mode="before", 

72 ) 

73 @classmethod 

74 def _optional_text(cls, value: Any) -> str: 

75 return "" if value is None else str(value).strip() 

76 

77 @field_validator("unit") 

78 @classmethod 

79 def _normalize_unit(cls, value: str) -> str: 

80 return normalize_economics_unit_notation(value) 

81 

82 @model_validator(mode="after") 

83 def _validate_role_fields(self) -> "CostCurveDriverSpec": 

84 if self.role == "formula_input" and not self.variable_symbol: 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true

85 raise ValueError("Formula input driver specs require variable_symbol.") 

86 if self.role == "discrete_selector" and self.variable_symbol: 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true

87 raise ValueError("Discrete selector driver specs cannot define variable_symbol.") 

88 for field_name in ("valid_min", "valid_max", "soft_maximum_adjustment_percent"): 

89 value = getattr(self, field_name) 

90 if value: 

91 _parse_decimal_text(value, field_name=field_name) 

92 return self 

93 

94 

95class CostCurveDriverSpecRead(CostCurveDriverSpec): 

96 """Read contract for driver specs with backend-provided unit choices.""" 

97 

98 model_config = ConfigDict(frozen=True, extra="forbid", title="CostCurveDriverSpec") 

99 

100 unit_options: list[UnitOptionPayload] = Field(default_factory=list) 

101 

102 

103class CostCurveDiscreteVariant(BaseModel): 

104 """One published curve candidate inside a discrete-family cost curve.""" 

105 

106 model_config = ConfigDict(frozen=True, extra="forbid") 

107 

108 key: str 

109 label: str 

110 selector_values: dict[str, str] 

111 expression_text: str 

112 valid_min: str = "" 

113 valid_max: str = "" 

114 valid_range_note: str = "" 

115 source_reference: str = "" 

116 notes: str = "" 

117 

118 @field_validator("key", "label", "expression_text") 

119 @classmethod 

120 def _required_text(cls, value: str) -> str: 

121 value = str(value or "").strip() 

122 if not value: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 raise ValueError("Required discrete variant fields cannot be blank.") 

124 return value 

125 

126 @field_validator("valid_min", "valid_max", "valid_range_note", "source_reference", "notes", mode="before") 

127 @classmethod 

128 def _optional_text(cls, value: Any) -> str: 

129 return "" if value is None else str(value).strip() 

130 

131 @field_validator("selector_values") 

132 @classmethod 

133 def _selector_values(cls, value: dict[str, Any]) -> dict[str, str]: 

134 normalized = {} 

135 for key, raw_value in value.items(): 

136 selector_key = str(key or "").strip() 

137 selector_value = str(raw_value or "").strip() 

138 if not selector_key or not selector_value: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true

139 raise ValueError("Discrete variant selector values cannot be blank.") 

140 _parse_decimal_text(selector_value, field_name=f"selector_values.{selector_key}") 

141 normalized[selector_key] = selector_value 

142 return normalized 

143 

144 @model_validator(mode="after") 

145 def _validate_optional_numbers(self) -> "CostCurveDiscreteVariant": 

146 for field_name in ("valid_min", "valid_max"): 

147 value = getattr(self, field_name) 

148 if value: 

149 _parse_decimal_text(value, field_name=field_name) 

150 return self 

151 

152 

153class CapitalCostDriverInput(BaseModel): 

154 """One persisted property/manual value selection for a capital-line driver.""" 

155 

156 model_config = ConfigDict(frozen=True, extra="forbid") 

157 

158 source: Literal["", "property", "manual"] = "" 

159 property_info: int | None = None 

160 manual_value: str = "" 

161 unit: str 

162 aggregate_function: CapitalScheduleAggregateFunction = "" 

163 aggregate_percentile: str = "50" 

164 aggregate_adjustment_percent: str = "0" 

165 

166 @classmethod 

167 def from_spec_default(cls, spec: CostCurveDriverSpec) -> "CapitalCostDriverInput": 

168 """Create the blank/default capital-line input for a declared spec.""" 

169 if "manual" in spec.source_options and spec.default_manual_value not in (None, ""): 

170 return cls( 

171 source="manual", 

172 property_info=None, 

173 manual_value=spec.default_manual_value, 

174 unit=spec.unit, 

175 ) 

176 if spec.source_options == ("manual",): 

177 return cls(source="", property_info=None, manual_value="", unit=spec.unit) 

178 return cls( 

179 source="", 

180 property_info=None, 

181 manual_value="", 

182 unit=spec.unit, 

183 ) 

184 

185 @field_validator("manual_value", mode="before") 

186 @classmethod 

187 def _manual_value_text(cls, value: Any) -> str: 

188 return "" if value is None else str(value).strip() 

189 

190 @field_validator("aggregate_percentile", "aggregate_adjustment_percent", mode="before") 

191 @classmethod 

192 def _aggregate_decimal_text(cls, value: Any) -> str: 

193 return "" if value is None else str(value).strip() 

194 

195 @field_validator("unit") 

196 @classmethod 

197 def _input_unit(cls, value: str) -> str: 

198 value = str(value or "").strip() 

199 if not value: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true

200 raise ValueError("Capital cost driver input unit cannot be blank.") 

201 return normalize_economics_unit_notation(value) 

202 

203 @model_validator(mode="after") 

204 def _validate_schedule_aggregate_fields(self) -> "CapitalCostDriverInput": 

205 percentile = _parse_decimal_text( 

206 self.aggregate_percentile or "50", 

207 field_name="aggregate_percentile", 

208 ) 

209 if percentile < 0 or percentile > 100: 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true

210 raise ValueError("aggregate_percentile must be between 0 and 100.") 

211 adjustment_percent = _parse_decimal_text( 

212 self.aggregate_adjustment_percent or "0", 

213 field_name="aggregate_adjustment_percent", 

214 ) 

215 if adjustment_percent < 0: 

216 raise ValueError("aggregate_adjustment_percent must be greater than or equal to 0.") 

217 if self.aggregate_function and self.source != "property": 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true

218 raise ValueError("Schedule aggregate fields require a property-backed driver input.") 

219 return self 

220 

221 

222_DRIVER_SPECS_ADAPTER = TypeAdapter(list[CostCurveDriverSpec]) 

223_DRIVER_INPUTS_ADAPTER = TypeAdapter(dict[str, CapitalCostDriverInput]) 

224_DISCRETE_VARIANTS_ADAPTER = TypeAdapter(list[CostCurveDiscreteVariant]) 

225 

226 

227def parse_required_driver_specs(specs: Any) -> tuple[CostCurveDriverSpec, ...]: 

228 """Validate and return the typed required driver spec list. 

229 

230 The uniqueness check is outside the single-spec Pydantic model because it 

231 depends on the whole list and keeps ``CapitalCostLine.driver_inputs`` keyed 

232 deterministically by spec key. 

233 """ 

234 if specs in (None, ""): 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 return () 

236 parsed_specs = tuple( 

237 _DRIVER_SPECS_ADAPTER.validate_python(_specs_with_generated_keys(specs)) 

238 ) 

239 seen_keys: set[str] = set() 

240 seen_symbols: set[str] = set() 

241 formula_input_count = 0 

242 primary_count = 0 

243 for spec in parsed_specs: 

244 if spec.key in seen_keys: 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true

245 raise ValueError(f"Driver spec key `{spec.key}` is duplicated.") 

246 seen_keys.add(spec.key) 

247 if spec.role == "formula_input": 

248 formula_input_count += 1 

249 if spec.variable_symbol in seen_symbols: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true

250 raise ValueError(f"Formula variable `{spec.variable_symbol}` is duplicated.") 

251 seen_symbols.add(spec.variable_symbol) 

252 primary_count += 1 if spec.primary else 0 

253 elif spec.primary: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 raise ValueError("Only formula input driver specs can be primary.") 

255 if formula_input_count == 0: 255 ↛ 256line 255 didn't jump to line 256 because the condition on line 255 was never true

256 raise ValueError("Cost curves require at least one formula input driver spec.") 

257 if primary_count != 1: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true

258 raise ValueError("Cost curves require exactly one primary formula input driver spec.") 

259 return parsed_specs 

260 

261 

262def normalize_required_driver_specs(specs: Any) -> list[CostCurveDriverSpecPayload]: 

263 """Validate and serialize the JSON-ready driver spec list.""" 

264 return [driver_spec_payload(spec) for spec in parse_required_driver_specs(specs)] 

265 

266 

267def _specs_with_generated_keys(specs: Any) -> Any: 

268 """Fill missing spec keys at the API boundary. 

269 

270 Key values are internal identifiers for stored capital-line driver inputs. 

271 User-authored payloads do not need to provide them, but existing template 

272 and stored keys are preserved exactly so saved selections stay stable. 

273 """ 

274 if not isinstance(specs, list): 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true

275 return specs 

276 seen_keys: set[str] = set() 

277 normalized_specs: list[Any] = [] 

278 for index, spec in enumerate(specs): 

279 if not isinstance(spec, dict): 279 ↛ 280line 279 didn't jump to line 280 because the condition on line 279 was never true

280 normalized_specs.append(spec) 

281 continue 

282 next_spec = {**spec} 

283 next_spec.pop("unit_options", None) 

284 key = str(next_spec.get("key") or "").strip() 

285 if not key: 

286 key = _generated_driver_spec_key(next_spec, index=index, seen_keys=seen_keys) 

287 next_spec["key"] = key 

288 seen_keys.add(key) 

289 normalized_specs.append(next_spec) 

290 return normalized_specs 

291 

292 

293def _generated_driver_spec_key( 

294 spec: dict[str, Any], *, index: int, seen_keys: set[str] 

295) -> str: 

296 base = ( 

297 _slug_value(str(spec.get("label") or "")) 

298 or _slug_value(str(spec.get("variable_symbol") or "")) 

299 or f"input_{index + 1}" 

300 ) 

301 candidate = base 

302 suffix = 2 

303 while candidate in seen_keys: 

304 candidate = f"{base}_{suffix}" 

305 suffix += 1 

306 return candidate 

307 

308 

309def _slug_value(value: str) -> str: 

310 return re.sub( 

311 r"(^_+|_+$)", 

312 "", 

313 re.sub(r"[^a-z0-9]+", "_", value.strip().lower()), 

314 ) 

315 

316 

317def parse_discrete_variants(variants: Any) -> tuple[CostCurveDiscreteVariant, ...]: 

318 """Validate and return typed discrete-family variant rows.""" 

319 if variants in (None, ""): 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 return () 

321 parsed_variants = tuple(_DISCRETE_VARIANTS_ADAPTER.validate_python(variants)) 

322 seen_keys: set[str] = set() 

323 for variant in parsed_variants: 

324 if variant.key in seen_keys: 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true

325 raise ValueError(f"Discrete variant key `{variant.key}` is duplicated.") 

326 seen_keys.add(variant.key) 

327 return parsed_variants 

328 

329 

330def normalize_discrete_variants(variants: Any) -> list[CostCurveDiscreteVariantPayload]: 

331 """Validate and serialize discrete-family variant rows for JSON storage.""" 

332 return [discrete_variant_payload(variant) for variant in parse_discrete_variants(variants)] 

333 

334 

335def normalize_capital_cost_driver_inputs( 

336 inputs: Any, 

337) -> CapitalCostDriverInputsPayload: 

338 """Validate and serialize keyed capital-line driver inputs. 

339 

340 `CapitalCostLine.driver_inputs` is a JSON object keyed by required driver 

341 spec key. Keeping this path Pydantic-backed gives the API a precise schema 

342 without weakening writes to arbitrary JSON. 

343 """ 

344 if inputs in (None, ""): 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true

345 return {} 

346 return { 

347 key: capital_cost_driver_input_payload(driver_input) 

348 for key, driver_input in _DRIVER_INPUTS_ADAPTER.validate_python(inputs).items() 

349 } 

350 

351 

352def driver_specs_payload( 

353 specs: Iterable[CostCurveDriverSpec], 

354) -> list[CostCurveDriverSpecPayload]: 

355 """Return JSON-ready driver specs from already-validated spec models.""" 

356 return [driver_spec_payload(spec) for spec in specs] 

357 

358 

359def driver_specs_read_payload( 

360 specs: Iterable[CostCurveDriverSpec], 

361) -> list[CostCurveDriverSpecReadPayload]: 

362 """Return API driver specs with read-only unit options attached.""" 

363 return [driver_spec_read_payload(spec) for spec in specs] 

364 

365 

366def default_driver_inputs_payload( 

367 specs: Iterable[CostCurveDriverSpec], 

368) -> CapitalCostDriverInputsPayload: 

369 """Build JSON-ready default capital-line driver inputs from typed specs.""" 

370 return { 

371 spec.key: capital_cost_driver_input_payload( 

372 CapitalCostDriverInput.from_spec_default(spec) 

373 ) 

374 for spec in specs 

375 } 

376 

377 

378def driver_spec_payload(spec: CostCurveDriverSpec) -> CostCurveDriverSpecPayload: 

379 """Serialize a typed driver spec without widening the payload type to Any.""" 

380 return { 

381 "key": spec.key, 

382 "label": spec.label, 

383 "role": spec.role, 

384 "variable_symbol": spec.variable_symbol, 

385 "unit": spec.unit, 

386 "required": spec.required, 

387 "primary": spec.primary, 

388 "valid_min": spec.valid_min, 

389 "valid_max": spec.valid_max, 

390 "valid_range_note": spec.valid_range_note, 

391 "soft_maximum_adjustment_percent": spec.soft_maximum_adjustment_percent, 

392 "default_manual_value": spec.default_manual_value, 

393 "source_options": list(spec.source_options), 

394 } 

395 

396 

397def driver_spec_read_payload(spec: CostCurveDriverSpec) -> CostCurveDriverSpecReadPayload: 

398 """Serialize one driver spec for API reads without changing stored JSON.""" 

399 return CostCurveDriverSpecRead( 

400 **driver_spec_payload(spec), 

401 unit_options=cost_curve_input_unit_options(spec.unit), 

402 ).model_dump(mode="json") 

403 

404 

405def discrete_variant_payload(variant: CostCurveDiscreteVariant) -> CostCurveDiscreteVariantPayload: 

406 """Serialize one discrete variant without widening the payload type to Any.""" 

407 return { 

408 "key": variant.key, 

409 "label": variant.label, 

410 "selector_values": variant.selector_values, 

411 "expression_text": variant.expression_text, 

412 "valid_min": variant.valid_min, 

413 "valid_max": variant.valid_max, 

414 "valid_range_note": variant.valid_range_note, 

415 "source_reference": variant.source_reference, 

416 "notes": variant.notes, 

417 } 

418 

419 

420def capital_cost_driver_input_payload( 

421 driver_input: CapitalCostDriverInput, 

422) -> CapitalCostDriverInputPayload: 

423 """Serialize one typed capital-line driver input without untyped dict access.""" 

424 return { 

425 "source": driver_input.source, 

426 "property_info": driver_input.property_info, 

427 "manual_value": driver_input.manual_value, 

428 "unit": driver_input.unit, 

429 "aggregate_function": driver_input.aggregate_function, 

430 "aggregate_percentile": driver_input.aggregate_percentile or "50", 

431 "aggregate_adjustment_percent": driver_input.aggregate_adjustment_percent or "0", 

432 } 

433 

434 

435def _parse_decimal_text(value: str, *, field_name: str) -> Decimal: 

436 try: 

437 decimal_value = Decimal(str(value)) 

438 except Exception as exc: 

439 raise ValueError(f"{field_name} must be a number.") from exc 

440 if not decimal_value.is_finite(): 440 ↛ 441line 440 didn't jump to line 441 because the condition on line 440 was never true

441 raise ValueError(f"{field_name} must be finite.") 

442 return decimal_value