Coverage for backend/django/Economics/costing/operating/stream_properties.py: 89%

265 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-06-23 21:51 +0000

1from __future__ import annotations 

2 

3from dataclasses import dataclass 

4from decimal import Decimal, InvalidOperation, ROUND_HALF_UP 

5 

6from django.core.exceptions import ObjectDoesNotExist 

7 

8from core.auxiliary.enums import ConType 

9from core.auxiliary.models.PropertyInfo import PropertyInfo 

10from Economics.shared.choices import ( 

11 DefaultRateType, 

12 OperatingLineBasisQuantitySource, 

13 OperatingLineCategory, 

14 OperatingLineEconomicEffect, 

15 OperatingLineRateSourceMode, 

16 OutletStreamDisposition, 

17) 

18from Economics.studies.models import EconomicsStudy 

19from Economics.costing.models import OperatingCostLine 

20from Economics.costing.operating.line_calculation import ( 

21 operating_line_rate_defaults_for_category, 

22) 

23from Economics.settings_profiles.services.settings_profiles import get_settings_profile 

24from flowsheetInternals.unitops.models.SimulationObject import SimulationObject 

25 

26 

27STREAM_OPERATING_LINE_SOURCE = "selected_output_stream_property" 

28UNIT_POWER_WORK_SOURCE_KIND = "unit_power_work_property" 

29UNIT_HEATING_DUTY_SOURCE_KIND = "unit_heating_duty_property" 

30UNIT_COOLING_DUTY_SOURCE_KIND = "unit_cooling_duty_property" 

31WORK_PROPERTY_KEYS = frozenset( 

32 { 

33 "activepower", 

34 "chargingpowerin", 

35 "chargingpowerout", 

36 "importexport", 

37 "inpower", 

38 "power", 

39 "powertransfer", 

40 "workelectrical", 

41 "workmechanical", 

42 } 

43) 

44HEATING_DUTY_PROPERTY_KEYS = frozenset({"heatadded", "heatdemand", "sink.heat"}) 

45COOLING_DUTY_PROPERTY_KEYS = frozenset({"heatdutyinverted", "heatremoved", "source.heat"}) 

46EXTERNAL_DUTY_OBJECT_TYPES = frozenset( 

47 { 

48 "heatexchanger", 

49 "heatexchanger1d", 

50 "heatexchangerlc", 

51 "heatexchangerntu", 

52 "plateheatexchanger", 

53 } 

54) 

55OPERATING_LINE_DECIMAL_QUANTUM = Decimal("0.00000001") 

56 

57 

58@dataclass(frozen=True) 

59class OperatingStreamPropertyOption: 

60 property_info: int 

61 stream_id: int 

62 stream_name: str 

63 source_object_id: int 

64 source_object_name: str 

65 source_kind: str 

66 property_key: str 

67 display_name: str 

68 unit: str 

69 unit_type: str 

70 value_preview: str 

71 has_value: bool 

72 suggested_group: str 

73 suggested_category: str 

74 suggested_disposition: str 

75 selected_operating_line: int | None = None 

76 

77 

78def output_stream_property_options(study: EconomicsStudy) -> list[OperatingStreamPropertyOption]: 

79 """Return scalar numeric properties that can seed operating lines.""" 

80 selected_line_by_property = { 

81 line.source_property_info_id: line.pk 

82 for line in study.operating_lines.filter( 

83 source=STREAM_OPERATING_LINE_SOURCE, 

84 source_property_info__isnull=False, 

85 ) 

86 } 

87 options: list[OperatingStreamPropertyOption] = [] 

88 seen_properties: set[int] = set() 

89 for stream, source_kind, group, category, disposition in _suggested_streams(study): 

90 for property_info in _eligible_stream_properties(stream): 

91 if not _property_matches_group(property_info, group): 

92 continue 

93 seen_properties.add(property_info.pk) 

94 value = property_info.get_value() 

95 options.append( 

96 OperatingStreamPropertyOption( 

97 property_info=property_info.pk, 

98 stream_id=stream.pk, 

99 stream_name=stream.componentName or f"Stream {stream.pk}", 

100 source_object_id=stream.pk, 

101 source_object_name=stream.componentName or f"Stream {stream.pk}", 

102 source_kind=source_kind, 

103 property_key=property_info.key, 

104 display_name=property_info.displayName, 

105 unit=property_info.unit or "", 

106 unit_type=property_info.unitType or "", 

107 value_preview="" if value in (None, "") else str(value), 

108 has_value=property_info.has_value(), 

109 suggested_group=group, 

110 suggested_category=category, 

111 suggested_disposition=disposition, 

112 selected_operating_line=selected_line_by_property.get(property_info.pk), 

113 ) 

114 ) 

115 for unit in _unit_operations(study): 

116 for property_info in _eligible_stream_properties(unit): 

117 source_kind = unit_energy_source_kind(unit=unit, property_info=property_info) 

118 if property_info.pk in seen_properties or source_kind is None: 

119 continue 

120 seen_properties.add(property_info.pk) 

121 value = property_info.get_value() 

122 options.append( 

123 OperatingStreamPropertyOption( 

124 property_info=property_info.pk, 

125 stream_id=unit.pk, 

126 stream_name=unit.componentName or f"Unit {unit.pk}", 

127 source_object_id=unit.pk, 

128 source_object_name=unit.componentName or f"Unit {unit.pk}", 

129 source_kind=source_kind, 

130 property_key=property_info.key, 

131 display_name=property_info.displayName, 

132 unit=property_info.unit or "", 

133 unit_type=property_info.unitType or "", 

134 value_preview="" if value in (None, "") else str(value), 

135 has_value=property_info.has_value(), 

136 suggested_group="energy", 

137 suggested_category=OperatingLineCategory.ENERGY, 

138 suggested_disposition="", 

139 selected_operating_line=selected_line_by_property.get(property_info.pk), 

140 ) 

141 ) 

142 return options 

143 

144 

145def create_operating_line_from_output_property( 

146 *, 

147 study: EconomicsStudy, 

148 property_info: PropertyInfo, 

149 category: str, 

150 economic_effect: str = OperatingLineEconomicEffect.COST, 

151 outlet_stream_disposition: str = "", 

152 rate_type: str | None = None, 

153) -> OperatingCostLine: 

154 """Create or update an operating line from a selected operating property.""" 

155 _validate_operating_property(study=study, property_info=property_info) 

156 category, outlet_stream_disposition = _normalize_category( 

157 category=category, 

158 outlet_stream_disposition=outlet_stream_disposition, 

159 ) 

160 economic_effect = _normalize_economic_effect( 

161 category=category, 

162 economic_effect=economic_effect, 

163 ) 

164 source_value = _decimal_property_value(property_info) 

165 settings_profile = get_settings_profile(study) 

166 currency = settings_profile.currency if settings_profile else "NZD" 

167 source_object = property_info.set.simulationObject 

168 source_option = next( 

169 option for option in output_stream_property_options(study) if option.property_info == property_info.pk 

170 ) 

171 inferred_rate_type = _inferred_rate_type_for_operating_property( 

172 category=category, 

173 source_kind=source_option.source_kind, 

174 ) 

175 rate_type = rate_type if rate_type is not None else inferred_rate_type 

176 rate_defaults = operating_line_rate_defaults_for_category( 

177 category=category, 

178 study=study, 

179 currency=currency, 

180 property_unit=property_info.unit or "", 

181 rate_type=rate_type, 

182 ) 

183 defaults = { 

184 "flowsheet": study.flowsheet, 

185 "label": operating_line_result_label( 

186 source_object=source_object, 

187 property_info=property_info, 

188 category=category, 

189 economic_effect=economic_effect, 

190 source_kind=source_option.source_kind, 

191 ), 

192 "line_type": category, 

193 "category": category, 

194 "economic_effect": economic_effect, 

195 "currency": currency, 

196 "basis_quantity": source_value, 

197 "basis_unit": property_info.unit or "", 

198 "basis_quantity_source": OperatingLineBasisQuantitySource.SOURCE_PROPERTY, 

199 "rate_amount": rate_defaults["rate_amount"], 

200 "rate_unit": rate_defaults["rate_unit"], 

201 "rate_type": rate_type or "", 

202 "rate_source_mode": OperatingLineRateSourceMode.PROJECT_DEFAULT if rate_type else OperatingLineRateSourceMode.CUSTOM, 

203 "calculation_method": "work_to_cost" if source_option.suggested_group == "energy" else "rate_times_quantity", 

204 "source_default_rate": rate_defaults["source_default_rate"], 

205 "outlet_stream_disposition": outlet_stream_disposition, 

206 "included": outlet_stream_disposition != OutletStreamDisposition.IGNORED, 

207 "manual": False, 

208 "source": STREAM_OPERATING_LINE_SOURCE, 

209 "warning_payload": { 

210 "source": STREAM_OPERATING_LINE_SOURCE, 

211 "source_kind": source_option.source_kind, 

212 "source_object_id": source_object.pk, 

213 "source_object_name": source_object.componentName, 

214 "source_object_type": source_object.objectType, 

215 "property_info_id": property_info.pk, 

216 "property_key": property_info.key, 

217 "property_name": property_info.displayName, 

218 }, 

219 } 

220 line, _ = OperatingCostLine.objects.update_or_create( 

221 study=study, 

222 source_property_info=property_info, 

223 source=STREAM_OPERATING_LINE_SOURCE, 

224 defaults=defaults, 

225 ) 

226 return line 

227 

228 

229def operating_line_result_label( 

230 *, 

231 source_object: SimulationObject, 

232 property_info: PropertyInfo, 

233 category: str, 

234 economic_effect: str = OperatingLineEconomicEffect.COST, 

235 source_kind: str = "", 

236) -> str: 

237 object_name = source_object.componentName or "Property source" 

238 if category == OperatingLineCategory.ENERGY: 

239 suffix = "credit" if economic_effect == OperatingLineEconomicEffect.REVENUE else "cost" 

240 if source_kind == UNIT_POWER_WORK_SOURCE_KIND: 

241 return f"{object_name} annual electricity {suffix}" 

242 if source_kind == UNIT_HEATING_DUTY_SOURCE_KIND: 

243 return f"{object_name} annual heating {suffix}" 

244 if source_kind == UNIT_COOLING_DUTY_SOURCE_KIND: 244 ↛ 246line 244 didn't jump to line 246 because the condition on line 244 was always true

245 return f"{object_name} annual cooling {suffix}" 

246 property_name = property_info.displayName or "energy" 

247 return f"{object_name} annual energy {suffix} from {property_name}" 

248 if category == OperatingLineCategory.FEEDSTOCK: 

249 return f"{object_name} annual feedstock cost" 

250 if category == OperatingLineCategory.OUTPUT_REVENUE: 250 ↛ 252line 250 didn't jump to line 252 because the condition on line 250 was always true

251 return f"{object_name} annual output revenue" 

252 if category == OperatingLineCategory.DISPOSAL: 

253 return f"{object_name} annual disposal cost" 

254 if category == OperatingLineCategory.MAINTENANCE: 

255 return f"{object_name} annual maintenance cost" 

256 property_name = property_info.displayName or "property" 

257 return f"{object_name} annual operating cost from {property_name}" 

258 

259 

260def display_label_for_operating_line(line: OperatingCostLine) -> str: 

261 if ( 

262 line.source != STREAM_OPERATING_LINE_SOURCE 

263 or not line.source_property_info_id 

264 or line.manual 

265 or line.basis_quantity_source != OperatingLineBasisQuantitySource.SOURCE_PROPERTY 

266 ): 

267 return line.label 

268 try: 

269 property_info = line.source_property_info 

270 source_object = property_info.set.simulationObject 

271 except (AttributeError, ObjectDoesNotExist): 

272 return line.label 

273 

274 legacy_label = f"{source_object.componentName or 'Property source'} {property_info.displayName}" 

275 if line.label != legacy_label: 275 ↛ 277line 275 didn't jump to line 277 because the condition on line 275 was always true

276 return line.label 

277 payload = line.warning_payload if isinstance(line.warning_payload, dict) else {} 

278 return operating_line_result_label( 

279 source_object=source_object, 

280 property_info=property_info, 

281 category=line.category or line.line_type, 

282 economic_effect=line.economic_effect, 

283 source_kind=str(payload.get("source_kind", "")), 

284 ) 

285 

286 

287def _inferred_rate_type_for_operating_property(*, category: str, source_kind: str) -> str: 

288 if category == OperatingLineCategory.ENERGY and source_kind == UNIT_POWER_WORK_SOURCE_KIND: 

289 return DefaultRateType.ELECTRICITY 

290 if category == OperatingLineCategory.MAINTENANCE: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true

291 return DefaultRateType.MAINTENANCE 

292 return "" 

293 

294 

295def _normalize_economic_effect(*, category: str, economic_effect: str) -> str: 

296 if category == OperatingLineCategory.OUTPUT_REVENUE: 

297 return OperatingLineEconomicEffect.REVENUE 

298 if economic_effect == OperatingLineEconomicEffect.REVENUE: 

299 return OperatingLineEconomicEffect.REVENUE 

300 return OperatingLineEconomicEffect.COST 

301 

302 

303def sync_operating_lines_for_property(property_info: PropertyInfo) -> int: 

304 """Refresh property-backed operating lines after their source property changes.""" 

305 from Economics.results.services.lifecycle.runs import mark_result_runs_stale_for_study 

306 

307 changed_study_ids = sync_operating_line_sources_for_property(property_info) 

308 stale_count = 0 

309 for study in EconomicsStudy.objects.filter(pk__in=changed_study_ids).order_by("pk"): 

310 stale_count += mark_result_runs_stale_for_study( 

311 study=study, 

312 reason="flowsheet_property_changed", 

313 ) 

314 return stale_count 

315 

316 

317def disconnect_operating_lines_for_deleted_property(property_info: PropertyInfo) -> int: 

318 """Clear source-backed quantities that referenced a property being deleted.""" 

319 from Economics.results.services.lifecycle.runs import mark_result_runs_stale_for_study 

320 

321 changed_study_ids: set[int] = set() 

322 lines = OperatingCostLine.objects.filter( 

323 flowsheet=property_info.flowsheet, 

324 source_property_info=property_info, 

325 basis_quantity_source=OperatingLineBasisQuantitySource.SOURCE_PROPERTY, 

326 ).select_related("study") 

327 for line in lines: 

328 line.source_property_info = None 

329 line.basis_quantity_source = OperatingLineBasisQuantitySource.MANUAL_OVERRIDE 

330 line.basis_quantity = None 

331 line.basis_unit = "" 

332 line.save( 

333 update_fields=[ 

334 "source_property_info", 

335 "basis_quantity_source", 

336 "basis_quantity", 

337 "basis_unit", 

338 "updated_at", 

339 ] 

340 ) 

341 changed_study_ids.add(line.study_id) 

342 stale_count = 0 

343 for study in EconomicsStudy.objects.filter(pk__in=changed_study_ids).order_by("pk"): 

344 stale_count += mark_result_runs_stale_for_study( 

345 study=study, 

346 reason="flowsheet_property_changed", 

347 ) 

348 return stale_count 

349 

350 

351def sync_operating_line_sources_for_property(property_info: PropertyInfo) -> set[int]: 

352 """Refresh persisted operating-line quantities backed by one property.""" 

353 source_value = _decimal_property_value(property_info) 

354 basis_unit = property_info.unit or "" 

355 changed_study_ids: set[int] = set() 

356 lines = OperatingCostLine.objects.filter( 

357 flowsheet=property_info.flowsheet, 

358 source=STREAM_OPERATING_LINE_SOURCE, 

359 source_property_info=property_info, 

360 basis_quantity_source=OperatingLineBasisQuantitySource.SOURCE_PROPERTY, 

361 ).select_related("study") 

362 for line in lines: 

363 changed_fields: list[str] = [] 

364 if line.basis_quantity != source_value: 

365 line.basis_quantity = source_value 

366 changed_fields.append("basis_quantity") 

367 if line.basis_unit != basis_unit: 

368 line.basis_unit = basis_unit 

369 changed_fields.append("basis_unit") 

370 if changed_fields: 

371 line.save(update_fields=[*changed_fields, "updated_at"]) 

372 changed_study_ids.add(line.study_id) 

373 return changed_study_ids 

374 

375 

376def sync_operating_line_sources_for_study(study: EconomicsStudy) -> set[int]: 

377 """Refresh all source-property operating quantities for a study before recalculation.""" 

378 changed_study_ids: set[int] = set() 

379 property_ids = ( 

380 OperatingCostLine.objects.filter( 

381 flowsheet=study.flowsheet, 

382 study=study, 

383 source=STREAM_OPERATING_LINE_SOURCE, 

384 source_property_info__isnull=False, 

385 basis_quantity_source=OperatingLineBasisQuantitySource.SOURCE_PROPERTY, 

386 ) 

387 .order_by() 

388 .values_list("source_property_info_id", flat=True) 

389 .distinct() 

390 ) 

391 for property_info in PropertyInfo.objects.filter( 

392 flowsheet=study.flowsheet, 

393 pk__in=property_ids, 

394 ): 

395 changed_study_ids.update(sync_operating_line_sources_for_property(property_info)) 

396 return changed_study_ids 

397 

398 

399def sync_project_default_operating_line_rates_for_study(study: EconomicsStudy, *, reason: str) -> int: 

400 """Refresh operating-line rates that are linked to the project default.""" 

401 from Economics.results.services.lifecycle.runs import mark_result_runs_stale_for_study 

402 

403 study = EconomicsStudy.objects.get(pk=study.pk) 

404 settings_profile = get_settings_profile(study) 

405 currency = settings_profile.currency if settings_profile else "NZD" 

406 changed = False 

407 lines = OperatingCostLine.objects.filter( 

408 flowsheet=study.flowsheet, 

409 study=study, 

410 rate_source_mode=OperatingLineRateSourceMode.PROJECT_DEFAULT, 

411 rate_type__gt="", 

412 ).select_related("source_default_rate") 

413 for line in lines: 

414 rate_defaults = operating_line_rate_defaults_for_category( 

415 category=line.category, 

416 study=study, 

417 currency=currency or line.currency or "NZD", 

418 property_unit=line.basis_unit, 

419 rate_type=line.rate_type, 

420 ) 

421 source_default_rate = rate_defaults["source_default_rate"] 

422 

423 changed_fields: list[str] = [] 

424 if line.source_default_rate_id != (source_default_rate.pk if source_default_rate is not None else None): 

425 line.source_default_rate = source_default_rate 

426 changed_fields.append("source_default_rate") 

427 for field_name in ("rate_amount", "rate_unit"): 

428 value = rate_defaults[field_name] 

429 if getattr(line, field_name) != value: 

430 setattr(line, field_name, value) 

431 changed_fields.append(field_name) 

432 if changed_fields: 432 ↛ 413line 432 didn't jump to line 413 because the condition on line 432 was always true

433 line.save(update_fields=[*changed_fields, "updated_at"]) 

434 changed = True 

435 

436 return mark_result_runs_stale_for_study(study=study, reason=reason) if changed else 0 

437 

438 

439def _suggested_streams(study: EconomicsStudy): 

440 for stream in _terminal_output_streams(study): 

441 yield ( 

442 stream, 

443 "terminal_output_stream", 

444 "output", 

445 OperatingLineCategory.OUTPUT_REVENUE, 

446 OutletStreamDisposition.SOLD, 

447 ) 

448 for stream in _starting_input_streams(study): 

449 yield ( 

450 stream, 

451 "starting_input_stream", 

452 "feedstock", 

453 OperatingLineCategory.FEEDSTOCK, 

454 "", 

455 ) 

456 

457 

458def _terminal_output_streams(study: EconomicsStudy): 

459 return ( 

460 SimulationObject.objects.filter( 

461 flowsheet=study.flowsheet, 

462 objectType="stream", 

463 connectedPorts__direction=ConType.Outlet, 

464 ) 

465 .exclude(connectedPorts__direction=ConType.Inlet) 

466 .distinct() 

467 .prefetch_related("properties__ContainedProperties__values") 

468 .order_by("componentName", "pk") 

469 ) 

470 

471 

472def _starting_input_streams(study: EconomicsStudy): 

473 return ( 

474 SimulationObject.objects.filter( 

475 flowsheet=study.flowsheet, 

476 objectType="stream", 

477 connectedPorts__direction=ConType.Inlet, 

478 ) 

479 .exclude(connectedPorts__direction=ConType.Outlet) 

480 .distinct() 

481 .prefetch_related("properties__ContainedProperties__values") 

482 .order_by("componentName", "pk") 

483 ) 

484 

485 

486def _unit_operations(study: EconomicsStudy): 

487 return ( 

488 SimulationObject.objects.filter(flowsheet=study.flowsheet) 

489 .exclude(objectType="stream") 

490 .prefetch_related("properties__ContainedProperties__values") 

491 .order_by("componentName", "pk") 

492 ) 

493 

494 

495def _eligible_stream_properties(stream: SimulationObject): 

496 property_set = getattr(stream, "properties", None) 

497 if property_set is None: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true

498 return [] 

499 return [ 

500 property_info 

501 for property_info in property_set.ContainedProperties.filter(type="numeric").order_by("displayName", "key", "pk") 

502 if _is_scalar_current_property(property_info) 

503 ] 

504 

505 

506def _is_scalar_current_property(property_info: PropertyInfo) -> bool: 

507 values = list(property_info.values.all()) 

508 return len(values) == 1 and values[0].value not in (None, "") 

509 

510 

511def _validate_operating_property(*, study: EconomicsStudy, property_info: PropertyInfo) -> None: 

512 available_ids = {option.property_info for option in output_stream_property_options(study)} 

513 if property_info.pk not in available_ids: 

514 raise ValueError("Operating-line source must be a suggested current scalar operating property.") 

515 

516 

517def _normalize_category(*, category: str, outlet_stream_disposition: str) -> tuple[str, str]: 

518 if category == OperatingLineCategory.OUTPUT_REVENUE: 

519 disposition = outlet_stream_disposition or OutletStreamDisposition.SOLD 

520 if disposition != OutletStreamDisposition.SOLD: 520 ↛ 521line 520 didn't jump to line 521 because the condition on line 520 was never true

521 raise ValueError("Sold outputs must use the sold disposition.") 

522 return OperatingLineCategory.OUTPUT_REVENUE, disposition 

523 if category == OperatingLineCategory.DISPOSAL: 523 ↛ 524line 523 didn't jump to line 524 because the condition on line 523 was never true

524 disposition = outlet_stream_disposition or OutletStreamDisposition.DISPOSED 

525 if disposition != OutletStreamDisposition.DISPOSED: 

526 raise ValueError("Disposed outputs must use the disposed disposition.") 

527 return OperatingLineCategory.DISPOSAL, disposition 

528 if category in (OperatingLineCategory.ENERGY, OperatingLineCategory.FEEDSTOCK): 528 ↛ 530line 528 didn't jump to line 530 because the condition on line 528 was always true

529 return category, "" 

530 raise ValueError("Output stream properties can be categorised as Energy, Feedstock, Sold output, or Disposed output.") 

531 

532 

533def _property_matches_group(property_info: PropertyInfo, group: str) -> bool: 

534 if group in ("output", "feedstock"): 534 ↛ 536line 534 didn't jump to line 536 because the condition on line 534 was always true

535 return _is_mass_flow_property(property_info) 

536 if group == "energy": 

537 return _is_power_or_work_property(property_info) 

538 return False 

539 

540 

541def unit_energy_source_kind(*, unit: SimulationObject, property_info: PropertyInfo) -> str | None: 

542 object_type = _normalized_property_key(unit.objectType) 

543 property_key = _normalized_property_key(property_info.key) 

544 if property_key in WORK_PROPERTY_KEYS or property_key.endswith(".work"): 

545 return UNIT_POWER_WORK_SOURCE_KIND 

546 if object_type in EXTERNAL_DUTY_OBJECT_TYPES: 

547 return None 

548 if property_key == "heatduty": 

549 if object_type == "cooler": 

550 return UNIT_COOLING_DUTY_SOURCE_KIND 

551 return UNIT_HEATING_DUTY_SOURCE_KIND 

552 if property_key in HEATING_DUTY_PROPERTY_KEYS: 

553 return UNIT_HEATING_DUTY_SOURCE_KIND 

554 if property_key in COOLING_DUTY_PROPERTY_KEYS: 

555 return UNIT_COOLING_DUTY_SOURCE_KIND 

556 return None 

557 

558 

559def _is_mass_flow_property(property_info: PropertyInfo) -> bool: 

560 text = _property_search_text(property_info) 

561 return "massflow" in text or ("mass" in text and "flow" in text) 

562 

563 

564def _is_power_or_work_property(property_info: PropertyInfo) -> bool: 

565 return unit_energy_source_kind( 

566 unit=property_info.set.simulationObject, 

567 property_info=property_info, 

568 ) is not None 

569 

570 

571def _property_search_text(property_info: PropertyInfo) -> str: 

572 return "".join( 

573 [ 

574 property_info.key or "", 

575 property_info.displayName or "", 

576 property_info.unitType or "", 

577 ] 

578 ).lower().replace("_", "").replace(" ", "").replace("-", "") 

579 

580 

581def _normalized_property_key(key: str) -> str: 

582 return (key or "").lower().replace("_", "").replace(" ", "").replace("-", "") 

583 

584 

585def _decimal_property_value(property_info: PropertyInfo) -> Decimal | None: 

586 value = property_info.get_value() 

587 if value in (None, ""): 

588 return None 

589 try: 

590 return Decimal(str(value)).quantize(OPERATING_LINE_DECIMAL_QUANTUM, rounding=ROUND_HALF_UP) 

591 except (InvalidOperation, TypeError, ValueError): 

592 return None