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,1139 @@
|
|
|
1
|
+
""":class:`SpineDBBackend` — EAV → in-memory polars materialiser.
|
|
2
|
+
|
|
3
|
+
The Backend opens a :class:`spinedb_api.DatabaseMapping`, applies a
|
|
4
|
+
scenario filter, eagerly fetches the parameter / entity tables (same
|
|
5
|
+
``fetch_all`` pattern used by :mod:`flextool.engine_polars._db_loader`
|
|
6
|
+
and :mod:`flextool.engine_polars._solve_config`), and exposes per-spec
|
|
7
|
+
materialiser methods that each return a canonical
|
|
8
|
+
:class:`polars.DataFrame`.
|
|
9
|
+
|
|
10
|
+
Step 2.5 scope
|
|
11
|
+
--------------
|
|
12
|
+
|
|
13
|
+
Items 1-4 of the work plan (specs/step_2_5_audit.md §7) absorb the
|
|
14
|
+
three spec-driven flatteners from the legacy ``input_writer`` into
|
|
15
|
+
this Backend:
|
|
16
|
+
|
|
17
|
+
* :meth:`parameter_defaults` (Item 2) — replaces ``write_default_values``
|
|
18
|
+
* :meth:`entities` (Item 3) — replaces ``write_entity``
|
|
19
|
+
* :meth:`parameter_values` (Item 4) — replaces ``write_parameter``
|
|
20
|
+
|
|
21
|
+
Subsequent items port the four input-time derivations (DC power flow,
|
|
22
|
+
process method, commodity ladders) and rewire orchestration to consume
|
|
23
|
+
Backend output via the Provider.
|
|
24
|
+
|
|
25
|
+
Hard rules
|
|
26
|
+
----------
|
|
27
|
+
|
|
28
|
+
* **No disk writes anywhere in the Backend.** Every method returns a
|
|
29
|
+
polars frame.
|
|
30
|
+
* **No disk read fallbacks.** The Backend reads SpineDB, full stop.
|
|
31
|
+
|
|
32
|
+
Note on :class:`flextool.engine_polars._spinedb_reader.SpineDbReader`
|
|
33
|
+
--------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
The pre-existing ``SpineDbReader`` (used by the native cascade LP
|
|
36
|
+
build path) is a sibling abstraction over the same EAV schema, but with a
|
|
37
|
+
different consumer contract — it returns per-(entity_class,
|
|
38
|
+
parameter_name) frames in the canonical-axis schema used by the
|
|
39
|
+
``_axis_enums``-driven LP build path, not the spec-driven tabular
|
|
40
|
+
``input/*.csv`` layout the input_writer historically emitted. Both
|
|
41
|
+
abstractions coexist; the Backend serves the ``input_writer`` spec
|
|
42
|
+
contract, ``SpineDbReader`` continues to serve the fast-path.
|
|
43
|
+
Unification is a follow-up beyond Step 2.5.
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import logging
|
|
48
|
+
from typing import Any, Iterable
|
|
49
|
+
|
|
50
|
+
import polars as pl
|
|
51
|
+
|
|
52
|
+
from flextool.common_utils.precision import format_scalar_for_csv
|
|
53
|
+
from flextool.spinedb_backend._axis_enums import (
|
|
54
|
+
AxisContract,
|
|
55
|
+
cast_against_contract,
|
|
56
|
+
load_axis_contract,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Structural-parameter skip list — preserved verbatim from
|
|
62
|
+
# ``input_writer.write_parameter`` (lines 2166-2191). These parameter
|
|
63
|
+
# names hold method names / structural identifiers / boolean flags
|
|
64
|
+
# rather than numerical coefficients; precision rounding would corrupt
|
|
65
|
+
# the model, so the effective precision is forced to 0 (passthrough).
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# v56 Batch F — three entity classes carry an explicit ``is_enabled``
|
|
70
|
+
# parameter that replaces the pre-v56 ``entity_alternative.active``
|
|
71
|
+
# gating. The Backend post-filters entities/parameter_values of these
|
|
72
|
+
# three classes against the resolved ``is_enabled`` value in the active
|
|
73
|
+
# scenario. See ``flextool.update_flextool.db_migration.
|
|
74
|
+
# _migrate_v56_reactivate_is_enabled_parameter`` for the migration that
|
|
75
|
+
# materialises the parameter on legacy DBs.
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_IS_ENABLED_CLASSES: frozenset[str] = frozenset({
|
|
79
|
+
"constraint",
|
|
80
|
+
"reserve__upDown__unit__node",
|
|
81
|
+
"reserve__upDown__connection__node",
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_STRUCTURAL_PARAM_NAMES: frozenset[str] = frozenset({
|
|
86
|
+
# method names
|
|
87
|
+
"ct_method", "transfer_method", "conversion_method",
|
|
88
|
+
"startup_method", "fork_method", "inflow_method",
|
|
89
|
+
"invest_method", "lifetime_method", "ramp_method",
|
|
90
|
+
"minimum_time_method", "storage_binding_method",
|
|
91
|
+
"storage_nested_fix_method", "storage_solve_horizon_method",
|
|
92
|
+
"storage_start_end_method", "profile_method", "reserve_method",
|
|
93
|
+
"co2_method", "loss_share_type", "price_method",
|
|
94
|
+
"capacity_margin_method", "energy_margin_method",
|
|
95
|
+
"solver", "solve_mode",
|
|
96
|
+
"solver_precommand", "solver_arguments",
|
|
97
|
+
# structural flags / references
|
|
98
|
+
"is_DC", "sense", "node_type",
|
|
99
|
+
"has_inertia", "has_non_synchronous", "include_stochastics",
|
|
100
|
+
"print_dispatch", "print_indicators",
|
|
101
|
+
"flow_aggregator",
|
|
102
|
+
"output_horizon",
|
|
103
|
+
# set membership / name references
|
|
104
|
+
"solves", "contains_solves", "model",
|
|
105
|
+
"realized_periods", "realized_invest_periods",
|
|
106
|
+
"fix_storage_periods", "invest_periods", "periods_available",
|
|
107
|
+
"version",
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SpineDBBackend:
|
|
112
|
+
"""In-memory SpineDB materialiser for the input_writer spec contract.
|
|
113
|
+
|
|
114
|
+
Opens the database, applies the scenario filter once, and
|
|
115
|
+
pre-fetches the entity / parameter_value tables. Each materialiser
|
|
116
|
+
method returns a polars frame matching the canonical CSV schema
|
|
117
|
+
that the legacy ``input/*.csv`` files used (column names in
|
|
118
|
+
``spec["header"]`` order).
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
db_url : str
|
|
123
|
+
SpineDB URL. Bare paths are auto-prefixed with ``sqlite:///``.
|
|
124
|
+
scenario_name : str | None
|
|
125
|
+
Scenario filter name. ``None`` disables filtering (matches the
|
|
126
|
+
``write_input`` semantics: when no scenario is supplied the DB
|
|
127
|
+
is consumed as-is).
|
|
128
|
+
precision_digits : int, default 0
|
|
129
|
+
Precision-rounding policy passed to
|
|
130
|
+
:func:`format_scalar_for_csv` during parameter materialisation.
|
|
131
|
+
``0`` disables rounding (byte-parity with the pre-precision-pass
|
|
132
|
+
emissions).
|
|
133
|
+
|
|
134
|
+
Notes
|
|
135
|
+
-----
|
|
136
|
+
The Backend holds an open :class:`spinedb_api.DatabaseMapping` only
|
|
137
|
+
inside the ``with``-block. Use it as a context manager:
|
|
138
|
+
|
|
139
|
+
.. code-block:: python
|
|
140
|
+
|
|
141
|
+
with SpineDBBackend(db_url, scenario) as backend:
|
|
142
|
+
frame = backend.entities(
|
|
143
|
+
classes=("commodity",),
|
|
144
|
+
header=("commodity",),
|
|
145
|
+
)
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
# Class-level defaults so ``__new__`` paths (e.g.
|
|
149
|
+
# ``flextool.input_derivation.run`` bypasses ``__init__`` and attaches
|
|
150
|
+
# ``_db`` / ``_api`` / ``_precision_digits`` manually) still expose the
|
|
151
|
+
# attributes Phase-2 cast code reads. ``_scenario_name`` defaults to
|
|
152
|
+
# ``None`` (unfiltered backend); callers that *do* want a scenario
|
|
153
|
+
# filter should construct via ``__init__``.
|
|
154
|
+
_scenario_name: str | None = None
|
|
155
|
+
_db_url: str = ""
|
|
156
|
+
# Lazy cache for parameter_value rows, keyed by
|
|
157
|
+
# ``(entity_class_name, parameter_definition_name)``. Populated by
|
|
158
|
+
# :meth:`_get_parameter_value_index` on first ``parameter_values``
|
|
159
|
+
# call. Replaces ~100 individual ``find_parameter_values(class=,
|
|
160
|
+
# param=)`` calls (each ~1.85s under spinedb-api's scenario filter on
|
|
161
|
+
# large databases) with one bulk ``find_parameter_values()`` call
|
|
162
|
+
# plus a Python partition (~22ms total) — a ~60x speed-up.
|
|
163
|
+
_parameter_value_index: dict | None = None
|
|
164
|
+
# v56 Batch F — lazy cache of ``frozenset[int]`` disabled-entity ids
|
|
165
|
+
# per entity_class in ``_IS_ENABLED_CLASSES``. Populated on the first
|
|
166
|
+
# ``find_entities`` / ``entities`` / ``parameter_values`` call for
|
|
167
|
+
# that class. Lifetime is tied to ``_open`` / ``close`` of this
|
|
168
|
+
# Backend instance (single scenario filter).
|
|
169
|
+
_disabled_entity_id_cache: dict | None = None
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
db_url: str,
|
|
174
|
+
scenario_name: str | None = None,
|
|
175
|
+
*,
|
|
176
|
+
precision_digits: int = 0,
|
|
177
|
+
) -> None:
|
|
178
|
+
url = str(db_url)
|
|
179
|
+
if "://" not in url:
|
|
180
|
+
url = f"sqlite:///{url}"
|
|
181
|
+
self._db_url = url
|
|
182
|
+
self._scenario_name = scenario_name
|
|
183
|
+
self._precision_digits = int(precision_digits)
|
|
184
|
+
self._db: Any | None = None
|
|
185
|
+
self._open()
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Lifecycle
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def _open(self) -> None:
|
|
192
|
+
"""Open the DB, apply the scenario filter, pre-fetch entities +
|
|
193
|
+
parameter values.
|
|
194
|
+
|
|
195
|
+
spinedb-api now requires the ``DatabaseMapping`` to be inside a
|
|
196
|
+
``with`` block before ``self.query()`` works — the underlying
|
|
197
|
+
``Session`` is only created in ``__enter__``. Pre-v56 code
|
|
198
|
+
could call ``scenario_filter_from_dict`` against a freshly
|
|
199
|
+
constructed ``DatabaseMapping``; modern spinedb-api raises
|
|
200
|
+
``SpineDBAPIError("session is None; did you forget to use the
|
|
201
|
+
DB map inside a 'with' block?")``. We therefore enter the
|
|
202
|
+
mapping's context here and keep it open for the lifetime of
|
|
203
|
+
this Backend — :meth:`close` exits the context. The
|
|
204
|
+
``_context_open_count`` machinery in ``DatabaseMapping`` lets
|
|
205
|
+
nested call sites (e.g. ``fetch_all`` → internal ``with self``)
|
|
206
|
+
re-enter without closing prematurely.
|
|
207
|
+
"""
|
|
208
|
+
import spinedb_api as api # late import: keeps cold-import cheap
|
|
209
|
+
from spinedb_api import DatabaseMapping
|
|
210
|
+
|
|
211
|
+
self._api = api
|
|
212
|
+
db = DatabaseMapping(self._db_url)
|
|
213
|
+
# Enter the mapping's context so ``self._session`` is created.
|
|
214
|
+
# Required by ``scenario_filter_from_dict`` (which calls
|
|
215
|
+
# ``db.query()`` while building the filter's subqueries) and by
|
|
216
|
+
# downstream ``find_*`` calls. Matched by :meth:`close`.
|
|
217
|
+
db.__enter__()
|
|
218
|
+
# ``fetch_all`` mirrors ``input_writer.write_input`` (line 1836-1837).
|
|
219
|
+
# Pre-fetching here means subsequent ``find_*`` calls are pure
|
|
220
|
+
# in-memory walks.
|
|
221
|
+
db.fetch_all("entity")
|
|
222
|
+
db.fetch_all("parameter_value")
|
|
223
|
+
if self._scenario_name:
|
|
224
|
+
scen_config = api.filters.scenario_filter.scenario_filter_config(
|
|
225
|
+
self._scenario_name,
|
|
226
|
+
)
|
|
227
|
+
api.filters.scenario_filter.scenario_filter_from_dict(db, scen_config)
|
|
228
|
+
self._db = db
|
|
229
|
+
|
|
230
|
+
def close(self) -> None:
|
|
231
|
+
"""Close the underlying DB connection. Idempotent."""
|
|
232
|
+
if self._db is not None:
|
|
233
|
+
# Exit the mapping's context manager entered by :meth:`_open`,
|
|
234
|
+
# so the Session that was created in ``__enter__`` is closed.
|
|
235
|
+
try:
|
|
236
|
+
self._db.__exit__(None, None, None)
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
try:
|
|
240
|
+
self._db.connection.close()
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
try:
|
|
244
|
+
self._db.close()
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
self._db = None
|
|
248
|
+
self._parameter_value_index = None
|
|
249
|
+
self._disabled_entity_id_cache = None
|
|
250
|
+
|
|
251
|
+
def __enter__(self) -> "SpineDBBackend":
|
|
252
|
+
if self._db is None:
|
|
253
|
+
self._open()
|
|
254
|
+
return self
|
|
255
|
+
|
|
256
|
+
def __exit__(self, *exc: Any) -> None:
|
|
257
|
+
self.close()
|
|
258
|
+
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
# Diagnostic properties
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def db_url(self) -> str:
|
|
265
|
+
return self._db_url
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def scenario_name(self) -> str | None:
|
|
269
|
+
return self._scenario_name
|
|
270
|
+
|
|
271
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
272
|
+
return (
|
|
273
|
+
f"SpineDBBackend(db_url={self._db_url!r}, "
|
|
274
|
+
f"scenario_name={self._scenario_name!r})"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# ------------------------------------------------------------------
|
|
278
|
+
# Raw EAV accessors — for input_derivation consumers
|
|
279
|
+
# ------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
def find_entities(self, *, entity_class_name: str) -> list:
|
|
282
|
+
"""Return all entity rows of *entity_class_name*.
|
|
283
|
+
|
|
284
|
+
Thin passthrough over the underlying
|
|
285
|
+
:meth:`spinedb_api.DatabaseMapping.find_entities`, exposed so
|
|
286
|
+
consumers in :mod:`flextool.input_derivation` (DC power flow,
|
|
287
|
+
process method, commodity ladder) can perform DB-driven
|
|
288
|
+
derivations without reaching past the Backend boundary.
|
|
289
|
+
|
|
290
|
+
v56 Batch F — for the three entity classes in
|
|
291
|
+
:data:`_IS_ENABLED_CLASSES` the result is post-filtered against
|
|
292
|
+
the explicit ``is_enabled`` parameter_value: entities whose
|
|
293
|
+
``is_enabled`` resolves to ``"no"`` in the active scenario are
|
|
294
|
+
dropped. Replaces the entity_alternative-based gating that
|
|
295
|
+
spinedb_api's scenario_filter applied pre-v56 (the migration
|
|
296
|
+
drops those rows for the three classes).
|
|
297
|
+
"""
|
|
298
|
+
if self._db is None:
|
|
299
|
+
raise RuntimeError("SpineDBBackend is closed")
|
|
300
|
+
entities = list(
|
|
301
|
+
self._db.find_entities(entity_class_name=entity_class_name)
|
|
302
|
+
)
|
|
303
|
+
if entity_class_name in _IS_ENABLED_CLASSES:
|
|
304
|
+
disabled_ids = self._disabled_entity_ids(entity_class_name)
|
|
305
|
+
if disabled_ids:
|
|
306
|
+
entities = [e for e in entities if e["id"] not in disabled_ids]
|
|
307
|
+
return entities
|
|
308
|
+
|
|
309
|
+
def _disabled_entity_ids(self, entity_class_name: str) -> frozenset[int]:
|
|
310
|
+
"""Return entity IDs of *entity_class_name* whose effective
|
|
311
|
+
``is_enabled`` parameter_value is ``"no"`` under the active
|
|
312
|
+
scenario filter.
|
|
313
|
+
|
|
314
|
+
Lazy-cached per class on first call. The scenario filter is
|
|
315
|
+
applied DB-wide at ``_open`` time (see
|
|
316
|
+
:meth:`scenario_filter_from_dict`), so
|
|
317
|
+
:meth:`spinedb_api.DatabaseMapping.find_parameter_values` already
|
|
318
|
+
returns the scenario-priority winner per (entity, parameter):
|
|
319
|
+
one row per entity, with the highest-priority alternative's
|
|
320
|
+
value resolved. We treat any returned row carrying
|
|
321
|
+
``parsed_value == "no"`` as disabling its entity.
|
|
322
|
+
|
|
323
|
+
On a pre-v56 database the ``is_enabled`` parameter_definition
|
|
324
|
+
does not exist; ``find_parameter_values`` then yields nothing
|
|
325
|
+
and every entity is treated as enabled — preserving legacy
|
|
326
|
+
behaviour for callers that open an un-migrated DB read-only.
|
|
327
|
+
"""
|
|
328
|
+
cache = self._disabled_entity_id_cache
|
|
329
|
+
if cache is None:
|
|
330
|
+
cache = {}
|
|
331
|
+
self._disabled_entity_id_cache = cache
|
|
332
|
+
if entity_class_name in cache:
|
|
333
|
+
return cache[entity_class_name]
|
|
334
|
+
if self._db is None:
|
|
335
|
+
raise RuntimeError("SpineDBBackend is closed")
|
|
336
|
+
try:
|
|
337
|
+
pv_rows = self._db.find_parameter_values(
|
|
338
|
+
entity_class_name=entity_class_name,
|
|
339
|
+
parameter_definition_name="is_enabled",
|
|
340
|
+
)
|
|
341
|
+
except Exception: # noqa: BLE001 — pre-v56 DBs lack the pdef
|
|
342
|
+
pv_rows = []
|
|
343
|
+
disabled: set[int] = set()
|
|
344
|
+
for pv in pv_rows:
|
|
345
|
+
if pv.get("type") != "str":
|
|
346
|
+
continue
|
|
347
|
+
if pv.get("parsed_value") == "no":
|
|
348
|
+
disabled.add(pv["entity_id"])
|
|
349
|
+
result = frozenset(disabled)
|
|
350
|
+
cache[entity_class_name] = result
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
def find_parameter_values(
|
|
354
|
+
self,
|
|
355
|
+
*,
|
|
356
|
+
entity_class_name: str,
|
|
357
|
+
parameter_definition_name: str,
|
|
358
|
+
) -> list:
|
|
359
|
+
"""Return all parameter-value rows for *(entity_class_name,
|
|
360
|
+
parameter_definition_name)*.
|
|
361
|
+
|
|
362
|
+
Same role as :meth:`find_entities`: a Backend-owned passthrough
|
|
363
|
+
so :mod:`flextool.input_derivation` derivations don't have to
|
|
364
|
+
re-implement the DB access layer.
|
|
365
|
+
"""
|
|
366
|
+
if self._db is None:
|
|
367
|
+
raise RuntimeError("SpineDBBackend is closed")
|
|
368
|
+
return self._db.find_parameter_values(
|
|
369
|
+
entity_class_name=entity_class_name,
|
|
370
|
+
parameter_definition_name=parameter_definition_name,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def api(self):
|
|
375
|
+
"""Expose the underlying :mod:`spinedb_api` module.
|
|
376
|
+
|
|
377
|
+
Several derivations need ``api.convert_map_to_table`` /
|
|
378
|
+
``api.parameter_value.from_database_to_dimension_count`` to
|
|
379
|
+
flatten nested Spine maps. Re-exposing the same module the
|
|
380
|
+
Backend imported avoids a duplicate import in every derivation
|
|
381
|
+
site (and keeps that import path single-sourced).
|
|
382
|
+
"""
|
|
383
|
+
if self._db is None:
|
|
384
|
+
raise RuntimeError("SpineDBBackend is closed")
|
|
385
|
+
return self._api
|
|
386
|
+
|
|
387
|
+
# ==================================================================
|
|
388
|
+
# Materialiser methods — fully filled in by Items 2-4 of the work
|
|
389
|
+
# plan. Each method returns a polars DataFrame; the caller is
|
|
390
|
+
# responsible for placing it in the Provider under the canonical
|
|
391
|
+
# frame key.
|
|
392
|
+
# ==================================================================
|
|
393
|
+
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
# parameter_defaults — Item 2
|
|
396
|
+
# ------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
def parameter_defaults(
|
|
399
|
+
self,
|
|
400
|
+
*,
|
|
401
|
+
cl_pars: Iterable[tuple[str, str]],
|
|
402
|
+
header: str,
|
|
403
|
+
filter_in_type: Iterable[str] | None = None,
|
|
404
|
+
only_value: bool = False,
|
|
405
|
+
axis_enums: dict[str, pl.Enum] | None = None,
|
|
406
|
+
contract: AxisContract | None = None,
|
|
407
|
+
) -> pl.DataFrame:
|
|
408
|
+
"""Materialise the default-value rows for one ``_DEFAULT_VALUES_SPECS`` entry.
|
|
409
|
+
|
|
410
|
+
Returns a polars frame.
|
|
411
|
+
|
|
412
|
+
Parameters
|
|
413
|
+
----------
|
|
414
|
+
cl_pars : iterable of (entity_class, parameter_name) tuples
|
|
415
|
+
The parameter-definition (class, name) pairs to materialise.
|
|
416
|
+
header : str
|
|
417
|
+
Comma-separated header for the canonical CSV layout. The
|
|
418
|
+
frame's columns are derived from this header so downstream
|
|
419
|
+
consumers see byte-identical schemas.
|
|
420
|
+
filter_in_type : iterable of str, optional
|
|
421
|
+
Restrict to parameter definitions whose ``default_type`` is
|
|
422
|
+
in this set. ``None`` accepts every supported scalar type.
|
|
423
|
+
only_value : bool, default False
|
|
424
|
+
When True the frame contains a single column (the value
|
|
425
|
+
only). Used by the ``db_version`` spec where the canonical
|
|
426
|
+
CSV is a one-column scalar dump.
|
|
427
|
+
"""
|
|
428
|
+
if self._db is None:
|
|
429
|
+
raise RuntimeError("SpineDBBackend is closed")
|
|
430
|
+
cl_pars = list(cl_pars)
|
|
431
|
+
cols = [c.strip() for c in header.split(",")]
|
|
432
|
+
filter_set = (
|
|
433
|
+
set(filter_in_type) if filter_in_type is not None else None
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
precision = self._precision_digits
|
|
437
|
+
# Match write_default_values' structural-param policy: when the
|
|
438
|
+
# default is one of the structural param names the precision is
|
|
439
|
+
# forced to 0 so the emitted text round-trips byte-for-byte with
|
|
440
|
+
# the on-disk legacy CSV. (The legacy code did not branch on
|
|
441
|
+
# the structural set for defaults — but the params in the only
|
|
442
|
+
# default_values spec entry are ``penalty_up`` / ``penalty_down``
|
|
443
|
+
# which are pure floats, so the policy is a strict superset.)
|
|
444
|
+
if any(par in _STRUCTURAL_PARAM_NAMES for _, par in cl_pars):
|
|
445
|
+
precision = 0
|
|
446
|
+
|
|
447
|
+
api = self._api
|
|
448
|
+
# ``find_parameter_definitions`` returns ALL definitions; filter
|
|
449
|
+
# locally by (class, name) to mirror write_default_values exactly.
|
|
450
|
+
definitions = self._db.find_parameter_definitions()
|
|
451
|
+
rows: list[list[str]] = []
|
|
452
|
+
# Origin breadcrumbs parallel to ``rows`` (Option C) — each entry
|
|
453
|
+
# is a dict captured at row-emit time so a downstream
|
|
454
|
+
# FlexDataIntegrityError can name the parameter / class that
|
|
455
|
+
# generated the offending row.
|
|
456
|
+
row_origins: list[dict[str, Any]] = []
|
|
457
|
+
from flextool.engine_polars._solve_state import FlexToolConfigError
|
|
458
|
+
|
|
459
|
+
for cl_par in cl_pars:
|
|
460
|
+
for definition in definitions:
|
|
461
|
+
if (
|
|
462
|
+
definition["entity_class_name"] != cl_par[0]
|
|
463
|
+
or definition["name"] != cl_par[1]
|
|
464
|
+
):
|
|
465
|
+
continue
|
|
466
|
+
if (
|
|
467
|
+
filter_set is not None
|
|
468
|
+
and definition["default_type"] not in filter_set
|
|
469
|
+
):
|
|
470
|
+
continue
|
|
471
|
+
dtype = definition["default_type"]
|
|
472
|
+
if dtype in ("str", "float", "bool"):
|
|
473
|
+
raw = api.from_database(
|
|
474
|
+
definition["default_value"], dtype,
|
|
475
|
+
)
|
|
476
|
+
formatted = format_scalar_for_csv(raw, precision)
|
|
477
|
+
if only_value:
|
|
478
|
+
rows.append([formatted])
|
|
479
|
+
else:
|
|
480
|
+
rows.append([
|
|
481
|
+
definition["entity_class_name"],
|
|
482
|
+
definition["name"],
|
|
483
|
+
formatted,
|
|
484
|
+
])
|
|
485
|
+
row_origins.append({
|
|
486
|
+
"parameter": definition["name"],
|
|
487
|
+
"entity": definition["entity_class_name"],
|
|
488
|
+
"scenario": self._scenario_name,
|
|
489
|
+
})
|
|
490
|
+
else:
|
|
491
|
+
raise FlexToolConfigError(
|
|
492
|
+
"Default_value found in a parameter definition not "
|
|
493
|
+
"of supported default type\nParameter: "
|
|
494
|
+
+ definition.get("parameter_definition_name", definition["name"])
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
frame = _rows_to_frame(rows, cols)
|
|
498
|
+
return self._maybe_cast(
|
|
499
|
+
frame, rows, row_origins, axis_enums, contract,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# ------------------------------------------------------------------
|
|
503
|
+
# entities — Item 3
|
|
504
|
+
# ------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
def entities(
|
|
507
|
+
self,
|
|
508
|
+
*,
|
|
509
|
+
classes: Iterable[str],
|
|
510
|
+
header: str,
|
|
511
|
+
entity_dimens: Iterable[Iterable[int]] | None = None,
|
|
512
|
+
axis_enums: dict[str, pl.Enum] | None = None,
|
|
513
|
+
contract: AxisContract | None = None,
|
|
514
|
+
) -> pl.DataFrame:
|
|
515
|
+
"""Materialise the entity rows for one ``_ENTITY_SPECS`` entry as a polars frame.
|
|
516
|
+
|
|
517
|
+
Parameters
|
|
518
|
+
----------
|
|
519
|
+
classes : iterable of str
|
|
520
|
+
The SpineDB entity-class names to union over. Each class
|
|
521
|
+
contributes its full entity set; the result is the
|
|
522
|
+
row-concatenation (in class-iteration order).
|
|
523
|
+
header : str
|
|
524
|
+
Comma-separated header for the canonical CSV layout.
|
|
525
|
+
entity_dimens : iterable of iterable of int, optional
|
|
526
|
+
Per-class re-ordering / projection of the
|
|
527
|
+
``entity_byname`` tuple — list of integer index lists, one
|
|
528
|
+
per class. Used to disambiguate multi-dimensional
|
|
529
|
+
entity-class joins where the same dim appears more than
|
|
530
|
+
once (e.g. ``connection__node__node``). ``None`` keeps the
|
|
531
|
+
byname tuple as-is.
|
|
532
|
+
"""
|
|
533
|
+
if self._db is None:
|
|
534
|
+
raise RuntimeError("SpineDBBackend is closed")
|
|
535
|
+
classes_list = list(classes)
|
|
536
|
+
dimens_list: list[list[int] | None]
|
|
537
|
+
if entity_dimens is None:
|
|
538
|
+
dimens_list = [None] * len(classes_list)
|
|
539
|
+
else:
|
|
540
|
+
dimens_list = [list(d) if d is not None else None
|
|
541
|
+
for d in entity_dimens]
|
|
542
|
+
cols = [c.strip() for c in header.split(",")]
|
|
543
|
+
|
|
544
|
+
rows: list[list[str]] = []
|
|
545
|
+
row_origins: list[dict[str, Any]] = []
|
|
546
|
+
for i, ent_class in enumerate(classes_list):
|
|
547
|
+
dim_proj = dimens_list[i] if i < len(dimens_list) else None
|
|
548
|
+
# v56 Batch F — filter is_enabled="no" entities out of the
|
|
549
|
+
# three affected classes. Replaces the entity_alternative
|
|
550
|
+
# gating spinedb_api's scenario_filter applied pre-v56.
|
|
551
|
+
disabled_ids: frozenset[int] = frozenset()
|
|
552
|
+
if ent_class in _IS_ENABLED_CLASSES:
|
|
553
|
+
disabled_ids = self._disabled_entity_ids(ent_class)
|
|
554
|
+
for entity in self._db.find_entities(entity_class_name=ent_class):
|
|
555
|
+
if disabled_ids and entity["id"] in disabled_ids:
|
|
556
|
+
continue
|
|
557
|
+
byname = entity["entity_byname"]
|
|
558
|
+
if dim_proj is None:
|
|
559
|
+
rows.append(list(byname))
|
|
560
|
+
else:
|
|
561
|
+
rows.append([byname[x] for x in dim_proj])
|
|
562
|
+
row_origins.append({
|
|
563
|
+
"parameter": None,
|
|
564
|
+
"entity": "->".join(str(b) for b in byname),
|
|
565
|
+
"scenario": self._scenario_name,
|
|
566
|
+
"entity_class": ent_class,
|
|
567
|
+
})
|
|
568
|
+
frame = _rows_to_frame(rows, cols)
|
|
569
|
+
return self._maybe_cast(
|
|
570
|
+
frame, rows, row_origins, axis_enums, contract,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# ------------------------------------------------------------------
|
|
574
|
+
# parameter_values — Item 4
|
|
575
|
+
# ------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
def parameter_values(
|
|
578
|
+
self,
|
|
579
|
+
*,
|
|
580
|
+
cl_pars: Iterable[tuple[str, str]],
|
|
581
|
+
header: str,
|
|
582
|
+
filter_in_type: Iterable[str] | None = None,
|
|
583
|
+
filter_out_index: str | None = None,
|
|
584
|
+
filter_in_value: Any = None,
|
|
585
|
+
no_value: bool = False,
|
|
586
|
+
param_print: bool = False,
|
|
587
|
+
dimens: Iterable[int] | None = None,
|
|
588
|
+
param_loc: int | None = None,
|
|
589
|
+
no_entity: bool | None = None,
|
|
590
|
+
axis_enums: dict[str, pl.Enum] | None = None,
|
|
591
|
+
contract: AxisContract | None = None,
|
|
592
|
+
) -> pl.DataFrame:
|
|
593
|
+
"""Materialise the parameter-value rows for one ``_PARAMETER_SPECS`` entry.
|
|
594
|
+
|
|
595
|
+
Handles the 1d / 2d / … map dimensionality logic, type / value /
|
|
596
|
+
index filters, the structural-parameter precision policy, the
|
|
597
|
+
``no_value`` /
|
|
598
|
+
``no_entity`` / ``param_print`` / ``param_loc`` schema shaping
|
|
599
|
+
flags — but returns a polars frame instead of writing CSV.
|
|
600
|
+
"""
|
|
601
|
+
if self._db is None:
|
|
602
|
+
raise RuntimeError("SpineDBBackend is closed")
|
|
603
|
+
from flextool.engine_polars._solve_state import FlexToolConfigError
|
|
604
|
+
|
|
605
|
+
cl_pars = list(cl_pars)
|
|
606
|
+
cols = [c.strip() for c in header.split(",")]
|
|
607
|
+
dimens_list = list(dimens) if dimens is not None else None
|
|
608
|
+
|
|
609
|
+
# Effective precision per the structural-param policy.
|
|
610
|
+
effective_precision = (
|
|
611
|
+
0 if any(par in _STRUCTURAL_PARAM_NAMES for _, par in cl_pars)
|
|
612
|
+
else self._precision_digits
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
# Map-dimensionality filter parsing — exactly write_parameter's
|
|
616
|
+
# rule. At most one ``Nd_map`` entry; if present, also accept
|
|
617
|
+
# the generic ``map`` type code via the filter.
|
|
618
|
+
type_filter_map_dim: int | list = []
|
|
619
|
+
filter_in_type_list: list[str] | None = None
|
|
620
|
+
if filter_in_type is not None:
|
|
621
|
+
filter_in_type_list = list(filter_in_type)
|
|
622
|
+
map_found = False
|
|
623
|
+
for tf in list(filter_in_type_list):
|
|
624
|
+
if tf in ("1d_map", "2d_map", "3d_map", "4d_map", "5d_map"):
|
|
625
|
+
if map_found:
|
|
626
|
+
message = (
|
|
627
|
+
"Trying to have two different dimensionalities in "
|
|
628
|
+
"the same parameter to be written out"
|
|
629
|
+
)
|
|
630
|
+
logging.error(message)
|
|
631
|
+
raise FlexToolConfigError(message)
|
|
632
|
+
map_found = True
|
|
633
|
+
type_filter_map_dim = int(tf[0])
|
|
634
|
+
filter_in_type_list.remove(tf)
|
|
635
|
+
if map_found:
|
|
636
|
+
filter_in_type_list.append("map")
|
|
637
|
+
|
|
638
|
+
api = self._api
|
|
639
|
+
|
|
640
|
+
# Use the cached (class, param) → list[param] index built by
|
|
641
|
+
# ``_get_parameter_value_index`` — one bulk ``find_parameter_values()``
|
|
642
|
+
# call instead of N filtered ones. Each filtered call is
|
|
643
|
+
# ~1.85s under spinedb-api's scenario filter on large fixtures;
|
|
644
|
+
# the bulk call + Python partition is ~2.8s total regardless of
|
|
645
|
+
# N, so once N > ~2 this is a net win.
|
|
646
|
+
index = self._get_parameter_value_index()
|
|
647
|
+
params: list = []
|
|
648
|
+
# v56 Batch F — when a (class, param) pair belongs to an
|
|
649
|
+
# is_enabled-gated class, drop parameter_value rows for entities
|
|
650
|
+
# whose ``is_enabled`` resolves to ``"no"`` in the active
|
|
651
|
+
# scenario. Without this filter the cascade would re-introduce
|
|
652
|
+
# disabled entities through their non-is_enabled
|
|
653
|
+
# parameter_values (e.g. ``constraint.constant`` /
|
|
654
|
+
# ``reserve__upDown__unit__node.reliability``).
|
|
655
|
+
for cl_par in cl_pars:
|
|
656
|
+
bucket = index.get((cl_par[0], cl_par[1]), ())
|
|
657
|
+
ent_class = cl_par[0]
|
|
658
|
+
if ent_class in _IS_ENABLED_CLASSES:
|
|
659
|
+
disabled_ids = self._disabled_entity_ids(ent_class)
|
|
660
|
+
if disabled_ids:
|
|
661
|
+
bucket = [
|
|
662
|
+
p for p in bucket
|
|
663
|
+
if p["entity_id"] not in disabled_ids
|
|
664
|
+
]
|
|
665
|
+
params.extend(bucket)
|
|
666
|
+
|
|
667
|
+
# Track A.6 — chunked row accumulator. Some specs (notably
|
|
668
|
+
# ``('profile', 'profile')`` on H2_trade) flatten to multi-million
|
|
669
|
+
# Python row-lists, which dominate input_derivation peak RSS even
|
|
670
|
+
# after Track A + A.5. Flushing the row accumulator to a polars
|
|
671
|
+
# sub-frame periodically and clearing the list keeps the
|
|
672
|
+
# in-flight Python overhead bounded to ``_ROWS_FLUSH_THRESHOLD``
|
|
673
|
+
# entries. The sub-frames are concatenated at end; for ≤1 chunk
|
|
674
|
+
# the path is identical to the pre-chunking code.
|
|
675
|
+
#
|
|
676
|
+
# Disabled when ``axis_enums`` is supplied: ``_maybe_cast``'s
|
|
677
|
+
# error-breadcrumb logic walks ``rows`` and ``row_origins`` in
|
|
678
|
+
# parallel, so partial-rows views would mis-attribute cast
|
|
679
|
+
# failures. axis_enum-supplied callsites are tests with small
|
|
680
|
+
# fixtures where the peak doesn't bind; safe to fall back.
|
|
681
|
+
_ROWS_FLUSH_THRESHOLD = 200_000
|
|
682
|
+
_can_chunk = axis_enums is None
|
|
683
|
+
chunk_frames: list[pl.DataFrame] = []
|
|
684
|
+
|
|
685
|
+
rows: list[list[str]] = []
|
|
686
|
+
# Origin breadcrumbs parallel to ``rows`` (Option C). Each
|
|
687
|
+
# entry is a dict of (parameter, entity, scenario, map_index)
|
|
688
|
+
# captured at row-emit time. Map rows append the map index
|
|
689
|
+
# specifically; scalar / array rows leave map_index as None.
|
|
690
|
+
#
|
|
691
|
+
# Track A.5 — these breadcrumbs are *only* consulted by
|
|
692
|
+
# :meth:`_maybe_cast` when an axis-enum cast fails. When
|
|
693
|
+
# ``axis_enums`` is ``None`` (the default for the
|
|
694
|
+
# ``input_derivation`` callsites) ``_maybe_cast`` returns the
|
|
695
|
+
# frame untouched and never reads ``row_origins``. Building 3+
|
|
696
|
+
# million-entry dict lists for that path is pure waste, so we
|
|
697
|
+
# short-circuit append to a no-op when no cast is requested.
|
|
698
|
+
# For axis-enum-enabled callsites the full origin dict list is
|
|
699
|
+
# still constructed and the error breadcrumb quality is
|
|
700
|
+
# unchanged.
|
|
701
|
+
row_origins: list[dict[str, Any]] = []
|
|
702
|
+
if axis_enums is None:
|
|
703
|
+
def _origin_append(_origin: dict[str, Any]) -> None:
|
|
704
|
+
pass
|
|
705
|
+
else:
|
|
706
|
+
_origin_append = row_origins.append # type: ignore[assignment]
|
|
707
|
+
scen = self._scenario_name
|
|
708
|
+
# Track A — wrap ``params`` so each :class:`spinedb_api.PublicItem`
|
|
709
|
+
# has its underlying ``MappedItem._parsed_value`` set to ``None``
|
|
710
|
+
# as iteration advances past it. ``parsed_value`` is a lazy
|
|
711
|
+
# property in spinedb-api: dropping the cached object releases the
|
|
712
|
+
# parsed ``Map`` / ``TimeSeries`` / ``Array`` and the next access
|
|
713
|
+
# would re-parse via ``from_database(value, type)``. Each
|
|
714
|
+
# ``(class, param)`` is touched exactly once per
|
|
715
|
+
# ``input_derivation`` pass, so this gives the full memory win at
|
|
716
|
+
# zero re-parse cost. The wrapper is safe even when callers
|
|
717
|
+
# ``continue`` mid-iteration: the next ``next()`` call enters the
|
|
718
|
+
# generator body BEFORE yielding the new item, so the previously
|
|
719
|
+
# yielded item is evicted no matter how the caller exited the
|
|
720
|
+
# body. At any moment at most one parsed_value lives.
|
|
721
|
+
# ``tests/spinedb_backend/test_parsed_value_eviction.py`` enforces
|
|
722
|
+
# this contract and warns loudly on spinedb-api API drift.
|
|
723
|
+
|
|
724
|
+
def _evict_as_we_go(items):
|
|
725
|
+
prev = None
|
|
726
|
+
for item in items:
|
|
727
|
+
if prev is not None:
|
|
728
|
+
prev.mapped_item._parsed_value = None
|
|
729
|
+
prev = item
|
|
730
|
+
yield item
|
|
731
|
+
if prev is not None:
|
|
732
|
+
prev.mapped_item._parsed_value = None
|
|
733
|
+
|
|
734
|
+
for param in _evict_as_we_go(params):
|
|
735
|
+
if _can_chunk and len(rows) >= _ROWS_FLUSH_THRESHOLD:
|
|
736
|
+
# Flush the in-flight rows into a sub-frame and start
|
|
737
|
+
# fresh. ``rows = []`` rebinds; the previous list
|
|
738
|
+
# becomes garbage as soon as ``_rows_to_frame`` returns.
|
|
739
|
+
chunk_frames.append(_rows_to_frame(rows, cols))
|
|
740
|
+
rows = []
|
|
741
|
+
if param["type"] is None:
|
|
742
|
+
continue
|
|
743
|
+
if filter_in_type_list and param["type"] not in filter_in_type_list:
|
|
744
|
+
continue
|
|
745
|
+
|
|
746
|
+
entity_byname = list(param["entity_byname"])
|
|
747
|
+
if dimens_list is not None:
|
|
748
|
+
tmp: list[Any] = [None] * len(entity_byname)
|
|
749
|
+
for i, dim in enumerate(dimens_list):
|
|
750
|
+
tmp[dim] = entity_byname[i]
|
|
751
|
+
entity_byname = tmp
|
|
752
|
+
|
|
753
|
+
if param_print:
|
|
754
|
+
if param_loc is not None:
|
|
755
|
+
collect = []
|
|
756
|
+
for i, byname in enumerate(entity_byname):
|
|
757
|
+
if i == param_loc:
|
|
758
|
+
collect.append(param["parameter_definition_name"])
|
|
759
|
+
collect.append(byname)
|
|
760
|
+
first_cols = collect
|
|
761
|
+
else:
|
|
762
|
+
if no_entity:
|
|
763
|
+
first_cols = [param["parameter_definition_name"]]
|
|
764
|
+
else:
|
|
765
|
+
first_cols = list(entity_byname) + [
|
|
766
|
+
param["parameter_definition_name"]
|
|
767
|
+
]
|
|
768
|
+
else:
|
|
769
|
+
first_cols = list(entity_byname)
|
|
770
|
+
|
|
771
|
+
pname = param["parameter_definition_name"]
|
|
772
|
+
ent_str = "->".join(
|
|
773
|
+
str(b) for b in param["entity_byname"]
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
ptype = param["type"]
|
|
777
|
+
if ptype == "map":
|
|
778
|
+
if (
|
|
779
|
+
filter_out_index
|
|
780
|
+
and param["parsed_value"].index_name == filter_out_index
|
|
781
|
+
):
|
|
782
|
+
continue
|
|
783
|
+
if (
|
|
784
|
+
filter_in_type_list
|
|
785
|
+
and isinstance(type_filter_map_dim, int)
|
|
786
|
+
and type_filter_map_dim
|
|
787
|
+
!= api.parameter_value.from_database_to_dimension_count(
|
|
788
|
+
param["value"], param["type"],
|
|
789
|
+
)
|
|
790
|
+
):
|
|
791
|
+
continue
|
|
792
|
+
value = param["parsed_value"]
|
|
793
|
+
if (
|
|
794
|
+
api.parameter_value.from_database_to_dimension_count(
|
|
795
|
+
param["value"], param["type"],
|
|
796
|
+
) <= 1
|
|
797
|
+
):
|
|
798
|
+
indexes = [str(ind) for ind in value.indexes]
|
|
799
|
+
pairs = list(zip(
|
|
800
|
+
indexes,
|
|
801
|
+
[format_scalar_for_csv(v, effective_precision)
|
|
802
|
+
for v in value.values],
|
|
803
|
+
))
|
|
804
|
+
for idx, val in pairs:
|
|
805
|
+
if no_value:
|
|
806
|
+
rows.append(first_cols + [idx])
|
|
807
|
+
else:
|
|
808
|
+
rows.append(first_cols + [idx, val])
|
|
809
|
+
_origin_append({
|
|
810
|
+
"parameter": pname,
|
|
811
|
+
"entity": ent_str,
|
|
812
|
+
"scenario": scen,
|
|
813
|
+
"map_index": idx,
|
|
814
|
+
})
|
|
815
|
+
else:
|
|
816
|
+
flat_map = api.convert_map_to_table(value)
|
|
817
|
+
for index in flat_map:
|
|
818
|
+
row = list(index)
|
|
819
|
+
if no_value:
|
|
820
|
+
rows.append(first_cols + row[:-1])
|
|
821
|
+
else:
|
|
822
|
+
row[-1] = format_scalar_for_csv(
|
|
823
|
+
row[-1], effective_precision,
|
|
824
|
+
)
|
|
825
|
+
rows.append(first_cols + row)
|
|
826
|
+
# The map index path is the row content before
|
|
827
|
+
# the value; render it as a joined-string for
|
|
828
|
+
# the breadcrumb. Use index[:-1] to drop the
|
|
829
|
+
# leaf value.
|
|
830
|
+
idx_path = "->".join(
|
|
831
|
+
str(x) for x in list(index)[:-1]
|
|
832
|
+
)
|
|
833
|
+
_origin_append({
|
|
834
|
+
"parameter": pname,
|
|
835
|
+
"entity": ent_str,
|
|
836
|
+
"scenario": scen,
|
|
837
|
+
"map_index": idx_path,
|
|
838
|
+
})
|
|
839
|
+
elif ptype in ("array", "time_series"):
|
|
840
|
+
# Reject array-valued ``node.storage_binding_method`` at
|
|
841
|
+
# ingestion. The 2026-04 list-valued design (now reverted)
|
|
842
|
+
# silently flattened arrays into one row per element,
|
|
843
|
+
# which downstream additive logic in
|
|
844
|
+
# ``calc_storage_vre.py`` and the constraint emitter
|
|
845
|
+
# turned into double-counted state-change residuals.
|
|
846
|
+
# Single-valued is the contract; arrays are a hard error
|
|
847
|
+
# with an actionable message pointing at the v52->v53
|
|
848
|
+
# migration (added in Phase 2).
|
|
849
|
+
if (
|
|
850
|
+
param["entity_class_name"] == "node"
|
|
851
|
+
and pname == "storage_binding_method"
|
|
852
|
+
):
|
|
853
|
+
try:
|
|
854
|
+
arr_values = list(param["parsed_value"].values)
|
|
855
|
+
except Exception:
|
|
856
|
+
arr_values = []
|
|
857
|
+
arr_repr = "[" + ", ".join(
|
|
858
|
+
repr(v) for v in arr_values
|
|
859
|
+
) + "]"
|
|
860
|
+
allowed = [
|
|
861
|
+
"bind_within_solve_blended_weights",
|
|
862
|
+
"bind_intraperiod_blocks",
|
|
863
|
+
"bind_within_solve",
|
|
864
|
+
"bind_within_period",
|
|
865
|
+
"bind_within_timeblock",
|
|
866
|
+
"bind_forward_only",
|
|
867
|
+
]
|
|
868
|
+
message = (
|
|
869
|
+
"node.storage_binding_method must be a single "
|
|
870
|
+
"string, not an array.\n"
|
|
871
|
+
f"Offending entity: {ent_str}\n"
|
|
872
|
+
f"Array contents: {arr_repr}\n"
|
|
873
|
+
f"Allowed single-string values: {allowed}\n"
|
|
874
|
+
"Pick `bind_within_solve_blended_weights` if the node "
|
|
875
|
+
"uses representative-period blended weights, "
|
|
876
|
+
"otherwise pick the dominant non-RP method from "
|
|
877
|
+
"your array.\n"
|
|
878
|
+
"Run the v52->v53 migration tool (added in "
|
|
879
|
+
"Phase 2) to convert existing databases "
|
|
880
|
+
"automatically."
|
|
881
|
+
)
|
|
882
|
+
logging.error(message)
|
|
883
|
+
raise FlexToolConfigError(message)
|
|
884
|
+
for i_arr, v in enumerate(param["parsed_value"].values):
|
|
885
|
+
rows.append(
|
|
886
|
+
list(entity_byname)
|
|
887
|
+
+ [format_scalar_for_csv(v, effective_precision)]
|
|
888
|
+
)
|
|
889
|
+
_origin_append({
|
|
890
|
+
"parameter": pname,
|
|
891
|
+
"entity": ent_str,
|
|
892
|
+
"scenario": scen,
|
|
893
|
+
"map_index": str(i_arr),
|
|
894
|
+
})
|
|
895
|
+
elif ptype in ("str", "float", "bool"):
|
|
896
|
+
if filter_in_value is not None:
|
|
897
|
+
# A set/frozenset → membership test (v58 enum gating, e.g.
|
|
898
|
+
# flow_aggregator ∈ {dispatch_plots_only, both}); a scalar →
|
|
899
|
+
# exact match (backward compatible).
|
|
900
|
+
if isinstance(filter_in_value, (set, frozenset)):
|
|
901
|
+
if param["parsed_value"] not in filter_in_value:
|
|
902
|
+
continue
|
|
903
|
+
elif param["parsed_value"] != filter_in_value:
|
|
904
|
+
continue
|
|
905
|
+
if no_value:
|
|
906
|
+
rows.append(first_cols)
|
|
907
|
+
else:
|
|
908
|
+
rows.append(
|
|
909
|
+
first_cols
|
|
910
|
+
+ [format_scalar_for_csv(
|
|
911
|
+
param["parsed_value"], effective_precision,
|
|
912
|
+
)]
|
|
913
|
+
)
|
|
914
|
+
_origin_append({
|
|
915
|
+
"parameter": pname,
|
|
916
|
+
"entity": ent_str,
|
|
917
|
+
"scenario": scen,
|
|
918
|
+
})
|
|
919
|
+
else:
|
|
920
|
+
supported = (
|
|
921
|
+
filter_in_type_list
|
|
922
|
+
if filter_in_type_list
|
|
923
|
+
else ["bool", "str", "float", "array", "time_series", "map"]
|
|
924
|
+
)
|
|
925
|
+
message = (
|
|
926
|
+
f"Input data found in a parameter not of supported type."
|
|
927
|
+
f"\nEntity: {','.join(map(str, entity_byname))}"
|
|
928
|
+
f"\nParameter: {param['parameter_definition_name']}"
|
|
929
|
+
f"\nSupported types: {supported}"
|
|
930
|
+
f"\nParameter type: {param['type']}"
|
|
931
|
+
)
|
|
932
|
+
logging.error(message)
|
|
933
|
+
raise FlexToolConfigError(message)
|
|
934
|
+
|
|
935
|
+
# Track A.6 — assemble the final frame from any flushed chunks
|
|
936
|
+
# plus the in-flight remainder. Single-chunk path is identical
|
|
937
|
+
# to the pre-chunking behaviour (one ``_rows_to_frame`` call,
|
|
938
|
+
# one ``_maybe_cast``). Multi-chunk path concatenates the
|
|
939
|
+
# sub-frames *before* the cast so ``_maybe_cast`` sees a single
|
|
940
|
+
# frame. ``row_origins`` is empty in the chunked path (Track A.5
|
|
941
|
+
# gated it to non-None axis_enums callers; chunked path is
|
|
942
|
+
# axis_enums-None only), so ``_maybe_cast``'s breadcrumb walk is
|
|
943
|
+
# a no-op there.
|
|
944
|
+
if chunk_frames:
|
|
945
|
+
if rows:
|
|
946
|
+
chunk_frames.append(_rows_to_frame(rows, cols))
|
|
947
|
+
rows = []
|
|
948
|
+
if len(chunk_frames) == 1:
|
|
949
|
+
frame = chunk_frames[0]
|
|
950
|
+
else:
|
|
951
|
+
frame = pl.concat(chunk_frames)
|
|
952
|
+
else:
|
|
953
|
+
frame = _rows_to_frame(rows, cols)
|
|
954
|
+
return self._maybe_cast(
|
|
955
|
+
frame, rows, row_origins, axis_enums, contract,
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
# ------------------------------------------------------------------
|
|
959
|
+
# Internal parameter_value cache
|
|
960
|
+
# ------------------------------------------------------------------
|
|
961
|
+
|
|
962
|
+
def _get_parameter_value_index(self) -> dict:
|
|
963
|
+
"""Build (lazily) and return ``{(class, param): [param_rows]}``.
|
|
964
|
+
|
|
965
|
+
``find_parameter_values(entity_class_name=, parameter_definition_name=)``
|
|
966
|
+
is ~1.85s per call under spinedb-api's scenario filter on large
|
|
967
|
+
fixtures — proportional to the in-memory parameter_value table,
|
|
968
|
+
not to the filter-selectivity. A bulk ``find_parameter_values()``
|
|
969
|
+
with no filter args returns *all* (scenario-filtered) rows in
|
|
970
|
+
~2.8s on the same fixture and partitions in ~22ms. For the
|
|
971
|
+
``input_derivation.run`` call shape (~100 individual filtered
|
|
972
|
+
calls per backend instance), the bulk-then-partition pattern
|
|
973
|
+
is ~60x faster.
|
|
974
|
+
|
|
975
|
+
The cache is invalidated by ``close()``. It is rebuilt
|
|
976
|
+
per-backend-instance, so callers that mutate the underlying
|
|
977
|
+
``DatabaseMapping`` mid-flight (no current caller does this)
|
|
978
|
+
would see stale data — flag this if it ever becomes relevant.
|
|
979
|
+
"""
|
|
980
|
+
if self._parameter_value_index is not None:
|
|
981
|
+
return self._parameter_value_index
|
|
982
|
+
if self._db is None:
|
|
983
|
+
raise RuntimeError("SpineDBBackend is closed")
|
|
984
|
+
all_params = self._db.find_parameter_values()
|
|
985
|
+
index: dict = {}
|
|
986
|
+
for p in all_params:
|
|
987
|
+
key = (p["entity_class_name"], p["parameter_definition_name"])
|
|
988
|
+
bucket = index.get(key)
|
|
989
|
+
if bucket is None:
|
|
990
|
+
index[key] = [p]
|
|
991
|
+
else:
|
|
992
|
+
bucket.append(p)
|
|
993
|
+
self._parameter_value_index = index
|
|
994
|
+
return index
|
|
995
|
+
|
|
996
|
+
# ------------------------------------------------------------------
|
|
997
|
+
# Internal cast helper
|
|
998
|
+
# ------------------------------------------------------------------
|
|
999
|
+
|
|
1000
|
+
def _maybe_cast(
|
|
1001
|
+
self,
|
|
1002
|
+
frame: pl.DataFrame,
|
|
1003
|
+
rows: list[list[Any]],
|
|
1004
|
+
row_origins: list[dict[str, Any]],
|
|
1005
|
+
axis_enums: dict[str, pl.Enum] | None,
|
|
1006
|
+
contract: AxisContract | None,
|
|
1007
|
+
) -> pl.DataFrame:
|
|
1008
|
+
"""Cast *frame* against the axis contract when *axis_enums* is
|
|
1009
|
+
non-None.
|
|
1010
|
+
|
|
1011
|
+
Origin breadcrumbs (Option C from the Phase 2 plan): the row
|
|
1012
|
+
builder kept a *row_origins* list parallel to *rows*; on a cast
|
|
1013
|
+
failure we identify the offending column / token by scanning
|
|
1014
|
+
the frame, then look up which row introduced that token and
|
|
1015
|
+
thread the row's origin dict into the raised
|
|
1016
|
+
:class:`FlexDataIntegrityError`.
|
|
1017
|
+
|
|
1018
|
+
When *axis_enums* is None the method is a no-op (pre-Phase-2
|
|
1019
|
+
behaviour: callers that don't opt-in see Utf8 frames).
|
|
1020
|
+
"""
|
|
1021
|
+
if axis_enums is None:
|
|
1022
|
+
return frame
|
|
1023
|
+
if contract is None:
|
|
1024
|
+
contract = load_axis_contract()
|
|
1025
|
+
# First pass: identify bad (column, token) before raising so
|
|
1026
|
+
# we can find its row in row_origins.
|
|
1027
|
+
bad_col: str | None = None
|
|
1028
|
+
bad_token: str | None = None
|
|
1029
|
+
for col in frame.columns:
|
|
1030
|
+
axis = contract.column_to_axis(col)
|
|
1031
|
+
if axis is None:
|
|
1032
|
+
continue
|
|
1033
|
+
dtype = axis_enums.get(axis.name)
|
|
1034
|
+
if dtype is None:
|
|
1035
|
+
continue
|
|
1036
|
+
vocab = set(dtype.categories.to_list())
|
|
1037
|
+
for v in frame[col].to_list():
|
|
1038
|
+
if v is None or v == "":
|
|
1039
|
+
continue
|
|
1040
|
+
if v not in vocab:
|
|
1041
|
+
bad_col = col
|
|
1042
|
+
bad_token = str(v)
|
|
1043
|
+
break
|
|
1044
|
+
if bad_col is not None:
|
|
1045
|
+
break
|
|
1046
|
+
|
|
1047
|
+
if bad_col is None:
|
|
1048
|
+
# All values fit — let cast_against_contract do the cast.
|
|
1049
|
+
return cast_against_contract(
|
|
1050
|
+
frame,
|
|
1051
|
+
contract=contract,
|
|
1052
|
+
axis_enums=axis_enums,
|
|
1053
|
+
origin={"scenario": self._scenario_name},
|
|
1054
|
+
backend=self,
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
# Identify the row that introduced the bad token to thread the
|
|
1058
|
+
# right breadcrumb into the error.
|
|
1059
|
+
origin_for_error: dict[str, Any] = {
|
|
1060
|
+
"scenario": self._scenario_name,
|
|
1061
|
+
}
|
|
1062
|
+
try:
|
|
1063
|
+
col_idx = frame.columns.index(bad_col)
|
|
1064
|
+
except ValueError: # pragma: no cover — defensive
|
|
1065
|
+
col_idx = -1
|
|
1066
|
+
if col_idx >= 0:
|
|
1067
|
+
for i, r in enumerate(rows):
|
|
1068
|
+
if col_idx < len(r) and str(r[col_idx]) == bad_token:
|
|
1069
|
+
if i < len(row_origins):
|
|
1070
|
+
origin_for_error = {
|
|
1071
|
+
**row_origins[i],
|
|
1072
|
+
"scenario": row_origins[i].get(
|
|
1073
|
+
"scenario", self._scenario_name,
|
|
1074
|
+
),
|
|
1075
|
+
}
|
|
1076
|
+
break
|
|
1077
|
+
|
|
1078
|
+
# Re-run cast_against_contract with the located breadcrumb so
|
|
1079
|
+
# the FlexDataIntegrityError carries the precise origin.
|
|
1080
|
+
return cast_against_contract(
|
|
1081
|
+
frame,
|
|
1082
|
+
contract=contract,
|
|
1083
|
+
axis_enums=axis_enums,
|
|
1084
|
+
origin=origin_for_error,
|
|
1085
|
+
backend=self,
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
# ---------------------------------------------------------------------------
|
|
1090
|
+
# Internal helpers
|
|
1091
|
+
# ---------------------------------------------------------------------------
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _rows_to_frame(rows: list[list[Any]], cols: list[str]) -> pl.DataFrame:
|
|
1095
|
+
"""Build an all-Utf8 polars frame from string-row data.
|
|
1096
|
+
|
|
1097
|
+
The legacy CSVs are pure text — every cell is the result of
|
|
1098
|
+
:func:`format_scalar_for_csv` or a raw entity name. Reading them
|
|
1099
|
+
back yields :class:`polars.Utf8` columns; we materialise the
|
|
1100
|
+
frames with the same schema so downstream consumers see byte-
|
|
1101
|
+
identical bytes when the Provider is snapshotted to disk.
|
|
1102
|
+
|
|
1103
|
+
A row with fewer cells than ``cols`` is padded with empty strings;
|
|
1104
|
+
rows with more cells are truncated. In practice every row already
|
|
1105
|
+
matches ``len(cols)`` — the defensive padding documents the
|
|
1106
|
+
invariant.
|
|
1107
|
+
"""
|
|
1108
|
+
schema = {c: pl.Utf8 for c in cols}
|
|
1109
|
+
if not rows:
|
|
1110
|
+
return pl.DataFrame(schema=schema)
|
|
1111
|
+
n = len(cols)
|
|
1112
|
+
norm: list[list[str]] = []
|
|
1113
|
+
for r in rows:
|
|
1114
|
+
if len(r) == n:
|
|
1115
|
+
norm.append([_to_str(x) for x in r])
|
|
1116
|
+
elif len(r) < n:
|
|
1117
|
+
norm.append([_to_str(x) for x in r] + [""] * (n - len(r)))
|
|
1118
|
+
else:
|
|
1119
|
+
norm.append([_to_str(x) for x in r[:n]])
|
|
1120
|
+
# Build column-oriented dict for cheap pl.DataFrame construction.
|
|
1121
|
+
data: dict[str, list[str]] = {c: [] for c in cols}
|
|
1122
|
+
for r in norm:
|
|
1123
|
+
for c, v in zip(cols, r):
|
|
1124
|
+
data[c].append(v)
|
|
1125
|
+
return pl.DataFrame(data, schema=schema)
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _to_str(x: Any) -> str:
|
|
1129
|
+
"""Coerce a row cell to its CSV-form string. Already-strings pass
|
|
1130
|
+
through; everything else goes via ``str(...)``.
|
|
1131
|
+
"""
|
|
1132
|
+
if isinstance(x, str):
|
|
1133
|
+
return x
|
|
1134
|
+
if x is None:
|
|
1135
|
+
return ""
|
|
1136
|
+
return str(x)
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
__all__ = ["SpineDBBackend"]
|