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,1165 @@
|
|
|
1
|
+
"""Production :class:`InputSource` implementation backed by SpineDB.
|
|
2
|
+
|
|
3
|
+
Wraps :class:`spinedb_api.DatabaseMapping`, applies a scenario filter
|
|
4
|
+
once at construction, and exposes per-(entity_class, parameter_name)
|
|
5
|
+
polars frames on demand. See ``audit/db_direct_param_map.md §5`` for
|
|
6
|
+
the binding spec.
|
|
7
|
+
|
|
8
|
+
Lazy-evaluation pattern
|
|
9
|
+
-----------------------
|
|
10
|
+
The constructor materialises three eager caches: the per-class entity
|
|
11
|
+
universe, the per-(class, parameter) row list, and the per-(class,
|
|
12
|
+
parameter) default. Subsequent :meth:`parameter` calls walk the
|
|
13
|
+
cached rows once, build the result with :class:`polars.LazyFrame`
|
|
14
|
+
chains (default broadcast / left-join / cast), and ``.collect()`` once
|
|
15
|
+
at the boundary. Callers receive eager DataFrames (the API contract);
|
|
16
|
+
internal composition stays lazy so polars can fuse / optimise the
|
|
17
|
+
chain.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import polars as pl
|
|
26
|
+
|
|
27
|
+
from flextool.spinedb_backend._axis_enums import (
|
|
28
|
+
AxisContract,
|
|
29
|
+
FlexDataIntegrityError,
|
|
30
|
+
cast_against_contract,
|
|
31
|
+
load_axis_contract,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Late imports of spinedb_api at construction time keep the import
|
|
35
|
+
# graph free of an unconditional dependency for users who only consume
|
|
36
|
+
# CSVs.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Helpers
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _coerce_index(idx: Any) -> Any:
|
|
44
|
+
"""Coerce a single index into a Python type polars likes.
|
|
45
|
+
|
|
46
|
+
``Map.indexes`` returns ``numpy.str_`` (and equivalent numpy
|
|
47
|
+
scalar types); polars happily accepts these but constructing a
|
|
48
|
+
:class:`pl.DataFrame` mixed-source from them is more reliable when
|
|
49
|
+
they're plain Python. Datetimes (``numpy.datetime64`` from
|
|
50
|
+
:class:`TimeSeries`) get returned to polars as-is — polars converts
|
|
51
|
+
them on construction.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(idx, np.str_):
|
|
54
|
+
return str(idx)
|
|
55
|
+
if isinstance(idx, np.generic):
|
|
56
|
+
# numpy.float64 / int64 / bool_ → Python scalar.
|
|
57
|
+
return idx.item()
|
|
58
|
+
return idx
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _coerce_value(v: Any) -> Any:
|
|
62
|
+
"""Coerce a leaf value into a Python type with reasonable polars
|
|
63
|
+
dtype inference. Strings stay strings; numeric numpy scalars become
|
|
64
|
+
Python floats / ints; booleans stay booleans; everything else
|
|
65
|
+
passes through (polars will reject blob-like values via the schema
|
|
66
|
+
check downstream).
|
|
67
|
+
"""
|
|
68
|
+
if isinstance(v, np.str_):
|
|
69
|
+
return str(v)
|
|
70
|
+
if isinstance(v, np.generic):
|
|
71
|
+
return v.item()
|
|
72
|
+
return v
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Default canonical column names per spec §4.2. Used when a `Map.index_name`
|
|
76
|
+
# is empty / generic. Probed in order of nesting depth so the topmost
|
|
77
|
+
# (period) gets the first slot.
|
|
78
|
+
_DEFAULT_INDEX_NAMES = ("period", "tier", "branch", "t", "sub_index")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# SpineDbReader
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SpineDbReader:
|
|
86
|
+
"""Read flextool input data directly from a SpineDB scenario.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
db_url : str
|
|
91
|
+
Spine sqlite URL. Bare paths are auto-prefixed with
|
|
92
|
+
``sqlite:///``.
|
|
93
|
+
scenario : str
|
|
94
|
+
Scenario name to apply via
|
|
95
|
+
:func:`spinedb_api.filters.scenario_filter.apply_scenario_filter_to_subqueries`.
|
|
96
|
+
|
|
97
|
+
The constructor opens the DB, applies the filter, and pre-builds
|
|
98
|
+
three caches:
|
|
99
|
+
|
|
100
|
+
* ``_entities[class_id]``: list of (entity_id, name, element_name_list).
|
|
101
|
+
* ``_param_rows[(class_id, pdef_id)]``: list of (entity_id, value, type).
|
|
102
|
+
* ``_param_defs[(class_id, pdef_name)]``: pdef row + parsed default.
|
|
103
|
+
|
|
104
|
+
The DB is closed at construction time after the caches are built —
|
|
105
|
+
no DB handle is held across calls.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# Construction
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
db_url: str,
|
|
114
|
+
scenario: str,
|
|
115
|
+
*,
|
|
116
|
+
axis_enums: dict[str, pl.Enum] | None = None,
|
|
117
|
+
contract: AxisContract | None = None,
|
|
118
|
+
):
|
|
119
|
+
url = str(db_url)
|
|
120
|
+
if "://" not in url:
|
|
121
|
+
url = f"sqlite:///{url}"
|
|
122
|
+
self._db_url = url
|
|
123
|
+
self._scenario = scenario
|
|
124
|
+
# Phase 2 cast-on-emit: when ``axis_enums`` is supplied, every
|
|
125
|
+
# frame returned by entities / parameter / parameter_explicit is
|
|
126
|
+
# cast against the contract before return. ``None`` keeps the
|
|
127
|
+
# pre-Phase-2 behaviour (Utf8 dim columns) so callers that
|
|
128
|
+
# haven't opted in yet see no change.
|
|
129
|
+
self._axis_enums = axis_enums
|
|
130
|
+
# The contract is needed unconditionally now: even when callers
|
|
131
|
+
# don't opt into the Phase-2 axis-enum cast on dim columns, the
|
|
132
|
+
# value-column dtype contract (parameter_value_dtypes) still
|
|
133
|
+
# applies so Map leaves can be normalised to Float64 / Utf8 /
|
|
134
|
+
# Boolean regardless of how the cells were authored in Spine.
|
|
135
|
+
if contract is None:
|
|
136
|
+
contract = load_axis_contract()
|
|
137
|
+
self._contract = contract
|
|
138
|
+
|
|
139
|
+
# Build caches once.
|
|
140
|
+
from spinedb_api import DatabaseMapping, from_database
|
|
141
|
+
from spinedb_api.filters.scenario_filter import (
|
|
142
|
+
apply_scenario_filter_to_subqueries,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
with DatabaseMapping(url) as db:
|
|
146
|
+
apply_scenario_filter_to_subqueries(db, scenario)
|
|
147
|
+
|
|
148
|
+
# Class id ↔ name map.
|
|
149
|
+
self._class_id_to_name: dict[int, str] = {}
|
|
150
|
+
self._class_name_to_id: dict[str, int] = {}
|
|
151
|
+
for c in db.query(db.entity_class_sq).all():
|
|
152
|
+
self._class_id_to_name[c.id] = c.name
|
|
153
|
+
self._class_name_to_id[c.name] = c.id
|
|
154
|
+
|
|
155
|
+
# Class dim names (from wide subquery). None for 0-dim.
|
|
156
|
+
self._class_dim_names: dict[int, list[str] | None] = {}
|
|
157
|
+
for c in db.query(db.wide_entity_class_sq).all():
|
|
158
|
+
if c.dimension_name_list:
|
|
159
|
+
self._class_dim_names[c.id] = c.dimension_name_list.split(",")
|
|
160
|
+
else:
|
|
161
|
+
self._class_dim_names[c.id] = None
|
|
162
|
+
|
|
163
|
+
# Per-class entities (via wide_entity_sq for element_name_list).
|
|
164
|
+
self._entities_by_class: dict[int, list[tuple[int, str, list[str] | None]]] = (
|
|
165
|
+
defaultdict(list)
|
|
166
|
+
)
|
|
167
|
+
for e in db.query(db.wide_entity_sq).all():
|
|
168
|
+
if e.element_name_list:
|
|
169
|
+
elements = e.element_name_list.split(",")
|
|
170
|
+
else:
|
|
171
|
+
elements = None
|
|
172
|
+
self._entities_by_class[e.class_id].append(
|
|
173
|
+
(e.id, e.name, elements)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Parameter definitions (cached eagerly, parsed defaults).
|
|
177
|
+
self._pdef_by_class_name: dict[tuple[int, str], dict] = {}
|
|
178
|
+
for p in db.query(db.wide_parameter_definition_sq).all():
|
|
179
|
+
default_val = from_database(p.default_value, p.default_type)
|
|
180
|
+
self._pdef_by_class_name[(p.entity_class_id, p.name)] = {
|
|
181
|
+
"id": p.id,
|
|
182
|
+
"name": p.name,
|
|
183
|
+
"default_value": default_val,
|
|
184
|
+
"default_type": p.default_type,
|
|
185
|
+
"value_list_id": p.parameter_value_list_id,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Per-(class_id, pdef_id) value rows. We load eagerly,
|
|
189
|
+
# parsing values via ``from_database`` once, so per-call
|
|
190
|
+
# ``parameter()`` is pure polars assembly.
|
|
191
|
+
self._param_rows: dict[tuple[int, int], list[tuple[int, Any]]] = (
|
|
192
|
+
defaultdict(list)
|
|
193
|
+
)
|
|
194
|
+
for r in db.query(db.parameter_value_sq).all():
|
|
195
|
+
v = from_database(r.value, r.type)
|
|
196
|
+
self._param_rows[(r.entity_class_id, r.parameter_definition_id)] \
|
|
197
|
+
.append((r.entity_id, v))
|
|
198
|
+
|
|
199
|
+
# entity_id → (class_id, name) for joining.
|
|
200
|
+
self._entity_by_id: dict[int, tuple[int, str]] = {}
|
|
201
|
+
for cls_id, ents in self._entities_by_class.items():
|
|
202
|
+
for eid, name, _ in ents:
|
|
203
|
+
self._entity_by_id[eid] = (cls_id, name)
|
|
204
|
+
|
|
205
|
+
# v56 Batch F — drop is_enabled="no" entities of the three
|
|
206
|
+
# gated classes from the caches. Mirrors
|
|
207
|
+
# :meth:`flextool.spinedb_backend._backend.SpineDBBackend.
|
|
208
|
+
# _disabled_entity_ids` so the cascade direct-param path
|
|
209
|
+
# (``InputSource.entities`` / ``InputSource.parameter`` via
|
|
210
|
+
# this reader) sees the same effective entity set as the
|
|
211
|
+
# input_derivation Backend.
|
|
212
|
+
_IS_ENABLED_CLASSES = (
|
|
213
|
+
"constraint",
|
|
214
|
+
"reserve__upDown__unit__node",
|
|
215
|
+
"reserve__upDown__connection__node",
|
|
216
|
+
)
|
|
217
|
+
for gated_cls_name in _IS_ENABLED_CLASSES:
|
|
218
|
+
gated_cls_id = self._class_name_to_id.get(gated_cls_name)
|
|
219
|
+
if gated_cls_id is None:
|
|
220
|
+
continue
|
|
221
|
+
is_enabled_pdef = self._pdef_by_class_name.get(
|
|
222
|
+
(gated_cls_id, "is_enabled"),
|
|
223
|
+
)
|
|
224
|
+
if is_enabled_pdef is None:
|
|
225
|
+
continue
|
|
226
|
+
disabled_ids: set[int] = set()
|
|
227
|
+
for eid, v in self._param_rows.get(
|
|
228
|
+
(gated_cls_id, is_enabled_pdef["id"]), [],
|
|
229
|
+
):
|
|
230
|
+
if v == "no":
|
|
231
|
+
disabled_ids.add(eid)
|
|
232
|
+
if not disabled_ids:
|
|
233
|
+
continue
|
|
234
|
+
# Drop disabled entities from the per-class entity list.
|
|
235
|
+
self._entities_by_class[gated_cls_id] = [
|
|
236
|
+
row for row in self._entities_by_class[gated_cls_id]
|
|
237
|
+
if row[0] not in disabled_ids
|
|
238
|
+
]
|
|
239
|
+
# Drop disabled entities from every parameter_value row
|
|
240
|
+
# cache of the same class. Walk every (cls_id, pdef_id)
|
|
241
|
+
# key whose cls_id matches; rebuild the row list without
|
|
242
|
+
# the disabled entries.
|
|
243
|
+
for (cls_id_key, pdef_id_key), rows in list(
|
|
244
|
+
self._param_rows.items(),
|
|
245
|
+
):
|
|
246
|
+
if cls_id_key != gated_cls_id:
|
|
247
|
+
continue
|
|
248
|
+
filtered = [
|
|
249
|
+
r for r in rows if r[0] not in disabled_ids
|
|
250
|
+
]
|
|
251
|
+
if filtered:
|
|
252
|
+
self._param_rows[(cls_id_key, pdef_id_key)] = (
|
|
253
|
+
filtered
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
# Empty buckets behave the same as missing
|
|
257
|
+
# buckets for ``parameter()`` (it uses
|
|
258
|
+
# ``.get(..., [])``); keep the cache compact.
|
|
259
|
+
del self._param_rows[(cls_id_key, pdef_id_key)]
|
|
260
|
+
# Drop disabled entries from the entity_id → (cls, name)
|
|
261
|
+
# map so downstream joiners can't accidentally re-emit
|
|
262
|
+
# them.
|
|
263
|
+
for did in disabled_ids:
|
|
264
|
+
self._entity_by_id.pop(did, None)
|
|
265
|
+
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
# Public diagnostics
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def db_url(self) -> str:
|
|
271
|
+
return self._db_url
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def scenario(self) -> str:
|
|
275
|
+
return self._scenario
|
|
276
|
+
|
|
277
|
+
def __repr__(self) -> str:
|
|
278
|
+
return f"SpineDbReader(db_url={self._db_url!r}, scenario={self._scenario!r})"
|
|
279
|
+
|
|
280
|
+
# ------------------------------------------------------------------
|
|
281
|
+
# Entity columns: ``[name]`` for 0-dim classes; one column per dim
|
|
282
|
+
# (named after the dim class) for n-dim relationships. Repeated
|
|
283
|
+
# dim classes get a 1-based suffix.
|
|
284
|
+
|
|
285
|
+
def _entity_columns(self, class_id: int) -> list[str]:
|
|
286
|
+
"""Resolve the column names for the given class's entity frame.
|
|
287
|
+
|
|
288
|
+
For 0-dim classes returns ``["name"]``. For n-dim relationships
|
|
289
|
+
returns the dim-class names, with repeats disambiguated by a
|
|
290
|
+
``_N`` suffix (e.g. ``connection__node__node`` →
|
|
291
|
+
``["connection", "node_1", "node_2"]``).
|
|
292
|
+
"""
|
|
293
|
+
dims = self._class_dim_names.get(class_id)
|
|
294
|
+
if dims is None:
|
|
295
|
+
return ["name"]
|
|
296
|
+
# Disambiguate duplicates.
|
|
297
|
+
seen: dict[str, int] = defaultdict(int)
|
|
298
|
+
cols: list[str] = []
|
|
299
|
+
# First pass: count occurrences.
|
|
300
|
+
counts: dict[str, int] = defaultdict(int)
|
|
301
|
+
for d in dims:
|
|
302
|
+
counts[d] += 1
|
|
303
|
+
# Second pass: emit, suffixing only those with multiplicity > 1.
|
|
304
|
+
for d in dims:
|
|
305
|
+
if counts[d] > 1:
|
|
306
|
+
seen[d] += 1
|
|
307
|
+
cols.append(f"{d}_{seen[d]}")
|
|
308
|
+
else:
|
|
309
|
+
cols.append(d)
|
|
310
|
+
return cols
|
|
311
|
+
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
# InputSource Protocol — entities
|
|
314
|
+
|
|
315
|
+
def entities(self, entity_class: str) -> pl.DataFrame:
|
|
316
|
+
cls_id = self._class_name_to_id.get(entity_class)
|
|
317
|
+
if cls_id is None:
|
|
318
|
+
# Unknown class — empty frame with a sensible schema.
|
|
319
|
+
return pl.DataFrame(schema={"name": pl.Utf8})
|
|
320
|
+
cols = self._entity_columns(cls_id)
|
|
321
|
+
rows = self._entities_by_class.get(cls_id, [])
|
|
322
|
+
if not rows:
|
|
323
|
+
return pl.DataFrame(schema={c: pl.Utf8 for c in cols})
|
|
324
|
+
if len(cols) == 1:
|
|
325
|
+
frame = (
|
|
326
|
+
pl.DataFrame({cols[0]: [name for _, name, _ in rows]},
|
|
327
|
+
schema={cols[0]: pl.Utf8})
|
|
328
|
+
.sort(cols)
|
|
329
|
+
)
|
|
330
|
+
return self._maybe_cast_frame(
|
|
331
|
+
frame,
|
|
332
|
+
entity_class=entity_class,
|
|
333
|
+
parameter_name=None,
|
|
334
|
+
)
|
|
335
|
+
# Multi-dim: split element_name_list into N columns.
|
|
336
|
+
data: dict[str, list[str]] = {c: [] for c in cols}
|
|
337
|
+
for _, _ent_name, elements in rows:
|
|
338
|
+
if elements is None or len(elements) != len(cols):
|
|
339
|
+
# Defensive: skip malformed rows rather than crash.
|
|
340
|
+
continue
|
|
341
|
+
for c, v in zip(cols, elements):
|
|
342
|
+
data[c].append(v)
|
|
343
|
+
schema = {c: pl.Utf8 for c in cols}
|
|
344
|
+
frame = pl.DataFrame(data, schema=schema).sort(cols)
|
|
345
|
+
return self._maybe_cast_frame(
|
|
346
|
+
frame,
|
|
347
|
+
entity_class=entity_class,
|
|
348
|
+
parameter_name=None,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# ------------------------------------------------------------------
|
|
352
|
+
# InputSource Protocol — parameter
|
|
353
|
+
|
|
354
|
+
def parameter_default(self, entity_class: str, parameter_name: str) -> Any:
|
|
355
|
+
cls_id = self._class_name_to_id.get(entity_class)
|
|
356
|
+
if cls_id is None:
|
|
357
|
+
return None
|
|
358
|
+
pdef = self._pdef_by_class_name.get((cls_id, parameter_name))
|
|
359
|
+
if pdef is None:
|
|
360
|
+
return None
|
|
361
|
+
return pdef["default_value"]
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------------
|
|
364
|
+
# Δ.17c — raw per-level index_name labels for a parameter.
|
|
365
|
+
#
|
|
366
|
+
# Used by :func:`flextool.engine_polars._param_shapes.resolve_param_shape`
|
|
367
|
+
# to validate the parameter's shape against an explicit allow-list
|
|
368
|
+
# (per the user's directive "read from database the dimensionality
|
|
369
|
+
# of the parameter" + "read the dimension index label from the
|
|
370
|
+
# database").
|
|
371
|
+
#
|
|
372
|
+
# Returns the list of raw ``Map.index_name`` labels per nesting
|
|
373
|
+
# depth: empty list for scalars; one entry per Map level for
|
|
374
|
+
# n-dim Maps. Labels are returned exactly as the DB authored them
|
|
375
|
+
# (no normalisation, no canonical default substitution) so the
|
|
376
|
+
# caller can detect "wrong index_name" and raise loudly.
|
|
377
|
+
#
|
|
378
|
+
# ``TimeSeries`` / ``Array`` collapse to a single canonical label
|
|
379
|
+
# (``"time"`` / ``""`` respectively) — neither is valid for the
|
|
380
|
+
# registry-routed parameters today, but rejecting them with an
|
|
381
|
+
# accurate label is better than silently treating them as a Map.
|
|
382
|
+
def parameter_shape_info(self, entity_class: str,
|
|
383
|
+
parameter_name: str) -> "list[str | None]":
|
|
384
|
+
cls_id = self._class_name_to_id.get(entity_class)
|
|
385
|
+
if cls_id is None:
|
|
386
|
+
raise KeyError(f"unknown entity_class {entity_class!r}")
|
|
387
|
+
pdef = self._pdef_by_class_name.get((cls_id, parameter_name))
|
|
388
|
+
if pdef is None:
|
|
389
|
+
raise KeyError(
|
|
390
|
+
f"unknown parameter ({entity_class!r}, {parameter_name!r})"
|
|
391
|
+
)
|
|
392
|
+
rows = self._param_rows.get((cls_id, pdef["id"]), [])
|
|
393
|
+
# Probe the deepest-nested row to capture the widest schema.
|
|
394
|
+
# Spine schema invariant: all rows for a parameter under a
|
|
395
|
+
# scenario share the same shape, but we're defensive in case
|
|
396
|
+
# a fixture mixes shapes (the resolver caller will raise on
|
|
397
|
+
# ambiguity downstream).
|
|
398
|
+
deepest: list[str | None] = []
|
|
399
|
+
for _eid, v in rows:
|
|
400
|
+
cand = self._index_name_path(v)
|
|
401
|
+
if len(cand) > len(deepest):
|
|
402
|
+
deepest = cand
|
|
403
|
+
if deepest:
|
|
404
|
+
return deepest
|
|
405
|
+
# No explicit rows — inspect the parameter's default_value (the
|
|
406
|
+
# schema default may itself be a Map). Otherwise treat as
|
|
407
|
+
# scalar (depth 0).
|
|
408
|
+
default = pdef["default_value"]
|
|
409
|
+
if default is not None:
|
|
410
|
+
return self._index_name_path(default)
|
|
411
|
+
return []
|
|
412
|
+
|
|
413
|
+
@staticmethod
|
|
414
|
+
def _index_name_path(v: Any) -> "list[str | None]":
|
|
415
|
+
"""Walk *v*'s nesting and return raw ``Map.index_name`` labels
|
|
416
|
+
per depth level. Used by :meth:`parameter_shape_info`.
|
|
417
|
+
|
|
418
|
+
Differs from :meth:`_discover_index_cols` in that it returns
|
|
419
|
+
the raw DB labels (``None`` / empty when unset) instead of the
|
|
420
|
+
canonical defaults — that's the whole point of the Δ.17c
|
|
421
|
+
resolver path.
|
|
422
|
+
"""
|
|
423
|
+
from spinedb_api.parameter_value import (
|
|
424
|
+
Map, TimeSeries, Array,
|
|
425
|
+
)
|
|
426
|
+
out: list[str | None] = []
|
|
427
|
+
cur = v
|
|
428
|
+
while True:
|
|
429
|
+
if isinstance(cur, Map):
|
|
430
|
+
out.append(cur.index_name if cur.index_name else None)
|
|
431
|
+
if len(cur.values) == 0:
|
|
432
|
+
break
|
|
433
|
+
cur = cur.values[0]
|
|
434
|
+
continue
|
|
435
|
+
if isinstance(cur, TimeSeries):
|
|
436
|
+
out.append("time")
|
|
437
|
+
break
|
|
438
|
+
if isinstance(cur, Array):
|
|
439
|
+
out.append("")
|
|
440
|
+
break
|
|
441
|
+
break
|
|
442
|
+
return out
|
|
443
|
+
|
|
444
|
+
def parameter_explicit(self, entity_class: str,
|
|
445
|
+
parameter_name: str) -> pl.DataFrame:
|
|
446
|
+
"""Like :meth:`parameter` but suppresses default-broadcast rows.
|
|
447
|
+
|
|
448
|
+
Returns ONLY entities that have a row in the parameter_value
|
|
449
|
+
table for the active scenario — i.e. an explicit override. The
|
|
450
|
+
Spine schema default never appears.
|
|
451
|
+
|
|
452
|
+
Useful for helpers that mirror flextool's preprocessing
|
|
453
|
+
``p_unit.get(name, None)`` semantic, where "absent" must remain
|
|
454
|
+
distinguishable from "present with the default value".
|
|
455
|
+
"""
|
|
456
|
+
cls_id = self._class_name_to_id.get(entity_class)
|
|
457
|
+
if cls_id is None:
|
|
458
|
+
raise KeyError(f"unknown entity_class {entity_class!r}")
|
|
459
|
+
pdef = self._pdef_by_class_name.get((cls_id, parameter_name))
|
|
460
|
+
if pdef is None:
|
|
461
|
+
raise KeyError(
|
|
462
|
+
f"unknown parameter ({entity_class!r}, {parameter_name!r})"
|
|
463
|
+
)
|
|
464
|
+
ent_cols = self._entity_columns(cls_id)
|
|
465
|
+
rows = self._param_rows.get((cls_id, pdef["id"]), [])
|
|
466
|
+
columns, index_cols, leaf_dtype = self._unroll_rows(
|
|
467
|
+
rows, ent_cols, parameter_name, entity_class=entity_class,
|
|
468
|
+
)
|
|
469
|
+
if not columns or not columns["value"]:
|
|
470
|
+
schema_in = {c: pl.Utf8 for c in ent_cols}
|
|
471
|
+
for ic in index_cols:
|
|
472
|
+
schema_in[ic] = pl.Utf8
|
|
473
|
+
schema_in["value"] = leaf_dtype or pl.Float64
|
|
474
|
+
return self._maybe_cast_frame(
|
|
475
|
+
pl.DataFrame(schema=schema_in),
|
|
476
|
+
entity_class=entity_class,
|
|
477
|
+
parameter_name=parameter_name,
|
|
478
|
+
)
|
|
479
|
+
overrides = {"value": leaf_dtype} if leaf_dtype is not None else None
|
|
480
|
+
# ``strict=False`` preserves the tolerance of the pre-columnar
|
|
481
|
+
# ``pl.DataFrame(list_of_dicts)`` constructor for heterogeneous
|
|
482
|
+
# Map-index columns (e.g. solve.invest_periods with mixed
|
|
483
|
+
# String / Int64 indexes across entities).
|
|
484
|
+
frame = self._finalize(
|
|
485
|
+
pl.DataFrame(columns, schema_overrides=overrides, strict=False).lazy(),
|
|
486
|
+
ent_cols + index_cols,
|
|
487
|
+
)
|
|
488
|
+
return self._maybe_cast_frame(
|
|
489
|
+
frame,
|
|
490
|
+
entity_class=entity_class,
|
|
491
|
+
parameter_name=parameter_name,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def parameter(self, entity_class: str, parameter_name: str) -> pl.DataFrame:
|
|
495
|
+
cls_id = self._class_name_to_id.get(entity_class)
|
|
496
|
+
if cls_id is None:
|
|
497
|
+
raise KeyError(f"unknown entity_class {entity_class!r}")
|
|
498
|
+
pdef = self._pdef_by_class_name.get((cls_id, parameter_name))
|
|
499
|
+
if pdef is None:
|
|
500
|
+
raise KeyError(
|
|
501
|
+
f"unknown parameter ({entity_class!r}, {parameter_name!r})"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
ent_cols = self._entity_columns(cls_id)
|
|
505
|
+
rows = self._param_rows.get((cls_id, pdef["id"]), [])
|
|
506
|
+
default = pdef["default_value"]
|
|
507
|
+
|
|
508
|
+
# Step 1 — unroll each row into per-column lists. The shape
|
|
509
|
+
# depends on the value's runtime type (scalar / Map / TimeSeries
|
|
510
|
+
# / Array). We don't know up-front whether the parameter is
|
|
511
|
+
# "scalar across all rows"; we infer per-row.
|
|
512
|
+
columns, index_cols, leaf_dtype = self._unroll_rows(
|
|
513
|
+
rows, ent_cols, parameter_name, entity_class=entity_class,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Step 2 — assemble the per-parameter LazyFrame. Empty rows
|
|
517
|
+
# collapse to a 0-row frame with the correct schema.
|
|
518
|
+
if not columns or not columns["value"]:
|
|
519
|
+
schema_in = {c: pl.Utf8 for c in ent_cols}
|
|
520
|
+
for ic in index_cols:
|
|
521
|
+
schema_in[ic] = pl.Utf8
|
|
522
|
+
schema_in["value"] = leaf_dtype or pl.Float64
|
|
523
|
+
v_lf = pl.DataFrame(schema=schema_in).lazy()
|
|
524
|
+
else:
|
|
525
|
+
overrides = {"value": leaf_dtype} if leaf_dtype is not None else None
|
|
526
|
+
# ``strict=False`` preserves pre-columnar tolerance of mixed
|
|
527
|
+
# Map-index types (see _spinedb_reader.py:415).
|
|
528
|
+
v_lf = pl.DataFrame(
|
|
529
|
+
columns, schema_overrides=overrides, strict=False,
|
|
530
|
+
).lazy()
|
|
531
|
+
|
|
532
|
+
# Step 3 — apply the §4.5 default policy lazily.
|
|
533
|
+
if default is None:
|
|
534
|
+
# None-skip: return rows as-is.
|
|
535
|
+
frame = self._finalize(v_lf, ent_cols + index_cols)
|
|
536
|
+
return self._maybe_cast_frame(
|
|
537
|
+
frame,
|
|
538
|
+
entity_class=entity_class,
|
|
539
|
+
parameter_name=parameter_name,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
if not index_cols:
|
|
543
|
+
# Scalar-default + scalar-parameter → broadcast. Build the
|
|
544
|
+
# entities frame from the raw cache (not via self.entities())
|
|
545
|
+
# so the join keys stay Utf8 — casting happens once at the
|
|
546
|
+
# end against the joined frame.
|
|
547
|
+
E = self._entities_frame_raw(cls_id, ent_cols).lazy()
|
|
548
|
+
joined = (E.join(v_lf, on=ent_cols, how="left")
|
|
549
|
+
.with_columns(pl.col("value").fill_null(default)))
|
|
550
|
+
frame = self._finalize(joined, ent_cols)
|
|
551
|
+
return self._maybe_cast_frame(
|
|
552
|
+
frame,
|
|
553
|
+
entity_class=entity_class,
|
|
554
|
+
parameter_name=parameter_name,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Scalar-default + indexed-parameter → return overrides only;
|
|
558
|
+
# the default is consumed via parameter_default() upstream.
|
|
559
|
+
frame = self._finalize(v_lf, ent_cols + index_cols)
|
|
560
|
+
return self._maybe_cast_frame(
|
|
561
|
+
frame,
|
|
562
|
+
entity_class=entity_class,
|
|
563
|
+
parameter_name=parameter_name,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def _entities_frame_raw(
|
|
567
|
+
self, cls_id: int, cols: list[str],
|
|
568
|
+
) -> pl.DataFrame:
|
|
569
|
+
"""Internal — build the raw Utf8 entities frame for *cls_id*
|
|
570
|
+
without going through :meth:`entities` (which would cast).
|
|
571
|
+
|
|
572
|
+
Used by :meth:`parameter` to keep the join keys Utf8 so the
|
|
573
|
+
join is a String/String op (polars 1.40 won't auto-coerce
|
|
574
|
+
Utf8↔Enum on join keys); the final frame is cast once before
|
|
575
|
+
return.
|
|
576
|
+
"""
|
|
577
|
+
rows = self._entities_by_class.get(cls_id, [])
|
|
578
|
+
if not rows:
|
|
579
|
+
return pl.DataFrame(schema={c: pl.Utf8 for c in cols})
|
|
580
|
+
if len(cols) == 1:
|
|
581
|
+
return (
|
|
582
|
+
pl.DataFrame({cols[0]: [name for _, name, _ in rows]},
|
|
583
|
+
schema={cols[0]: pl.Utf8})
|
|
584
|
+
.sort(cols)
|
|
585
|
+
)
|
|
586
|
+
data: dict[str, list[str]] = {c: [] for c in cols}
|
|
587
|
+
for _, _ent_name, elements in rows:
|
|
588
|
+
if elements is None or len(elements) != len(cols):
|
|
589
|
+
continue
|
|
590
|
+
for c, v in zip(cols, elements):
|
|
591
|
+
data[c].append(v)
|
|
592
|
+
return pl.DataFrame(
|
|
593
|
+
data, schema={c: pl.Utf8 for c in cols},
|
|
594
|
+
).sort(cols)
|
|
595
|
+
|
|
596
|
+
def _maybe_cast_frame(
|
|
597
|
+
self,
|
|
598
|
+
frame: pl.DataFrame,
|
|
599
|
+
*,
|
|
600
|
+
entity_class: str,
|
|
601
|
+
parameter_name: str | None,
|
|
602
|
+
) -> pl.DataFrame:
|
|
603
|
+
"""Cast *frame* against the configured axis enums + contract.
|
|
604
|
+
|
|
605
|
+
When the reader was constructed with ``axis_enums=None``
|
|
606
|
+
(default), the frame passes through unchanged — pre-Phase-2
|
|
607
|
+
back-compat. When ``axis_enums`` is non-None, every dim
|
|
608
|
+
column resolved via :meth:`AxisContract.column_to_axis` is
|
|
609
|
+
cast strictly; a vocabulary miss raises
|
|
610
|
+
:class:`FlexDataIntegrityError` with
|
|
611
|
+
``(parameter, entity_class, scenario)`` threaded as the
|
|
612
|
+
origin breadcrumb.
|
|
613
|
+
|
|
614
|
+
Phase 4 — for 0-dim entity frames whose single column is
|
|
615
|
+
``name``, cast that column against the axis whose source class
|
|
616
|
+
is ``entity_class`` (looked up in the contract). Without this
|
|
617
|
+
hook the ``name`` column remains Utf8 and every downstream
|
|
618
|
+
``pl.col("name").alias("p")`` produces a Utf8 ``p`` that mixes
|
|
619
|
+
SchemaError-fully with the Enum-typed cascade.
|
|
620
|
+
"""
|
|
621
|
+
if self._axis_enums is None:
|
|
622
|
+
return frame
|
|
623
|
+
# Pre-cast: handle entity-class element columns. For 0-dim
|
|
624
|
+
# classes the single column is ``name``; for n-dim relationships
|
|
625
|
+
# the columns are the dim-class names (``unit``, ``node``,
|
|
626
|
+
# ``node_1``, ``node_2``, ``connection`` …). Each such column
|
|
627
|
+
# carries entities of its dim class, so we cast against the
|
|
628
|
+
# axis whose source claims that class.
|
|
629
|
+
if self._contract is not None:
|
|
630
|
+
element_casts: list[pl.Expr] = []
|
|
631
|
+
for col in frame.columns:
|
|
632
|
+
if col == "name":
|
|
633
|
+
axis = self._axis_for_entity_class(entity_class)
|
|
634
|
+
elif col == "value":
|
|
635
|
+
# ``value`` is data, never a dim column.
|
|
636
|
+
axis = None
|
|
637
|
+
else:
|
|
638
|
+
# ``unit``, ``node``, ``connection``, … — element of
|
|
639
|
+
# a relationship class. Strip a trailing ``_N``
|
|
640
|
+
# disambiguator (``node_1`` / ``node_2`` → ``node``).
|
|
641
|
+
base = col.rsplit("_", 1)[0] if (
|
|
642
|
+
"_" in col and col.rsplit("_", 1)[1].isdigit()
|
|
643
|
+
) else col
|
|
644
|
+
axis = self._axis_for_entity_class(base)
|
|
645
|
+
if axis is None:
|
|
646
|
+
continue
|
|
647
|
+
target = self._axis_enums.get(axis.name)
|
|
648
|
+
if target is None or frame.schema[col] == target:
|
|
649
|
+
continue
|
|
650
|
+
# Polars' numeric → Enum cast interprets the source value
|
|
651
|
+
# as a POSITIONAL INDEX into the enum's categories. When
|
|
652
|
+
# the source column is numeric (e.g. a Spine 1d_map with
|
|
653
|
+
# numeric keys whose level was labelled "constraint" /
|
|
654
|
+
# other axis-synonym name), casting to the matching axis
|
|
655
|
+
# enum would silently produce category-by-position
|
|
656
|
+
# tokens. Skip the cast — numeric columns are never dim
|
|
657
|
+
# columns under this contract.
|
|
658
|
+
if frame.schema[col].is_numeric():
|
|
659
|
+
continue
|
|
660
|
+
element_casts.append(
|
|
661
|
+
pl.col(col).cast(target, strict=False)
|
|
662
|
+
)
|
|
663
|
+
if element_casts:
|
|
664
|
+
frame = frame.with_columns(element_casts)
|
|
665
|
+
# Scenario-trim Map-inner-key noise. When the reader is
|
|
666
|
+
# bound to a scenario filter, the axis enums (e.g. ``d``
|
|
667
|
+
# period) reflect only the scenario-active tokens. Map
|
|
668
|
+
# parameters may carry inner keys that exist in the broader
|
|
669
|
+
# DB but were authored outside the active scenario — those
|
|
670
|
+
# rows must be silently dropped so the strict cast below
|
|
671
|
+
# succeeds. Entity-reference columns
|
|
672
|
+
# (source_type=="entity_class"/"entity_class_union") are
|
|
673
|
+
# vocab-correct by construction (the backend pre-filtered
|
|
674
|
+
# them), so any out-of-vocab token there is a true typo and
|
|
675
|
+
# must still raise.
|
|
676
|
+
if self._scenario is not None and self._contract is not None:
|
|
677
|
+
drop_mask: pl.Series | None = None
|
|
678
|
+
for col in frame.columns:
|
|
679
|
+
axis = self._contract.column_to_axis(col)
|
|
680
|
+
if axis is None or axis.source_type != "parameter_keys":
|
|
681
|
+
continue
|
|
682
|
+
dtype = self._axis_enums.get(axis.name)
|
|
683
|
+
if dtype is None:
|
|
684
|
+
continue
|
|
685
|
+
if frame.schema[col].is_numeric():
|
|
686
|
+
continue
|
|
687
|
+
vocab = set(dtype.categories.to_list())
|
|
688
|
+
bad_rows = [
|
|
689
|
+
(v is not None and v != "" and v not in vocab)
|
|
690
|
+
for v in frame[col].to_list()
|
|
691
|
+
]
|
|
692
|
+
if not any(bad_rows):
|
|
693
|
+
continue
|
|
694
|
+
col_mask = pl.Series(bad_rows, dtype=pl.Boolean)
|
|
695
|
+
drop_mask = (
|
|
696
|
+
col_mask if drop_mask is None else (drop_mask | col_mask)
|
|
697
|
+
)
|
|
698
|
+
if drop_mask is not None:
|
|
699
|
+
frame = frame.filter(~drop_mask)
|
|
700
|
+
origin = {
|
|
701
|
+
"parameter": parameter_name,
|
|
702
|
+
"entity": entity_class,
|
|
703
|
+
"scenario": self._scenario,
|
|
704
|
+
}
|
|
705
|
+
return cast_against_contract(
|
|
706
|
+
frame,
|
|
707
|
+
contract=self._contract,
|
|
708
|
+
axis_enums=self._axis_enums,
|
|
709
|
+
origin=origin,
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
def _axis_for_entity_class(self, entity_class: str):
|
|
713
|
+
"""Return the contract axis whose source includes *entity_class*.
|
|
714
|
+
|
|
715
|
+
Walks the contract's axes for ``source_type == "entity_class"``
|
|
716
|
+
matching ``entity_class`` directly, or ``source_type ==
|
|
717
|
+
"entity_class_union"`` whose list includes ``entity_class``.
|
|
718
|
+
Returns the first matching :class:`AxisSpec`; ``None`` when no
|
|
719
|
+
axis claims this class (synthetic classes, methods, etc.).
|
|
720
|
+
|
|
721
|
+
When two axes claim the same class (e.g. ``node`` is sourced
|
|
722
|
+
by both ``n`` directly AND by ``e`` (the entity union)), the
|
|
723
|
+
single-class axis wins — its enum is narrower and more
|
|
724
|
+
precise than the union.
|
|
725
|
+
"""
|
|
726
|
+
if self._contract is None:
|
|
727
|
+
return None
|
|
728
|
+
single: object | None = None
|
|
729
|
+
union: object | None = None
|
|
730
|
+
for axis in self._contract.axes:
|
|
731
|
+
if axis.source_type == "entity_class":
|
|
732
|
+
if axis.source == entity_class:
|
|
733
|
+
single = axis
|
|
734
|
+
elif axis.source_type == "entity_class_union":
|
|
735
|
+
if entity_class in (axis.source or []):
|
|
736
|
+
if union is None:
|
|
737
|
+
union = axis
|
|
738
|
+
return single if single is not None else union
|
|
739
|
+
|
|
740
|
+
# ------------------------------------------------------------------
|
|
741
|
+
# Unrolling the per-row blob into rectangular rows
|
|
742
|
+
|
|
743
|
+
def _unroll_rows(
|
|
744
|
+
self,
|
|
745
|
+
rows: list[tuple[int, Any]],
|
|
746
|
+
ent_cols: list[str],
|
|
747
|
+
parameter_name: str,
|
|
748
|
+
*,
|
|
749
|
+
entity_class: str,
|
|
750
|
+
) -> tuple[dict[str, list], list[str], pl.DataType | None]:
|
|
751
|
+
"""Walk each (entity_id, parsed_value) pair and emit a columnar
|
|
752
|
+
dict of per-column lists ready for ``pl.DataFrame``.
|
|
753
|
+
|
|
754
|
+
Columnar layout: ``columns[col_name]`` is a list, one entry per
|
|
755
|
+
scalar leaf. Every list has the same length. Building columns
|
|
756
|
+
directly is ~9x faster than the previous row-of-dicts pattern
|
|
757
|
+
when handed to ``pl.DataFrame`` (per the
|
|
758
|
+
``arrow_value_direct_read_study.md`` benchmark). The recursion
|
|
759
|
+
uses a positional ``idx_path`` mutated in place via append /
|
|
760
|
+
pop, avoiding ``dict(base)`` copies at every Map node.
|
|
761
|
+
|
|
762
|
+
Returns the columns dict, the index column names (uniform
|
|
763
|
+
across rows for the parameter), and the inferred leaf dtype.
|
|
764
|
+
Empty input returns ``({}, [], None)``.
|
|
765
|
+
|
|
766
|
+
Dim columns leave as Utf8 lists; the caller's
|
|
767
|
+
``_maybe_cast_frame`` (i.e.
|
|
768
|
+
:func:`flextool.spinedb_backend._axis_enums.cast_against_contract`)
|
|
769
|
+
applies the contract-axis Enum cast with its
|
|
770
|
+
(parameter, entity, scenario) error breadcrumbs. Don't wire
|
|
771
|
+
enum dtypes here — keep the construction step orthogonal to
|
|
772
|
+
the Phase 4 enum refactor for forward-compatibility with the
|
|
773
|
+
future Arrow-native read.
|
|
774
|
+
"""
|
|
775
|
+
if not rows:
|
|
776
|
+
return {}, [], None
|
|
777
|
+
|
|
778
|
+
# Drop explicitly-null parameter values before shape discovery.
|
|
779
|
+
#
|
|
780
|
+
# A Spine ``null`` value (parsed to Python ``None`` by
|
|
781
|
+
# ``from_database``) carries no data — it is how a
|
|
782
|
+
# higher-priority alternative *clears* a value set in the base
|
|
783
|
+
# alternative (e.g. ``S4_Dry`` nulling out a base Map on
|
|
784
|
+
# ``node.constraint_invested_capacity_coeff``). Such a row must
|
|
785
|
+
# resolve to "unset" for that entity, NOT to a value-less scalar
|
|
786
|
+
# row. Left in place, a scalar-``None`` row overriding a base
|
|
787
|
+
# Map makes index discovery see no index column (a scalar
|
|
788
|
+
# discovers ``[]``); the frame then comes back as ``[name,
|
|
789
|
+
# value]`` and the downstream shape resolver (e.g.
|
|
790
|
+
# ``_resolve_constraint_index_col``) raises on the missing index
|
|
791
|
+
# axis. Routing whole-null values to absence instead makes them
|
|
792
|
+
# behave exactly like an unset parameter — already the common,
|
|
793
|
+
# well-handled case (most parameters are absent in most
|
|
794
|
+
# scenarios; every consumer guards ``df.height == 0``).
|
|
795
|
+
#
|
|
796
|
+
# SCOPE: this drops only a *whole* null value (``v is None``),
|
|
797
|
+
# never a null *leaf* inside a Map / TimeSeries / Array — those
|
|
798
|
+
# parse to container objects (not ``None``) and pass through
|
|
799
|
+
# untouched, so a Map's key-set / domain is never silently
|
|
800
|
+
# shrunk here. A null leaf inside a Map does not trigger the
|
|
801
|
+
# bug anyway: the sibling structure still yields the index
|
|
802
|
+
# column, so the shape resolver is satisfied. Dropping whole-
|
|
803
|
+
# null rows here (before discovery) is lossless for index
|
|
804
|
+
# discovery because a scalar ``None`` contributes no index
|
|
805
|
+
# column regardless (``_discover_index_cols(None) == []``).
|
|
806
|
+
rows = [(eid, v) for eid, v in rows if v is not None]
|
|
807
|
+
if not rows:
|
|
808
|
+
return {}, [], None
|
|
809
|
+
|
|
810
|
+
# Discover index columns from the widest-shaped row (spec
|
|
811
|
+
# §5.2.5). In practice flextool's params don't mix shapes
|
|
812
|
+
# within one scenario — but we're defensive.
|
|
813
|
+
index_cols: list[str] = []
|
|
814
|
+
for _eid, v in rows:
|
|
815
|
+
cand = self._discover_index_cols(v, parameter_name)
|
|
816
|
+
if len(cand) > len(index_cols):
|
|
817
|
+
index_cols = cand
|
|
818
|
+
|
|
819
|
+
# Pre-allocate one list per output column.
|
|
820
|
+
col_names = ent_cols + index_cols + ["value"]
|
|
821
|
+
columns: dict[str, list] = {name: [] for name in col_names}
|
|
822
|
+
|
|
823
|
+
# Walk each (entity_id, parsed_value) pair. ``idx_path`` is a
|
|
824
|
+
# positional list mirroring ``index_cols`` — mutated via
|
|
825
|
+
# append/pop inside the recursion, no per-node dict copy.
|
|
826
|
+
idx_path: list[Any] = []
|
|
827
|
+
for eid, v in rows:
|
|
828
|
+
cls_id, ent_name = self._entity_by_id[eid]
|
|
829
|
+
ent_values = self._entity_dim_values(cls_id, ent_name)
|
|
830
|
+
self._unroll_value(
|
|
831
|
+
v, index_cols, columns, ent_cols, ent_values, idx_path,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
# Apply the contract dtype only to parameters that contain at
|
|
835
|
+
# least one Spine Map row. Map is the only shape where Spine
|
|
836
|
+
# silently drops type information (cells may be authored as
|
|
837
|
+
# the string "1" instead of the float 1.0), so this is the
|
|
838
|
+
# only path that needs the contract's lenient coerce.
|
|
839
|
+
#
|
|
840
|
+
# Other shapes bypass the contract:
|
|
841
|
+
#
|
|
842
|
+
# * Scalars (``str`` / ``float`` / ``bool``) — spinedb-api
|
|
843
|
+
# returns them with the correct native Python type. Forcing
|
|
844
|
+
# the contract default of ``Float64`` here would reject every
|
|
845
|
+
# scalar string parameter (e.g. ``node.node_type``).
|
|
846
|
+
# * ``TimeSeries`` — binary float arrays; no string-leaf risk.
|
|
847
|
+
# * ``Array`` — every FlexTool Array parameter holds Utf8
|
|
848
|
+
# leaves (period names, solve names, CLI args). None are
|
|
849
|
+
# numeric, so the contract default would reject them
|
|
850
|
+
# (e.g. ``model.solves`` = ``["y2020_2day_dispatch", ...]``).
|
|
851
|
+
from spinedb_api.parameter_value import Map
|
|
852
|
+
has_map = any(isinstance(v, Map) for _, v in rows)
|
|
853
|
+
if has_map:
|
|
854
|
+
leaf_dtype = self._contract.value_dtype_for(
|
|
855
|
+
entity_class, parameter_name,
|
|
856
|
+
)
|
|
857
|
+
self._coerce_value_column(
|
|
858
|
+
columns, ent_cols, index_cols,
|
|
859
|
+
leaf_dtype=leaf_dtype,
|
|
860
|
+
entity_class=entity_class,
|
|
861
|
+
parameter_name=parameter_name,
|
|
862
|
+
)
|
|
863
|
+
else:
|
|
864
|
+
leaf_dtype = None
|
|
865
|
+
sample = next(
|
|
866
|
+
(x for x in columns["value"] if x is not None), None,
|
|
867
|
+
)
|
|
868
|
+
if isinstance(sample, bool):
|
|
869
|
+
leaf_dtype = pl.Boolean
|
|
870
|
+
elif isinstance(sample, (int, float)):
|
|
871
|
+
leaf_dtype = pl.Float64
|
|
872
|
+
elif isinstance(sample, str):
|
|
873
|
+
leaf_dtype = pl.Utf8
|
|
874
|
+
|
|
875
|
+
return columns, index_cols, leaf_dtype
|
|
876
|
+
|
|
877
|
+
def _coerce_value_column(
|
|
878
|
+
self,
|
|
879
|
+
columns: dict[str, list],
|
|
880
|
+
ent_cols: list[str],
|
|
881
|
+
index_cols: list[str],
|
|
882
|
+
*,
|
|
883
|
+
leaf_dtype: pl.DataType,
|
|
884
|
+
entity_class: str,
|
|
885
|
+
parameter_name: str,
|
|
886
|
+
) -> None:
|
|
887
|
+
"""In-place coerce ``columns["value"]`` to the contract dtype.
|
|
888
|
+
|
|
889
|
+
Spine does not enforce types inside Map values, so the same
|
|
890
|
+
parameter may carry float ``1.0`` and string ``"1"`` in
|
|
891
|
+
different cells. ``pl.DataFrame(..., schema_overrides=...)``
|
|
892
|
+
with a strict cast would either reject the column outright or
|
|
893
|
+
silently null the str leaves under ``strict=False``. Neither
|
|
894
|
+
is what we want — we want lenient parsing (Spine reality)
|
|
895
|
+
plus a loud, breadcrumb-rich error for genuinely unparseable
|
|
896
|
+
cells.
|
|
897
|
+
|
|
898
|
+
For ``Float64``: replace every ``str`` leaf with ``float(s)``
|
|
899
|
+
and raise :class:`FlexDataIntegrityError` on the first
|
|
900
|
+
unparseable token, with the offending entity name / map
|
|
901
|
+
index path / raw value embedded in the message.
|
|
902
|
+
|
|
903
|
+
For ``Boolean``: similar, with ``"yes"`` / ``"no"`` / ``"true"``
|
|
904
|
+
/ ``"false"`` (case-insensitive) accepted.
|
|
905
|
+
|
|
906
|
+
For ``Utf8``: no-op.
|
|
907
|
+
"""
|
|
908
|
+
if leaf_dtype == pl.Utf8:
|
|
909
|
+
return
|
|
910
|
+
vals = columns["value"]
|
|
911
|
+
if leaf_dtype == pl.Float64:
|
|
912
|
+
for i, v in enumerate(vals):
|
|
913
|
+
if v is None or isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
914
|
+
continue
|
|
915
|
+
if isinstance(v, str):
|
|
916
|
+
s = v.strip()
|
|
917
|
+
try:
|
|
918
|
+
vals[i] = float(s)
|
|
919
|
+
continue
|
|
920
|
+
except ValueError:
|
|
921
|
+
pass
|
|
922
|
+
self._raise_value_dtype_error(
|
|
923
|
+
columns, ent_cols, index_cols,
|
|
924
|
+
bad_index=i,
|
|
925
|
+
entity_class=entity_class,
|
|
926
|
+
parameter_name=parameter_name,
|
|
927
|
+
expected="Float64 (number)",
|
|
928
|
+
raw_value=v,
|
|
929
|
+
)
|
|
930
|
+
elif leaf_dtype == pl.Boolean:
|
|
931
|
+
for i, v in enumerate(vals):
|
|
932
|
+
if v is None or isinstance(v, bool):
|
|
933
|
+
continue
|
|
934
|
+
if isinstance(v, str):
|
|
935
|
+
s = v.strip().lower()
|
|
936
|
+
if s in ("yes", "true", "1"):
|
|
937
|
+
vals[i] = True
|
|
938
|
+
continue
|
|
939
|
+
if s in ("no", "false", "0"):
|
|
940
|
+
vals[i] = False
|
|
941
|
+
continue
|
|
942
|
+
self._raise_value_dtype_error(
|
|
943
|
+
columns, ent_cols, index_cols,
|
|
944
|
+
bad_index=i,
|
|
945
|
+
entity_class=entity_class,
|
|
946
|
+
parameter_name=parameter_name,
|
|
947
|
+
expected="Boolean (yes/no/true/false)",
|
|
948
|
+
raw_value=v,
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
def _raise_value_dtype_error(
|
|
952
|
+
self,
|
|
953
|
+
columns: dict[str, list],
|
|
954
|
+
ent_cols: list[str],
|
|
955
|
+
index_cols: list[str],
|
|
956
|
+
*,
|
|
957
|
+
bad_index: int,
|
|
958
|
+
entity_class: str,
|
|
959
|
+
parameter_name: str,
|
|
960
|
+
expected: str,
|
|
961
|
+
raw_value: Any,
|
|
962
|
+
) -> None:
|
|
963
|
+
"""Raise :class:`FlexDataIntegrityError` describing one bad cell.
|
|
964
|
+
|
|
965
|
+
Reconstructs the entity name tuple and the map-index path from
|
|
966
|
+
the parallel ``columns`` lists so the message points at the
|
|
967
|
+
exact Spine cell to fix.
|
|
968
|
+
"""
|
|
969
|
+
entity = tuple(columns[c][bad_index] for c in ent_cols)
|
|
970
|
+
idx_path = tuple(columns[c][bad_index] for c in index_cols)
|
|
971
|
+
idx_repr = " / ".join(str(x) for x in idx_path) if idx_path else "(scalar)"
|
|
972
|
+
raise FlexDataIntegrityError(
|
|
973
|
+
f"Parameter value type mismatch for "
|
|
974
|
+
f"{entity_class}.{parameter_name}: "
|
|
975
|
+
f"expected {expected} but cell "
|
|
976
|
+
f"entity={entity}, index_path={idx_repr} "
|
|
977
|
+
f"has value {raw_value!r} ({type(raw_value).__name__}). "
|
|
978
|
+
f"Edit the Spine database to store this cell as a number "
|
|
979
|
+
f"(not as text)."
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
def _entity_dim_values(self, class_id: int, ent_name: str) -> list[str]:
|
|
983
|
+
"""Return the dim-element values for *ent_name* in class
|
|
984
|
+
*class_id*. For 0-dim classes returns ``[ent_name]``; for
|
|
985
|
+
n-relationships, returns the cached element list.
|
|
986
|
+
"""
|
|
987
|
+
dims = self._class_dim_names.get(class_id)
|
|
988
|
+
if dims is None:
|
|
989
|
+
return [ent_name]
|
|
990
|
+
for eid, name, elements in self._entities_by_class[class_id]:
|
|
991
|
+
if name == ent_name:
|
|
992
|
+
if elements is None:
|
|
993
|
+
return [ent_name]
|
|
994
|
+
return elements
|
|
995
|
+
return [ent_name]
|
|
996
|
+
|
|
997
|
+
def _discover_index_cols(self, v: Any, parameter_name: str) -> list[str]:
|
|
998
|
+
"""Walk the value's nesting structure and return the index
|
|
999
|
+
column names. Scalar values return ``[]``. Nested
|
|
1000
|
+
:class:`Map` / :class:`TimeSeries` / :class:`Array` return one
|
|
1001
|
+
name per level, falling back to canonical defaults from
|
|
1002
|
+
``_DEFAULT_INDEX_NAMES`` when ``index_name`` is empty.
|
|
1003
|
+
"""
|
|
1004
|
+
from spinedb_api.parameter_value import (
|
|
1005
|
+
Map, TimeSeries, Array,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
cols: list[str] = []
|
|
1009
|
+
depth = 0
|
|
1010
|
+
cur = v
|
|
1011
|
+
while True:
|
|
1012
|
+
if isinstance(cur, Map):
|
|
1013
|
+
name = cur.index_name or _DEFAULT_INDEX_NAMES[
|
|
1014
|
+
depth if depth < len(_DEFAULT_INDEX_NAMES) else -1
|
|
1015
|
+
]
|
|
1016
|
+
# Map's "time" index is conventionally 't' in flextool.
|
|
1017
|
+
if name == "time":
|
|
1018
|
+
name = "t"
|
|
1019
|
+
# Disambiguate when this level's index_name collides
|
|
1020
|
+
# with one already produced at an outer level — e.g.
|
|
1021
|
+
# ``examples.sqlite::invest_5weeks.invest_periods`` is
|
|
1022
|
+
# a 2D Map where BOTH levels carry ``index_name='x'``.
|
|
1023
|
+
# Without disambiguation the outer (anchor) and inner
|
|
1024
|
+
# (period) indexes both land in a single ``x`` column
|
|
1025
|
+
# and the outer is silently overwritten (deepest-wins
|
|
1026
|
+
# in _emit_leaf). That loses the anchor → window
|
|
1027
|
+
# mapping the synthetic per-sub-solve cascade needs
|
|
1028
|
+
# (see ``_derived_params._solve_periods``). Append a
|
|
1029
|
+
# depth suffix to keep both levels addressable.
|
|
1030
|
+
if name in cols:
|
|
1031
|
+
name = f"{name}_{depth + 1}"
|
|
1032
|
+
cols.append(name)
|
|
1033
|
+
depth += 1
|
|
1034
|
+
# Probe the first child value to continue.
|
|
1035
|
+
if len(cur.values) == 0:
|
|
1036
|
+
break
|
|
1037
|
+
cur = cur.values[0]
|
|
1038
|
+
continue
|
|
1039
|
+
if isinstance(cur, TimeSeries):
|
|
1040
|
+
cols.append("t")
|
|
1041
|
+
break
|
|
1042
|
+
if isinstance(cur, Array):
|
|
1043
|
+
# Use a non-axis name so cast_against_contract leaves
|
|
1044
|
+
# this column alone. "i" collides with the canonical
|
|
1045
|
+
# tier_index axis ("i") whose vocabulary comes from
|
|
1046
|
+
# commodity.price_ladder_*; Array indices are
|
|
1047
|
+
# position-only and have no semantic axis.
|
|
1048
|
+
cols.append("_array_index")
|
|
1049
|
+
break
|
|
1050
|
+
break
|
|
1051
|
+
return cols
|
|
1052
|
+
|
|
1053
|
+
def _unroll_value(self, v: Any, index_cols: list[str],
|
|
1054
|
+
columns: dict[str, list],
|
|
1055
|
+
ent_cols: list[str],
|
|
1056
|
+
ent_values: list[str],
|
|
1057
|
+
idx_path: list[Any]) -> None:
|
|
1058
|
+
"""Recursively unroll *v* into per-column lists in ``columns``.
|
|
1059
|
+
|
|
1060
|
+
``idx_path`` is a positional list parallel to ``index_cols`` —
|
|
1061
|
+
mutated via append/pop as we descend. Each scalar leaf
|
|
1062
|
+
triggers an ``_emit_leaf`` that appends entity values + the
|
|
1063
|
+
current ``idx_path`` (padded with ``None`` for any
|
|
1064
|
+
unreached trailing index columns) + the coerced leaf into the
|
|
1065
|
+
relevant column lists.
|
|
1066
|
+
"""
|
|
1067
|
+
from spinedb_api.parameter_value import (
|
|
1068
|
+
Map, TimeSeries, Array,
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
# Scalar leaf.
|
|
1072
|
+
if not isinstance(v, (Map, TimeSeries, Array)):
|
|
1073
|
+
self._emit_leaf(
|
|
1074
|
+
columns, ent_cols, ent_values, idx_path, index_cols,
|
|
1075
|
+
_coerce_value(v),
|
|
1076
|
+
)
|
|
1077
|
+
return
|
|
1078
|
+
|
|
1079
|
+
# Map / TimeSeries / Array all recurse identically once we know
|
|
1080
|
+
# which index column applies at the current depth. Map's
|
|
1081
|
+
# ``index_name`` is already resolved into ``index_cols`` at
|
|
1082
|
+
# discovery time; TimeSeries / Array fall through to the
|
|
1083
|
+
# depth-indexed slot (or ``i`` if we've outrun the discovered
|
|
1084
|
+
# index_cols, e.g. a shape-mixed parameter).
|
|
1085
|
+
len(idx_path)
|
|
1086
|
+
if isinstance(v, Map):
|
|
1087
|
+
for idx, child in zip(v.indexes, v.values):
|
|
1088
|
+
idx_path.append(_coerce_index(idx))
|
|
1089
|
+
self._unroll_value(
|
|
1090
|
+
child, index_cols, columns, ent_cols, ent_values, idx_path,
|
|
1091
|
+
)
|
|
1092
|
+
idx_path.pop()
|
|
1093
|
+
return
|
|
1094
|
+
|
|
1095
|
+
if isinstance(v, TimeSeries):
|
|
1096
|
+
for idx, val in zip(v.indexes, v.values):
|
|
1097
|
+
idx_path.append(_coerce_index(idx))
|
|
1098
|
+
self._emit_leaf(
|
|
1099
|
+
columns, ent_cols, ent_values, idx_path, index_cols,
|
|
1100
|
+
_coerce_value(val),
|
|
1101
|
+
)
|
|
1102
|
+
idx_path.pop()
|
|
1103
|
+
return
|
|
1104
|
+
|
|
1105
|
+
if isinstance(v, Array):
|
|
1106
|
+
for i, val in enumerate(v.values):
|
|
1107
|
+
idx_path.append(i)
|
|
1108
|
+
self._emit_leaf(
|
|
1109
|
+
columns, ent_cols, ent_values, idx_path, index_cols,
|
|
1110
|
+
_coerce_value(val),
|
|
1111
|
+
)
|
|
1112
|
+
idx_path.pop()
|
|
1113
|
+
return
|
|
1114
|
+
|
|
1115
|
+
def _emit_leaf(self, columns: dict[str, list],
|
|
1116
|
+
ent_cols: list[str],
|
|
1117
|
+
ent_values: list[str],
|
|
1118
|
+
idx_path: list[Any],
|
|
1119
|
+
index_cols: list[str],
|
|
1120
|
+
value: Any) -> None:
|
|
1121
|
+
"""Append one scalar leaf row across all per-column lists.
|
|
1122
|
+
|
|
1123
|
+
Pads trailing ``index_cols`` (those beyond ``len(idx_path)``)
|
|
1124
|
+
with ``None`` so every column list ends up at the same length
|
|
1125
|
+
even when a parameter mixes shallow and deep shapes within a
|
|
1126
|
+
scenario.
|
|
1127
|
+
|
|
1128
|
+
Duplicate names in ``index_cols`` (e.g. a nested Map where
|
|
1129
|
+
every level shares the same ``index_name='x'``) collapse to a
|
|
1130
|
+
single column via the polars dict-of-lists deduplication. In
|
|
1131
|
+
that case we use a *last-wins* policy per leaf so the deepest
|
|
1132
|
+
Map level's index value survives — matching the pre-columnar
|
|
1133
|
+
``pl.DataFrame(list_of_dicts)`` behaviour where each
|
|
1134
|
+
``child_base[col_name] = ...`` overwrote the outer entry.
|
|
1135
|
+
"""
|
|
1136
|
+
for col, val in zip(ent_cols, ent_values):
|
|
1137
|
+
columns[col].append(val)
|
|
1138
|
+
# Resolve duplicate column names by keeping the last index_path
|
|
1139
|
+
# value per name (deepest-wins; pad with None when idx_path is
|
|
1140
|
+
# shorter than index_cols).
|
|
1141
|
+
n_path = len(idx_path)
|
|
1142
|
+
per_col_value: dict[str, Any] = {}
|
|
1143
|
+
for i, col in enumerate(index_cols):
|
|
1144
|
+
if i < n_path:
|
|
1145
|
+
per_col_value[col] = idx_path[i]
|
|
1146
|
+
else:
|
|
1147
|
+
per_col_value.setdefault(col, None)
|
|
1148
|
+
for col, v in per_col_value.items():
|
|
1149
|
+
columns[col].append(v)
|
|
1150
|
+
columns["value"].append(value)
|
|
1151
|
+
|
|
1152
|
+
# ------------------------------------------------------------------
|
|
1153
|
+
# Materialisation
|
|
1154
|
+
|
|
1155
|
+
def _finalize(self, lf: pl.LazyFrame, sort_cols: list[str]) -> pl.DataFrame:
|
|
1156
|
+
"""Sort by ``sort_cols`` (deterministic row order — §4.3) and
|
|
1157
|
+
collect. Single materialisation point per :meth:`parameter`
|
|
1158
|
+
call so the polars optimiser can fuse the chain.
|
|
1159
|
+
"""
|
|
1160
|
+
# Only sort by columns actually present (defensive).
|
|
1161
|
+
cols_present = lf.collect_schema().names()
|
|
1162
|
+
keep = [c for c in sort_cols if c in cols_present]
|
|
1163
|
+
if keep:
|
|
1164
|
+
lf = lf.sort(keep)
|
|
1165
|
+
return lf.collect()
|