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,1267 @@
|
|
|
1
|
+
"""Write FlexTool processed results into a SpineDB (the ``spinedb`` output
|
|
2
|
+
write-method).
|
|
3
|
+
|
|
4
|
+
This module dumps the engine's in-memory ``results`` dict of pandas
|
|
5
|
+
DataFrames (built in :mod:`flextool.process_outputs.write_outputs`) into a
|
|
6
|
+
SpineDB that uses the FlexTool **results schema**
|
|
7
|
+
(``flextool/schemas/spinedb_results_schema.json``). Multiple runs coexist
|
|
8
|
+
in one DB, separated by Spine **alternatives** (one alternative per
|
|
9
|
+
scenario; multiple solves coexist inside the ``solve`` Map axis).
|
|
10
|
+
|
|
11
|
+
Schema note: the results schema JSON was generated by trimming the three
|
|
12
|
+
duplicate reserve entity classes (``unit__reserve__upDown__node.1/.2/.3``)
|
|
13
|
+
from the legacy results template — they carry no parameter definitions and
|
|
14
|
+
nothing references them. The trimmed schema has 12 entity classes and 58
|
|
15
|
+
parameter definitions.
|
|
16
|
+
|
|
17
|
+
Key correctness facts (verified against the engine code, 2026-06-03):
|
|
18
|
+
|
|
19
|
+
* The ``results`` dict keys are the RAW engine table names (e.g.
|
|
20
|
+
``unit_outputNode_dt_ee``), NOT renamed display names.
|
|
21
|
+
* ``import_data`` does NOT auto-create relationship member entities, so the
|
|
22
|
+
writer explicitly emits every member element entity (each ``unit``,
|
|
23
|
+
``node``, ``connection``, ``group``, ``reserve``, ``upDown``, plus the
|
|
24
|
+
literal ``model``-class entities ``"cost"`` and ``"model"``) in addition
|
|
25
|
+
to the relationship entities.
|
|
26
|
+
* Capacity Map keys stay ``existing``/``invested``/``divested``/``total``
|
|
27
|
+
(no ``retired`` rename).
|
|
28
|
+
* Discount-factor params (#33/#34) derive from
|
|
29
|
+
``par.inflation_factor_investment_yearly`` /
|
|
30
|
+
``par.inflation_factor_operations_yearly``. ``par`` is threaded in as an
|
|
31
|
+
optional keyword; when absent those two params are skipped.
|
|
32
|
+
* Empty / all-NaN cells emit nothing (no empty Maps, no spurious 0).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import math
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
import pandas as pd
|
|
45
|
+
from spinedb_api import DatabaseMapping, import_data
|
|
46
|
+
from spinedb_api.parameter_value import Map
|
|
47
|
+
|
|
48
|
+
from flextool.process_outputs._output_meta import FACTOR, result_key_summary
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
_SCHEMA_PATH = os.path.join(
|
|
53
|
+
os.path.dirname(os.path.dirname(__file__)),
|
|
54
|
+
"schemas",
|
|
55
|
+
"spinedb_results_schema.json",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Whitelist: raw engine table name -> spec.
|
|
61
|
+
#
|
|
62
|
+
# spec fields:
|
|
63
|
+
# ec : target entity class name
|
|
64
|
+
# param : target parameter definition name
|
|
65
|
+
# entity_dims : number of leading column levels that form the entity byname
|
|
66
|
+
# (after reordering to schema dim order). 0 means a literal
|
|
67
|
+
# fixed entity (see ``fixed_entity``).
|
|
68
|
+
# fixed_entity: literal byname tuple to use when entity_dims == 0
|
|
69
|
+
# (cost params: "cost" / "model").
|
|
70
|
+
# has_time : True for timestep (_dt) frames -> Map [solve]->period->time;
|
|
71
|
+
# False for period (_d) frames -> Map [solve]->period.
|
|
72
|
+
# category : how to treat the remaining (non-entity) column levels:
|
|
73
|
+
# None -> single value column, no category nesting
|
|
74
|
+
# "header" -> remaining column levels become the OUTERMOST
|
|
75
|
+
# Map keys (matching importer position:"header")
|
|
76
|
+
# reserve_filter: 'unit' or 'connection' -> route rows whose process column
|
|
77
|
+
# is a unit / connection respectively.
|
|
78
|
+
# topology : 'connection_triple' -> column is a single connection name;
|
|
79
|
+
# reconstruct the (connection, node, node) byname from
|
|
80
|
+
# ``s.process_source_sink``.
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
SPINEDB_OUTPUT_SPEC: dict[str, dict[str, Any]] = {
|
|
84
|
+
# --- node -------------------------------------------------------------
|
|
85
|
+
"node_dt_ep": dict(ec="node", param="balance_t", entity_dims=1,
|
|
86
|
+
has_time=True, category="header"),
|
|
87
|
+
"node_d_ep": dict(ec="node", param="balance", entity_dims=1,
|
|
88
|
+
has_time=False, category="header"),
|
|
89
|
+
"node_state_dt_e": dict(ec="node", param="state_t", entity_dims=1,
|
|
90
|
+
has_time=True, category=None),
|
|
91
|
+
"node_prices_dt_e": dict(ec="node", param="price_t", entity_dims=1,
|
|
92
|
+
has_time=True, category=None),
|
|
93
|
+
"node_capacity_ed_p": dict(ec="node", param="capacity", entity_dims=1,
|
|
94
|
+
has_time=False, category="header",
|
|
95
|
+
entity_index="node"),
|
|
96
|
+
"dual_invest_effective_node_d_e": dict(ec="node", param="invest_marginal",
|
|
97
|
+
entity_dims=1, has_time=False,
|
|
98
|
+
category=None),
|
|
99
|
+
# --- unit -------------------------------------------------------------
|
|
100
|
+
"unit_online_dt_e": dict(ec="unit", param="online_t", entity_dims=1,
|
|
101
|
+
has_time=True, category=None, entity_filter="unit"),
|
|
102
|
+
"unit_online_average_d_e": dict(ec="unit", param="online_average",
|
|
103
|
+
entity_dims=1, has_time=False,
|
|
104
|
+
category=None, entity_filter="unit"),
|
|
105
|
+
"unit_startup_d_e": dict(ec="unit", param="startup_cumulative",
|
|
106
|
+
entity_dims=1, has_time=False, category=None,
|
|
107
|
+
entity_filter="unit"),
|
|
108
|
+
"unit_capacity_ed_p": dict(ec="unit", param="capacity", entity_dims=1,
|
|
109
|
+
has_time=False, category="header",
|
|
110
|
+
entity_index="unit"),
|
|
111
|
+
"dual_invest_effective_unit_d_e": dict(ec="unit", param="invest_marginal",
|
|
112
|
+
entity_dims=1, has_time=False,
|
|
113
|
+
category=None),
|
|
114
|
+
"process_co2_d_eee": dict(ec="unit", param="CO2_annualized",
|
|
115
|
+
entity_dims=1, has_time=False, category="header",
|
|
116
|
+
co2_type="unit"),
|
|
117
|
+
# --- unit__node -------------------------------------------------------
|
|
118
|
+
"unit_outputNode_dt_ee": dict(ec="unit__node", param="flow_t",
|
|
119
|
+
entity_dims=2, has_time=True, category=None),
|
|
120
|
+
"unit_inputNode_dt_ee": dict(ec="unit__node", param="flow_t",
|
|
121
|
+
entity_dims=2, has_time=True, category=None),
|
|
122
|
+
"unit_outputNode_d_ee": dict(ec="unit__node", param="flow_annualized",
|
|
123
|
+
entity_dims=2, has_time=False, category=None),
|
|
124
|
+
"unit_inputNode_d_ee": dict(ec="unit__node", param="flow_annualized",
|
|
125
|
+
entity_dims=2, has_time=False, category=None),
|
|
126
|
+
"unit_ramp_outputs_dt_ee": dict(ec="unit__node", param="ramp_t",
|
|
127
|
+
entity_dims=2, has_time=True, category=None),
|
|
128
|
+
"unit_ramp_inputs_dt_ee": dict(ec="unit__node", param="ramp_t",
|
|
129
|
+
entity_dims=2, has_time=True, category=None),
|
|
130
|
+
"unit_outputs_cf_d_ee": dict(ec="unit__node", param="cf", entity_dims=2,
|
|
131
|
+
has_time=False, category=None),
|
|
132
|
+
"unit_inputs_cf_d_ee": dict(ec="unit__node", param="cf", entity_dims=2,
|
|
133
|
+
has_time=False, category=None),
|
|
134
|
+
"unit_curtailment_outputNode_dt_ee": dict(ec="unit__node",
|
|
135
|
+
param="curtailment_t",
|
|
136
|
+
entity_dims=2, has_time=True,
|
|
137
|
+
category=None),
|
|
138
|
+
"unit_curtailment_share_outputNode_d_ee": dict(ec="unit__node",
|
|
139
|
+
param="curtailment_share",
|
|
140
|
+
entity_dims=2,
|
|
141
|
+
has_time=False,
|
|
142
|
+
category=None),
|
|
143
|
+
# --- connection__node__node ------------------------------------------
|
|
144
|
+
"connection_dt_eee": dict(ec="connection__node__node", param="flow_t",
|
|
145
|
+
entity_dims=1, has_time=True, category=None,
|
|
146
|
+
topology="connection_triple"),
|
|
147
|
+
"connection_d_eee": dict(ec="connection__node__node",
|
|
148
|
+
param="flow_annualized", entity_dims=1,
|
|
149
|
+
has_time=False, category=None,
|
|
150
|
+
topology="connection_triple"),
|
|
151
|
+
"connection_leftward_dt_eee": dict(ec="connection__node__node",
|
|
152
|
+
param="flow_to_first_node_t",
|
|
153
|
+
entity_dims=1, has_time=True,
|
|
154
|
+
category=None,
|
|
155
|
+
topology="connection_triple"),
|
|
156
|
+
"connection_leftward_d_eee": dict(ec="connection__node__node",
|
|
157
|
+
param="flow_to_first_node_annualized",
|
|
158
|
+
entity_dims=1, has_time=False,
|
|
159
|
+
category=None,
|
|
160
|
+
topology="connection_triple"),
|
|
161
|
+
"connection_rightward_dt_eee": dict(ec="connection__node__node",
|
|
162
|
+
param="flow_to_second_node_t",
|
|
163
|
+
entity_dims=1, has_time=True,
|
|
164
|
+
category=None,
|
|
165
|
+
topology="connection_triple"),
|
|
166
|
+
"connection_rightward_d_eee": dict(ec="connection__node__node",
|
|
167
|
+
param="flow_to_second_node_annualized",
|
|
168
|
+
entity_dims=1, has_time=False,
|
|
169
|
+
category=None,
|
|
170
|
+
topology="connection_triple"),
|
|
171
|
+
"connection_cf_d_e": dict(ec="connection__node__node", param="cf",
|
|
172
|
+
entity_dims=1, has_time=False, category=None,
|
|
173
|
+
topology="connection_triple"),
|
|
174
|
+
# --- connection -------------------------------------------------------
|
|
175
|
+
"connection_capacity_ed_p": dict(ec="connection", param="capacity",
|
|
176
|
+
entity_dims=1, has_time=False,
|
|
177
|
+
category="header",
|
|
178
|
+
entity_index="connection"),
|
|
179
|
+
"dual_invest_effective_connection_d_e": dict(ec="connection",
|
|
180
|
+
param="invest_marginal",
|
|
181
|
+
entity_dims=1, has_time=False,
|
|
182
|
+
category=None),
|
|
183
|
+
# --- per-entity cost break-down (period level, category in columns) ---
|
|
184
|
+
# Entity is an INDEX level (named unit/connection/node) and columns are
|
|
185
|
+
# cost categories — same shape as the capacity tables, so they route
|
|
186
|
+
# through the entity_index serializer.
|
|
187
|
+
"cost_unit_annualized_d_ec": dict(ec="unit", param="cost_annualized",
|
|
188
|
+
entity_dims=1, has_time=False,
|
|
189
|
+
category="header", entity_index="unit"),
|
|
190
|
+
"cost_unit_discounted_d_ec": dict(ec="unit", param="cost_discounted",
|
|
191
|
+
entity_dims=1, has_time=False,
|
|
192
|
+
category="header", entity_index="unit"),
|
|
193
|
+
"cost_connection_annualized_d_ec": dict(ec="connection",
|
|
194
|
+
param="cost_annualized",
|
|
195
|
+
entity_dims=1, has_time=False,
|
|
196
|
+
category="header",
|
|
197
|
+
entity_index="connection"),
|
|
198
|
+
"cost_connection_discounted_d_ec": dict(ec="connection",
|
|
199
|
+
param="cost_discounted",
|
|
200
|
+
entity_dims=1, has_time=False,
|
|
201
|
+
category="header",
|
|
202
|
+
entity_index="connection"),
|
|
203
|
+
"cost_node_annualized_d_ec": dict(ec="node", param="cost_annualized",
|
|
204
|
+
entity_dims=1, has_time=False,
|
|
205
|
+
category="header", entity_index="node"),
|
|
206
|
+
"cost_node_discounted_d_ec": dict(ec="node", param="cost_discounted",
|
|
207
|
+
entity_dims=1, has_time=False,
|
|
208
|
+
category="header", entity_index="node"),
|
|
209
|
+
# --- model (cost) -----------------------------------------------------
|
|
210
|
+
"costs_dt_p": dict(ec="model", param="cost_t", entity_dims=0,
|
|
211
|
+
fixed_entity=("cost",), has_time=True,
|
|
212
|
+
category="header"),
|
|
213
|
+
"annualized_costs_d_p": dict(ec="model", param="cost_annualized",
|
|
214
|
+
entity_dims=0, fixed_entity=("cost",),
|
|
215
|
+
has_time=False, category="header"),
|
|
216
|
+
"costs_discounted_d_p": dict(ec="model", param="cost_discounted_solve",
|
|
217
|
+
entity_dims=0, fixed_entity=("model",),
|
|
218
|
+
has_time=False, category="header",
|
|
219
|
+
collapse_solve=True),
|
|
220
|
+
"costs_discounted_p_": dict(ec="model", param="cost_discounted_total",
|
|
221
|
+
entity_dims=0, fixed_entity=("model",),
|
|
222
|
+
has_time=False, category="header",
|
|
223
|
+
series_category=True),
|
|
224
|
+
# --- group ------------------------------------------------------------
|
|
225
|
+
"nodeGroup_flows_dt_g": dict(ec="group", param="flow_t", entity_dims=1,
|
|
226
|
+
has_time=True, category=None),
|
|
227
|
+
"nodeGroup_flows_d_g": dict(ec="group", param="flow_annualized",
|
|
228
|
+
entity_dims=1, has_time=False, category=None),
|
|
229
|
+
"nodeGroup_flows_dt_gpe": dict(ec="group", param="sum_flow_t",
|
|
230
|
+
entity_dims=1, has_time=True,
|
|
231
|
+
category="header"),
|
|
232
|
+
"nodeGroup_flows_d_gpe": dict(ec="group", param="sum_flow_annualized",
|
|
233
|
+
entity_dims=1, has_time=False,
|
|
234
|
+
category="header"),
|
|
235
|
+
"nodeGroup_gd_p": dict(ec="group", param="indicator", entity_dims=1,
|
|
236
|
+
has_time=False, category="header",
|
|
237
|
+
entity_index="group"),
|
|
238
|
+
"nodeGroup_VRE_share_dt_g": dict(ec="group", param="VRE_share_t",
|
|
239
|
+
entity_dims=1, has_time=True,
|
|
240
|
+
category=None),
|
|
241
|
+
"CO2_d_g": dict(ec="group", param="CO2_annualized", entity_dims=1,
|
|
242
|
+
has_time=False, category=None),
|
|
243
|
+
"nodeGroup_inertia_dt_g": dict(ec="group", param="inertia_t",
|
|
244
|
+
entity_dims=1, has_time=True,
|
|
245
|
+
category=None),
|
|
246
|
+
"nodeGroup_inertia_largest_flow_dt_g": dict(ec="group",
|
|
247
|
+
param="inertia_largest_flow_t",
|
|
248
|
+
entity_dims=1, has_time=True,
|
|
249
|
+
category=None),
|
|
250
|
+
"nodeGroup_unit_node_inertia_dt_gee": dict(ec="group",
|
|
251
|
+
param="inertia_unit_node_t",
|
|
252
|
+
entity_dims=1, has_time=True,
|
|
253
|
+
category="header"),
|
|
254
|
+
"nodeGroup_slack_capacity_margin_d_g": dict(ec="group",
|
|
255
|
+
param="slack_capacity_margin",
|
|
256
|
+
entity_dims=1, has_time=False,
|
|
257
|
+
category=None),
|
|
258
|
+
"nodeGroup_slack_inertia_dt_g": dict(ec="group", param="slack_inertia_t",
|
|
259
|
+
entity_dims=1, has_time=True,
|
|
260
|
+
category=None),
|
|
261
|
+
"nodeGroup_slack_nonsync_dt_g": dict(ec="group", param="slack_nonsync_t",
|
|
262
|
+
entity_dims=1, has_time=True,
|
|
263
|
+
category=None),
|
|
264
|
+
# --- group__reserve__upDown ------------------------------------------
|
|
265
|
+
"nodeGroup_slack_reserve_dt_eeg": dict(ec="group__reserve__upDown",
|
|
266
|
+
param="slack_reserve_t",
|
|
267
|
+
entity_dims=3, has_time=True,
|
|
268
|
+
category=None),
|
|
269
|
+
# --- unit/connection__reserve__upDown__node --------------------------
|
|
270
|
+
"process_reserve_upDown_node_dt_eppe": dict(
|
|
271
|
+
ec="unit__reserve__upDown__node", param="reservation_t",
|
|
272
|
+
entity_dims=4, has_time=True, category=None, reserve_split=True),
|
|
273
|
+
"process_reserve_average_d_eppe": dict(
|
|
274
|
+
ec="unit__reserve__upDown__node", param="reservation_average",
|
|
275
|
+
entity_dims=4, has_time=False, category=None, reserve_split=True),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# Public API
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def derive_alternative_name(scenario_name: str, solve_name: str) -> str:
|
|
284
|
+
"""Return the Spine alternative name for a run.
|
|
285
|
+
|
|
286
|
+
v1: deterministically returns ``scenario_name`` (no timestamp / random,
|
|
287
|
+
honoring cross-process determinism). Multiple solves coexist inside the
|
|
288
|
+
``solve`` Map axis of each value; multiple runs coexist as distinct
|
|
289
|
+
alternatives. A future ``--results-alternative`` override hooks here.
|
|
290
|
+
"""
|
|
291
|
+
return scenario_name
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def ensure_results_db(db_url: str) -> None:
|
|
295
|
+
"""Create the results DB from the schema JSON if it is absent; no-op if
|
|
296
|
+
it already exists.
|
|
297
|
+
|
|
298
|
+
The schema JSON is fed straight to ``import_data`` (its top-level keys
|
|
299
|
+
``entity_classes`` / ``parameter_definitions`` / ``alternatives`` are
|
|
300
|
+
valid ``import_data`` kwargs). ``import_data`` of classes/params is
|
|
301
|
+
idempotent, so re-running against an existing DB would be harmless, but
|
|
302
|
+
we short-circuit on an existing sqlite file for speed.
|
|
303
|
+
|
|
304
|
+
Concurrency contract (the Toolbox re-create context launches parallel
|
|
305
|
+
scenario iterations into ONE shared ``results.sqlite``):
|
|
306
|
+
|
|
307
|
+
* **Creation serializes via a sidecar lock.** For ``sqlite:///`` targets
|
|
308
|
+
the create-if-absent is guarded by an exclusive sidecar lock on
|
|
309
|
+
``<db>.lock`` (``fcntl.flock`` on POSIX, ``msvcrt.locking`` on
|
|
310
|
+
Windows), so exactly ONE process
|
|
311
|
+
ever builds the schema. Inside the lock we re-check for the target and,
|
|
312
|
+
if another process already created it, discard our temp build and NEVER
|
|
313
|
+
``os.replace`` over it — so a late rename can never clobber a DB another
|
|
314
|
+
process has already begun importing into. The schema is built into a
|
|
315
|
+
unique temp file in the target directory and ``os.replace``-d into place
|
|
316
|
+
(atomic on the same filesystem) only when the target is still absent.
|
|
317
|
+
* **Subsequent per-process IMPORTs append and serialize via the sqlite
|
|
318
|
+
busy-timeout.** Each scenario writes its own alternative through
|
|
319
|
+
``write_spinedb`` -> ``import_data``; concurrent appends to the shared
|
|
320
|
+
sqlite file are serialized by spinedb_api's connection busy-timeout —
|
|
321
|
+
``DatabaseMapping.create_engine`` passes ``connect_args={"timeout":
|
|
322
|
+
sqlite_timeout}`` (default 1800 s), the pysqlite busy-timeout, so a
|
|
323
|
+
writer that finds the DB locked WAITS and retries rather than failing
|
|
324
|
+
with ``SQLITE_BUSY``. We therefore do NOT hold the creation lock across
|
|
325
|
+
the import — appends are independent writers and only the cold-start
|
|
326
|
+
create must be single-writer.
|
|
327
|
+
|
|
328
|
+
Non-sqlite URLs (e.g. a shared server) fall back to the direct in-place
|
|
329
|
+
create (no atomic rename / file lock possible across a network DSN).
|
|
330
|
+
"""
|
|
331
|
+
if db_url.startswith("sqlite:///"):
|
|
332
|
+
path = db_url[len("sqlite:///"):]
|
|
333
|
+
# A present DB may still lag the current schema (additive params /
|
|
334
|
+
# classes shipped after it was built). Bring it up to date without
|
|
335
|
+
# taking the cold-start lock in the steady state.
|
|
336
|
+
if os.path.exists(path):
|
|
337
|
+
_top_up_schema_if_stale(db_url)
|
|
338
|
+
return
|
|
339
|
+
_ensure_results_db_atomic(db_url, path)
|
|
340
|
+
return
|
|
341
|
+
# Non-sqlite target: cannot atomically rename; create in place.
|
|
342
|
+
_import_schema(db_url)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _required_schema_sets() -> tuple[set[str], set[tuple[str, str]]]:
|
|
346
|
+
"""The full set of entity-class names and ``(entity_class, param)`` pairs
|
|
347
|
+
the current results schema requires, read from the schema JSON."""
|
|
348
|
+
with open(_SCHEMA_PATH, encoding="utf-8") as fh:
|
|
349
|
+
schema = json.load(fh)
|
|
350
|
+
required_classes = {ec[0] for ec in schema["entity_classes"]}
|
|
351
|
+
required_params = {(pd[0], pd[1]) for pd in schema["parameter_definitions"]}
|
|
352
|
+
return required_classes, required_params
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _top_up_schema_if_stale(db_url: str) -> None:
|
|
356
|
+
"""Bring a pre-existing sqlite results DB up to the current schema.
|
|
357
|
+
|
|
358
|
+
An additive schema change (a new entity class or parameter definition)
|
|
359
|
+
never reaches a ``results.sqlite`` built by an older FlexTool: the
|
|
360
|
+
cold-start create short-circuits on the existing file, so a later
|
|
361
|
+
``write_spinedb`` crashes when it references a param the DB lacks. Detect
|
|
362
|
+
the gap with a **lock-free, read-only gate** and, only when the DB is
|
|
363
|
+
stale, import the missing schema keys **under the sidecar lock** so
|
|
364
|
+
concurrent callers converge on ONE top-up (no duplicate rows, no
|
|
365
|
+
UNIQUE-violation).
|
|
366
|
+
"""
|
|
367
|
+
required_classes, required_params = _required_schema_sets()
|
|
368
|
+
|
|
369
|
+
# Gate: lock-free, read-only. In the steady state (nothing missing) we
|
|
370
|
+
# never take the lock and never write. The gate mapping MUST be closed
|
|
371
|
+
# before we take the lock: its item cache would otherwise go stale under
|
|
372
|
+
# another process's concurrent commit (critique BLOCKER #1).
|
|
373
|
+
with DatabaseMapping(db_url) as db:
|
|
374
|
+
have_classes = {c["name"] for c in db.get_entity_class_items()}
|
|
375
|
+
have_params = {(p["entity_class_name"], p["name"])
|
|
376
|
+
for p in db.get_parameter_definition_items()}
|
|
377
|
+
missing_classes = required_classes - have_classes
|
|
378
|
+
missing_params = required_params - have_params
|
|
379
|
+
if not missing_classes and not missing_params:
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
# Stale path: serialize the top-up under the sidecar lock.
|
|
383
|
+
path = db_url[len("sqlite:///"):]
|
|
384
|
+
lock_path = path + ".lock"
|
|
385
|
+
with open(lock_path, "a") as lock_fh:
|
|
386
|
+
_lock_exclusive(lock_fh)
|
|
387
|
+
try:
|
|
388
|
+
# FRESH mapping under the lock (do NOT reuse the gate mapping — its
|
|
389
|
+
# cache is stale after another process's commit). Re-read the
|
|
390
|
+
# have-sets: another process may already have topped up.
|
|
391
|
+
db = DatabaseMapping(db_url)
|
|
392
|
+
try:
|
|
393
|
+
have_classes = {c["name"]
|
|
394
|
+
for c in db.get_entity_class_items()}
|
|
395
|
+
have_params = {(p["entity_class_name"], p["name"])
|
|
396
|
+
for p in db.get_parameter_definition_items()}
|
|
397
|
+
still_missing = (required_classes - have_classes) or (
|
|
398
|
+
required_params - have_params)
|
|
399
|
+
if not still_missing:
|
|
400
|
+
return # another process already topped up
|
|
401
|
+
with open(_SCHEMA_PATH, encoding="utf-8") as fh:
|
|
402
|
+
schema = json.load(fh)
|
|
403
|
+
_apply_derived_descriptions(schema)
|
|
404
|
+
# Import ALL schema keys EXCEPT ``alternatives`` (never re-seed
|
|
405
|
+
# 'Base' or touch user run alternatives). Derive the kwargs
|
|
406
|
+
# rather than hardcoding entity_classes/parameter_definitions,
|
|
407
|
+
# so any future value-list / superclass key is carried too.
|
|
408
|
+
# Re-importing existing entity classes merges on NAME
|
|
409
|
+
# harmlessly; a FUTURE *new* entity class carrying a hardcoded
|
|
410
|
+
# id in the schema JSON would need its id stripped here to
|
|
411
|
+
# avoid an id collision on this top-up path.
|
|
412
|
+
count, errors = import_data(
|
|
413
|
+
db, **{k: v for k, v in schema.items()
|
|
414
|
+
if k != "alternatives"})
|
|
415
|
+
if errors:
|
|
416
|
+
raise RuntimeError(
|
|
417
|
+
f"Failed to top up results schema: {len(errors)} "
|
|
418
|
+
f"errors: {errors[:5]}"
|
|
419
|
+
)
|
|
420
|
+
db.commit_session("Top-up results schema")
|
|
421
|
+
logger.debug("Topped up results DB schema (%d items)", count)
|
|
422
|
+
finally:
|
|
423
|
+
db.close()
|
|
424
|
+
finally:
|
|
425
|
+
_unlock(lock_fh)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _lock_exclusive(lock_fh) -> None:
|
|
429
|
+
"""Acquire a blocking exclusive lock on an open file handle.
|
|
430
|
+
|
|
431
|
+
Portable across POSIX (``fcntl.flock``) and Windows (``msvcrt.locking``);
|
|
432
|
+
``fcntl`` does not exist on Windows, so dispatch on what imports."""
|
|
433
|
+
try:
|
|
434
|
+
import fcntl
|
|
435
|
+
except ImportError:
|
|
436
|
+
import msvcrt
|
|
437
|
+
import time
|
|
438
|
+
|
|
439
|
+
# ``msvcrt.locking`` locks ``nbytes`` from the current file position;
|
|
440
|
+
# lock a single byte at offset 0. Use the non-blocking variant in a
|
|
441
|
+
# retry loop so we block indefinitely rather than giving up after the
|
|
442
|
+
# ~10s LK_LOCK ceiling.
|
|
443
|
+
lock_fh.seek(0)
|
|
444
|
+
while True:
|
|
445
|
+
try:
|
|
446
|
+
msvcrt.locking(lock_fh.fileno(), msvcrt.LK_NBLCK, 1)
|
|
447
|
+
return
|
|
448
|
+
except OSError:
|
|
449
|
+
time.sleep(0.05)
|
|
450
|
+
else:
|
|
451
|
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _unlock(lock_fh) -> None:
|
|
455
|
+
"""Release a lock taken by :func:`_lock_exclusive` (portable)."""
|
|
456
|
+
try:
|
|
457
|
+
import fcntl
|
|
458
|
+
except ImportError:
|
|
459
|
+
import msvcrt
|
|
460
|
+
|
|
461
|
+
lock_fh.seek(0)
|
|
462
|
+
try:
|
|
463
|
+
msvcrt.locking(lock_fh.fileno(), msvcrt.LK_UNLCK, 1)
|
|
464
|
+
except OSError:
|
|
465
|
+
pass
|
|
466
|
+
else:
|
|
467
|
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _replace_with_retry(src: str, dst: str, *, attempts: int = 20,
|
|
471
|
+
delay: float = 0.05) -> None:
|
|
472
|
+
"""``os.replace(src, dst)`` retrying transient Windows sharing errors.
|
|
473
|
+
|
|
474
|
+
On Windows a file that was just closed can stay briefly locked, so the
|
|
475
|
+
rename raises ``PermissionError`` (WinError 32). POSIX never hits this,
|
|
476
|
+
so the first attempt always succeeds there."""
|
|
477
|
+
import time
|
|
478
|
+
|
|
479
|
+
for i in range(attempts):
|
|
480
|
+
try:
|
|
481
|
+
os.replace(src, dst)
|
|
482
|
+
return
|
|
483
|
+
except PermissionError:
|
|
484
|
+
if i == attempts - 1:
|
|
485
|
+
raise
|
|
486
|
+
time.sleep(delay)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _ensure_results_db_atomic(db_url: str, path: str) -> None:
|
|
490
|
+
"""Create the schema DB at ``path`` if absent, safe under concurrent
|
|
491
|
+
cold-start callers writing the SAME shared results DB.
|
|
492
|
+
|
|
493
|
+
The create-if-absent is serialized by an exclusive lock on a sidecar
|
|
494
|
+
``<db>.lock`` file (``fcntl.flock`` on POSIX, ``msvcrt.locking`` on
|
|
495
|
+
Windows): only the winner of the lock builds the schema. Both the cheap
|
|
496
|
+
pre-check (in :func:`ensure_results_db`) and the re-check UNDER the lock
|
|
497
|
+
guard against ``os.replace`` clobbering a target another process has
|
|
498
|
+
already created (and may already be importing into) — the rename happens
|
|
499
|
+
only while the target is still absent."""
|
|
500
|
+
import uuid
|
|
501
|
+
|
|
502
|
+
target_dir = os.path.dirname(path) or "."
|
|
503
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
504
|
+
|
|
505
|
+
lock_path = path + ".lock"
|
|
506
|
+
# The lock file persists (it is the rendez-vous point); only the lock is
|
|
507
|
+
# released. Opened in append mode so concurrent opens never truncate it.
|
|
508
|
+
with open(lock_path, "a") as lock_fh:
|
|
509
|
+
_lock_exclusive(lock_fh)
|
|
510
|
+
try:
|
|
511
|
+
# Re-check UNDER the lock: another process may have created the
|
|
512
|
+
# target between our pre-check and acquiring the lock. If so we
|
|
513
|
+
# must NOT build/rename — clobbering it could corrupt an import
|
|
514
|
+
# already in progress in that process.
|
|
515
|
+
if os.path.exists(path):
|
|
516
|
+
return
|
|
517
|
+
tmp_path = os.path.join(
|
|
518
|
+
target_dir, f".results-{uuid.uuid4().hex}.sqlite.tmp")
|
|
519
|
+
tmp_url = "sqlite:///" + tmp_path
|
|
520
|
+
try:
|
|
521
|
+
_import_schema(tmp_url)
|
|
522
|
+
# Still absent (we hold the lock, so it cannot appear now) —
|
|
523
|
+
# commit the build atomically. Retry the rename briefly: on
|
|
524
|
+
# Windows a just-closed SQLite handle can linger a moment and
|
|
525
|
+
# ``os.replace`` raises ``PermissionError`` (WinError 32) until
|
|
526
|
+
# it is fully released.
|
|
527
|
+
_replace_with_retry(tmp_path, path)
|
|
528
|
+
finally:
|
|
529
|
+
# Discard the temp on any error mid-build.
|
|
530
|
+
if os.path.exists(tmp_path):
|
|
531
|
+
try:
|
|
532
|
+
os.remove(tmp_path)
|
|
533
|
+
except OSError:
|
|
534
|
+
pass
|
|
535
|
+
finally:
|
|
536
|
+
_unlock(lock_fh)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ---------------------------------------------------------------------------
|
|
540
|
+
# Parameter-definition descriptions: prose from the schema, UNITS from code.
|
|
541
|
+
#
|
|
542
|
+
# The checked-in results schema carries rich, hand-written prose (per-category
|
|
543
|
+
# breakdowns). We keep that prose but make the **unit** authoritative from the
|
|
544
|
+
# single source (``_output_meta``): at DB-create, for every param that maps to
|
|
545
|
+
# a known transform, we replace the description's leading ``[unit]`` token with
|
|
546
|
+
# the derived one and prepend it where missing. Empty descriptions are filled
|
|
547
|
+
# wholesale. Params with no mapped transform (e.g. ``cf``) keep their
|
|
548
|
+
# hand-written description untouched. This makes unit drift structurally
|
|
549
|
+
# impossible — the units are GENERATED into the schema, never compared against
|
|
550
|
+
# it — so the old CI drift ratchet is retired.
|
|
551
|
+
# ---------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
# Leading unit token: ``[MW]``, ``[per unit]``, or the cost tables' ``M[CUR]``.
|
|
554
|
+
_LEADING_UNIT_RE = re.compile(r"^\s*(?:M\s*\[[^\]]*\]|\[[^\]]*\])\s*")
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _derived_param_meta() -> tuple[dict[tuple[str, str], tuple[str, str]],
|
|
558
|
+
dict[str, tuple[str, str]]]:
|
|
559
|
+
"""Build derived ``(unit, short_text)`` keyed by ``(ec, param)`` and an
|
|
560
|
+
unambiguous-by-param fallback.
|
|
561
|
+
|
|
562
|
+
The ``(ec, param)`` map drives the exact class; the param-only fallback
|
|
563
|
+
covers classes absent from :data:`SPINEDB_OUTPUT_SPEC` that share a param
|
|
564
|
+
name with a documented one (e.g. the ``connection__reserve__*`` params),
|
|
565
|
+
but only when that param has a single distinct unit across all classes.
|
|
566
|
+
"""
|
|
567
|
+
by_ecp: dict[tuple[str, str], tuple[str, str]] = {}
|
|
568
|
+
units_by_param: dict[str, set[str]] = {}
|
|
569
|
+
text_by_param: dict[str, tuple[str, str]] = {}
|
|
570
|
+
for table_name, spec in SPINEDB_OUTPUT_SPEC.items():
|
|
571
|
+
param = spec.get("param")
|
|
572
|
+
summary = result_key_summary(table_name)
|
|
573
|
+
if not param or summary is None:
|
|
574
|
+
continue
|
|
575
|
+
unit, _sem, tooltip = summary
|
|
576
|
+
if unit == "ratio": # result_key_summary's unitless sentinel
|
|
577
|
+
unit = "" # → renders as "[per unit]" via _unit_tag
|
|
578
|
+
by_ecp.setdefault((spec.get("ec"), param), (unit, tooltip))
|
|
579
|
+
units_by_param.setdefault(param, set()).add(unit)
|
|
580
|
+
text_by_param.setdefault(param, (unit, tooltip))
|
|
581
|
+
# Params sourced from ``par`` (not a results table): the two discount
|
|
582
|
+
# factors are dimensionless (FACTOR).
|
|
583
|
+
for p in ("investments discount factor", "operations discount factor"):
|
|
584
|
+
units_by_param.setdefault(p, {FACTOR.unit})
|
|
585
|
+
text_by_param.setdefault(p, (FACTOR.unit, FACTOR.tooltip))
|
|
586
|
+
unambiguous = {p: text_by_param[p]
|
|
587
|
+
for p, us in units_by_param.items() if len(us) == 1}
|
|
588
|
+
return by_ecp, unambiguous
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _unit_tag(unit: str) -> str:
|
|
592
|
+
"""The bracketed unit token; unitless quantities render as ``[per unit]``."""
|
|
593
|
+
return f"[{unit}]" if unit else "[per unit]"
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def derived_param_unit_map() -> dict[tuple[str, str], str]:
|
|
597
|
+
"""``{(entity_class, param): derived_unit}`` for params with a known
|
|
598
|
+
transform — the authoritative units merged into the schema."""
|
|
599
|
+
by_ecp, _unambiguous = _derived_param_meta()
|
|
600
|
+
return {key: unit for key, (unit, _text) in by_ecp.items()}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _apply_derived_descriptions(schema: dict) -> None:
|
|
604
|
+
"""Merge derived units into ``parameter_definition`` descriptions.
|
|
605
|
+
|
|
606
|
+
Mutates ``schema['parameter_definitions']`` in place: an entry is
|
|
607
|
+
``[entity_class, param, default, value_list, description, type]``. For
|
|
608
|
+
every param with a derived unit, the leading unit token is replaced with
|
|
609
|
+
the derived one (prose preserved); empty descriptions are filled from the
|
|
610
|
+
derived short text. Params without a derived unit are left untouched."""
|
|
611
|
+
by_ecp, unambiguous = _derived_param_meta()
|
|
612
|
+
for entry in schema.get("parameter_definitions", []):
|
|
613
|
+
ec, param, existing = entry[0], entry[1], entry[4]
|
|
614
|
+
derived = by_ecp.get((ec, param)) or unambiguous.get(param)
|
|
615
|
+
if not derived:
|
|
616
|
+
continue
|
|
617
|
+
unit, short_text = derived
|
|
618
|
+
prose = _LEADING_UNIT_RE.sub("", existing) if existing else short_text
|
|
619
|
+
entry[4] = f"{_unit_tag(unit)} {prose}".strip()
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _import_schema(db_url: str) -> None:
|
|
623
|
+
"""Load the results schema JSON into the DB at ``db_url`` (created if
|
|
624
|
+
needed).
|
|
625
|
+
|
|
626
|
+
Uses an explicit ``db.close()`` rather than the ``with DatabaseMapping``
|
|
627
|
+
context manager: ``__exit__`` only closes the *session*, leaving the
|
|
628
|
+
engine's connection pool holding the SQLite file open. ``close()``
|
|
629
|
+
disposes the engine, releasing the handle. This matters on Windows,
|
|
630
|
+
which refuses to ``os.replace`` a file with an open handle (POSIX has no
|
|
631
|
+
such restriction)."""
|
|
632
|
+
with open(_SCHEMA_PATH, encoding="utf-8") as fh:
|
|
633
|
+
schema = json.load(fh)
|
|
634
|
+
_apply_derived_descriptions(schema)
|
|
635
|
+
db = DatabaseMapping(db_url, create=True)
|
|
636
|
+
try:
|
|
637
|
+
count, errors = import_data(db, **schema)
|
|
638
|
+
if errors:
|
|
639
|
+
raise RuntimeError(
|
|
640
|
+
f"Failed to initialize results schema: {len(errors)} errors: "
|
|
641
|
+
f"{errors[:5]}"
|
|
642
|
+
)
|
|
643
|
+
db.commit_session("Initialized results schema")
|
|
644
|
+
logger.debug("Initialized results DB schema (%d items)", count)
|
|
645
|
+
finally:
|
|
646
|
+
db.close()
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def write_spinedb(results, s, db_url, scenario_name, solve_name,
|
|
650
|
+
*, purge_alternative=True, par=None) -> int:
|
|
651
|
+
"""Write the processed ``results`` into the SpineDB at ``db_url``.
|
|
652
|
+
|
|
653
|
+
Parameters
|
|
654
|
+
----------
|
|
655
|
+
results : dict[str, pandas.DataFrame]
|
|
656
|
+
Processed results keyed by RAW engine table name.
|
|
657
|
+
s : object
|
|
658
|
+
The engine ``s`` (sets) namespace. Used for solve reconstruction
|
|
659
|
+
(``solve_period_time`` / ``solve_period``), process partitioning
|
|
660
|
+
(``process_unit`` / ``process_connection``) and connection topology
|
|
661
|
+
(``process_source_sink``).
|
|
662
|
+
db_url : str
|
|
663
|
+
Target SpineDB URL (e.g. ``sqlite:///.../results.sqlite``).
|
|
664
|
+
scenario_name, solve_name : str
|
|
665
|
+
Run identifiers. ``scenario_name`` becomes the alternative name;
|
|
666
|
+
``solve_name`` is used as the ``solve`` Map key for frames whose
|
|
667
|
+
solve axis cannot be reconstructed per-row.
|
|
668
|
+
purge_alternative : bool, keyword-only, default True
|
|
669
|
+
Delete existing ``parameter_value`` items in the target alternative
|
|
670
|
+
before writing (so a re-run replaces rather than duplicates).
|
|
671
|
+
par : object, keyword-only, default None
|
|
672
|
+
The engine ``par`` (parameters) namespace. Required only for the
|
|
673
|
+
two discount-factor params (#33/#34); when None those are skipped.
|
|
674
|
+
|
|
675
|
+
Returns
|
|
676
|
+
-------
|
|
677
|
+
int
|
|
678
|
+
The ``import_data`` count for the parameter-value write.
|
|
679
|
+
"""
|
|
680
|
+
ensure_results_db(db_url)
|
|
681
|
+
alternative_name = derive_alternative_name(scenario_name, solve_name)
|
|
682
|
+
|
|
683
|
+
if purge_alternative:
|
|
684
|
+
_purge_alternative(db_url, alternative_name)
|
|
685
|
+
|
|
686
|
+
entities, parameter_values, entity_alternatives = collect_spinedb_payload(
|
|
687
|
+
results, s, alternative_name, solve_name, par=par,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
with DatabaseMapping(db_url) as db:
|
|
691
|
+
count, errors = import_data(
|
|
692
|
+
db,
|
|
693
|
+
alternatives=[(alternative_name, f"FlexTool results: {alternative_name}")],
|
|
694
|
+
entities=entities,
|
|
695
|
+
parameter_values=parameter_values,
|
|
696
|
+
entity_alternatives=entity_alternatives,
|
|
697
|
+
)
|
|
698
|
+
if errors:
|
|
699
|
+
raise RuntimeError(
|
|
700
|
+
f"Failed to write results to {db_url}: {len(errors)} errors: "
|
|
701
|
+
f"{errors[:5]}"
|
|
702
|
+
)
|
|
703
|
+
db.commit_session(f"FlexTool results: {alternative_name}")
|
|
704
|
+
|
|
705
|
+
logger.debug(
|
|
706
|
+
"Wrote %d items to SpineDB alternative '%s' (%d entities, %d values)",
|
|
707
|
+
count, alternative_name, len(entities), len(parameter_values),
|
|
708
|
+
)
|
|
709
|
+
return count
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# ---------------------------------------------------------------------------
|
|
713
|
+
# Payload collection
|
|
714
|
+
# ---------------------------------------------------------------------------
|
|
715
|
+
|
|
716
|
+
def collect_spinedb_payload(results, s, alternative_name, solve_name,
|
|
717
|
+
*, par=None):
|
|
718
|
+
"""Build the ``(entities, parameter_values, entity_alternatives)`` lists
|
|
719
|
+
for one run by iterating :data:`SPINEDB_OUTPUT_SPEC`.
|
|
720
|
+
|
|
721
|
+
Member element entities are collected explicitly (``import_data`` does
|
|
722
|
+
not auto-create them) and deduplicated. Whitelist keys absent from
|
|
723
|
+
``results`` (or empty / all-NaN) are skipped with a debug log.
|
|
724
|
+
"""
|
|
725
|
+
entity_set: set[tuple[str, tuple]] = set()
|
|
726
|
+
parameter_values: list[tuple] = []
|
|
727
|
+
|
|
728
|
+
member_classes = _member_class_map(s)
|
|
729
|
+
|
|
730
|
+
for table_name, spec in SPINEDB_OUTPUT_SPEC.items():
|
|
731
|
+
if table_name not in results:
|
|
732
|
+
logger.debug("spinedb: table '%s' absent from results — skipped",
|
|
733
|
+
table_name)
|
|
734
|
+
continue
|
|
735
|
+
df = results[table_name]
|
|
736
|
+
if df is None:
|
|
737
|
+
continue
|
|
738
|
+
if isinstance(df, pd.Series):
|
|
739
|
+
df = df.to_frame()
|
|
740
|
+
if isinstance(df, pd.DataFrame) and df.empty:
|
|
741
|
+
logger.debug("spinedb: table '%s' empty — skipped", table_name)
|
|
742
|
+
continue
|
|
743
|
+
try:
|
|
744
|
+
pvs = df_to_spine_maps(df, spec, s, solve_name, par=par,
|
|
745
|
+
alternative_name=alternative_name)
|
|
746
|
+
except Exception:
|
|
747
|
+
logger.exception(
|
|
748
|
+
"spinedb: failed to serialize table '%s' — skipped",
|
|
749
|
+
table_name)
|
|
750
|
+
continue
|
|
751
|
+
for ec, byname, value in pvs:
|
|
752
|
+
parameter_values.append(
|
|
753
|
+
(ec, byname, spec["param"], value, alternative_name))
|
|
754
|
+
entity_set.add((ec, byname))
|
|
755
|
+
# explicitly emit member element entities
|
|
756
|
+
for member in _member_entities(ec, byname, member_classes):
|
|
757
|
+
entity_set.add(member)
|
|
758
|
+
|
|
759
|
+
# Discount factors (#33 / #34) derive from par, not from results.
|
|
760
|
+
if par is not None:
|
|
761
|
+
for fam_attr, param_name in (
|
|
762
|
+
("inflation_factor_investment_yearly", "investments discount factor"),
|
|
763
|
+
("inflation_factor_operations_yearly", "operations discount factor"),
|
|
764
|
+
):
|
|
765
|
+
value = _discount_factor_map(par, fam_attr, s, solve_name)
|
|
766
|
+
if value is not None:
|
|
767
|
+
parameter_values.append(
|
|
768
|
+
("model", ("model",), param_name, value, alternative_name))
|
|
769
|
+
entity_set.add(("model", ("model",)))
|
|
770
|
+
else:
|
|
771
|
+
logger.debug("spinedb: par is None — discount-factor params skipped")
|
|
772
|
+
|
|
773
|
+
entities = sorted(entity_set, key=lambda x: (x[0], x[1]))
|
|
774
|
+
entity_alternatives = [
|
|
775
|
+
(ec, byname, alternative_name, True) for ec, byname in entities
|
|
776
|
+
]
|
|
777
|
+
return entities, parameter_values, entity_alternatives
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _member_class_map(s):
|
|
781
|
+
"""Map each relationship entity class to the element class of each of
|
|
782
|
+
its dimensions, for explicit member-entity emission."""
|
|
783
|
+
return {
|
|
784
|
+
"unit__node": ("unit", "node"),
|
|
785
|
+
"connection__node__node": ("connection", "node", "node"),
|
|
786
|
+
"group__reserve__upDown": ("reserve", "upDown", "group"),
|
|
787
|
+
"unit__reserve__upDown__node": ("unit", "reserve", "upDown", "node"),
|
|
788
|
+
"connection__reserve__upDown__node":
|
|
789
|
+
("connection", "reserve", "upDown", "node"),
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _member_entities(ec, byname, member_classes):
|
|
794
|
+
"""Yield ``(element_class, (element,))`` for every member of a
|
|
795
|
+
relationship byname. 0-dim classes yield nothing extra (the entity
|
|
796
|
+
itself is already in the set)."""
|
|
797
|
+
dims = member_classes.get(ec)
|
|
798
|
+
if dims is None:
|
|
799
|
+
return
|
|
800
|
+
for elem_class, elem in zip(dims, byname):
|
|
801
|
+
yield (elem_class, (elem,))
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
# ---------------------------------------------------------------------------
|
|
805
|
+
# DataFrame -> Spine Map serialization
|
|
806
|
+
# ---------------------------------------------------------------------------
|
|
807
|
+
|
|
808
|
+
def df_to_spine_maps(df, spec, s, solve_name, *, par=None, alternative_name=None):
|
|
809
|
+
"""Serialize one engine DataFrame into a list of
|
|
810
|
+
``(entity_class, byname, value)`` triples per :data:`SPINEDB_OUTPUT_SPEC`.
|
|
811
|
+
|
|
812
|
+
The value is a (possibly nested) :class:`spinedb_api.parameter_value.Map`
|
|
813
|
+
with nesting ``[category ->] [solve ->] period [-> time] -> value`` and
|
|
814
|
+
any category dimension as the OUTERMOST key.
|
|
815
|
+
"""
|
|
816
|
+
ec = spec["ec"]
|
|
817
|
+
has_time = spec["has_time"]
|
|
818
|
+
|
|
819
|
+
# Special period-less aggregated frames.
|
|
820
|
+
if spec.get("series_category"):
|
|
821
|
+
return _serialize_total_series(df, spec)
|
|
822
|
+
|
|
823
|
+
# Entity-in-index frames (capacity, indicator): the entity is an index
|
|
824
|
+
# level, columns are categories.
|
|
825
|
+
if spec.get("entity_index"):
|
|
826
|
+
return _serialize_entity_index(df, spec, s, solve_name)
|
|
827
|
+
|
|
828
|
+
df = _ensure_solve_level(df, s, has_time)
|
|
829
|
+
|
|
830
|
+
# Reduce the column MultiIndex / Index to a uniform tuple form and
|
|
831
|
+
# split into (entity-part, category-part). Each yield is
|
|
832
|
+
# ``(byname, cat_key, series, row_ec)`` where ``row_ec`` overrides the
|
|
833
|
+
# spec entity class for that row (reserve-split routing) or is None.
|
|
834
|
+
rows = _iter_columns(df, spec, s)
|
|
835
|
+
|
|
836
|
+
out: list[tuple] = []
|
|
837
|
+
for byname, cat_key, series, row_ec in rows:
|
|
838
|
+
if byname is None:
|
|
839
|
+
continue
|
|
840
|
+
value = _series_to_map(series, has_time, cat_key,
|
|
841
|
+
collapse_solve=spec.get("collapse_solve", False),
|
|
842
|
+
solve_name=solve_name)
|
|
843
|
+
if value is None:
|
|
844
|
+
continue
|
|
845
|
+
out.append((row_ec or ec, byname, cat_key, value))
|
|
846
|
+
|
|
847
|
+
return _assemble_entity_values(out, spec)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _assemble_entity_values(rows, spec):
|
|
851
|
+
"""Combine per-(entity, category) Maps into one value per entity.
|
|
852
|
+
|
|
853
|
+
When ``category == "header"`` the category dimension is the outermost
|
|
854
|
+
Map key; otherwise each entity has a single value column.
|
|
855
|
+
"""
|
|
856
|
+
use_category = spec.get("category") == "header"
|
|
857
|
+
by_entity: dict[tuple, list[tuple]] = {}
|
|
858
|
+
order: list[tuple] = []
|
|
859
|
+
for row_ec, byname, cat_key, value in rows:
|
|
860
|
+
key = (row_ec, byname)
|
|
861
|
+
if key not in by_entity:
|
|
862
|
+
by_entity[key] = []
|
|
863
|
+
order.append(key)
|
|
864
|
+
by_entity[key].append((cat_key, value))
|
|
865
|
+
|
|
866
|
+
result: list[tuple] = []
|
|
867
|
+
for key in order:
|
|
868
|
+
ec, byname = key
|
|
869
|
+
items = by_entity[key]
|
|
870
|
+
if use_category:
|
|
871
|
+
keys, vals = [], []
|
|
872
|
+
for cat_key, value in items:
|
|
873
|
+
if value is None:
|
|
874
|
+
continue
|
|
875
|
+
keys.append(_cat_key_str(cat_key))
|
|
876
|
+
vals.append(value)
|
|
877
|
+
if not keys:
|
|
878
|
+
continue
|
|
879
|
+
result.append((ec, byname, Map(keys, vals)))
|
|
880
|
+
else:
|
|
881
|
+
# No category nesting: a single value per entity. If multiple
|
|
882
|
+
# source columns mapped to the same entity (e.g. output + input
|
|
883
|
+
# node frames merged), keep the first non-None.
|
|
884
|
+
value = next((v for _c, v in items if v is not None), None)
|
|
885
|
+
if value is None:
|
|
886
|
+
continue
|
|
887
|
+
result.append((ec, byname, value))
|
|
888
|
+
return result
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _cat_key_str(cat_key):
|
|
892
|
+
"""Render a category key (str or tuple) as a single Map index string."""
|
|
893
|
+
if cat_key is None:
|
|
894
|
+
return ""
|
|
895
|
+
if isinstance(cat_key, tuple):
|
|
896
|
+
return "__".join(str(x) for x in cat_key)
|
|
897
|
+
return str(cat_key)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
# ---------------------------------------------------------------------------
|
|
901
|
+
# Column iteration / entity byname construction
|
|
902
|
+
# ---------------------------------------------------------------------------
|
|
903
|
+
|
|
904
|
+
def _iter_columns(df, spec, s):
|
|
905
|
+
"""Yield ``(byname, category_key, series)`` for each data column.
|
|
906
|
+
|
|
907
|
+
``byname`` is the entity byname tuple (schema dim order, ``updown``
|
|
908
|
+
normalized to ``upDown``). ``category_key`` is the remaining column
|
|
909
|
+
levels (or None). ``series`` is the index->value Series for that column.
|
|
910
|
+
"""
|
|
911
|
+
entity_dims = spec["entity_dims"]
|
|
912
|
+
topology = spec.get("topology")
|
|
913
|
+
reserve_split = spec.get("reserve_split", False)
|
|
914
|
+
co2_type = spec.get("co2_type")
|
|
915
|
+
entity_filter = spec.get("entity_filter")
|
|
916
|
+
|
|
917
|
+
units = set(getattr(s, "process_unit", []))
|
|
918
|
+
connections = set(getattr(s, "process_connection", []))
|
|
919
|
+
|
|
920
|
+
# Fixed-entity (cost) frames: every column is a category.
|
|
921
|
+
if entity_dims == 0:
|
|
922
|
+
fixed = spec["fixed_entity"]
|
|
923
|
+
for col in df.columns:
|
|
924
|
+
yield fixed, col, df[col], None
|
|
925
|
+
return
|
|
926
|
+
|
|
927
|
+
triple_lookup = None
|
|
928
|
+
if topology == "connection_triple":
|
|
929
|
+
triple_lookup = _connection_triple_lookup(s)
|
|
930
|
+
|
|
931
|
+
for col in df.columns:
|
|
932
|
+
col_t = col if isinstance(col, tuple) else (col,)
|
|
933
|
+
|
|
934
|
+
if co2_type is not None:
|
|
935
|
+
# columns: (type, process, commodity, node)
|
|
936
|
+
ctype, process, commodity, node = col_t
|
|
937
|
+
if ctype != co2_type:
|
|
938
|
+
continue
|
|
939
|
+
yield (process,), (commodity, node), df[col], None
|
|
940
|
+
continue
|
|
941
|
+
|
|
942
|
+
if topology == "connection_triple":
|
|
943
|
+
conn = col_t[0]
|
|
944
|
+
triple = triple_lookup.get(conn)
|
|
945
|
+
if triple is None:
|
|
946
|
+
logger.debug("spinedb: no topology for connection '%s' — skipped",
|
|
947
|
+
conn)
|
|
948
|
+
continue
|
|
949
|
+
yield triple, None, df[col], None
|
|
950
|
+
continue
|
|
951
|
+
|
|
952
|
+
if reserve_split:
|
|
953
|
+
# columns: (process, reserve, updown, node)
|
|
954
|
+
process, reserve, updown, node = col_t
|
|
955
|
+
byname = (process, reserve, _norm_updown(updown), node)
|
|
956
|
+
if process in units:
|
|
957
|
+
yield byname, None, df[col], "unit__reserve__upDown__node"
|
|
958
|
+
continue
|
|
959
|
+
if process in connections:
|
|
960
|
+
yield byname, None, df[col], "connection__reserve__upDown__node"
|
|
961
|
+
continue
|
|
962
|
+
logger.debug("spinedb: reserve process '%s' neither unit nor "
|
|
963
|
+
"connection — skipped", process)
|
|
964
|
+
continue
|
|
965
|
+
|
|
966
|
+
if entity_filter == "unit" and col_t[0] not in units:
|
|
967
|
+
continue
|
|
968
|
+
if entity_filter == "connection" and col_t[0] not in connections:
|
|
969
|
+
continue
|
|
970
|
+
|
|
971
|
+
entity_part = col_t[:entity_dims]
|
|
972
|
+
cat_part = col_t[entity_dims:]
|
|
973
|
+
byname = tuple(_norm_updown(x) for x in entity_part)
|
|
974
|
+
cat = cat_part[0] if len(cat_part) == 1 else (cat_part or None)
|
|
975
|
+
if isinstance(cat, tuple) and not cat:
|
|
976
|
+
cat = None
|
|
977
|
+
yield byname, cat, df[col], None
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _norm_updown(value):
|
|
981
|
+
"""Normalize the ``updown`` label to schema's ``upDown`` spelling."""
|
|
982
|
+
if value == "updown":
|
|
983
|
+
return "upDown"
|
|
984
|
+
if value == "up":
|
|
985
|
+
return "up"
|
|
986
|
+
if value == "down":
|
|
987
|
+
return "down"
|
|
988
|
+
return value
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
# ---------------------------------------------------------------------------
|
|
992
|
+
# Series -> Map
|
|
993
|
+
# ---------------------------------------------------------------------------
|
|
994
|
+
|
|
995
|
+
def _series_to_map(series, has_time, cat_key, *, collapse_solve, solve_name):
|
|
996
|
+
"""Convert a Series indexed by ``[solve,] period [, time]`` into a
|
|
997
|
+
nested Map ``[solve ->] period [-> time] -> value``.
|
|
998
|
+
|
|
999
|
+
``collapse_solve`` aggregates the whole frame under a single
|
|
1000
|
+
``solve_name`` key (cost_discounted_solve — the engine frame lacks a
|
|
1001
|
+
per-row solve axis). Empty / all-NaN cells are dropped.
|
|
1002
|
+
"""
|
|
1003
|
+
names = list(series.index.names)
|
|
1004
|
+
|
|
1005
|
+
if collapse_solve:
|
|
1006
|
+
inner = _build_period_map(series, has_time, names)
|
|
1007
|
+
if inner is None:
|
|
1008
|
+
return None
|
|
1009
|
+
return Map([str(solve_name)], [inner])
|
|
1010
|
+
|
|
1011
|
+
if "solve" in names:
|
|
1012
|
+
solve_keys: list[str] = []
|
|
1013
|
+
solve_vals: list = []
|
|
1014
|
+
for solve, sub in series.groupby(level="solve", sort=False):
|
|
1015
|
+
sub = sub.droplevel("solve")
|
|
1016
|
+
inner = _build_period_map(sub, has_time,
|
|
1017
|
+
[n for n in names if n != "solve"])
|
|
1018
|
+
if inner is None:
|
|
1019
|
+
continue
|
|
1020
|
+
solve_keys.append(str(solve))
|
|
1021
|
+
solve_vals.append(inner)
|
|
1022
|
+
if not solve_keys:
|
|
1023
|
+
return None
|
|
1024
|
+
return Map(solve_keys, solve_vals)
|
|
1025
|
+
|
|
1026
|
+
# No solve level (could not be reconstructed) — wrap under solve_name.
|
|
1027
|
+
inner = _build_period_map(series, has_time, names)
|
|
1028
|
+
if inner is None:
|
|
1029
|
+
return None
|
|
1030
|
+
return Map([str(solve_name)], [inner])
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _build_period_map(series, has_time, names):
|
|
1034
|
+
"""Build ``period -> [time ->] value`` from a Series whose index is
|
|
1035
|
+
``period [, time]`` (no solve level)."""
|
|
1036
|
+
if has_time and "time" in names:
|
|
1037
|
+
period_keys: list[str] = []
|
|
1038
|
+
period_vals: list = []
|
|
1039
|
+
for period, sub in series.groupby(level="period", sort=False):
|
|
1040
|
+
sub = sub.droplevel("period") if sub.index.nlevels > 1 else sub
|
|
1041
|
+
t_keys: list[str] = []
|
|
1042
|
+
t_vals: list[float] = []
|
|
1043
|
+
for time_idx, val in sub.items():
|
|
1044
|
+
if _is_empty(val):
|
|
1045
|
+
continue
|
|
1046
|
+
t_keys.append(str(time_idx))
|
|
1047
|
+
t_vals.append(float(val))
|
|
1048
|
+
if not t_keys:
|
|
1049
|
+
continue
|
|
1050
|
+
period_keys.append(str(period))
|
|
1051
|
+
period_vals.append(Map(t_keys, t_vals))
|
|
1052
|
+
if not period_keys:
|
|
1053
|
+
return None
|
|
1054
|
+
return Map(period_keys, period_vals)
|
|
1055
|
+
|
|
1056
|
+
# period -> value
|
|
1057
|
+
p_keys: list[str] = []
|
|
1058
|
+
p_vals: list[float] = []
|
|
1059
|
+
if "period" in names and series.index.nlevels > 1:
|
|
1060
|
+
series = series.groupby(level="period", sort=False).first()
|
|
1061
|
+
for period_idx, val in series.items():
|
|
1062
|
+
if _is_empty(val):
|
|
1063
|
+
continue
|
|
1064
|
+
p_keys.append(str(period_idx))
|
|
1065
|
+
p_vals.append(float(val))
|
|
1066
|
+
if not p_keys:
|
|
1067
|
+
return None
|
|
1068
|
+
return Map(p_keys, p_vals)
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _is_empty(val) -> bool:
|
|
1072
|
+
"""True for NaN / None values that must not be emitted."""
|
|
1073
|
+
if val is None:
|
|
1074
|
+
return True
|
|
1075
|
+
try:
|
|
1076
|
+
return bool(math.isnan(val))
|
|
1077
|
+
except (TypeError, ValueError):
|
|
1078
|
+
return False
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
# ---------------------------------------------------------------------------
|
|
1082
|
+
# Solve reconstruction (mirrors the CSV branch, write_outputs.py:940-978)
|
|
1083
|
+
# ---------------------------------------------------------------------------
|
|
1084
|
+
|
|
1085
|
+
def _ensure_solve_level(df, s, has_time):
|
|
1086
|
+
"""Add a ``solve`` index level to ``df`` if it lacks one, mirroring the
|
|
1087
|
+
CSV branch logic. Returns ``df`` unchanged when ``solve`` is already
|
|
1088
|
+
present or no ``period`` level exists."""
|
|
1089
|
+
names = list(df.index.names)
|
|
1090
|
+
if "solve" in names or "period" not in names:
|
|
1091
|
+
return df
|
|
1092
|
+
df = df.copy()
|
|
1093
|
+
if has_time and "time" in names:
|
|
1094
|
+
spt = s.solve_period_time
|
|
1095
|
+
if spt.droplevel("solve").duplicated().any():
|
|
1096
|
+
spt = spt[~spt.droplevel("solve").duplicated(keep="last")]
|
|
1097
|
+
pt_to_solve = dict(
|
|
1098
|
+
zip(spt.droplevel("solve"), spt.get_level_values("solve")))
|
|
1099
|
+
# Key off the named 'period'/'time' levels, not positional index
|
|
1100
|
+
# slots — entity-first indexes (e.g. (unit, period, time)) otherwise
|
|
1101
|
+
# read the wrong levels and miss every lookup.
|
|
1102
|
+
period_level = df.index.get_level_values("period")
|
|
1103
|
+
time_level = df.index.get_level_values("time")
|
|
1104
|
+
solve_vals = [pt_to_solve.get((p, t), "")
|
|
1105
|
+
for p, t in zip(period_level, time_level)]
|
|
1106
|
+
df.index = pd.MultiIndex.from_arrays(
|
|
1107
|
+
[solve_vals] + [df.index.get_level_values(n) for n in df.index.names],
|
|
1108
|
+
names=["solve"] + list(df.index.names),
|
|
1109
|
+
)
|
|
1110
|
+
else:
|
|
1111
|
+
unique_sp = s.solve_period[
|
|
1112
|
+
~s.solve_period.droplevel("solve").duplicated(keep="last")]
|
|
1113
|
+
period_to_solve = dict(zip(unique_sp.get_level_values("period"),
|
|
1114
|
+
unique_sp.get_level_values("solve")))
|
|
1115
|
+
if df.index.nlevels == 1:
|
|
1116
|
+
solve_vals = df.index.map(lambda p: period_to_solve.get(p, ""))
|
|
1117
|
+
df.index = pd.MultiIndex.from_arrays(
|
|
1118
|
+
[solve_vals, df.index], names=["solve", df.index.name])
|
|
1119
|
+
else:
|
|
1120
|
+
period_level = df.index.get_level_values("period")
|
|
1121
|
+
solve_vals = period_level.map(lambda p: period_to_solve.get(p, ""))
|
|
1122
|
+
df.index = pd.MultiIndex.from_arrays(
|
|
1123
|
+
[solve_vals] + [df.index.get_level_values(n)
|
|
1124
|
+
for n in df.index.names],
|
|
1125
|
+
names=["solve"] + list(df.index.names),
|
|
1126
|
+
)
|
|
1127
|
+
return df
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
# ---------------------------------------------------------------------------
|
|
1131
|
+
# Connection topology
|
|
1132
|
+
# ---------------------------------------------------------------------------
|
|
1133
|
+
|
|
1134
|
+
def _connection_triple_lookup(s):
|
|
1135
|
+
"""Build ``connection -> (connection, node, node)`` from
|
|
1136
|
+
``s.process_source_sink`` filtered to connections.
|
|
1137
|
+
|
|
1138
|
+
The frames keyed by a bare connection name (``connection_dt_eee`` etc.)
|
|
1139
|
+
need the (source, sink) nodes to form the ``connection__node__node``
|
|
1140
|
+
byname. The first matching ``(process, source, sink)`` row wins."""
|
|
1141
|
+
lookup: dict[str, tuple] = {}
|
|
1142
|
+
pss = getattr(s, "process_source_sink", None)
|
|
1143
|
+
connections = set(getattr(s, "process_connection", []))
|
|
1144
|
+
if pss is None:
|
|
1145
|
+
return lookup
|
|
1146
|
+
for process, source, sink in pss:
|
|
1147
|
+
if process not in connections:
|
|
1148
|
+
continue
|
|
1149
|
+
if process not in lookup:
|
|
1150
|
+
lookup[process] = (process, source, sink)
|
|
1151
|
+
return lookup
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
# ---------------------------------------------------------------------------
|
|
1155
|
+
# Cost helpers
|
|
1156
|
+
# ---------------------------------------------------------------------------
|
|
1157
|
+
|
|
1158
|
+
def _serialize_total_series(series, spec):
|
|
1159
|
+
"""Serialize ``costs_discounted_p_`` (a per-category Series, no period
|
|
1160
|
+
axis) into one ``Map(category -> value)`` for entity ``("model",)``."""
|
|
1161
|
+
if isinstance(series, pd.DataFrame):
|
|
1162
|
+
# to_frame() of a Series yields a 1-column frame; squeeze it back.
|
|
1163
|
+
if series.shape[1] == 1:
|
|
1164
|
+
series = series.iloc[:, 0]
|
|
1165
|
+
else:
|
|
1166
|
+
return []
|
|
1167
|
+
keys, vals = [], []
|
|
1168
|
+
for cat, val in series.items():
|
|
1169
|
+
if _is_empty(val):
|
|
1170
|
+
continue
|
|
1171
|
+
keys.append(_cat_key_str(cat))
|
|
1172
|
+
vals.append(float(val))
|
|
1173
|
+
if not keys:
|
|
1174
|
+
return []
|
|
1175
|
+
return [(spec["ec"], spec["fixed_entity"], Map(keys, vals))]
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _serialize_entity_index(df, spec, s, solve_name):
|
|
1179
|
+
"""Serialize an entity-in-index frame (capacity / indicator): index is
|
|
1180
|
+
``(entity[, period[, time]])`` and columns are categories.
|
|
1181
|
+
|
|
1182
|
+
Emits ``(ec, (entity,), Map(category -> [solve ->] period [-> time]))``
|
|
1183
|
+
per entity, dropping all-empty cells / categories."""
|
|
1184
|
+
ec = spec["ec"]
|
|
1185
|
+
has_time = spec["has_time"]
|
|
1186
|
+
entity_level = spec["entity_index"]
|
|
1187
|
+
if entity_level not in (df.index.names or []):
|
|
1188
|
+
logger.debug("spinedb: entity level '%s' not in index of '%s' — skipped",
|
|
1189
|
+
entity_level, spec["param"])
|
|
1190
|
+
return []
|
|
1191
|
+
|
|
1192
|
+
result: list[tuple] = []
|
|
1193
|
+
for entity, sub in df.groupby(level=entity_level, sort=False):
|
|
1194
|
+
sub = sub.droplevel(entity_level)
|
|
1195
|
+
sub = _ensure_solve_level(sub, s, has_time)
|
|
1196
|
+
cat_keys: list[str] = []
|
|
1197
|
+
cat_vals: list = []
|
|
1198
|
+
for col in sub.columns:
|
|
1199
|
+
value = _series_to_map(sub[col], has_time, None,
|
|
1200
|
+
collapse_solve=False, solve_name=solve_name)
|
|
1201
|
+
if value is None:
|
|
1202
|
+
continue
|
|
1203
|
+
cat_keys.append(_cat_key_str(col))
|
|
1204
|
+
cat_vals.append(value)
|
|
1205
|
+
if not cat_keys:
|
|
1206
|
+
continue
|
|
1207
|
+
result.append((ec, (entity,), Map(cat_keys, cat_vals)))
|
|
1208
|
+
return result
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _discount_factor_map(par, attr, s, solve_name):
|
|
1212
|
+
"""Build a ``Map(solve -> period -> value)`` from a per-period Series on
|
|
1213
|
+
``par`` (the inflation factors). Returns None when unavailable/empty."""
|
|
1214
|
+
series = getattr(par, attr, None)
|
|
1215
|
+
if series is None:
|
|
1216
|
+
return None
|
|
1217
|
+
if isinstance(series, pd.DataFrame):
|
|
1218
|
+
if series.shape[1] != 1:
|
|
1219
|
+
return None
|
|
1220
|
+
series = series.iloc[:, 0]
|
|
1221
|
+
if not isinstance(series, pd.Series) or series.empty:
|
|
1222
|
+
return None
|
|
1223
|
+
# Period -> solve reconstruction, then nest solve -> period -> value.
|
|
1224
|
+
period_to_solve = None
|
|
1225
|
+
sp = getattr(s, "solve_period", None)
|
|
1226
|
+
if sp is not None:
|
|
1227
|
+
unique_sp = sp[~sp.droplevel("solve").duplicated(keep="last")]
|
|
1228
|
+
period_to_solve = dict(zip(unique_sp.get_level_values("period"),
|
|
1229
|
+
unique_sp.get_level_values("solve")))
|
|
1230
|
+
by_solve: dict[str, list] = {}
|
|
1231
|
+
order: list[str] = []
|
|
1232
|
+
for period, val in series.items():
|
|
1233
|
+
if _is_empty(val):
|
|
1234
|
+
continue
|
|
1235
|
+
solve = (period_to_solve.get(period) if period_to_solve else None) \
|
|
1236
|
+
or solve_name
|
|
1237
|
+
solve = str(solve)
|
|
1238
|
+
if solve not in by_solve:
|
|
1239
|
+
by_solve[solve] = ([], [])
|
|
1240
|
+
order.append(solve)
|
|
1241
|
+
by_solve[solve][0].append(str(period))
|
|
1242
|
+
by_solve[solve][1].append(float(val))
|
|
1243
|
+
if not order:
|
|
1244
|
+
return None
|
|
1245
|
+
solve_keys, solve_vals = [], []
|
|
1246
|
+
for solve in order:
|
|
1247
|
+
pk, pv = by_solve[solve]
|
|
1248
|
+
solve_keys.append(solve)
|
|
1249
|
+
solve_vals.append(Map(pk, pv))
|
|
1250
|
+
return Map(solve_keys, solve_vals)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
# ---------------------------------------------------------------------------
|
|
1254
|
+
# Alternative purge
|
|
1255
|
+
# ---------------------------------------------------------------------------
|
|
1256
|
+
|
|
1257
|
+
def _purge_alternative(db_url, alternative_name):
|
|
1258
|
+
"""Delete existing ``parameter_value`` items in ``alternative_name`` so a
|
|
1259
|
+
re-run replaces rather than duplicates. Entities are shared across
|
|
1260
|
+
alternatives and are not purged."""
|
|
1261
|
+
with DatabaseMapping(db_url) as db:
|
|
1262
|
+
items = db.get_parameter_value_items(alternative_name=alternative_name)
|
|
1263
|
+
if not items:
|
|
1264
|
+
return
|
|
1265
|
+
for item in items:
|
|
1266
|
+
db.remove_item("parameter_value", item["id"])
|
|
1267
|
+
db.commit_session(f"Purge results alternative: {alternative_name}")
|