flextool 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- flextool-4.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
"""Build axis enums from the canonical contract + a SpineDBBackend.
|
|
2
|
+
|
|
3
|
+
This module is the EAV → axis-vocabulary bridge for the pl.Enum dtype
|
|
4
|
+
refactor. It reads the contract at schemas/flextool_axis_contract.json,
|
|
5
|
+
queries SpineDBBackend for entity-class vocabularies and parameter-map
|
|
6
|
+
keys, and emits a dict[str, pl.Enum] keyed by axis name.
|
|
7
|
+
|
|
8
|
+
The cast helper :func:`cast_against_contract` validates frames at the
|
|
9
|
+
Backend/SpineDbReader emit boundaries (see Phase 2). On vocabulary
|
|
10
|
+
miss, it raises :class:`FlexDataIntegrityError` with a beginner-friendly
|
|
11
|
+
4-paragraph message that names the offending token, where it appeared,
|
|
12
|
+
and a short list of next-step suggestions.
|
|
13
|
+
|
|
14
|
+
The contract is the authoritative source of axis names + sources;
|
|
15
|
+
this module never invents an axis. Synthetic-token allowlist entries
|
|
16
|
+
declared in the contract are folded into the relevant axis enum so
|
|
17
|
+
literal tokens introduced by cascade writers (e.g. ``eff`` / ``noEff``
|
|
18
|
+
for the branch axis, ``default`` for the block axis) round-trip
|
|
19
|
+
without integrity errors.
|
|
20
|
+
|
|
21
|
+
Public API
|
|
22
|
+
----------
|
|
23
|
+
|
|
24
|
+
* :class:`AxisSpec` — one parsed axis row.
|
|
25
|
+
* :class:`AxisContract` — full parsed contract.
|
|
26
|
+
* :func:`load_axis_contract` — parse the JSON file.
|
|
27
|
+
* :func:`build_axis_enums` — emit ``{axis_name: pl.Enum}``.
|
|
28
|
+
* :func:`cast_against_contract` — cast a frame's dim columns; raise
|
|
29
|
+
:class:`FlexDataIntegrityError` on vocabulary miss.
|
|
30
|
+
* :class:`FlexDataIntegrityError` — beginner-friendly cast failure.
|
|
31
|
+
|
|
32
|
+
This is Phase 1 of the pl.Enum dtype refactor. Phase 2 wires the
|
|
33
|
+
cast helper into the Backend's emit boundary; Phase 4 activates it
|
|
34
|
+
across the cascade. See ``specs/enum_dtype_refactor_plan.md`` for the
|
|
35
|
+
full plan.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any, Iterable, Sequence
|
|
43
|
+
|
|
44
|
+
import polars as pl
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_DTYPE_BY_NAME: dict[str, pl.DataType] = {
|
|
48
|
+
"Float64": pl.Float64,
|
|
49
|
+
"Utf8": pl.Utf8,
|
|
50
|
+
"Boolean": pl.Boolean,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Contract path resolution
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _default_contract_path() -> Path:
|
|
60
|
+
"""Return the bundled ``flextool/schemas/flextool_axis_contract.json``.
|
|
61
|
+
|
|
62
|
+
Resolved via :mod:`importlib.resources` so the lookup works in both
|
|
63
|
+
editable and wheel installs.
|
|
64
|
+
"""
|
|
65
|
+
from flextool._resources import package_data_path
|
|
66
|
+
return package_data_path("schemas/flextool_axis_contract.json")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Dataclasses
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class AxisSpec:
|
|
76
|
+
"""One axis entry parsed from the contract.
|
|
77
|
+
|
|
78
|
+
Mirrors the JSON shape directly — every field corresponds to a
|
|
79
|
+
``axes[i]`` key, with sensible empties when the key is absent.
|
|
80
|
+
|
|
81
|
+
Attributes
|
|
82
|
+
----------
|
|
83
|
+
name : str
|
|
84
|
+
The short axis name (e.g. ``"n"``, ``"p"``, ``"branch"``).
|
|
85
|
+
label : str
|
|
86
|
+
Human-readable label (e.g. ``"node"``, ``"process"``). Used in
|
|
87
|
+
:class:`FlexDataIntegrityError` to render beginner messages.
|
|
88
|
+
source_type : str
|
|
89
|
+
One of ``"entity_class"``, ``"entity_class_union"``,
|
|
90
|
+
``"parameter_keys"``, ``"parameter_value_list"``, ``"synthetic"``.
|
|
91
|
+
source : Any
|
|
92
|
+
Raw ``source`` field from the contract. Shape depends on
|
|
93
|
+
``source_type`` (see contract JSON schema).
|
|
94
|
+
filter : str | None
|
|
95
|
+
Free-text filter description. Informational only — vocabulary
|
|
96
|
+
construction does not apply scenario filters here (the Backend
|
|
97
|
+
does that upstream).
|
|
98
|
+
tokens : list[str] | None
|
|
99
|
+
Hardcoded tokens for ``source_type == "synthetic"`` axes.
|
|
100
|
+
column_synonyms : list[str]
|
|
101
|
+
Column names that should be cast against this axis enum. The
|
|
102
|
+
axis ``name`` itself is always implicit.
|
|
103
|
+
note : str | None
|
|
104
|
+
Free-text note for human readers.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
name: str
|
|
108
|
+
label: str
|
|
109
|
+
source_type: str
|
|
110
|
+
source: Any
|
|
111
|
+
filter: str | None
|
|
112
|
+
tokens: list[str] | None
|
|
113
|
+
column_synonyms: list[str]
|
|
114
|
+
note: str | None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class AxisContract:
|
|
119
|
+
"""The full parsed contract.
|
|
120
|
+
|
|
121
|
+
Attributes
|
|
122
|
+
----------
|
|
123
|
+
axes : tuple[AxisSpec, ...]
|
|
124
|
+
All axis rows in declaration order.
|
|
125
|
+
synthetic_token_allowlist : tuple[dict, ...]
|
|
126
|
+
Raw entries from ``synthetic_token_allowlist`` — each is a
|
|
127
|
+
``{"axis": str, "tokens": list[str], ...}`` dict. The
|
|
128
|
+
``build_axis_enums`` folds these into the right axis enum.
|
|
129
|
+
mixed_vocab_columns : dict
|
|
130
|
+
Raw ``mixed_vocab_columns`` block — ``{"confirmed": [...],
|
|
131
|
+
"pending_audit": [...]}``. Columns in ``confirmed`` are cast
|
|
132
|
+
against the ``e`` (entity union) axis enum.
|
|
133
|
+
non_dim_columns : dict
|
|
134
|
+
Raw ``non_dim_columns`` block — ``{"confirmed": [...]}``.
|
|
135
|
+
These columns are NOT cast (they hold data values, not
|
|
136
|
+
dimension tokens).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
axes: tuple[AxisSpec, ...]
|
|
140
|
+
synthetic_token_allowlist: tuple[dict, ...]
|
|
141
|
+
mixed_vocab_columns: dict
|
|
142
|
+
non_dim_columns: dict
|
|
143
|
+
parameter_value_dtypes: dict = field(default_factory=dict)
|
|
144
|
+
|
|
145
|
+
def value_dtype_for(
|
|
146
|
+
self, entity_class: str, parameter: str,
|
|
147
|
+
) -> pl.DataType:
|
|
148
|
+
"""Return the leaf polars dtype declared for a parameter's value
|
|
149
|
+
column. Falls back to the contract's ``default`` (``Float64``
|
|
150
|
+
when the section is absent).
|
|
151
|
+
|
|
152
|
+
The dtype names in the contract (``"Float64"``, ``"Utf8"``,
|
|
153
|
+
``"Boolean"``) are mapped here to polars types. An unknown
|
|
154
|
+
name in the contract raises ``ValueError`` — caller bug,
|
|
155
|
+
not user data.
|
|
156
|
+
"""
|
|
157
|
+
section = self.parameter_value_dtypes or {}
|
|
158
|
+
overrides = section.get("overrides", {})
|
|
159
|
+
name = overrides.get(f"{entity_class}.{parameter}")
|
|
160
|
+
if name is None:
|
|
161
|
+
name = section.get("default", "Float64")
|
|
162
|
+
return _DTYPE_BY_NAME[name]
|
|
163
|
+
|
|
164
|
+
def by_name(self, name: str) -> AxisSpec:
|
|
165
|
+
"""Look up an axis by its short name.
|
|
166
|
+
|
|
167
|
+
Raises
|
|
168
|
+
------
|
|
169
|
+
KeyError
|
|
170
|
+
If *name* is not declared in the contract.
|
|
171
|
+
"""
|
|
172
|
+
for axis in self.axes:
|
|
173
|
+
if axis.name == name:
|
|
174
|
+
return axis
|
|
175
|
+
raise KeyError(
|
|
176
|
+
f"axis {name!r} is not declared in the contract"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def column_to_axis(self, column_name: str) -> AxisSpec | None:
|
|
180
|
+
"""Map a column name to the axis whose enum should cast it.
|
|
181
|
+
|
|
182
|
+
Resolution order:
|
|
183
|
+
1. If *column_name* is in ``non_dim_columns.confirmed``, return
|
|
184
|
+
``None`` (the column is NOT a dim — leave its dtype alone).
|
|
185
|
+
2. If *column_name* is in ``mixed_vocab_columns.confirmed``,
|
|
186
|
+
return the ``e`` (entity union) axis.
|
|
187
|
+
3. If a declared axis has *column_name* in its
|
|
188
|
+
``column_synonyms``, return that axis.
|
|
189
|
+
4. If *column_name* matches an axis ``name`` directly, return
|
|
190
|
+
that axis.
|
|
191
|
+
5. Otherwise return ``None``.
|
|
192
|
+
"""
|
|
193
|
+
if column_name in self.non_dim_columns.get("confirmed", []):
|
|
194
|
+
return None
|
|
195
|
+
if column_name in self.mixed_vocab_columns.get("confirmed", []):
|
|
196
|
+
try:
|
|
197
|
+
return self.by_name("e")
|
|
198
|
+
except KeyError:
|
|
199
|
+
return None
|
|
200
|
+
for axis in self.axes:
|
|
201
|
+
if column_name in axis.column_synonyms:
|
|
202
|
+
return axis
|
|
203
|
+
for axis in self.axes:
|
|
204
|
+
if axis.name == column_name:
|
|
205
|
+
return axis
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
# Contract loader
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load_axis_contract(path: Path | None = None) -> AxisContract:
|
|
215
|
+
"""Parse ``schemas/flextool_axis_contract.json`` into an :class:`AxisContract`.
|
|
216
|
+
|
|
217
|
+
Parameters
|
|
218
|
+
----------
|
|
219
|
+
path : Path | None
|
|
220
|
+
Override the default contract path (used for testing). When
|
|
221
|
+
``None``, defaults to ``<repo>/schemas/flextool_axis_contract.json``.
|
|
222
|
+
|
|
223
|
+
Raises
|
|
224
|
+
------
|
|
225
|
+
FileNotFoundError
|
|
226
|
+
If *path* does not exist.
|
|
227
|
+
json.JSONDecodeError
|
|
228
|
+
If the file is not valid JSON.
|
|
229
|
+
"""
|
|
230
|
+
if path is None:
|
|
231
|
+
path = _default_contract_path()
|
|
232
|
+
path = Path(path)
|
|
233
|
+
with path.open() as fh:
|
|
234
|
+
raw = json.load(fh)
|
|
235
|
+
axes_list = []
|
|
236
|
+
for row in raw.get("axes", []):
|
|
237
|
+
axes_list.append(AxisSpec(
|
|
238
|
+
name=row["name"],
|
|
239
|
+
label=row.get("label", row["name"]),
|
|
240
|
+
source_type=row["source_type"],
|
|
241
|
+
source=row.get("source"),
|
|
242
|
+
filter=row.get("filter"),
|
|
243
|
+
tokens=row.get("tokens"),
|
|
244
|
+
column_synonyms=list(row.get("column_synonyms", [])),
|
|
245
|
+
note=row.get("note"),
|
|
246
|
+
))
|
|
247
|
+
return AxisContract(
|
|
248
|
+
axes=tuple(axes_list),
|
|
249
|
+
synthetic_token_allowlist=tuple(
|
|
250
|
+
raw.get("synthetic_token_allowlist", [])
|
|
251
|
+
),
|
|
252
|
+
mixed_vocab_columns=dict(raw.get("mixed_vocab_columns", {})),
|
|
253
|
+
non_dim_columns=dict(raw.get("non_dim_columns", {})),
|
|
254
|
+
parameter_value_dtypes=dict(raw.get("parameter_value_dtypes", {})),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
# Vocabulary builders
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _dedup_keep_order(items: Iterable[str]) -> list[str]:
|
|
264
|
+
"""Return a list of *items* with duplicates removed; first
|
|
265
|
+
occurrence wins.
|
|
266
|
+
|
|
267
|
+
Used everywhere we union vocabularies — keeps the contract's
|
|
268
|
+
declaration order observable in the resulting enum.
|
|
269
|
+
"""
|
|
270
|
+
seen: set[str] = set()
|
|
271
|
+
out: list[str] = []
|
|
272
|
+
for x in items:
|
|
273
|
+
if x in seen:
|
|
274
|
+
continue
|
|
275
|
+
seen.add(x)
|
|
276
|
+
out.append(x)
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _entity_names(backend: Any, entity_class: str) -> list[str]:
|
|
281
|
+
"""Return the entity names for *entity_class*.
|
|
282
|
+
|
|
283
|
+
``backend.find_entities`` returns the raw spinedb_api rows; each
|
|
284
|
+
row's ``"name"`` field is the entity name we want for the enum
|
|
285
|
+
vocabulary.
|
|
286
|
+
"""
|
|
287
|
+
rows = backend.find_entities(entity_class_name=entity_class)
|
|
288
|
+
return [row["name"] for row in rows]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _allowlist_tokens_for(contract: AxisContract, axis_name: str) -> list[str]:
|
|
292
|
+
"""Return the synthetic-allowlist tokens for *axis_name*, in
|
|
293
|
+
declaration order, deduplicated.
|
|
294
|
+
"""
|
|
295
|
+
out: list[str] = []
|
|
296
|
+
for entry in contract.synthetic_token_allowlist:
|
|
297
|
+
if entry.get("axis") == axis_name:
|
|
298
|
+
out.extend(entry.get("tokens", []))
|
|
299
|
+
return _dedup_keep_order(out)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _map_keys_at_depth(parsed_value: Any, depths: list[int]) -> int:
|
|
303
|
+
"""Return the maximum nesting depth of a Spine Map ``parsed_value``.
|
|
304
|
+
|
|
305
|
+
Walks ``parsed_value.indexes`` / ``parsed_value.values`` recursively,
|
|
306
|
+
appending each level's depth to *depths*. Used to discover the
|
|
307
|
+
``i`` axis vocabulary length (max depth of
|
|
308
|
+
``commodity.price_ladder_*`` parameter maps).
|
|
309
|
+
"""
|
|
310
|
+
# If the value is itself a Map, walk one level deeper.
|
|
311
|
+
if hasattr(parsed_value, "values") and hasattr(parsed_value, "indexes"):
|
|
312
|
+
# Each level contributes 1; recurse into the nested maps to find
|
|
313
|
+
# the deepest leaf.
|
|
314
|
+
local_depth = 1
|
|
315
|
+
children_max = 0
|
|
316
|
+
for v in parsed_value.values:
|
|
317
|
+
child_depth = _map_keys_at_depth(v, depths)
|
|
318
|
+
if child_depth > children_max:
|
|
319
|
+
children_max = child_depth
|
|
320
|
+
return local_depth + children_max
|
|
321
|
+
return 0
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _collect_parameter_map_keys(
|
|
325
|
+
backend: Any,
|
|
326
|
+
entity_class: str,
|
|
327
|
+
parameter: str,
|
|
328
|
+
) -> list[str]:
|
|
329
|
+
"""Collect the top-level keys of all maps in
|
|
330
|
+
``(entity_class, parameter)``.
|
|
331
|
+
|
|
332
|
+
For each parameter-value row whose ``type == "map"``, gather the
|
|
333
|
+
``parsed_value.indexes`` (top-level keys); union across all rows
|
|
334
|
+
preserving first-occurrence order.
|
|
335
|
+
"""
|
|
336
|
+
rows = backend.find_parameter_values(
|
|
337
|
+
entity_class_name=entity_class,
|
|
338
|
+
parameter_definition_name=parameter,
|
|
339
|
+
)
|
|
340
|
+
out: list[str] = []
|
|
341
|
+
for param in rows:
|
|
342
|
+
if param.get("type") != "map":
|
|
343
|
+
continue
|
|
344
|
+
pv = param.get("parsed_value")
|
|
345
|
+
if pv is None or not hasattr(pv, "indexes"):
|
|
346
|
+
continue
|
|
347
|
+
for idx in pv.indexes:
|
|
348
|
+
out.append(str(idx))
|
|
349
|
+
return _dedup_keep_order(out)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _collect_parameter_array_values(
|
|
353
|
+
backend: Any,
|
|
354
|
+
entity_class: str,
|
|
355
|
+
parameter: str,
|
|
356
|
+
) -> list[str]:
|
|
357
|
+
"""Collect the values of all array parameters in
|
|
358
|
+
``(entity_class, parameter)``.
|
|
359
|
+
|
|
360
|
+
Used for sources like ``solve.realized_periods`` whose value is a
|
|
361
|
+
Spine Array of period labels.
|
|
362
|
+
"""
|
|
363
|
+
rows = backend.find_parameter_values(
|
|
364
|
+
entity_class_name=entity_class,
|
|
365
|
+
parameter_definition_name=parameter,
|
|
366
|
+
)
|
|
367
|
+
out: list[str] = []
|
|
368
|
+
for param in rows:
|
|
369
|
+
if param.get("type") != "array":
|
|
370
|
+
continue
|
|
371
|
+
pv = param.get("parsed_value")
|
|
372
|
+
if pv is None or not hasattr(pv, "values"):
|
|
373
|
+
continue
|
|
374
|
+
for v in pv.values:
|
|
375
|
+
out.append(str(v))
|
|
376
|
+
return _dedup_keep_order(out)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _collect_parameter_scalar_values(
|
|
380
|
+
backend: Any,
|
|
381
|
+
entity_class: str,
|
|
382
|
+
parameter: str,
|
|
383
|
+
) -> list[str]:
|
|
384
|
+
"""Collect the scalar string values of ``(entity_class, parameter)``.
|
|
385
|
+
|
|
386
|
+
Used for the ``branch`` axis (``solve.stochastic_branches`` is a
|
|
387
|
+
parameter whose scalar string value names the active branch).
|
|
388
|
+
"""
|
|
389
|
+
rows = backend.find_parameter_values(
|
|
390
|
+
entity_class_name=entity_class,
|
|
391
|
+
parameter_definition_name=parameter,
|
|
392
|
+
)
|
|
393
|
+
out: list[str] = []
|
|
394
|
+
for param in rows:
|
|
395
|
+
ptype = param.get("type")
|
|
396
|
+
if ptype not in ("str", "float", "bool"):
|
|
397
|
+
continue
|
|
398
|
+
pv = param.get("parsed_value")
|
|
399
|
+
if pv is None:
|
|
400
|
+
continue
|
|
401
|
+
out.append(str(pv))
|
|
402
|
+
return _dedup_keep_order(out)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _discover_tier_vocabulary(
|
|
406
|
+
backend: Any,
|
|
407
|
+
entity_class: str,
|
|
408
|
+
parameter_prefix: str,
|
|
409
|
+
) -> list[str]:
|
|
410
|
+
"""Discover the ``i`` (tier_index) vocabulary by collecting the
|
|
411
|
+
distinct tier-level keys across all parameter definitions in
|
|
412
|
+
*entity_class* whose name starts with *parameter_prefix*.
|
|
413
|
+
|
|
414
|
+
The ``commodity.price_ladder_*`` maps come in two shapes:
|
|
415
|
+
|
|
416
|
+
* ``price_ladder_cumulative``: depth-2 — ``tier → {price, quantity}``.
|
|
417
|
+
Tier keys live at the top level.
|
|
418
|
+
* ``price_ladder_annual``: depth-3 — ``period → tier → {price, quantity}``.
|
|
419
|
+
Tier keys live one nesting below the top.
|
|
420
|
+
|
|
421
|
+
The invariant across both shapes is that tier keys sit at the
|
|
422
|
+
level *immediately above the {price, quantity} leaves* — i.e. the
|
|
423
|
+
level whose immediate child Map's sub-indexes are non-Map scalars.
|
|
424
|
+
This helper walks the map dynamically and grabs index labels at
|
|
425
|
+
that depth, supporting both shapes uniformly.
|
|
426
|
+
|
|
427
|
+
Returns
|
|
428
|
+
-------
|
|
429
|
+
list[str]
|
|
430
|
+
Distinct tier keys in numeric-ascending order (or
|
|
431
|
+
first-occurrence order if any key is non-numeric). Empty
|
|
432
|
+
when no map parameter rows exist.
|
|
433
|
+
"""
|
|
434
|
+
db = backend._db
|
|
435
|
+
if db is None: # pragma: no cover — backend lifecycle
|
|
436
|
+
return []
|
|
437
|
+
definitions = db.find_parameter_definitions()
|
|
438
|
+
candidates = [
|
|
439
|
+
d["name"] for d in definitions
|
|
440
|
+
if d.get("entity_class_name") == entity_class
|
|
441
|
+
and d.get("name", "").startswith(parameter_prefix)
|
|
442
|
+
]
|
|
443
|
+
if not candidates:
|
|
444
|
+
return []
|
|
445
|
+
|
|
446
|
+
def _collect_tier_level(node, out: list[str]) -> None:
|
|
447
|
+
"""Recursively walk *node* and append indexes at the tier
|
|
448
|
+
level (the level whose immediate children are leaf-only maps).
|
|
449
|
+
"""
|
|
450
|
+
if not hasattr(node, "indexes") or not hasattr(node, "values"):
|
|
451
|
+
return
|
|
452
|
+
# Inspect the first child to decide whether THIS node is the
|
|
453
|
+
# tier level. A tier-level node's children are maps whose
|
|
454
|
+
# own children are non-Map scalars (the {price, quantity}
|
|
455
|
+
# leaves). If the first child is itself a map-of-maps,
|
|
456
|
+
# recurse into each child instead.
|
|
457
|
+
first_child = next(iter(node.values), None)
|
|
458
|
+
if first_child is None:
|
|
459
|
+
return
|
|
460
|
+
if hasattr(first_child, "values"):
|
|
461
|
+
grandchild = next(iter(first_child.values), None)
|
|
462
|
+
if grandchild is not None and hasattr(grandchild, "indexes"):
|
|
463
|
+
# Grandchild is itself a Map → first_child is NOT a
|
|
464
|
+
# tier-leaf map → recurse one level deeper.
|
|
465
|
+
for child in node.values:
|
|
466
|
+
_collect_tier_level(child, out)
|
|
467
|
+
return
|
|
468
|
+
# Either first_child is a leaf scalar (depth-1 map: caller is
|
|
469
|
+
# already at tier level — but that shouldn't happen for ladder
|
|
470
|
+
# params; treat defensively) or first_child is a map whose
|
|
471
|
+
# children are scalars (the {price, quantity} leaf map). In
|
|
472
|
+
# the latter case, THIS node's indexes are the tier labels.
|
|
473
|
+
for idx in node.indexes:
|
|
474
|
+
out.append(str(idx))
|
|
475
|
+
|
|
476
|
+
tier_keys: list[str] = []
|
|
477
|
+
for pname in candidates:
|
|
478
|
+
rows = backend.find_parameter_values(
|
|
479
|
+
entity_class_name=entity_class,
|
|
480
|
+
parameter_definition_name=pname,
|
|
481
|
+
)
|
|
482
|
+
for param in rows:
|
|
483
|
+
if param.get("type") != "map":
|
|
484
|
+
continue
|
|
485
|
+
pv = param.get("parsed_value")
|
|
486
|
+
if pv is None or not hasattr(pv, "values"):
|
|
487
|
+
continue
|
|
488
|
+
_collect_tier_level(pv, tier_keys)
|
|
489
|
+
unique = _dedup_keep_order(tier_keys)
|
|
490
|
+
try:
|
|
491
|
+
return sorted(unique, key=int)
|
|
492
|
+
except ValueError:
|
|
493
|
+
return unique
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _collect_stochastic_branch_period_tokens(backend: Any) -> list[str]:
|
|
497
|
+
"""Enumerate forecast-branch period tokens from
|
|
498
|
+
``solve.stochastic_branches``.
|
|
499
|
+
|
|
500
|
+
The cascade constructs stochastic forecast-branch period labels by
|
|
501
|
+
splicing each realised period with each declared branch name:
|
|
502
|
+
``"{period}_{branch}"`` (see ``_stochastic.py:492`` /
|
|
503
|
+
``_stochastic.py:522`` where ``solve_branch = period + "_" + branch``).
|
|
504
|
+
These tokens flow through the cascade as values in the period (``d``)
|
|
505
|
+
column of frames like ``period__branch.csv``,
|
|
506
|
+
``period_in_use_set.csv``, and downstream constraint / parameter
|
|
507
|
+
frames (``nodeBalance_eq``, ``maxFlow``, ``maxState``,
|
|
508
|
+
``process_constraint_equal``).
|
|
509
|
+
|
|
510
|
+
Without these tokens in the period-axis Enum vocabulary, the
|
|
511
|
+
cascade's non-strict cast nulls them silently, dropping 60-75 % of
|
|
512
|
+
the rows in the listed constraint frames and yielding a degraded LP
|
|
513
|
+
objective. Mirrors the ``branch.tokens`` allowlist pattern in
|
|
514
|
+
spirit, but the tokens are derived per-fixture (one per period ×
|
|
515
|
+
branch) rather than hard-coded.
|
|
516
|
+
|
|
517
|
+
The Spine Map for ``solve.stochastic_branches`` is shaped
|
|
518
|
+
``period → branch → timestep → realized_yn``; level-1 keys are
|
|
519
|
+
periods, level-2 keys are branch names. We walk both levels and
|
|
520
|
+
emit the canonical ``"{period}_{branch}"`` token for every
|
|
521
|
+
encountered (period, branch) pair across all solve entities.
|
|
522
|
+
|
|
523
|
+
Returns
|
|
524
|
+
-------
|
|
525
|
+
list[str]
|
|
526
|
+
Forecast-branch period tokens in first-occurrence order; empty
|
|
527
|
+
for deterministic fixtures (where ``solve.stochastic_branches``
|
|
528
|
+
is empty / absent).
|
|
529
|
+
"""
|
|
530
|
+
rows = backend.find_parameter_values(
|
|
531
|
+
entity_class_name="solve",
|
|
532
|
+
parameter_definition_name="stochastic_branches",
|
|
533
|
+
)
|
|
534
|
+
out: list[str] = []
|
|
535
|
+
for param in rows:
|
|
536
|
+
if param.get("type") != "map":
|
|
537
|
+
continue
|
|
538
|
+
pv = param.get("parsed_value")
|
|
539
|
+
if pv is None or not hasattr(pv, "indexes"):
|
|
540
|
+
continue
|
|
541
|
+
# Top level: period keys. Each value is itself a Map keyed by
|
|
542
|
+
# branch name.
|
|
543
|
+
for period, branch_map in zip(pv.indexes, pv.values):
|
|
544
|
+
period_str = str(period)
|
|
545
|
+
if not hasattr(branch_map, "indexes"):
|
|
546
|
+
continue
|
|
547
|
+
for branch in branch_map.indexes:
|
|
548
|
+
out.append(f"{period_str}_{branch}")
|
|
549
|
+
return _dedup_keep_order(out)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _build_period_vocab(backend: Any, spec_source: dict) -> list[str]:
|
|
553
|
+
"""Build the ``d`` (period) axis vocabulary.
|
|
554
|
+
|
|
555
|
+
Per the contract, ``d`` is sourced from
|
|
556
|
+
* keys of ``solve.years_represented`` (Map),
|
|
557
|
+
* values of ``solve.realized_periods`` (Array),
|
|
558
|
+
* keys of ``solve.invest_periods`` (Map),
|
|
559
|
+
* keys of ``solve.realized_invest_periods`` (Map).
|
|
560
|
+
All four are unioned (deterministic order).
|
|
561
|
+
|
|
562
|
+
For stochastic fixtures the period vocabulary is additionally
|
|
563
|
+
widened with the forecast-branch period tokens derived from
|
|
564
|
+
``solve.stochastic_branches`` (see
|
|
565
|
+
:func:`_collect_stochastic_branch_period_tokens`). The cascade
|
|
566
|
+
constructs ``"{period}_{branch}"`` labels and casts them against
|
|
567
|
+
this Enum at multiple sites (period__branch overlay,
|
|
568
|
+
``period_in_use_set``, ``nodeBalance_eq``, etc.); without the
|
|
569
|
+
widening the non-strict cast silently nulls 60-75 % of rows in
|
|
570
|
+
stochastic LPs.
|
|
571
|
+
"""
|
|
572
|
+
entity_class = spec_source.get("entity_class", "solve")
|
|
573
|
+
params = spec_source.get("parameters", [])
|
|
574
|
+
out: list[str] = []
|
|
575
|
+
for param_name in params:
|
|
576
|
+
# Maps first (keys), arrays second (values).
|
|
577
|
+
keys = _collect_parameter_map_keys(backend, entity_class, param_name)
|
|
578
|
+
out.extend(keys)
|
|
579
|
+
vals = _collect_parameter_array_values(
|
|
580
|
+
backend, entity_class, param_name
|
|
581
|
+
)
|
|
582
|
+
out.extend(vals)
|
|
583
|
+
# Stochastic forecast-branch period tokens (empty for deterministic
|
|
584
|
+
# fixtures — _collect_parameter_values returns no map rows).
|
|
585
|
+
out.extend(_collect_stochastic_branch_period_tokens(backend))
|
|
586
|
+
return _dedup_keep_order(out)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _build_block_vocab(backend: Any, spec_source: dict) -> list[str]:
|
|
590
|
+
"""Build the ``block`` axis vocabulary.
|
|
591
|
+
|
|
592
|
+
Per the contract (``axes/block.source``), the block axis source is
|
|
593
|
+
``group.new_stepduration`` with ``key_kind: "values_plus_default"``.
|
|
594
|
+
Block names are the names of ``group`` entities that carry a
|
|
595
|
+
non-null ``new_stepduration`` parameter value — regardless of
|
|
596
|
+
whether the parameter is authored as a per-period Map or as a
|
|
597
|
+
scalar-per-entity. The synthetic ``default`` token is appended
|
|
598
|
+
later by the ``tokens_default_extension`` allowlist (folded by the
|
|
599
|
+
caller via :func:`_allowlist_tokens_for` for the ``block`` axis).
|
|
600
|
+
|
|
601
|
+
When ``key_kind == "values_plus_default"`` we enumerate group
|
|
602
|
+
entities whose ``new_stepduration`` value is set to anything (Map
|
|
603
|
+
or scalar). For other ``key_kind`` values (or none specified) we
|
|
604
|
+
fall back to the original Map-key extraction for backwards
|
|
605
|
+
compatibility.
|
|
606
|
+
"""
|
|
607
|
+
entity_class = spec_source.get("entity_class", "group")
|
|
608
|
+
parameter = spec_source.get("parameter", "new_stepduration")
|
|
609
|
+
key_kind = spec_source.get("key_kind")
|
|
610
|
+
if key_kind == "values_plus_default":
|
|
611
|
+
rows = backend.find_parameter_values(
|
|
612
|
+
entity_class_name=entity_class,
|
|
613
|
+
parameter_definition_name=parameter,
|
|
614
|
+
)
|
|
615
|
+
out: list[str] = []
|
|
616
|
+
for param in rows:
|
|
617
|
+
if param.get("parsed_value") is None:
|
|
618
|
+
continue
|
|
619
|
+
ent = param.get("entity_byname")
|
|
620
|
+
if not ent:
|
|
621
|
+
continue
|
|
622
|
+
# ``entity_byname`` is a tuple — for the single-dim 'group'
|
|
623
|
+
# class the entity name lives at index 0.
|
|
624
|
+
out.append(str(ent[0]))
|
|
625
|
+
return _dedup_keep_order(out)
|
|
626
|
+
return _collect_parameter_map_keys(backend, entity_class, parameter)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def build_axis_enums(
|
|
630
|
+
backend: Any,
|
|
631
|
+
contract: AxisContract,
|
|
632
|
+
) -> dict[str, pl.Enum]:
|
|
633
|
+
"""Build the ``{axis_name: pl.Enum}`` mapping from *contract* +
|
|
634
|
+
*backend*.
|
|
635
|
+
|
|
636
|
+
For each axis in the contract:
|
|
637
|
+
* ``entity_class`` — vocabulary = ``find_entities(class)`` names.
|
|
638
|
+
* ``entity_class_union`` — union across the listed classes
|
|
639
|
+
(first-occurrence order preserved).
|
|
640
|
+
* ``parameter_keys`` — vocabulary depends on the specific axis:
|
|
641
|
+
- ``t``: keys of ``timeline.timestep_duration`` map.
|
|
642
|
+
- ``d``: union of period keys/values across the four solve
|
|
643
|
+
parameters (see :func:`_build_period_vocab`).
|
|
644
|
+
- ``i``: integers ``"1"..."N"`` where N is the max map depth
|
|
645
|
+
across ``commodity.price_ladder_*`` parameters.
|
|
646
|
+
- ``block``: keys of ``group.new_stepduration`` maps.
|
|
647
|
+
- ``d_anchor``: empty here — populated per-solve at Phase 3
|
|
648
|
+
handoff.
|
|
649
|
+
* ``parameter_value_list`` — scalar values of the named parameter
|
|
650
|
+
(e.g. ``solve.stochastic_branches`` for ``branch``).
|
|
651
|
+
* ``synthetic`` — use ``spec.tokens`` verbatim.
|
|
652
|
+
|
|
653
|
+
Synthetic-allowlist tokens (e.g. ``eff``/``noEff`` for ``branch``,
|
|
654
|
+
``default`` for ``block``) are appended to every axis's vocabulary.
|
|
655
|
+
|
|
656
|
+
Returns
|
|
657
|
+
-------
|
|
658
|
+
dict[str, pl.Enum]
|
|
659
|
+
Keyed by axis ``name``. Every contract axis appears in the
|
|
660
|
+
result; axes with empty vocabulary get an empty enum (still a
|
|
661
|
+
valid Enum dtype, just with zero categories).
|
|
662
|
+
"""
|
|
663
|
+
out: dict[str, pl.Enum] = {}
|
|
664
|
+
for axis in contract.axes:
|
|
665
|
+
vocab: list[str] = []
|
|
666
|
+
st = axis.source_type
|
|
667
|
+
src = axis.source
|
|
668
|
+
if st == "entity_class":
|
|
669
|
+
vocab = _entity_names(backend, src)
|
|
670
|
+
elif st == "entity_class_union":
|
|
671
|
+
members: list[str] = []
|
|
672
|
+
for cls in src or []:
|
|
673
|
+
members.extend(_entity_names(backend, cls))
|
|
674
|
+
vocab = _dedup_keep_order(members)
|
|
675
|
+
elif st == "parameter_keys":
|
|
676
|
+
spec_src = src if isinstance(src, dict) else {}
|
|
677
|
+
if axis.name == "t":
|
|
678
|
+
vocab = _collect_parameter_map_keys(
|
|
679
|
+
backend,
|
|
680
|
+
spec_src.get("entity_class", "timeline"),
|
|
681
|
+
spec_src.get("parameter", "timestep_duration"),
|
|
682
|
+
)
|
|
683
|
+
elif axis.name == "d":
|
|
684
|
+
vocab = _build_period_vocab(backend, spec_src)
|
|
685
|
+
elif axis.name == "i":
|
|
686
|
+
# Per the contract i_axis_depth review note: discover
|
|
687
|
+
# the tier vocabulary by walking the level-2 keys of
|
|
688
|
+
# every ``commodity.price_ladder_*`` map. This emits
|
|
689
|
+
# the actual tier labels (typically integer strings
|
|
690
|
+
# ``"1"``..``"N"``) rather than a hard-coded ceiling.
|
|
691
|
+
vocab = _discover_tier_vocabulary(
|
|
692
|
+
backend,
|
|
693
|
+
spec_src.get("entity_class", "commodity"),
|
|
694
|
+
spec_src.get("parameter_prefix", "price_ladder_"),
|
|
695
|
+
)
|
|
696
|
+
elif axis.name == "block":
|
|
697
|
+
vocab = _build_block_vocab(backend, spec_src)
|
|
698
|
+
elif axis.name == "d_anchor":
|
|
699
|
+
# Per-solve carrier; built at Phase 3 handoff time.
|
|
700
|
+
vocab = []
|
|
701
|
+
else:
|
|
702
|
+
# Generic fallback: union over listed parameters.
|
|
703
|
+
ec = spec_src.get("entity_class")
|
|
704
|
+
params = spec_src.get("parameters") or []
|
|
705
|
+
if "parameter" in spec_src:
|
|
706
|
+
params = [spec_src["parameter"]]
|
|
707
|
+
gathered: list[str] = []
|
|
708
|
+
for pname in params:
|
|
709
|
+
if ec is None:
|
|
710
|
+
continue
|
|
711
|
+
gathered.extend(
|
|
712
|
+
_collect_parameter_map_keys(backend, ec, pname)
|
|
713
|
+
)
|
|
714
|
+
vocab = _dedup_keep_order(gathered)
|
|
715
|
+
elif st == "parameter_value_list":
|
|
716
|
+
spec_src = src if isinstance(src, dict) else {}
|
|
717
|
+
ec = spec_src.get("entity_class")
|
|
718
|
+
pn = spec_src.get("parameter")
|
|
719
|
+
if ec is not None and pn is not None:
|
|
720
|
+
vocab = _collect_parameter_scalar_values(backend, ec, pn)
|
|
721
|
+
else:
|
|
722
|
+
vocab = []
|
|
723
|
+
# Branch axis: the cascade column ``b`` (renamed from
|
|
724
|
+
# ``branch`` in _derived_branch.py:150) carries the
|
|
725
|
+
# ``"{period}_{branch}"`` sibling-period tokens emitted by
|
|
726
|
+
# _stochastic.py:492 (``solve_branch = period + "_" + branch``)
|
|
727
|
+
# plus the base period token itself. These flow through
|
|
728
|
+
# ``period__branch.csv`` / ``solve_branch_weight.csv`` and
|
|
729
|
+
# downstream joins; without them in the branch enum
|
|
730
|
+
# vocabulary, non-strict casts silently null the ``b``
|
|
731
|
+
# column on stochastic fixtures and break the LP joins.
|
|
732
|
+
if axis.name == "branch":
|
|
733
|
+
# Only widen for stochastic fixtures: if no
|
|
734
|
+
# ``solve.stochastic_branches`` entries exist, the
|
|
735
|
+
# period__branch overlay is not constructed and no
|
|
736
|
+
# base-period tokens flow into the ``b`` column.
|
|
737
|
+
# Polluting the branch vocab with base periods on
|
|
738
|
+
# deterministic fixtures changes the branch Enum and
|
|
739
|
+
# cascades through downstream joins that key on ``b``.
|
|
740
|
+
stoch_tokens = (
|
|
741
|
+
_collect_stochastic_branch_period_tokens(backend))
|
|
742
|
+
if stoch_tokens:
|
|
743
|
+
vocab = list(vocab)
|
|
744
|
+
vocab.extend(stoch_tokens)
|
|
745
|
+
# Base period tokens — ``period__branch.csv``
|
|
746
|
+
# includes ``(period, period)`` rows where the
|
|
747
|
+
# ``b`` value is the bare period name (see
|
|
748
|
+
# _stochastic.py:463).
|
|
749
|
+
base_periods: list[str] = []
|
|
750
|
+
for solve_param in ["years_represented",
|
|
751
|
+
"invest_periods",
|
|
752
|
+
"realized_invest_periods"]:
|
|
753
|
+
base_periods.extend(
|
|
754
|
+
_collect_parameter_map_keys(
|
|
755
|
+
backend, "solve", solve_param
|
|
756
|
+
)
|
|
757
|
+
)
|
|
758
|
+
base_periods.extend(
|
|
759
|
+
_collect_parameter_array_values(
|
|
760
|
+
backend, "solve", "realized_periods"
|
|
761
|
+
)
|
|
762
|
+
)
|
|
763
|
+
vocab.extend(base_periods)
|
|
764
|
+
vocab = _dedup_keep_order(vocab)
|
|
765
|
+
elif st == "synthetic":
|
|
766
|
+
vocab = list(axis.tokens or [])
|
|
767
|
+
else:
|
|
768
|
+
# Unknown source_type — empty vocabulary. Contract schema
|
|
769
|
+
# validation upstream should prevent this from happening in
|
|
770
|
+
# practice; we degrade gracefully here.
|
|
771
|
+
vocab = []
|
|
772
|
+
|
|
773
|
+
# Merge synthetic-allowlist tokens (always last; dedup keeps
|
|
774
|
+
# original tokens' priority).
|
|
775
|
+
vocab = _dedup_keep_order(
|
|
776
|
+
list(vocab) + _allowlist_tokens_for(contract, axis.name)
|
|
777
|
+
)
|
|
778
|
+
out[axis.name] = pl.Enum(vocab)
|
|
779
|
+
return out
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
# ---------------------------------------------------------------------------
|
|
783
|
+
# Cast helper + integrity error
|
|
784
|
+
# ---------------------------------------------------------------------------
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
_BANNER = "=" * 72
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
class FlexDataIntegrityError(ValueError):
|
|
791
|
+
"""A column carried a token not in its axis enum vocabulary.
|
|
792
|
+
|
|
793
|
+
Raised by :func:`cast_against_contract` when a strict cast fails.
|
|
794
|
+
The message is a 4-paragraph beginner-friendly explanation: what
|
|
795
|
+
the unknown token is, where it appeared, the size of the declared
|
|
796
|
+
vocabulary, and a short list of next steps.
|
|
797
|
+
"""
|
|
798
|
+
|
|
799
|
+
@classmethod
|
|
800
|
+
def from_cast_failure(
|
|
801
|
+
cls,
|
|
802
|
+
*,
|
|
803
|
+
axis_name: str,
|
|
804
|
+
axis_friendly: str,
|
|
805
|
+
bad_token: str,
|
|
806
|
+
vocabulary_size: int,
|
|
807
|
+
parameter: str | None = None,
|
|
808
|
+
entity: str | None = None,
|
|
809
|
+
map_index: str | None = None,
|
|
810
|
+
scenario: str | None = None,
|
|
811
|
+
suggestions: Sequence[str] | None = None,
|
|
812
|
+
) -> "FlexDataIntegrityError":
|
|
813
|
+
"""Render the canonical 4-paragraph error message.
|
|
814
|
+
|
|
815
|
+
Parameters
|
|
816
|
+
----------
|
|
817
|
+
axis_name : str
|
|
818
|
+
Short axis name (e.g. ``"n"``) — used to build the
|
|
819
|
+
``axis_friendly`` plural in the third paragraph.
|
|
820
|
+
axis_friendly : str
|
|
821
|
+
Human-readable axis label (e.g. ``"node"``) — used in the
|
|
822
|
+
opening line and the cardinality sentence.
|
|
823
|
+
bad_token : str
|
|
824
|
+
The token that wasn't in the enum vocabulary.
|
|
825
|
+
vocabulary_size : int
|
|
826
|
+
Number of declared tokens; rendered in the cardinality
|
|
827
|
+
sentence (``"Your input database lists {N} nodes."``).
|
|
828
|
+
parameter, entity, map_index, scenario : str | None
|
|
829
|
+
Origin breadcrumbs. Each becomes a line in the "Where it
|
|
830
|
+
appeared" block; missing breadcrumbs are replaced with a
|
|
831
|
+
``(unknown)`` placeholder so the message shape stays
|
|
832
|
+
stable.
|
|
833
|
+
suggestions : sequence of str | None
|
|
834
|
+
Up to 4 hints rendered as bullets in the final paragraph.
|
|
835
|
+
None / empty means a generic "add the entity" fallback
|
|
836
|
+
bullet is appended.
|
|
837
|
+
"""
|
|
838
|
+
# Plural: just append 's' — the axis labels in the contract are
|
|
839
|
+
# all simple nouns (node, process, commodity, ...) that
|
|
840
|
+
# pluralise cleanly. If a future label breaks this rule, the
|
|
841
|
+
# cast helper still raises the error correctly — only the
|
|
842
|
+
# cardinality sentence reads slightly off.
|
|
843
|
+
plural = axis_friendly + "s" if not axis_friendly.endswith("s") else axis_friendly
|
|
844
|
+
|
|
845
|
+
where_lines = [
|
|
846
|
+
f" In parameter: {parameter or '(unknown)'}",
|
|
847
|
+
f" On entity: {entity or '(unknown)'}",
|
|
848
|
+
f" Inside a map at: key index {map_index or '(unknown)'}",
|
|
849
|
+
f" Active scenario: {scenario or '(unknown)'}",
|
|
850
|
+
]
|
|
851
|
+
|
|
852
|
+
if suggestions is None or len(suggestions) == 0:
|
|
853
|
+
bullets = [
|
|
854
|
+
f" - Check {axis_friendly} name {bad_token!r} for a typo against your DB.",
|
|
855
|
+
f" - If {bad_token!r} is truly new, add it to the source DB under "
|
|
856
|
+
f"the {axis_friendly} entity class.",
|
|
857
|
+
]
|
|
858
|
+
else:
|
|
859
|
+
bullets = [f" - {hint}" for hint in suggestions[:4]]
|
|
860
|
+
if not any("add" in b.lower() and "DB" in b for b in bullets):
|
|
861
|
+
# Always close with the "add to DB" fallback if the
|
|
862
|
+
# caller didn't already include it.
|
|
863
|
+
if len(bullets) < 4:
|
|
864
|
+
bullets.append(
|
|
865
|
+
f" - If {bad_token!r} is truly new, add it to the source "
|
|
866
|
+
f"DB under the {axis_friendly} entity class."
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
para3_tail = (
|
|
870
|
+
f' is not one of them. This token appeared while the cascade was '
|
|
871
|
+
f'reading {parameter or "an input parameter"} and casting it to '
|
|
872
|
+
f'the {axis_friendly} axis enum.'
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
msg = (
|
|
876
|
+
f"\n{_BANNER}\n"
|
|
877
|
+
f"Found an unknown {axis_friendly} name {bad_token!r} "
|
|
878
|
+
f"in your input data.\n"
|
|
879
|
+
f"\n"
|
|
880
|
+
f"Where it appeared:\n"
|
|
881
|
+
+ "\n".join(where_lines) + "\n"
|
|
882
|
+
f"\n"
|
|
883
|
+
f"Your input database lists {vocabulary_size} {plural}. "
|
|
884
|
+
f"{bad_token!r}{para3_tail}\n"
|
|
885
|
+
f"\n"
|
|
886
|
+
f"What to do:\n"
|
|
887
|
+
+ "\n".join(bullets) + "\n"
|
|
888
|
+
f"{_BANNER}"
|
|
889
|
+
)
|
|
890
|
+
return cls(msg)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _levenshtein(a: str, b: str) -> int:
|
|
894
|
+
"""Compute the Levenshtein edit distance between *a* and *b*.
|
|
895
|
+
|
|
896
|
+
Small standalone implementation — no external dep. Used by
|
|
897
|
+
:func:`_lookup_similar_classes` to surface "did you mean ...?"
|
|
898
|
+
suggestions in the integrity-error message.
|
|
899
|
+
"""
|
|
900
|
+
if a == b:
|
|
901
|
+
return 0
|
|
902
|
+
if len(a) < len(b):
|
|
903
|
+
a, b = b, a
|
|
904
|
+
if len(b) == 0:
|
|
905
|
+
return len(a)
|
|
906
|
+
prev = list(range(len(b) + 1))
|
|
907
|
+
for i, ca in enumerate(a, start=1):
|
|
908
|
+
curr = [i]
|
|
909
|
+
for j, cb in enumerate(b, start=1):
|
|
910
|
+
cost = 0 if ca == cb else 1
|
|
911
|
+
curr.append(min(
|
|
912
|
+
curr[j - 1] + 1, # insert
|
|
913
|
+
prev[j] + 1, # delete
|
|
914
|
+
prev[j - 1] + cost, # substitute
|
|
915
|
+
))
|
|
916
|
+
prev = curr
|
|
917
|
+
return prev[-1]
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _lookup_similar_classes(
|
|
921
|
+
token: str,
|
|
922
|
+
backend: Any,
|
|
923
|
+
intended_axis: AxisSpec,
|
|
924
|
+
all_classes: list[str],
|
|
925
|
+
) -> list[str]:
|
|
926
|
+
"""Build a short list of suggestion hints for the bad *token*.
|
|
927
|
+
|
|
928
|
+
Cross-class lookup (most actionable):
|
|
929
|
+
For each entity class in *all_classes*, check whether *token* is
|
|
930
|
+
an entity in that class. If found, prepend a hint like
|
|
931
|
+
``"the commodity 'token'"`` — beginner-helpful when the user
|
|
932
|
+
typed e.g. a commodity name in a node column.
|
|
933
|
+
|
|
934
|
+
Levenshtein typo suggestions:
|
|
935
|
+
Compute edit distance from *token* against every entity name in
|
|
936
|
+
the *intended_axis*'s source class. Return up to 2 candidates
|
|
937
|
+
with distance ≤ 2.
|
|
938
|
+
|
|
939
|
+
The total list is capped at 3 hints so the
|
|
940
|
+
:class:`FlexDataIntegrityError` rendering has room for the
|
|
941
|
+
``add this entity if truly new`` fallback as the 4th bullet.
|
|
942
|
+
"""
|
|
943
|
+
hints: list[str] = []
|
|
944
|
+
# 1. Cross-class lookup.
|
|
945
|
+
for cls in all_classes:
|
|
946
|
+
try:
|
|
947
|
+
rows = backend.find_entities(entity_class_name=cls)
|
|
948
|
+
except Exception: # noqa: BLE001 — defensive
|
|
949
|
+
continue
|
|
950
|
+
for r in rows:
|
|
951
|
+
if r.get("name") == token:
|
|
952
|
+
hints.append(f"{token!r} exists as a {cls}, not a {intended_axis.label}.")
|
|
953
|
+
# Cross-class hit is the strongest signal; stop after one.
|
|
954
|
+
break
|
|
955
|
+
if hints:
|
|
956
|
+
break
|
|
957
|
+
|
|
958
|
+
# 2. Levenshtein typo suggestions within the intended class.
|
|
959
|
+
intended_classes: list[str] = []
|
|
960
|
+
if intended_axis.source_type == "entity_class":
|
|
961
|
+
intended_classes = [str(intended_axis.source)]
|
|
962
|
+
elif intended_axis.source_type == "entity_class_union":
|
|
963
|
+
intended_classes = list(intended_axis.source or [])
|
|
964
|
+
typo_candidates: list[tuple[int, str]] = []
|
|
965
|
+
for cls in intended_classes:
|
|
966
|
+
try:
|
|
967
|
+
rows = backend.find_entities(entity_class_name=cls)
|
|
968
|
+
except Exception: # noqa: BLE001 — defensive
|
|
969
|
+
continue
|
|
970
|
+
for r in rows:
|
|
971
|
+
name = r.get("name")
|
|
972
|
+
if not name or name == token:
|
|
973
|
+
continue
|
|
974
|
+
dist = _levenshtein(token, name)
|
|
975
|
+
if dist <= 2:
|
|
976
|
+
typo_candidates.append((dist, name))
|
|
977
|
+
typo_candidates.sort()
|
|
978
|
+
for _dist, name in typo_candidates[:2]:
|
|
979
|
+
hints.append(f"did you mean {name!r}?")
|
|
980
|
+
|
|
981
|
+
return hints[:3]
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def cast_against_contract(
|
|
985
|
+
frame: pl.DataFrame,
|
|
986
|
+
*,
|
|
987
|
+
contract: AxisContract,
|
|
988
|
+
axis_enums: dict[str, pl.Enum],
|
|
989
|
+
origin: dict | None = None,
|
|
990
|
+
backend: Any | None = None,
|
|
991
|
+
) -> pl.DataFrame:
|
|
992
|
+
"""Cast every dim column of *frame* to its canonical enum dtype.
|
|
993
|
+
|
|
994
|
+
For each column in ``frame.columns``:
|
|
995
|
+
* If :meth:`AxisContract.column_to_axis` resolves it to an
|
|
996
|
+
:class:`AxisSpec`, cast the column to ``axis_enums[axis.name]``
|
|
997
|
+
with ``strict=True``.
|
|
998
|
+
* On cast failure (a token not in the enum vocabulary), raise
|
|
999
|
+
:class:`FlexDataIntegrityError` with the *origin* breadcrumbs
|
|
1000
|
+
threaded in.
|
|
1001
|
+
* If no axis maps the column, leave it alone.
|
|
1002
|
+
|
|
1003
|
+
Parameters
|
|
1004
|
+
----------
|
|
1005
|
+
frame : pl.DataFrame
|
|
1006
|
+
Eager frame to cast. Returned unchanged if no dim columns are
|
|
1007
|
+
present.
|
|
1008
|
+
contract : AxisContract
|
|
1009
|
+
The canonical axis contract.
|
|
1010
|
+
axis_enums : dict[str, pl.Enum]
|
|
1011
|
+
Result of :func:`build_axis_enums`.
|
|
1012
|
+
origin : dict | None
|
|
1013
|
+
Optional breadcrumb dict — supported keys: ``parameter``,
|
|
1014
|
+
``entity``, ``map_index``, ``scenario``. Threaded into
|
|
1015
|
+
:meth:`FlexDataIntegrityError.from_cast_failure`.
|
|
1016
|
+
backend : Any | None
|
|
1017
|
+
Optional :class:`SpineDBBackend` — used by
|
|
1018
|
+
:func:`_lookup_similar_classes` to render
|
|
1019
|
+
"did you mean ...?" suggestions in the error message. When
|
|
1020
|
+
``None``, the error still renders, just without suggestions.
|
|
1021
|
+
|
|
1022
|
+
Returns
|
|
1023
|
+
-------
|
|
1024
|
+
pl.DataFrame
|
|
1025
|
+
The cast frame (or the original if no dim columns were
|
|
1026
|
+
present).
|
|
1027
|
+
"""
|
|
1028
|
+
origin = dict(origin or {})
|
|
1029
|
+
cast_exprs = []
|
|
1030
|
+
cast_pairs: list[tuple[str, AxisSpec]] = []
|
|
1031
|
+
for col in frame.columns:
|
|
1032
|
+
axis = contract.column_to_axis(col)
|
|
1033
|
+
if axis is None:
|
|
1034
|
+
continue
|
|
1035
|
+
dtype = axis_enums.get(axis.name)
|
|
1036
|
+
if dtype is None:
|
|
1037
|
+
continue
|
|
1038
|
+
# Polars' numeric → Enum cast interprets the numeric value as a
|
|
1039
|
+
# POSITIONAL INDEX into the enum's categories — so a Float64 / Int
|
|
1040
|
+
# column whose name happens to match an axis synonym (e.g. a
|
|
1041
|
+
# Spine 1d_map[<constraint-like-key-name>] where the keys are
|
|
1042
|
+
# numeric durations) would silently get reinterpreted as
|
|
1043
|
+
# category-by-position. That is a contract-axis bug, not a
|
|
1044
|
+
# vocabulary mismatch: numeric columns are never dim columns
|
|
1045
|
+
# under this contract. Skip the cast.
|
|
1046
|
+
src_dtype = frame.schema[col]
|
|
1047
|
+
if src_dtype.is_numeric():
|
|
1048
|
+
continue
|
|
1049
|
+
cast_pairs.append((col, axis))
|
|
1050
|
+
cast_exprs.append(pl.col(col).cast(dtype, strict=True))
|
|
1051
|
+
|
|
1052
|
+
if not cast_exprs:
|
|
1053
|
+
return frame
|
|
1054
|
+
|
|
1055
|
+
try:
|
|
1056
|
+
return frame.with_columns(cast_exprs)
|
|
1057
|
+
except pl.exceptions.InvalidOperationError as exc:
|
|
1058
|
+
# Find which column / token failed. Polars only reports the
|
|
1059
|
+
# column / value, not the axis — we walk the cast pairs ourself
|
|
1060
|
+
# and raise on the first miss.
|
|
1061
|
+
bad_axis: AxisSpec | None = None
|
|
1062
|
+
bad_token: str = "<unknown>"
|
|
1063
|
+
for col, axis in cast_pairs:
|
|
1064
|
+
dtype = axis_enums[axis.name]
|
|
1065
|
+
vocab = set(dtype.categories.to_list())
|
|
1066
|
+
values = frame[col].to_list()
|
|
1067
|
+
for v in values:
|
|
1068
|
+
if v is None:
|
|
1069
|
+
continue
|
|
1070
|
+
if v not in vocab:
|
|
1071
|
+
bad_axis = axis
|
|
1072
|
+
bad_token = str(v)
|
|
1073
|
+
break
|
|
1074
|
+
if bad_axis is not None:
|
|
1075
|
+
break
|
|
1076
|
+
|
|
1077
|
+
if bad_axis is None:
|
|
1078
|
+
# Couldn't identify the offending value — re-raise with a
|
|
1079
|
+
# softer integrity error using the original exception text.
|
|
1080
|
+
raise FlexDataIntegrityError(
|
|
1081
|
+
f"polars rejected the dim-column cast: {exc}"
|
|
1082
|
+
) from exc
|
|
1083
|
+
|
|
1084
|
+
all_classes = []
|
|
1085
|
+
for ax in contract.axes:
|
|
1086
|
+
if ax.source_type == "entity_class":
|
|
1087
|
+
all_classes.append(str(ax.source))
|
|
1088
|
+
elif ax.source_type == "entity_class_union":
|
|
1089
|
+
all_classes.extend(list(ax.source or []))
|
|
1090
|
+
all_classes = _dedup_keep_order(all_classes)
|
|
1091
|
+
|
|
1092
|
+
if backend is not None:
|
|
1093
|
+
suggestions = _lookup_similar_classes(
|
|
1094
|
+
bad_token, backend, bad_axis, all_classes,
|
|
1095
|
+
)
|
|
1096
|
+
else:
|
|
1097
|
+
suggestions = []
|
|
1098
|
+
|
|
1099
|
+
raise FlexDataIntegrityError.from_cast_failure(
|
|
1100
|
+
axis_name=bad_axis.name,
|
|
1101
|
+
axis_friendly=bad_axis.label,
|
|
1102
|
+
bad_token=bad_token,
|
|
1103
|
+
vocabulary_size=len(axis_enums[bad_axis.name].categories),
|
|
1104
|
+
parameter=origin.get("parameter"),
|
|
1105
|
+
entity=origin.get("entity"),
|
|
1106
|
+
map_index=origin.get("map_index"),
|
|
1107
|
+
scenario=origin.get("scenario"),
|
|
1108
|
+
suggestions=suggestions,
|
|
1109
|
+
) from exc
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
__all__ = [
|
|
1113
|
+
"AxisSpec",
|
|
1114
|
+
"AxisContract",
|
|
1115
|
+
"load_axis_contract",
|
|
1116
|
+
"build_axis_enums",
|
|
1117
|
+
"cast_against_contract",
|
|
1118
|
+
"FlexDataIntegrityError",
|
|
1119
|
+
]
|