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,977 @@
|
|
|
1
|
+
"""Write SheetData objects (from read_self_describing_excel) to a SpineDB.
|
|
2
|
+
|
|
3
|
+
The writer uses a two-pass import strategy:
|
|
4
|
+
1. First pass — sheets with 1-dimensional entity classes (node, unit, …)
|
|
5
|
+
2. Second pass — sheets with multi-dimensional entity classes (unit__inputNode, …)
|
|
6
|
+
|
|
7
|
+
This guarantees that base entities exist before relationship entities reference them.
|
|
8
|
+
|
|
9
|
+
Entity classes and parameter definitions are assumed to already exist in the
|
|
10
|
+
target database (initialized from the FlexTool template). This module only
|
|
11
|
+
adds entities, alternatives, entity_alternatives, and parameter_values.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from spinedb_api import Array, Asterisk, DatabaseMapping, Map, SpineDBAPIError, to_database
|
|
21
|
+
from spinedb_api.exception import NothingToCommit
|
|
22
|
+
|
|
23
|
+
from flextool.process_inputs.read_self_describing_excel import SheetData
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# The special pseudo-parameter that maps to entity_alternative rows.
|
|
28
|
+
_ENTITY_EXISTENCE = "entity existence"
|
|
29
|
+
|
|
30
|
+
# Index-cell marker the exporter writes for a zero-length Array/Map value.
|
|
31
|
+
# Mirrors excel_writer.EMPTY_COLLECTION_SENTINEL — keep the two in sync.
|
|
32
|
+
_EMPTY_COLLECTION_SENTINEL = "(empty)"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Helpers
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _natural_key(v: Any) -> tuple[int, Any]:
|
|
41
|
+
"""Sort key that orders integer-like strings numerically.
|
|
42
|
+
|
|
43
|
+
Map/Array index values arrive as strings from the Excel reader. A plain
|
|
44
|
+
string sort orders an integer axis (e.g. ladder tiers) lexically —
|
|
45
|
+
``"1","10","11",…,"2"`` — silently reordering it versus the authored
|
|
46
|
+
``"1","2",…,"10","11"``. Returns ``(0, int)`` for integer-like values so
|
|
47
|
+
they sort numerically ahead of, and separately from, genuine strings
|
|
48
|
+
``(1, str)`` — keeping the order total and deterministic for mixed axes.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
return (0, int(str(v)))
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
return (1, str(v))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_multidimensional(sheet: SheetData) -> bool:
|
|
57
|
+
"""Return True if the sheet's entity class has more than one dimension."""
|
|
58
|
+
for ec in sheet.metadata.entity_classes:
|
|
59
|
+
if len(ec.dimensions) > 1:
|
|
60
|
+
return True
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _registry_facet_targets() -> "dict[tuple[str, str], list[tuple[str, tuple[str, ...], frozenset[str]]]]":
|
|
65
|
+
"""Return the registry's facet-target map.
|
|
66
|
+
|
|
67
|
+
Keys are ``(entity_class, facet_key)`` — e.g. ``("commodity", "price")`` and
|
|
68
|
+
``("commodity", "quantity")``. Values are lists of
|
|
69
|
+
``(canonical_param_name, outer_axis_names, facet_keys)`` tuples covering
|
|
70
|
+
EVERY allowed shape with a facet leaf for that entity class. The
|
|
71
|
+
caller picks the right canonical name by matching the observed outer
|
|
72
|
+
axis vocabulary to ``outer_axis_names`` (e.g. ``("tier",)`` vs
|
|
73
|
+
``("period", "tier")``).
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
from flextool.engine_polars._param_shapes import (
|
|
77
|
+
LeafKind,
|
|
78
|
+
PARAM_ALLOWED_SHAPES,
|
|
79
|
+
facet_keys,
|
|
80
|
+
shape_to_axes,
|
|
81
|
+
)
|
|
82
|
+
except Exception: # pragma: no cover — registry must be importable
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
out: dict[tuple[str, str], list[tuple[str, tuple[str, ...], frozenset[str]]]] = {}
|
|
86
|
+
axis_to_label = {"d": "period", "t": "time", "i": "tier"}
|
|
87
|
+
for (ec, pname), shapes in PARAM_ALLOWED_SHAPES.items():
|
|
88
|
+
for shape in shapes:
|
|
89
|
+
axes = shape_to_axes(shape)
|
|
90
|
+
if axes.leaf is not LeafKind.FACET_PRICE_QUANTITY:
|
|
91
|
+
continue
|
|
92
|
+
outer = tuple(
|
|
93
|
+
axis_to_label.get(getattr(ax, "value", ""), getattr(ax, "value", ""))
|
|
94
|
+
for ax in axes.map_levels
|
|
95
|
+
)
|
|
96
|
+
fkeys = facet_keys(axes.leaf)
|
|
97
|
+
for fk in fkeys:
|
|
98
|
+
out.setdefault((ec, fk), []).append((pname, outer, fkeys))
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _index_tuple(rec: dict[str, Any]) -> tuple[tuple[str, str], ...]:
|
|
103
|
+
"""Build the (axis_name, value) tuple for *rec* in left-to-right order.
|
|
104
|
+
|
|
105
|
+
Combines ``extra_index_values`` (left of rightmost) with the
|
|
106
|
+
``(index_name, index_value)`` pair from the standard record fields.
|
|
107
|
+
"""
|
|
108
|
+
parts: list[tuple[str, str]] = []
|
|
109
|
+
for axis, val in rec.get("extra_index_values") or []:
|
|
110
|
+
if val is not None:
|
|
111
|
+
parts.append((axis, val))
|
|
112
|
+
if rec.get("index_name") and rec.get("index_value") is not None:
|
|
113
|
+
parts.append((rec["index_name"], rec["index_value"]))
|
|
114
|
+
return tuple(parts)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _combine_facet_records(
|
|
118
|
+
records: list[dict[str, Any]],
|
|
119
|
+
sheet_name: str = "",
|
|
120
|
+
) -> list[dict[str, Any]]:
|
|
121
|
+
"""Combine facet-leaf records emitted by the ladder reader into single
|
|
122
|
+
nested-Map records per canonical parameter.
|
|
123
|
+
|
|
124
|
+
The reader treats the two ``parameter: price`` / ``parameter: quantity``
|
|
125
|
+
Excel columns as separate parameter records. Spine encodes them as a
|
|
126
|
+
single nested Map under a canonical name (e.g.
|
|
127
|
+
``commodity.price_ladder_cumulative``) — the registry tells us which.
|
|
128
|
+
|
|
129
|
+
Each ladder parameter lives on its own dedicated sheet whose title IS the
|
|
130
|
+
canonical parameter name, so ``sheet_name`` is the authoritative
|
|
131
|
+
disambiguator: when two canonical params share the same outer-axes shape
|
|
132
|
+
(e.g. both ``price_ladder_annual`` and ``price_ladder_cumulative`` allow a
|
|
133
|
+
2d ``(tier,)`` Map), matching on ``outer_axes`` alone would silently pick
|
|
134
|
+
whichever the registry happens to list first, relabelling the parameter.
|
|
135
|
+
|
|
136
|
+
Records that don't match the registry's facet vocabulary pass through
|
|
137
|
+
unchanged.
|
|
138
|
+
"""
|
|
139
|
+
facet_targets = _registry_facet_targets()
|
|
140
|
+
if not facet_targets:
|
|
141
|
+
return records
|
|
142
|
+
|
|
143
|
+
# Bucket key: (entity_class, entity_byname, alternative, outer_index_tuple)
|
|
144
|
+
# where outer_index_tuple is the (axis, value)... part of the row.
|
|
145
|
+
BucketKey = tuple[str, tuple, str, tuple[tuple[str, str], ...]]
|
|
146
|
+
buckets: dict[BucketKey, dict[str, Any]] = {}
|
|
147
|
+
# Track which record-list positions belong to each bucket, so we can
|
|
148
|
+
# drop them in one go.
|
|
149
|
+
bucket_indices: dict[BucketKey, list[int]] = {}
|
|
150
|
+
# Per (entity_class, entity_byname, alt) — track the outer-axes
|
|
151
|
+
# signature so all rows in one group share the same canonical param.
|
|
152
|
+
group_axes: dict[tuple[str, tuple, str], tuple[str, ...]] = {}
|
|
153
|
+
|
|
154
|
+
drop_indices: set[int] = set()
|
|
155
|
+
out: list[dict[str, Any]] = []
|
|
156
|
+
|
|
157
|
+
for i, rec in enumerate(records):
|
|
158
|
+
pname = rec.get("param_name") or ""
|
|
159
|
+
ec = rec.get("entity_class") or ""
|
|
160
|
+
targets = facet_targets.get((ec, pname))
|
|
161
|
+
if not targets:
|
|
162
|
+
continue
|
|
163
|
+
# This record is a facet-leaf candidate. We must still check that
|
|
164
|
+
# the index axes line up with a registered shape.
|
|
165
|
+
idx_pairs = _index_tuple(rec)
|
|
166
|
+
outer_axes = tuple(axis for axis, _ in idx_pairs)
|
|
167
|
+
# Pick the registered (canonical_name, outer_axes, fkeys) entry
|
|
168
|
+
# whose outer_axes matches our observed axes. The facet axes
|
|
169
|
+
# never appear in the index tuple (they're separate columns), so
|
|
170
|
+
# equality is exact. When several canonical params share the same
|
|
171
|
+
# outer-axes shape, prefer the one whose name matches the source
|
|
172
|
+
# sheet (the dedicated ladder sheet is named after its parameter);
|
|
173
|
+
# fall back to the first outer-axes match only when the sheet name
|
|
174
|
+
# is unknown or doesn't correspond to a candidate.
|
|
175
|
+
match: tuple[str, tuple[str, ...], frozenset[str]] | None = None
|
|
176
|
+
for cand in targets:
|
|
177
|
+
if cand[1] == outer_axes and cand[0] == sheet_name:
|
|
178
|
+
match = cand
|
|
179
|
+
break
|
|
180
|
+
if match is None:
|
|
181
|
+
for cand in targets:
|
|
182
|
+
if cand[1] == outer_axes:
|
|
183
|
+
match = cand
|
|
184
|
+
break
|
|
185
|
+
if match is None:
|
|
186
|
+
# No registered shape matches; treat as a normal record.
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
canonical_name, _axes, fkeys = match
|
|
190
|
+
alt = rec.get("alternative") or ""
|
|
191
|
+
ent = rec.get("entity_byname") or ()
|
|
192
|
+
group_key = (ec, ent, alt)
|
|
193
|
+
prior = group_axes.get(group_key)
|
|
194
|
+
if prior is not None and prior != outer_axes:
|
|
195
|
+
# Same (entity, alt) carries TWO different outer-axes shapes.
|
|
196
|
+
# The registry allows depth-2 and depth-3 to coexist on the
|
|
197
|
+
# same entity (price_ladder_annual), but only one canonical
|
|
198
|
+
# name per (entity, alt) — we refuse to merge silently in
|
|
199
|
+
# that case. Surface a warning and keep the records as-is.
|
|
200
|
+
logger.warning(
|
|
201
|
+
"Facet records for %s.%s under (%s, alt=%s) carry mixed "
|
|
202
|
+
"index axes %s vs %s; skipping facet combination.",
|
|
203
|
+
ec, canonical_name, ent, alt, prior, outer_axes,
|
|
204
|
+
)
|
|
205
|
+
continue
|
|
206
|
+
group_axes[group_key] = outer_axes
|
|
207
|
+
|
|
208
|
+
bkey: BucketKey = (ec, ent, alt, idx_pairs)
|
|
209
|
+
bucket = buckets.get(bkey)
|
|
210
|
+
if bucket is None:
|
|
211
|
+
bucket = {
|
|
212
|
+
"canonical_name": canonical_name,
|
|
213
|
+
"facet_keys": fkeys,
|
|
214
|
+
"facet_values": {},
|
|
215
|
+
"template": rec,
|
|
216
|
+
}
|
|
217
|
+
buckets[bkey] = bucket
|
|
218
|
+
bucket_indices[bkey] = []
|
|
219
|
+
if pname in fkeys:
|
|
220
|
+
bucket["facet_values"][pname] = rec.get("value")
|
|
221
|
+
bucket_indices[bkey].append(i)
|
|
222
|
+
drop_indices.add(i)
|
|
223
|
+
|
|
224
|
+
if not buckets:
|
|
225
|
+
return records
|
|
226
|
+
|
|
227
|
+
# Group buckets by (entity_class, entity_byname, alt, canonical_name)
|
|
228
|
+
# to build one Map per canonical record.
|
|
229
|
+
GroupKey = tuple[str, tuple, str, str]
|
|
230
|
+
grouped: dict[GroupKey, list[tuple[tuple[tuple[str, str], ...], dict[str, Any]]]] = (
|
|
231
|
+
defaultdict(list)
|
|
232
|
+
)
|
|
233
|
+
for bkey, bucket in buckets.items():
|
|
234
|
+
ec, ent, alt, idx_pairs = bkey
|
|
235
|
+
gkey = (ec, ent, alt, bucket["canonical_name"])
|
|
236
|
+
grouped[gkey].append((idx_pairs, bucket))
|
|
237
|
+
|
|
238
|
+
# Emit one combined record per group.
|
|
239
|
+
template_alt: dict[GroupKey, dict[str, Any]] = {}
|
|
240
|
+
for gkey, entries in grouped.items():
|
|
241
|
+
ec, ent, alt, canonical_name = gkey
|
|
242
|
+
# Determine outer-axes signature (uniform across entries within a
|
|
243
|
+
# group — group_axes guarantees it).
|
|
244
|
+
sample_pairs = entries[0][0]
|
|
245
|
+
outer_axes = tuple(axis for axis, _ in sample_pairs)
|
|
246
|
+
|
|
247
|
+
# Sort entries deterministically by outer index tuple values.
|
|
248
|
+
# Numeric-aware: a 1-based integer tier axis must order 1,2,…,10,11
|
|
249
|
+
# (numeric), not the lexical 1,10,11,…,2 a plain string sort yields —
|
|
250
|
+
# the latter silently reorders the ladder versus its authored form
|
|
251
|
+
# and breaks DB round-trip byte-parity.
|
|
252
|
+
entries.sort(key=lambda e: tuple(_natural_key(v) for _, v in e[0]))
|
|
253
|
+
|
|
254
|
+
# Build the nested Map. Inner-leaf Map: facet -> value (axis=
|
|
255
|
+
# "price" per the DB encoding hack).
|
|
256
|
+
facet_keys_order = list(entries[0][1]["facet_keys"])
|
|
257
|
+
# Canonical ordering: price, quantity
|
|
258
|
+
if set(facet_keys_order) == {"price", "quantity"}:
|
|
259
|
+
facet_keys_order = ["price", "quantity"]
|
|
260
|
+
else:
|
|
261
|
+
facet_keys_order.sort()
|
|
262
|
+
|
|
263
|
+
def _leaf_map(facet_values: dict[str, Any]) -> Map:
|
|
264
|
+
# The innermost Map's axis carries the facet key (price /
|
|
265
|
+
# quantity). The canonical DB omits ``index_name`` for this
|
|
266
|
+
# level — Spine's encoder treats the facet axis as anonymous
|
|
267
|
+
# because the keys themselves are the schema. Leaving
|
|
268
|
+
# ``index_name`` unset keeps the JSON byte-identical to the
|
|
269
|
+
# canonical authoring form.
|
|
270
|
+
idxs = facet_keys_order
|
|
271
|
+
vals = [facet_values.get(k) for k in idxs]
|
|
272
|
+
return Map(indexes=idxs, values=vals)
|
|
273
|
+
|
|
274
|
+
if len(outer_axes) == 1:
|
|
275
|
+
# Depth-2 Map(tier -> facet_map)
|
|
276
|
+
inner_indexes = [pairs[0][1] for pairs, _ in entries]
|
|
277
|
+
inner_values = [_leaf_map(b["facet_values"]) for _, b in entries]
|
|
278
|
+
top_map = Map(
|
|
279
|
+
indexes=inner_indexes,
|
|
280
|
+
values=inner_values,
|
|
281
|
+
index_name=outer_axes[0],
|
|
282
|
+
)
|
|
283
|
+
elif len(outer_axes) == 2:
|
|
284
|
+
# Depth-3 Map(period -> Map(tier -> facet_map)). Group by
|
|
285
|
+
# first axis (period).
|
|
286
|
+
from collections import defaultdict as _dd
|
|
287
|
+
by_period: "dict[str, list[tuple[str, dict[str, Any]]]]" = _dd(list)
|
|
288
|
+
for pairs, bucket in entries:
|
|
289
|
+
p_val = pairs[0][1]
|
|
290
|
+
t_val = pairs[1][1]
|
|
291
|
+
by_period[p_val].append((t_val, bucket["facet_values"]))
|
|
292
|
+
period_indexes = list(by_period.keys()) # insertion order
|
|
293
|
+
period_values = []
|
|
294
|
+
for p in period_indexes:
|
|
295
|
+
inner_entries = by_period[p]
|
|
296
|
+
tier_indexes = [t for t, _ in inner_entries]
|
|
297
|
+
tier_values = [_leaf_map(fv) for _, fv in inner_entries]
|
|
298
|
+
period_values.append(Map(
|
|
299
|
+
indexes=tier_indexes,
|
|
300
|
+
values=tier_values,
|
|
301
|
+
index_name=outer_axes[1],
|
|
302
|
+
))
|
|
303
|
+
top_map = Map(
|
|
304
|
+
indexes=period_indexes,
|
|
305
|
+
values=period_values,
|
|
306
|
+
index_name=outer_axes[0],
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
# Shouldn't happen for registered facet shapes.
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
template = entries[0][1]["template"]
|
|
313
|
+
combined = dict(template)
|
|
314
|
+
combined["param_name"] = canonical_name
|
|
315
|
+
combined["value"] = top_map
|
|
316
|
+
combined["index_value"] = None
|
|
317
|
+
combined["index_name"] = None
|
|
318
|
+
combined.pop("extra_index_values", None)
|
|
319
|
+
combined["data_type"] = "map"
|
|
320
|
+
template_alt[gkey] = combined
|
|
321
|
+
|
|
322
|
+
# Final output: original records minus dropped ones, plus combined.
|
|
323
|
+
for i, rec in enumerate(records):
|
|
324
|
+
if i in drop_indices:
|
|
325
|
+
continue
|
|
326
|
+
out.append(rec)
|
|
327
|
+
out.extend(template_alt.values())
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _build_nested_map(
|
|
332
|
+
entries: list[tuple[tuple[tuple[str, str], ...], Any]],
|
|
333
|
+
) -> Map:
|
|
334
|
+
"""Build a depth-N nested ``Map`` from per-leaf ``(index_pairs, value)``.
|
|
335
|
+
|
|
336
|
+
``index_pairs`` is the left-to-right ``((axis0, val0), (axis1, val1),
|
|
337
|
+
…)`` tuple for one leaf cell (length N == nesting depth). All entries
|
|
338
|
+
must share the same depth and the same per-level axis names. Returns
|
|
339
|
+
``Map(val0 -> Map(val1 -> … -> leaf))`` with each level's
|
|
340
|
+
``index_name`` set from its axis, preserving the first-seen order of
|
|
341
|
+
keys at every level (numeric-aware so an integer axis stays
|
|
342
|
+
``1,2,…,10`` rather than the lexical ``1,10,…,2``).
|
|
343
|
+
|
|
344
|
+
The leaf scalar values are inserted verbatim — the reader has already
|
|
345
|
+
coerced them to the declared leaf dtype (e.g. ``float``).
|
|
346
|
+
"""
|
|
347
|
+
# Recursive grouping on the first axis, preserving insertion order.
|
|
348
|
+
def _recurse(
|
|
349
|
+
rows: list[tuple[tuple[tuple[str, str], ...], Any]],
|
|
350
|
+
) -> Map:
|
|
351
|
+
axis_name = rows[0][0][0][0]
|
|
352
|
+
# Group rows by this level's value, keeping first-seen order.
|
|
353
|
+
order: list[str] = []
|
|
354
|
+
buckets: dict[str, list[tuple[tuple[tuple[str, str], ...], Any]]] = (
|
|
355
|
+
defaultdict(list)
|
|
356
|
+
)
|
|
357
|
+
for pairs, val in rows:
|
|
358
|
+
key = pairs[0][1]
|
|
359
|
+
if key not in buckets:
|
|
360
|
+
order.append(key)
|
|
361
|
+
# Drop the consumed (this-level) pair for the recursion.
|
|
362
|
+
buckets[key].append((pairs[1:], val))
|
|
363
|
+
# Ordering policy: when EVERY key at this level is integer-like,
|
|
364
|
+
# sort numerically so an integer axis stays 1,2,…,10,11 rather than
|
|
365
|
+
# the lexical 1,10,11,…,2 a string sort yields. Otherwise preserve
|
|
366
|
+
# the first-seen (authoring / sheet-row) order — a genuine string
|
|
367
|
+
# axis (e.g. branch names ``realized``, ``f1``) must NOT be
|
|
368
|
+
# alphabetised, which would silently reorder it versus the source.
|
|
369
|
+
if all(_natural_key(k)[0] == 0 for k in order):
|
|
370
|
+
order.sort(key=_natural_key)
|
|
371
|
+
indexes: list[str] = []
|
|
372
|
+
values: list[Any] = []
|
|
373
|
+
for key in order:
|
|
374
|
+
child_rows = buckets[key]
|
|
375
|
+
indexes.append(key)
|
|
376
|
+
if child_rows and child_rows[0][0]:
|
|
377
|
+
# Deeper levels remain — recurse.
|
|
378
|
+
values.append(_recurse(child_rows))
|
|
379
|
+
else:
|
|
380
|
+
# Leaf level — single value per key.
|
|
381
|
+
values.append(child_rows[0][1])
|
|
382
|
+
return Map(indexes=indexes, values=values, index_name=axis_name)
|
|
383
|
+
|
|
384
|
+
return _recurse(entries)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _group_multi_index_records(
|
|
388
|
+
records: list[dict[str, Any]],
|
|
389
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
390
|
+
"""Split out generic multi-index (nested-Map) records.
|
|
391
|
+
|
|
392
|
+
A record carrying a non-empty ``extra_index_values`` describes ONE leaf
|
|
393
|
+
of a depth-N nested float Map (the stochastic ``_s`` reader and the
|
|
394
|
+
ladder reader both emit these). Facet-leaf ladder records are already
|
|
395
|
+
consumed upstream by :func:`_combine_facet_records`, so anything still
|
|
396
|
+
carrying ``extra_index_values`` here is a GENERIC nested Map that has no
|
|
397
|
+
other reconstruction path.
|
|
398
|
+
|
|
399
|
+
Returns ``(passthrough, nested_map_records)``: ``passthrough`` keeps
|
|
400
|
+
every record WITHOUT ``extra_index_values`` for the existing
|
|
401
|
+
scalar/single-index grouper; ``nested_map_records`` are the rebuilt
|
|
402
|
+
collapsed records (one per entity/param/alt group) with a nested Map
|
|
403
|
+
``value``.
|
|
404
|
+
"""
|
|
405
|
+
passthrough: list[dict[str, Any]] = []
|
|
406
|
+
# group key -> list of (index_pairs, value)
|
|
407
|
+
groups: dict[tuple, list[tuple[tuple[tuple[str, str], ...], Any]]] = (
|
|
408
|
+
defaultdict(list)
|
|
409
|
+
)
|
|
410
|
+
templates: dict[tuple, dict[str, Any]] = {}
|
|
411
|
+
|
|
412
|
+
for rec in records:
|
|
413
|
+
extra = rec.get("extra_index_values")
|
|
414
|
+
if not extra or rec.get("index_value") is None:
|
|
415
|
+
passthrough.append(rec)
|
|
416
|
+
continue
|
|
417
|
+
pairs = _index_tuple(rec) # full left-to-right (axis, value) tuple
|
|
418
|
+
if len(pairs) < 2:
|
|
419
|
+
# Not genuinely multi-level after dropping empties — let the
|
|
420
|
+
# single-index grouper handle it.
|
|
421
|
+
passthrough.append(rec)
|
|
422
|
+
continue
|
|
423
|
+
key = (
|
|
424
|
+
rec["entity_class"],
|
|
425
|
+
rec["entity_byname"],
|
|
426
|
+
rec["param_name"],
|
|
427
|
+
rec["alternative"],
|
|
428
|
+
)
|
|
429
|
+
groups[key].append((pairs, rec["value"]))
|
|
430
|
+
if key not in templates:
|
|
431
|
+
templates[key] = rec
|
|
432
|
+
|
|
433
|
+
nested: list[dict[str, Any]] = []
|
|
434
|
+
for key, entries in groups.items():
|
|
435
|
+
top_map = _build_nested_map(entries)
|
|
436
|
+
rec = dict(templates[key])
|
|
437
|
+
rec["value"] = top_map
|
|
438
|
+
rec["index_value"] = None
|
|
439
|
+
rec["index_name"] = None
|
|
440
|
+
rec.pop("extra_index_values", None)
|
|
441
|
+
rec["data_type"] = "map"
|
|
442
|
+
nested.append(rec)
|
|
443
|
+
|
|
444
|
+
return passthrough, nested
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _group_map_records(
|
|
448
|
+
records: list[dict[str, Any]],
|
|
449
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
450
|
+
"""Split records into scalar records and grouped Map records.
|
|
451
|
+
|
|
452
|
+
Scalar records have no ``index_value``. Map records share the same
|
|
453
|
+
(entity_class, entity_byname, param_name, alternative) key and are
|
|
454
|
+
collected into a single Map value per group.
|
|
455
|
+
|
|
456
|
+
Generic multi-index records (carrying ``extra_index_values`` — emitted
|
|
457
|
+
by the stochastic ``_s`` reader) are first peeled off and rebuilt into
|
|
458
|
+
depth-N nested Maps via :func:`_group_multi_index_records`; the
|
|
459
|
+
remaining single-index records flow through the original logic below.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
(scalar_records, map_records) where each map_record is a dict with
|
|
463
|
+
keys identical to an input record but ``value`` replaced by a
|
|
464
|
+
:class:`spinedb_api.Map` built from all the grouped index entries.
|
|
465
|
+
"""
|
|
466
|
+
records, nested_maps = _group_multi_index_records(records)
|
|
467
|
+
|
|
468
|
+
scalars: list[dict[str, Any]] = []
|
|
469
|
+
# key -> list of (index_value, value) in insertion order
|
|
470
|
+
map_groups: dict[tuple, list[tuple[str, Any]]] = defaultdict(list)
|
|
471
|
+
# key -> first record (used as template for the collapsed record)
|
|
472
|
+
map_templates: dict[tuple, dict[str, Any]] = {}
|
|
473
|
+
|
|
474
|
+
for rec in records:
|
|
475
|
+
if rec["param_name"] == _ENTITY_EXISTENCE:
|
|
476
|
+
scalars.append(rec)
|
|
477
|
+
continue
|
|
478
|
+
|
|
479
|
+
if rec.get("index_value") is not None:
|
|
480
|
+
key = (
|
|
481
|
+
rec["entity_class"],
|
|
482
|
+
rec["entity_byname"],
|
|
483
|
+
rec["param_name"],
|
|
484
|
+
rec["alternative"],
|
|
485
|
+
)
|
|
486
|
+
map_groups[key].append((rec["index_value"], rec["value"]))
|
|
487
|
+
if key not in map_templates:
|
|
488
|
+
map_templates[key] = rec
|
|
489
|
+
else:
|
|
490
|
+
scalars.append(rec)
|
|
491
|
+
|
|
492
|
+
# Build collapsed Map records
|
|
493
|
+
collapsed: list[dict[str, Any]] = []
|
|
494
|
+
for key, entries in map_groups.items():
|
|
495
|
+
indexes = [e[0] for e in entries]
|
|
496
|
+
values = [e[1] for e in entries]
|
|
497
|
+
index_name = map_templates[key].get("index_name") or "index"
|
|
498
|
+
rec = dict(map_templates[key])
|
|
499
|
+
# When the source sheet's data type marks this parameter as an
|
|
500
|
+
# array (e.g. ``"string (array)"`` / ``"boolean (array)"``), the
|
|
501
|
+
# cells are array-expanded rows where the index cell holds the
|
|
502
|
+
# array slot number OR the period token. Reconstruct an Array
|
|
503
|
+
# so the round-trip preserves the SpineDB ``"array"`` type — a
|
|
504
|
+
# hard requirement for ``model.solves``, ``solve.realized_periods``
|
|
505
|
+
# and friends, which downstream code (engine_polars._solve_config
|
|
506
|
+
# ``params_to_dict``) only treats as ordered sequences when the
|
|
507
|
+
# parsed value is an ``Array`` instance. Index_name is preserved
|
|
508
|
+
# from the source so the engine's axis labels stay intact.
|
|
509
|
+
dtype = (rec.get("data_type") or "").lower()
|
|
510
|
+
is_array_dtype = "(array)" in dtype or dtype.endswith(" array") or dtype == "array"
|
|
511
|
+
if indexes and all(i == _EMPTY_COLLECTION_SENTINEL for i in indexes):
|
|
512
|
+
# Sentinel row written by the exporter for a zero-length
|
|
513
|
+
# Array/Map (excel_writer.EMPTY_COLLECTION_SENTINEL): rebuild an
|
|
514
|
+
# empty collection so an explicit empty override (e.g.
|
|
515
|
+
# solve.contains_solves = []) round-trips instead of collapsing
|
|
516
|
+
# into "no value" and inheriting a lower-ranked alternative.
|
|
517
|
+
if is_array_dtype:
|
|
518
|
+
value = Array(values=[])
|
|
519
|
+
else:
|
|
520
|
+
value = Map(indexes=[], values=[])
|
|
521
|
+
elif is_array_dtype:
|
|
522
|
+
value = Array(values=values, index_name=index_name)
|
|
523
|
+
else:
|
|
524
|
+
value = Map(indexes=indexes, values=values, index_name=index_name)
|
|
525
|
+
rec["value"] = value
|
|
526
|
+
rec["index_value"] = None # no longer individual
|
|
527
|
+
collapsed.append(rec)
|
|
528
|
+
|
|
529
|
+
collapsed.extend(nested_maps)
|
|
530
|
+
return scalars, collapsed
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ---------------------------------------------------------------------------
|
|
534
|
+
# Core writing logic for one sheet
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _write_sheet(
|
|
539
|
+
sheet: SheetData,
|
|
540
|
+
db: DatabaseMapping,
|
|
541
|
+
alternatives_added: set[str],
|
|
542
|
+
entities_added: set[tuple[str, tuple[str, ...]]],
|
|
543
|
+
entity_alts_added: set[tuple[str, tuple[str, ...], str]],
|
|
544
|
+
param_values_added: set[tuple[str, tuple[str, ...], str, str]],
|
|
545
|
+
) -> None:
|
|
546
|
+
"""Write a single SheetData into *db*.
|
|
547
|
+
|
|
548
|
+
The caller-owned sets track what has already been added so that
|
|
549
|
+
duplicates are silently skipped.
|
|
550
|
+
|
|
551
|
+
``param_values_added`` keys on
|
|
552
|
+
``(entity_class, entity_byname, param_name, alternative)`` — the unique
|
|
553
|
+
identity of a Spine parameter_value. A parameter that can be either
|
|
554
|
+
constant or period-indexed (e.g. ``node.existing``) is split across a
|
|
555
|
+
``*_c`` constant sheet and a ``*_p`` periodic sheet by the exporter;
|
|
556
|
+
older workbooks emitted the scalar value on BOTH. The constant sheet is
|
|
557
|
+
written first (pass-1 workbook order), so first-writer-wins keeps the
|
|
558
|
+
correctly-typed float and silently drops the periodic-sheet duplicate
|
|
559
|
+
instead of surfacing an alarming "already a parameter_value" warning.
|
|
560
|
+
"""
|
|
561
|
+
# -- Link sheets (entity relationships only, no parameters) -------------
|
|
562
|
+
if sheet.link_entities:
|
|
563
|
+
for ec in sheet.metadata.entity_classes:
|
|
564
|
+
for byname in sheet.link_entities:
|
|
565
|
+
key = (ec.class_name, byname)
|
|
566
|
+
if key not in entities_added:
|
|
567
|
+
entities_added.add(key)
|
|
568
|
+
try:
|
|
569
|
+
db.add_entity(
|
|
570
|
+
entity_class_name=ec.class_name,
|
|
571
|
+
entity_byname=byname,
|
|
572
|
+
)
|
|
573
|
+
except SpineDBAPIError as exc:
|
|
574
|
+
logger.warning(
|
|
575
|
+
"Could not add link entity %s (%s): %s",
|
|
576
|
+
byname, ec.class_name, exc,
|
|
577
|
+
)
|
|
578
|
+
return
|
|
579
|
+
|
|
580
|
+
if not sheet.records:
|
|
581
|
+
return
|
|
582
|
+
|
|
583
|
+
# -- Alternatives -------------------------------------------------------
|
|
584
|
+
for rec in sheet.records:
|
|
585
|
+
alt = rec.get("alternative")
|
|
586
|
+
if alt and alt not in alternatives_added:
|
|
587
|
+
alternatives_added.add(alt)
|
|
588
|
+
try:
|
|
589
|
+
db.add_alternative(name=alt)
|
|
590
|
+
except SpineDBAPIError as exc:
|
|
591
|
+
logger.warning("Could not add alternative '%s': %s", alt, exc)
|
|
592
|
+
|
|
593
|
+
# -- Entities -----------------------------------------------------------
|
|
594
|
+
for rec in sheet.records:
|
|
595
|
+
ec_name = rec.get("entity_class")
|
|
596
|
+
byname = rec.get("entity_byname")
|
|
597
|
+
if ec_name and byname:
|
|
598
|
+
key = (ec_name, byname)
|
|
599
|
+
if key not in entities_added:
|
|
600
|
+
entities_added.add(key)
|
|
601
|
+
try:
|
|
602
|
+
db.add_entity(
|
|
603
|
+
entity_class_name=ec_name,
|
|
604
|
+
entity_byname=byname,
|
|
605
|
+
)
|
|
606
|
+
except SpineDBAPIError as exc:
|
|
607
|
+
logger.warning(
|
|
608
|
+
"Could not add entity %s (%s): %s",
|
|
609
|
+
byname, ec_name, exc,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
# -- Combine facet-leaf records (price_ladder_*) ------------------------
|
|
613
|
+
# Reader emits ``price`` / ``quantity`` as separate per-row records;
|
|
614
|
+
# the registry tells us they belong to a canonical Map-valued param
|
|
615
|
+
# (e.g. ``commodity.price_ladder_cumulative``). Combination must
|
|
616
|
+
# happen BEFORE the generic Map grouper so the resulting top-level
|
|
617
|
+
# Map flows through the scalar branch as a single rec with type=map.
|
|
618
|
+
records = _combine_facet_records(sheet.records, sheet.sheet_name)
|
|
619
|
+
|
|
620
|
+
# -- Split records into scalars and maps --------------------------------
|
|
621
|
+
scalars, maps = _group_map_records(records)
|
|
622
|
+
|
|
623
|
+
# -- Entity existence (entity_alternative) ------------------------------
|
|
624
|
+
for rec in scalars:
|
|
625
|
+
if rec["param_name"] != _ENTITY_EXISTENCE:
|
|
626
|
+
continue
|
|
627
|
+
ec_name = rec["entity_class"]
|
|
628
|
+
byname = rec["entity_byname"]
|
|
629
|
+
alt = rec["alternative"]
|
|
630
|
+
active = bool(rec["value"])
|
|
631
|
+
ea_key = (ec_name, byname, alt)
|
|
632
|
+
if ea_key not in entity_alts_added:
|
|
633
|
+
entity_alts_added.add(ea_key)
|
|
634
|
+
try:
|
|
635
|
+
db.add_entity_alternative(
|
|
636
|
+
entity_class_name=ec_name,
|
|
637
|
+
entity_byname=byname,
|
|
638
|
+
alternative_name=alt,
|
|
639
|
+
active=active,
|
|
640
|
+
)
|
|
641
|
+
except SpineDBAPIError as exc:
|
|
642
|
+
logger.warning(
|
|
643
|
+
"Could not add entity_alternative %s %s '%s': %s",
|
|
644
|
+
ec_name, byname, alt, exc,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
# -- Scalar parameter values --------------------------------------------
|
|
648
|
+
for rec in scalars:
|
|
649
|
+
if rec["param_name"] == _ENTITY_EXISTENCE:
|
|
650
|
+
continue
|
|
651
|
+
if not rec["param_name"]:
|
|
652
|
+
continue # entity-only record — no param to write
|
|
653
|
+
pv_key = (
|
|
654
|
+
rec["entity_class"], rec["entity_byname"],
|
|
655
|
+
rec["param_name"], rec["alternative"],
|
|
656
|
+
)
|
|
657
|
+
if pv_key in param_values_added:
|
|
658
|
+
continue # already written by a sibling sheet (constant wins)
|
|
659
|
+
param_values_added.add(pv_key)
|
|
660
|
+
value, type_ = to_database(rec["value"])
|
|
661
|
+
try:
|
|
662
|
+
db.add_parameter_value(
|
|
663
|
+
entity_class_name=rec["entity_class"],
|
|
664
|
+
entity_byname=rec["entity_byname"],
|
|
665
|
+
parameter_definition_name=rec["param_name"],
|
|
666
|
+
alternative_name=rec["alternative"],
|
|
667
|
+
value=value,
|
|
668
|
+
type=type_,
|
|
669
|
+
)
|
|
670
|
+
except SpineDBAPIError as exc:
|
|
671
|
+
logger.warning(
|
|
672
|
+
"Could not add parameter value %s.%s (%s, alt=%s): %s",
|
|
673
|
+
rec["entity_class"], rec["param_name"],
|
|
674
|
+
rec["entity_byname"], rec["alternative"], exc,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
# -- Map parameter values -----------------------------------------------
|
|
678
|
+
for rec in maps:
|
|
679
|
+
pv_key = (
|
|
680
|
+
rec["entity_class"], rec["entity_byname"],
|
|
681
|
+
rec["param_name"], rec["alternative"],
|
|
682
|
+
)
|
|
683
|
+
if pv_key in param_values_added:
|
|
684
|
+
continue # already written by a sibling sheet
|
|
685
|
+
param_values_added.add(pv_key)
|
|
686
|
+
value, type_ = to_database(rec["value"])
|
|
687
|
+
try:
|
|
688
|
+
db.add_parameter_value(
|
|
689
|
+
entity_class_name=rec["entity_class"],
|
|
690
|
+
entity_byname=rec["entity_byname"],
|
|
691
|
+
parameter_definition_name=rec["param_name"],
|
|
692
|
+
alternative_name=rec["alternative"],
|
|
693
|
+
value=value,
|
|
694
|
+
type=type_,
|
|
695
|
+
)
|
|
696
|
+
except SpineDBAPIError as exc:
|
|
697
|
+
logger.warning(
|
|
698
|
+
"Could not add map parameter %s.%s (%s, alt=%s): %s",
|
|
699
|
+
rec["entity_class"], rec["param_name"],
|
|
700
|
+
rec["entity_byname"], rec["alternative"], exc,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# ---------------------------------------------------------------------------
|
|
705
|
+
# Ensure 0-dimensional entities referenced by multi-dim sheets
|
|
706
|
+
# ---------------------------------------------------------------------------
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _ensure_dimension_entities(
|
|
710
|
+
data_sheets_nd: list[SheetData],
|
|
711
|
+
db: DatabaseMapping,
|
|
712
|
+
entities_added: set[tuple[str, tuple[str, ...]]],
|
|
713
|
+
) -> None:
|
|
714
|
+
"""Create any missing 0-dim entities referenced as dimensions in multi-dim sheets.
|
|
715
|
+
|
|
716
|
+
Entity classes like ``reserve``, ``upDown``, ``model``, ``group``, ``commodity``
|
|
717
|
+
may have entities that only appear as dimension elements in multi-dimensional
|
|
718
|
+
relationship entities (e.g. ``reserve__upDown__unit__node``). They never get
|
|
719
|
+
their own sheet, so they would be missing after pass 1.
|
|
720
|
+
|
|
721
|
+
This function scans all multi-dim SheetData records *and* link_entities and
|
|
722
|
+
adds the missing 0-dim entities before pass 2 creates the relationships.
|
|
723
|
+
"""
|
|
724
|
+
for sheet in data_sheets_nd:
|
|
725
|
+
for ec in sheet.metadata.entity_classes:
|
|
726
|
+
if len(ec.dimensions) <= 1:
|
|
727
|
+
continue
|
|
728
|
+
|
|
729
|
+
# From regular records
|
|
730
|
+
for rec in sheet.records:
|
|
731
|
+
if rec.get("entity_class") != ec.class_name:
|
|
732
|
+
continue
|
|
733
|
+
byname = rec.get("entity_byname")
|
|
734
|
+
if not byname or len(byname) != len(ec.dimensions):
|
|
735
|
+
continue
|
|
736
|
+
for dim_name, elem_name in zip(ec.dimensions, byname):
|
|
737
|
+
if not elem_name:
|
|
738
|
+
continue
|
|
739
|
+
key = (dim_name, (elem_name,))
|
|
740
|
+
if key not in entities_added:
|
|
741
|
+
entities_added.add(key)
|
|
742
|
+
try:
|
|
743
|
+
db.add_entity(
|
|
744
|
+
entity_class_name=dim_name,
|
|
745
|
+
entity_byname=(elem_name,),
|
|
746
|
+
)
|
|
747
|
+
except SpineDBAPIError:
|
|
748
|
+
pass # already exists or class doesn't exist
|
|
749
|
+
|
|
750
|
+
# From link_entities
|
|
751
|
+
for byname in sheet.link_entities:
|
|
752
|
+
if len(byname) != len(ec.dimensions):
|
|
753
|
+
continue
|
|
754
|
+
for dim_name, elem_name in zip(ec.dimensions, byname):
|
|
755
|
+
if not elem_name:
|
|
756
|
+
continue
|
|
757
|
+
key = (dim_name, (elem_name,))
|
|
758
|
+
if key not in entities_added:
|
|
759
|
+
entities_added.add(key)
|
|
760
|
+
try:
|
|
761
|
+
db.add_entity(
|
|
762
|
+
entity_class_name=dim_name,
|
|
763
|
+
entity_byname=(elem_name,),
|
|
764
|
+
)
|
|
765
|
+
except SpineDBAPIError:
|
|
766
|
+
pass # already exists or class doesn't exist
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# ---------------------------------------------------------------------------
|
|
770
|
+
# Scenario sheet handling
|
|
771
|
+
# ---------------------------------------------------------------------------
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _write_scenarios(
|
|
775
|
+
sheet: SheetData,
|
|
776
|
+
db: DatabaseMapping,
|
|
777
|
+
alternatives_added: set[str],
|
|
778
|
+
) -> None:
|
|
779
|
+
"""Handle a scenario sheet.
|
|
780
|
+
|
|
781
|
+
Scenario sheets have records where entity_class == 'scenario'.
|
|
782
|
+
Record structure (from parse_scenario_sheet):
|
|
783
|
+
entity_class: 'scenario'
|
|
784
|
+
entity_byname: ('scenario_name',)
|
|
785
|
+
param_name: row label (e.g. 'base_alternative', 'alternative_1')
|
|
786
|
+
alternative: the alternative name
|
|
787
|
+
value: rank (int)
|
|
788
|
+
"""
|
|
789
|
+
if not sheet.records:
|
|
790
|
+
return
|
|
791
|
+
|
|
792
|
+
# Group records by scenario name → list of (alternative_name, rank)
|
|
793
|
+
scenario_alts: dict[str, list[tuple[str, int]]] = defaultdict(list)
|
|
794
|
+
|
|
795
|
+
for rec in sheet.records:
|
|
796
|
+
scenario_name = rec.get("entity_byname")
|
|
797
|
+
if scenario_name and isinstance(scenario_name, tuple):
|
|
798
|
+
scenario_name = scenario_name[0]
|
|
799
|
+
|
|
800
|
+
if not scenario_name:
|
|
801
|
+
continue
|
|
802
|
+
|
|
803
|
+
# The 'alternative' field holds the actual alternative name
|
|
804
|
+
alt_name = rec.get("alternative")
|
|
805
|
+
value = rec.get("value")
|
|
806
|
+
|
|
807
|
+
if not alt_name or alt_name == _ENTITY_EXISTENCE:
|
|
808
|
+
continue
|
|
809
|
+
|
|
810
|
+
if isinstance(value, (int, float)):
|
|
811
|
+
rank = int(value)
|
|
812
|
+
else:
|
|
813
|
+
rank = 0
|
|
814
|
+
|
|
815
|
+
scenario_alts[scenario_name].append((alt_name, rank))
|
|
816
|
+
|
|
817
|
+
# Write scenarios and their alternatives
|
|
818
|
+
for scenario_name, alt_ranks in scenario_alts.items():
|
|
819
|
+
try:
|
|
820
|
+
db.add_scenario(name=scenario_name)
|
|
821
|
+
except SpineDBAPIError as exc:
|
|
822
|
+
logger.warning("Could not add scenario '%s': %s", scenario_name, exc)
|
|
823
|
+
|
|
824
|
+
# Sort by rank before adding
|
|
825
|
+
alt_ranks.sort(key=lambda x: x[1])
|
|
826
|
+
for rank, (alt_name, _original_rank) in enumerate(alt_ranks):
|
|
827
|
+
# Ensure the alternative exists
|
|
828
|
+
if alt_name not in alternatives_added:
|
|
829
|
+
alternatives_added.add(alt_name)
|
|
830
|
+
try:
|
|
831
|
+
db.add_alternative(name=alt_name)
|
|
832
|
+
except SpineDBAPIError as exc:
|
|
833
|
+
logger.warning(
|
|
834
|
+
"Could not add alternative '%s': %s", alt_name, exc,
|
|
835
|
+
)
|
|
836
|
+
try:
|
|
837
|
+
db.add_scenario_alternative(
|
|
838
|
+
scenario_name=scenario_name,
|
|
839
|
+
alternative_name=alt_name,
|
|
840
|
+
rank=rank,
|
|
841
|
+
)
|
|
842
|
+
except SpineDBAPIError as exc:
|
|
843
|
+
logger.warning(
|
|
844
|
+
"Could not add scenario_alternative %s -> %s (rank %d): %s",
|
|
845
|
+
scenario_name, alt_name, rank, exc,
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _is_scenario_sheet(sheet: SheetData) -> bool:
|
|
850
|
+
"""Return True if the sheet looks like a scenario definition sheet."""
|
|
851
|
+
name = sheet.sheet_name.lower()
|
|
852
|
+
if "scenario" in name:
|
|
853
|
+
return True
|
|
854
|
+
# Also check entity class names
|
|
855
|
+
for ec in sheet.metadata.entity_classes:
|
|
856
|
+
if "scenario" in ec.class_name.lower():
|
|
857
|
+
return True
|
|
858
|
+
return False
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
# ---------------------------------------------------------------------------
|
|
862
|
+
# Purge helper
|
|
863
|
+
# ---------------------------------------------------------------------------
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _purge_database(db: DatabaseMapping, keep_entities: bool = False) -> None:
|
|
867
|
+
"""Remove data from the database before import.
|
|
868
|
+
|
|
869
|
+
Args:
|
|
870
|
+
keep_entities: If True, keep entities (useful when importing into a
|
|
871
|
+
fresh template DB where structural entities like upDown should
|
|
872
|
+
be preserved). Alternatives, scenarios, and parameter values
|
|
873
|
+
are always purged.
|
|
874
|
+
"""
|
|
875
|
+
try:
|
|
876
|
+
db.remove_parameter_value(id=Asterisk)
|
|
877
|
+
if not keep_entities:
|
|
878
|
+
db.remove_entity(id=Asterisk)
|
|
879
|
+
db.remove_alternative(id=Asterisk)
|
|
880
|
+
db.remove_scenario(id=Asterisk)
|
|
881
|
+
db.commit_session("Purged database before import")
|
|
882
|
+
except NothingToCommit:
|
|
883
|
+
pass
|
|
884
|
+
except SpineDBAPIError as exc:
|
|
885
|
+
raise RuntimeError(f"Failed to purge database: {exc}") from exc
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
# ---------------------------------------------------------------------------
|
|
889
|
+
# Public API
|
|
890
|
+
# ---------------------------------------------------------------------------
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def write_sheet_data_to_db(
|
|
894
|
+
sheet_data_list: list[SheetData],
|
|
895
|
+
db_url: str,
|
|
896
|
+
purge_first: bool = True,
|
|
897
|
+
keep_entities: bool = False,
|
|
898
|
+
) -> None:
|
|
899
|
+
"""Write parsed SheetData objects into a SpineDB.
|
|
900
|
+
|
|
901
|
+
Uses a two-pass strategy so that 1-dimensional entities are created
|
|
902
|
+
before multi-dimensional (relationship) entities that reference them.
|
|
903
|
+
|
|
904
|
+
Args:
|
|
905
|
+
sheet_data_list: Output of :func:`read_self_describing_excel`.
|
|
906
|
+
db_url: SQLAlchemy-style database URL, e.g.
|
|
907
|
+
``sqlite:///path/to/db.sqlite``.
|
|
908
|
+
purge_first: If True, remove existing data from the database
|
|
909
|
+
before writing (keeps entity classes and parameter definitions).
|
|
910
|
+
keep_entities: If True (and purge_first is True), keep existing
|
|
911
|
+
entities during purge. Useful when importing into a fresh
|
|
912
|
+
template DB that has structural entities (like upDown).
|
|
913
|
+
"""
|
|
914
|
+
with DatabaseMapping(db_url, create=False, upgrade=True) as db:
|
|
915
|
+
if purge_first:
|
|
916
|
+
_purge_database(db, keep_entities=keep_entities)
|
|
917
|
+
|
|
918
|
+
# Tracking sets to avoid duplicate add calls
|
|
919
|
+
alternatives_added: set[str] = set()
|
|
920
|
+
entities_added: set[tuple[str, tuple[str, ...]]] = set()
|
|
921
|
+
entity_alts_added: set[tuple[str, tuple[str, ...], str]] = set()
|
|
922
|
+
param_values_added: set[tuple[str, tuple[str, ...], str, str]] = set()
|
|
923
|
+
|
|
924
|
+
# Separate scenario sheets from data sheets
|
|
925
|
+
scenario_sheets: list[SheetData] = []
|
|
926
|
+
data_sheets_1d: list[SheetData] = []
|
|
927
|
+
data_sheets_nd: list[SheetData] = []
|
|
928
|
+
|
|
929
|
+
for sheet in sheet_data_list:
|
|
930
|
+
if _is_scenario_sheet(sheet):
|
|
931
|
+
scenario_sheets.append(sheet)
|
|
932
|
+
elif _is_multidimensional(sheet):
|
|
933
|
+
data_sheets_nd.append(sheet)
|
|
934
|
+
else:
|
|
935
|
+
data_sheets_1d.append(sheet)
|
|
936
|
+
|
|
937
|
+
# Pass 1: 1-dimensional entity classes
|
|
938
|
+
for sheet in data_sheets_1d:
|
|
939
|
+
logger.info(
|
|
940
|
+
"Pass 1 — writing sheet '%s' (%d records, %d links)",
|
|
941
|
+
sheet.sheet_name, len(sheet.records), len(sheet.link_entities),
|
|
942
|
+
)
|
|
943
|
+
_write_sheet(
|
|
944
|
+
sheet, db, alternatives_added, entities_added,
|
|
945
|
+
entity_alts_added, param_values_added,
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
# Pass 1.5: ensure 0-dim entities referenced by multi-dim sheets
|
|
949
|
+
_ensure_dimension_entities(data_sheets_nd, db, entities_added)
|
|
950
|
+
|
|
951
|
+
# Pass 2: multi-dimensional entity classes
|
|
952
|
+
for sheet in data_sheets_nd:
|
|
953
|
+
logger.info(
|
|
954
|
+
"Pass 2 — writing sheet '%s' (%d records, %d links)",
|
|
955
|
+
sheet.sheet_name, len(sheet.records), len(sheet.link_entities),
|
|
956
|
+
)
|
|
957
|
+
_write_sheet(
|
|
958
|
+
sheet, db, alternatives_added, entities_added,
|
|
959
|
+
entity_alts_added, param_values_added,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
# Pass 3: scenario sheets
|
|
963
|
+
for sheet in scenario_sheets:
|
|
964
|
+
logger.info(
|
|
965
|
+
"Writing scenario sheet '%s' (%d records)",
|
|
966
|
+
sheet.sheet_name, len(sheet.records),
|
|
967
|
+
)
|
|
968
|
+
_write_scenarios(sheet, db, alternatives_added)
|
|
969
|
+
|
|
970
|
+
# Commit everything
|
|
971
|
+
try:
|
|
972
|
+
db.commit_session("Imported data from self-describing Excel")
|
|
973
|
+
logger.info("Successfully committed all imported data")
|
|
974
|
+
except NothingToCommit:
|
|
975
|
+
logger.info("No new data to commit")
|
|
976
|
+
except SpineDBAPIError as exc:
|
|
977
|
+
raise RuntimeError(f"Failed to commit imported data: {exc}") from exc
|