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,222 @@
|
|
|
1
|
+
"""Typed dataclasses replacing the raw dicts ``combined_dfs`` and ``combined_mapping_dfs``.
|
|
2
|
+
|
|
3
|
+
TimeSeriesResults — combined time-series result DataFrames (scenario in column MultiIndex).
|
|
4
|
+
DispatchMappings — dispatch mapping DataFrames combined across scenarios (scenario in row index).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, fields
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Parquet filenames that are dispatch mapping / structural files loaded separately
|
|
15
|
+
# by dispatch_mappings.py. These are NOT time-series results and should be excluded
|
|
16
|
+
# from the TimeSeriesResults.from_dict() unknown-key warning.
|
|
17
|
+
_DISPATCH_MAPPING_FILENAMES: frozenset[str] = frozenset({
|
|
18
|
+
# Loaded by dispatch_mappings.load_dispatch_mappings():
|
|
19
|
+
'group_node',
|
|
20
|
+
'group_process_node',
|
|
21
|
+
'nodeGroupIndicators', # → dispatch_groups (dispatch scoping for indicator groups)
|
|
22
|
+
'flowGroupIndicators',
|
|
23
|
+
'nodeGroupDispatch__processGroup_Unit_to_group',
|
|
24
|
+
'nodeGroupDispatch__processGroup_Group_to_unit',
|
|
25
|
+
'nodeGroupDispatch__processGroup_Connection',
|
|
26
|
+
'nodeGroupDispatch__processGroup__process__unit__to_node',
|
|
27
|
+
'nodeGroupDispatch__processGroup__process__node__to_unit',
|
|
28
|
+
'nodeGroupDispatch__processGroup__process__connection__to_node',
|
|
29
|
+
'nodeGroupDispatch__processGroup__process__node__to_connection',
|
|
30
|
+
'nodeGroupDispatch__process__unit__to_node_Not_in_aggregate',
|
|
31
|
+
'nodeGroupDispatch__process__node__to_unit_Not_in_aggregate',
|
|
32
|
+
'nodeGroupDispatch__process__connection__to_node_Not_in_aggregate',
|
|
33
|
+
'nodeGroupDispatch__process__node__to_connection_Not_in_aggregate',
|
|
34
|
+
'nodeGroupDispatch__connection_Not_in_aggregate',
|
|
35
|
+
'nodeGroupDispatch__process_fully_inside',
|
|
36
|
+
# Other structural/metadata files (not time-series):
|
|
37
|
+
'group_process',
|
|
38
|
+
'nodeGroupDispatch',
|
|
39
|
+
# DC power flow structural sets:
|
|
40
|
+
'node_dc_power_flow',
|
|
41
|
+
'connection_dc_power_flow',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class TimeSeriesResults:
|
|
47
|
+
"""Combined time-series result DataFrames (scenario in column MultiIndex).
|
|
48
|
+
|
|
49
|
+
To add a new FlexTool output variable:
|
|
50
|
+
1. Add a field below with type ``pd.DataFrame | None = None``
|
|
51
|
+
2. The field name must exactly match the parquet filename (without .parquet)
|
|
52
|
+
3. from_dict() will populate it automatically; a warning flags unknown files
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# --- Unit outputs ---
|
|
56
|
+
unit_outputNode_dt_ee: pd.DataFrame | None = None
|
|
57
|
+
unit_outputNode_d_ee: pd.DataFrame | None = None
|
|
58
|
+
unit_inputNode_dt_ee: pd.DataFrame | None = None
|
|
59
|
+
unit_inputNode_d_ee: pd.DataFrame | None = None
|
|
60
|
+
unit_outputs_cf_d_ee: pd.DataFrame | None = None
|
|
61
|
+
unit_inputs_cf_d_ee: pd.DataFrame | None = None
|
|
62
|
+
unit_curtailment_outputNode_dt_ee: pd.DataFrame | None = None
|
|
63
|
+
unit_curtailment_outputNode_d_ee: pd.DataFrame | None = None
|
|
64
|
+
unit_curtailment_share_outputNode_dt_ee: pd.DataFrame | None = None
|
|
65
|
+
unit_curtailment_share_outputNode_d_ee: pd.DataFrame | None = None
|
|
66
|
+
unit_VRE_potential_outputNode_dt_ee: pd.DataFrame | None = None
|
|
67
|
+
unit_VRE_potential_outputNode_d_ee: pd.DataFrame | None = None
|
|
68
|
+
unit_ramp_outputs_dt_ee: pd.DataFrame | None = None
|
|
69
|
+
unit_ramp_inputs_dt_ee: pd.DataFrame | None = None
|
|
70
|
+
unit_online_dt_e: pd.DataFrame | None = None
|
|
71
|
+
unit_online_average_d_e: pd.DataFrame | None = None
|
|
72
|
+
unit_startup_d_e: pd.DataFrame | None = None
|
|
73
|
+
unit_capacity_ed_p: pd.DataFrame | None = None
|
|
74
|
+
|
|
75
|
+
# --- Connection outputs ---
|
|
76
|
+
connection_leftward_dt_eee: pd.DataFrame | None = None
|
|
77
|
+
connection_leftward_d_eee: pd.DataFrame | None = None
|
|
78
|
+
connection_rightward_dt_eee: pd.DataFrame | None = None
|
|
79
|
+
connection_rightward_d_eee: pd.DataFrame | None = None
|
|
80
|
+
connection_losses_dt_eee: pd.DataFrame | None = None
|
|
81
|
+
connection_losses_d_eee: pd.DataFrame | None = None
|
|
82
|
+
connection_dt_eee: pd.DataFrame | None = None
|
|
83
|
+
connection_d_eee: pd.DataFrame | None = None
|
|
84
|
+
connection_cf_d_e: pd.DataFrame | None = None
|
|
85
|
+
connection_capacity_ed_p: pd.DataFrame | None = None
|
|
86
|
+
|
|
87
|
+
# --- Node outputs ---
|
|
88
|
+
node_d_ep: pd.DataFrame | None = None
|
|
89
|
+
node_dt_ep: pd.DataFrame | None = None
|
|
90
|
+
node_inflow__dt: pd.DataFrame | None = None
|
|
91
|
+
node_prices_dt_e: pd.DataFrame | None = None
|
|
92
|
+
node_state_dt_e: pd.DataFrame | None = None
|
|
93
|
+
node_slack_up_dt_e: pd.DataFrame | None = None
|
|
94
|
+
node_slack_up_d_e: pd.DataFrame | None = None
|
|
95
|
+
node_slack_down_dt_e: pd.DataFrame | None = None
|
|
96
|
+
node_slack_down_d_e: pd.DataFrame | None = None
|
|
97
|
+
node_capacity_ed_p: pd.DataFrame | None = None
|
|
98
|
+
|
|
99
|
+
# --- NodeGroup outputs ---
|
|
100
|
+
nodeGroup_flows_d_gpe: pd.DataFrame | None = None
|
|
101
|
+
nodeGroup_flows_dt_gpe: pd.DataFrame | None = None
|
|
102
|
+
nodeGroup_flows_d_g: pd.DataFrame | None = None
|
|
103
|
+
nodeGroup_flows_dt_g: pd.DataFrame | None = None
|
|
104
|
+
nodeGroup_gd_p: pd.DataFrame | None = None
|
|
105
|
+
nodeGroup_gdt_p: pd.DataFrame | None = None
|
|
106
|
+
nodeGroup_VRE_share_d_g: pd.DataFrame | None = None
|
|
107
|
+
nodeGroup_VRE_share_dt_g: pd.DataFrame | None = None
|
|
108
|
+
nodeGroup_inertia_dt_g: pd.DataFrame | None = None
|
|
109
|
+
nodeGroup_inertia_largest_flow_dt_g: pd.DataFrame | None = None
|
|
110
|
+
nodeGroup_unit_node_inertia_dt_gee: pd.DataFrame | None = None
|
|
111
|
+
nodeGroup_slack_capacity_margin_d_g: pd.DataFrame | None = None
|
|
112
|
+
nodeGroup_slack_inertia_d_g: pd.DataFrame | None = None
|
|
113
|
+
nodeGroup_slack_inertia_dt_g: pd.DataFrame | None = None
|
|
114
|
+
nodeGroup_slack_nonsync_d_g: pd.DataFrame | None = None
|
|
115
|
+
nodeGroup_slack_nonsync_dt_g: pd.DataFrame | None = None
|
|
116
|
+
nodeGroup_slack_reserve_d_eeg: pd.DataFrame | None = None
|
|
117
|
+
nodeGroup_slack_reserve_dt_eeg: pd.DataFrame | None = None
|
|
118
|
+
|
|
119
|
+
# --- Costs ---
|
|
120
|
+
costs_dt_p: pd.DataFrame | None = None
|
|
121
|
+
costs_discounted_d_p: pd.DataFrame | None = None
|
|
122
|
+
costs_discounted_p_: pd.DataFrame | None = None
|
|
123
|
+
annualized_costs_d_p: pd.DataFrame | None = None
|
|
124
|
+
|
|
125
|
+
# --- Per-entity cost break-down (unit/connection/node, period level) ---
|
|
126
|
+
cost_unit_annualized_d_ec: pd.DataFrame | None = None
|
|
127
|
+
cost_connection_annualized_d_ec: pd.DataFrame | None = None
|
|
128
|
+
cost_node_annualized_d_ec: pd.DataFrame | None = None
|
|
129
|
+
cost_unit_discounted_d_ec: pd.DataFrame | None = None
|
|
130
|
+
cost_connection_discounted_d_ec: pd.DataFrame | None = None
|
|
131
|
+
cost_node_discounted_d_ec: pd.DataFrame | None = None
|
|
132
|
+
|
|
133
|
+
# --- CO2 and process outputs ---
|
|
134
|
+
CO2__: pd.DataFrame | None = None
|
|
135
|
+
CO2_d_g: pd.DataFrame | None = None
|
|
136
|
+
co2_price_period_d_g: pd.DataFrame | None = None
|
|
137
|
+
co2_price_total_d_g: pd.DataFrame | None = None
|
|
138
|
+
process_co2_d_eee: pd.DataFrame | None = None
|
|
139
|
+
process_reserve_average_d_eppe: pd.DataFrame | None = None
|
|
140
|
+
process_reserve_upDown_node_dt_eppe: pd.DataFrame | None = None
|
|
141
|
+
|
|
142
|
+
# --- DC power flow ---
|
|
143
|
+
dc_angle_dt_e: pd.DataFrame | None = None
|
|
144
|
+
dc_angle_diff_dt_e: pd.DataFrame | None = None
|
|
145
|
+
|
|
146
|
+
# --- Other ---
|
|
147
|
+
reserve_prices_dt_ppg: pd.DataFrame | None = None
|
|
148
|
+
years_represented__d: pd.DataFrame | None = None
|
|
149
|
+
dual_invest_effective_node_d_e: pd.DataFrame | None = None
|
|
150
|
+
dual_invest_effective_unit_d_e: pd.DataFrame | None = None
|
|
151
|
+
dual_invest_effective_connection_d_e: pd.DataFrame | None = None
|
|
152
|
+
|
|
153
|
+
# --- Flow groups (v58) ---
|
|
154
|
+
flowGroup_gd_p: pd.DataFrame | None = None
|
|
155
|
+
flowGroup_gd_t: pd.DataFrame | None = None
|
|
156
|
+
|
|
157
|
+
def to_dict(self) -> dict[str, pd.DataFrame]:
|
|
158
|
+
"""All non-None DataFrames as a dict, for plot_dict_of_dataframes()."""
|
|
159
|
+
return {
|
|
160
|
+
f.name: getattr(self, f.name)
|
|
161
|
+
for f in fields(self)
|
|
162
|
+
if getattr(self, f.name) is not None
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def from_dict(cls, d: dict[str, pd.DataFrame]) -> TimeSeriesResults:
|
|
167
|
+
"""Build from the raw dict produced by combine_parquet_files().
|
|
168
|
+
|
|
169
|
+
Warns about unknown keys (signals a field needs to be added).
|
|
170
|
+
Dispatch mapping files (loaded separately) are silently excluded.
|
|
171
|
+
"""
|
|
172
|
+
known = {f.name for f in fields(cls)}
|
|
173
|
+
unknown = set(d.keys()) - known - _DISPATCH_MAPPING_FILENAMES
|
|
174
|
+
if unknown:
|
|
175
|
+
print(
|
|
176
|
+
f"Warning: Result variables not registered in TimeSeriesResults "
|
|
177
|
+
f"(add fields to data_models.py): {sorted(unknown)}"
|
|
178
|
+
)
|
|
179
|
+
return cls(**{k: v for k, v in d.items() if k in known})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class DispatchMappings:
|
|
184
|
+
"""Dispatch mapping DataFrames combined across scenarios (scenario in row index).
|
|
185
|
+
|
|
186
|
+
Use get_for_scenario(field_name, scenario) to extract a per-scenario slice.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
dispatch_groups: pd.DataFrame | None = None
|
|
190
|
+
group_node: pd.DataFrame | None = None
|
|
191
|
+
group_process_node: pd.DataFrame | None = None
|
|
192
|
+
processGroup_Unit_to_group: pd.DataFrame | None = None
|
|
193
|
+
processGroup_Group_to_unit: pd.DataFrame | None = None
|
|
194
|
+
processGroup_Connection: pd.DataFrame | None = None
|
|
195
|
+
processGroup_unit_to_node_members: pd.DataFrame | None = None
|
|
196
|
+
processGroup_node_to_unit_members: pd.DataFrame | None = None
|
|
197
|
+
processGroup_connection_to_node_members: pd.DataFrame | None = None
|
|
198
|
+
processGroup_node_to_connection_members: pd.DataFrame | None = None
|
|
199
|
+
not_in_aggregate_unit_to_node: pd.DataFrame | None = None
|
|
200
|
+
not_in_aggregate_node_to_unit: pd.DataFrame | None = None
|
|
201
|
+
not_in_aggregate_connection_to_node: pd.DataFrame | None = None
|
|
202
|
+
not_in_aggregate_node_to_connection: pd.DataFrame | None = None
|
|
203
|
+
not_in_aggregate_connection: pd.DataFrame | None = None
|
|
204
|
+
process_fully_inside: pd.DataFrame | None = None
|
|
205
|
+
|
|
206
|
+
def get_for_scenario(self, field_name: str, scenario: str) -> pd.DataFrame | None:
|
|
207
|
+
"""Extract per-scenario slice from a mapping field.
|
|
208
|
+
|
|
209
|
+
Handles xs() returning Series when only one row matches.
|
|
210
|
+
Returns None if field is absent or scenario not found.
|
|
211
|
+
"""
|
|
212
|
+
df = getattr(self, field_name, None)
|
|
213
|
+
if df is None or df.empty:
|
|
214
|
+
return None
|
|
215
|
+
if df.index.name == 'scenario':
|
|
216
|
+
if scenario not in df.index:
|
|
217
|
+
return None
|
|
218
|
+
result = df.xs(scenario)
|
|
219
|
+
if isinstance(result, pd.Series):
|
|
220
|
+
return result.to_frame().T
|
|
221
|
+
return result
|
|
222
|
+
return df
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Load parquet files from scenario folders and combine into TimeSeriesResults.
|
|
2
|
+
|
|
3
|
+
Functions:
|
|
4
|
+
- read_scenario_folders : read scenario→folder mapping from Spine DB
|
|
5
|
+
- collect_parquet_files : gather parquet paths grouped by filename
|
|
6
|
+
- combine_parquet_files : concat per-scenario parquets into combined DataFrames
|
|
7
|
+
- get_scenario_results : top-level convenience (returns TimeSeriesResults)
|
|
8
|
+
- combine_scenario_parquets : combine per-scenario parquets from disk (for GUI)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pandas as pd
|
|
17
|
+
from spinedb_api import DatabaseMapping
|
|
18
|
+
|
|
19
|
+
from flextool.lean_parquet import read_lean_parquet, write_lean_parquet
|
|
20
|
+
from flextool.scenario_comparison.data_models import TimeSeriesResults
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_scenario_folders(db_url: str) -> dict[str, str]:
|
|
24
|
+
"""Read the scenario database to get all folder paths.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
db_url : str
|
|
29
|
+
Database URL containing scenario information
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
dict
|
|
34
|
+
Dictionary mapping scenario names to folder paths
|
|
35
|
+
"""
|
|
36
|
+
scenario_folders: dict[str, str] = {}
|
|
37
|
+
|
|
38
|
+
with DatabaseMapping(db_url) as db_map:
|
|
39
|
+
# At the results stage, scenarios have been flattened to alternatives.
|
|
40
|
+
# Get alternative names from URL filter config (set by Spine Toolbox)
|
|
41
|
+
# or fall back to reading all alternatives from the database.
|
|
42
|
+
filter_configs = db_map.get_filter_configs()
|
|
43
|
+
alternative_names: list[str] = []
|
|
44
|
+
for cfg in filter_configs:
|
|
45
|
+
if cfg.get('type') == 'alternative_filter':
|
|
46
|
+
alternative_names = cfg['alternatives']
|
|
47
|
+
break
|
|
48
|
+
if not alternative_names:
|
|
49
|
+
alternative_names = [
|
|
50
|
+
a["name"] for a in db_map.get_alternative_items()
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
for alt_name in alternative_names:
|
|
54
|
+
param_values = db_map.get_parameter_value_items(
|
|
55
|
+
entity_class_name="scenario",
|
|
56
|
+
entity_name=alt_name,
|
|
57
|
+
parameter_definition_name="output_location",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if param_values:
|
|
61
|
+
folder_path = param_values[0]["parsed_value"]
|
|
62
|
+
scenario_folders[alt_name] = folder_path
|
|
63
|
+
|
|
64
|
+
return scenario_folders
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def collect_parquet_files(
|
|
68
|
+
scenario_folders: dict[str, str],
|
|
69
|
+
output_subdir: str = "output_parquet",
|
|
70
|
+
) -> dict[str, list[tuple[str, Path]]]:
|
|
71
|
+
"""Collect all parquet files from all scenario folders.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
scenario_folders : dict
|
|
76
|
+
Dictionary mapping scenario names to folder paths
|
|
77
|
+
output_subdir : str
|
|
78
|
+
Subdirectory within each folder containing parquet files
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
dict
|
|
83
|
+
Dictionary mapping filename to list of (scenario_name, file_path) tuples
|
|
84
|
+
"""
|
|
85
|
+
files_by_name: dict[str, list[tuple[str, Path]]] = {}
|
|
86
|
+
|
|
87
|
+
for scenario_name, folder_path in scenario_folders.items():
|
|
88
|
+
parquet_dir = Path(folder_path) / output_subdir / scenario_name
|
|
89
|
+
|
|
90
|
+
if not parquet_dir.exists():
|
|
91
|
+
print(f"Warning: {parquet_dir} does not exist for scenario {scenario_name}")
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
for parquet_file in sorted(parquet_dir.glob("*.parquet")):
|
|
95
|
+
filename = parquet_file.name
|
|
96
|
+
|
|
97
|
+
# Skip metadata files (not result variables)
|
|
98
|
+
if filename == 'timeline_breaks.parquet':
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
if filename not in files_by_name:
|
|
102
|
+
files_by_name[filename] = []
|
|
103
|
+
|
|
104
|
+
files_by_name[filename].append((scenario_name, parquet_file))
|
|
105
|
+
|
|
106
|
+
return files_by_name
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def combine_parquet_files(
|
|
110
|
+
files_by_name: dict[str, list[tuple[str, Path]]],
|
|
111
|
+
num_scenarios: int = 0,
|
|
112
|
+
) -> dict[str, pd.DataFrame]:
|
|
113
|
+
"""Combine parquet files across scenarios into dataframes.
|
|
114
|
+
|
|
115
|
+
The parquet files are expected to have scenario information in a multi-index
|
|
116
|
+
column level (highest level), so they are appended along the column axis.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
files_by_name : dict
|
|
121
|
+
Dictionary mapping filename to list of (scenario_name, file_path) tuples
|
|
122
|
+
num_scenarios : int
|
|
123
|
+
Total number of scenarios being combined (for warning about missing files)
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
dict
|
|
128
|
+
Dictionary mapping filename (without extension) to combined dataframe
|
|
129
|
+
"""
|
|
130
|
+
combined_dfs: dict[str, pd.DataFrame] = {}
|
|
131
|
+
|
|
132
|
+
for filename, scenario_files in files_by_name.items():
|
|
133
|
+
variable_name = filename.replace('.parquet', '')
|
|
134
|
+
|
|
135
|
+
dfs_to_append = []
|
|
136
|
+
|
|
137
|
+
for scenario_name, file_path in scenario_files:
|
|
138
|
+
try:
|
|
139
|
+
df = read_lean_parquet(file_path)
|
|
140
|
+
# Deduplicate index (can occur with overlapping solve windows)
|
|
141
|
+
if df.index.duplicated().any():
|
|
142
|
+
df = df[~df.index.duplicated(keep='last')]
|
|
143
|
+
# Re-tag the scenario column level to the folder identity
|
|
144
|
+
# (the loop key) so two folders holding the same model
|
|
145
|
+
# scenario stay distinct and all downstream slicing keys off
|
|
146
|
+
# the folder name. No-op (and byte-identical) when the
|
|
147
|
+
# baked-in tag already equals the folder, e.g. the DB/CLI
|
|
148
|
+
# path. See specs/dispatch_scenario_identity_retag.md.
|
|
149
|
+
if (
|
|
150
|
+
isinstance(df.columns, pd.MultiIndex)
|
|
151
|
+
and 'scenario' in df.columns.names
|
|
152
|
+
):
|
|
153
|
+
src = list(df.columns.get_level_values('scenario').unique())
|
|
154
|
+
if src != [scenario_name]:
|
|
155
|
+
df = df.rename(
|
|
156
|
+
columns={s: scenario_name for s in src},
|
|
157
|
+
level='scenario',
|
|
158
|
+
)
|
|
159
|
+
elif (
|
|
160
|
+
not isinstance(df.columns, pd.MultiIndex)
|
|
161
|
+
and df.columns.name == 'scenario'
|
|
162
|
+
):
|
|
163
|
+
# Entity-less variables (e.g. period-indexed costs ``_p_``)
|
|
164
|
+
# carry a single-level column index named 'scenario'. The
|
|
165
|
+
# MultiIndex branch above skips these, so two folders that
|
|
166
|
+
# share a baked model tag (``trade_only_shipping_4`` and
|
|
167
|
+
# ``_2`` both baked as ``trade_only_shipping``) would emit
|
|
168
|
+
# duplicate columns after the cross-scenario concat and
|
|
169
|
+
# crash the downstream ``stack``. Re-tag here too.
|
|
170
|
+
src = list(df.columns.unique())
|
|
171
|
+
if src != [scenario_name]:
|
|
172
|
+
df = df.rename(columns={s: scenario_name for s in src})
|
|
173
|
+
dfs_to_append.append(df)
|
|
174
|
+
|
|
175
|
+
except Exception as e:
|
|
176
|
+
print(f"Error reading {file_path} for scenario {scenario_name}: {e}")
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if dfs_to_append:
|
|
180
|
+
combined_df = pd.concat(dfs_to_append, axis=1)
|
|
181
|
+
combined_dfs[variable_name] = combined_df
|
|
182
|
+
if num_scenarios > 0 and len(dfs_to_append) < num_scenarios:
|
|
183
|
+
print(f"Found and combined only {len(dfs_to_append)} out of {num_scenarios} "
|
|
184
|
+
f"possible files for variable '{variable_name}' (shape: {combined_df.shape})")
|
|
185
|
+
else:
|
|
186
|
+
print(f"Warning: No valid data found for {filename}")
|
|
187
|
+
|
|
188
|
+
return combined_dfs
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def build_scenario_folders_from_dir(
|
|
192
|
+
base_dir: Path,
|
|
193
|
+
scenario_names: list[str],
|
|
194
|
+
) -> dict[str, str]:
|
|
195
|
+
"""Build scenario-to-folder mapping from a directory of per-scenario parquet subdirectories.
|
|
196
|
+
|
|
197
|
+
The returned mapping has the same format as :func:`read_scenario_folders`.
|
|
198
|
+
Each scenario maps to ``str(base_dir)`` so that the existing
|
|
199
|
+
``collect_parquet_files`` / ``combine_dispatch_mappings`` helpers resolve
|
|
200
|
+
the final parquet directory as ``base_dir / scenario_name`` (when called
|
|
201
|
+
with ``output_subdir=""``).
|
|
202
|
+
|
|
203
|
+
Parameters
|
|
204
|
+
----------
|
|
205
|
+
base_dir : Path
|
|
206
|
+
Root directory that contains one subdirectory per scenario, each
|
|
207
|
+
holding ``*.parquet`` files directly.
|
|
208
|
+
scenario_names : list[str]
|
|
209
|
+
Scenario (alternative) names whose subdirectories should be included.
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
dict[str, str]
|
|
214
|
+
``{"scenario_name": str(base_dir), ...}`` for every requested scenario
|
|
215
|
+
whose subdirectory exists under *base_dir*.
|
|
216
|
+
"""
|
|
217
|
+
base_dir = Path(base_dir)
|
|
218
|
+
scenario_folders: dict[str, str] = {}
|
|
219
|
+
for name in scenario_names:
|
|
220
|
+
scenario_dir = base_dir / name
|
|
221
|
+
if not scenario_dir.is_dir():
|
|
222
|
+
print(f"Warning: expected scenario directory {scenario_dir} does not exist – skipping")
|
|
223
|
+
continue
|
|
224
|
+
scenario_folders[name] = str(base_dir)
|
|
225
|
+
return scenario_folders
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_scenario_results(
|
|
229
|
+
db_url: str | None = None,
|
|
230
|
+
parquet_subdir: str = 'output_parquet',
|
|
231
|
+
scenario_folders: dict[str, str] | None = None,
|
|
232
|
+
) -> tuple[dict[str, str], TimeSeriesResults]:
|
|
233
|
+
"""Load and combine all scenario parquet files into TimeSeriesResults.
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
----------
|
|
237
|
+
db_url : str, optional
|
|
238
|
+
Database URL containing scenario information. Ignored when
|
|
239
|
+
*scenario_folders* is supplied.
|
|
240
|
+
parquet_subdir : str
|
|
241
|
+
Subdirectory within each scenario folder containing parquet files
|
|
242
|
+
scenario_folders : dict[str, str], optional
|
|
243
|
+
Pre-built scenario-name → folder-path mapping (e.g. from
|
|
244
|
+
:func:`build_scenario_folders_from_dir`). When provided the
|
|
245
|
+
database is not queried.
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
tuple[dict[str, str], TimeSeriesResults]
|
|
250
|
+
(scenario_folders, results) — folder mapping and combined time-series data
|
|
251
|
+
"""
|
|
252
|
+
if scenario_folders is None:
|
|
253
|
+
if db_url is None:
|
|
254
|
+
raise ValueError("Either db_url or scenario_folders must be provided")
|
|
255
|
+
print(f"Reading scenario information from {db_url}...")
|
|
256
|
+
scenario_folders = read_scenario_folders(db_url)
|
|
257
|
+
print(f"Found {len(scenario_folders)} scenarios: {list(scenario_folders.keys())}. Processing...", flush=True)
|
|
258
|
+
|
|
259
|
+
files_by_name = collect_parquet_files(scenario_folders, parquet_subdir)
|
|
260
|
+
print(f"Found {len(files_by_name)} unique result variables from parquet subdirectories")
|
|
261
|
+
|
|
262
|
+
num_scenarios = len(scenario_folders)
|
|
263
|
+
combined_dfs = combine_parquet_files(files_by_name, num_scenarios=num_scenarios)
|
|
264
|
+
|
|
265
|
+
return scenario_folders, TimeSeriesResults.from_dict(combined_dfs)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def combine_scenario_parquets(
|
|
269
|
+
project_path: Path,
|
|
270
|
+
scenario_names: list[str],
|
|
271
|
+
output_dir: Path | None = None,
|
|
272
|
+
) -> Path:
|
|
273
|
+
"""Combine per-scenario parquet files into comparison parquets.
|
|
274
|
+
|
|
275
|
+
Reads parquets from ``project_path/output_parquet/<scenario>/`` for each
|
|
276
|
+
scenario, concatenates them with scenario as the top column MultiIndex
|
|
277
|
+
level, and writes the combined files to *output_dir* (defaults to
|
|
278
|
+
``project_path/output_parquet_comparison/``).
|
|
279
|
+
|
|
280
|
+
Also writes:
|
|
281
|
+
- ``_metadata.json`` with the scenario list
|
|
282
|
+
- ``timeline_breaks.parquet`` (merged from all scenarios)
|
|
283
|
+
|
|
284
|
+
Parameters
|
|
285
|
+
----------
|
|
286
|
+
project_path : Path
|
|
287
|
+
Project root directory.
|
|
288
|
+
scenario_names : list[str]
|
|
289
|
+
Scenario names to combine.
|
|
290
|
+
output_dir : Path, optional
|
|
291
|
+
Where to write combined parquets. Defaults to
|
|
292
|
+
``project_path / "output_parquet_comparison"``.
|
|
293
|
+
|
|
294
|
+
Returns
|
|
295
|
+
-------
|
|
296
|
+
Path
|
|
297
|
+
The output directory.
|
|
298
|
+
"""
|
|
299
|
+
project_path = Path(project_path)
|
|
300
|
+
parquet_base = project_path / "output_parquet"
|
|
301
|
+
|
|
302
|
+
if output_dir is None:
|
|
303
|
+
output_dir = project_path / "output_parquet_comparison"
|
|
304
|
+
output_dir = Path(output_dir)
|
|
305
|
+
|
|
306
|
+
# 1. Build scenario folder mapping
|
|
307
|
+
scenario_folders = build_scenario_folders_from_dir(parquet_base, scenario_names)
|
|
308
|
+
if not scenario_folders:
|
|
309
|
+
raise FileNotFoundError(
|
|
310
|
+
f"No scenario directories found under {parquet_base} "
|
|
311
|
+
f"for scenarios: {scenario_names}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# 2. Collect all parquet files grouped by filename
|
|
315
|
+
files_by_name = collect_parquet_files(scenario_folders, output_subdir="")
|
|
316
|
+
|
|
317
|
+
# 3. Combine parquet files across scenarios
|
|
318
|
+
combined_dfs = combine_parquet_files(
|
|
319
|
+
files_by_name, num_scenarios=len(scenario_names)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# 4. Write combined DataFrames to output_dir
|
|
323
|
+
# Skip mapping DataFrames (flat columns) — they have duplicate column
|
|
324
|
+
# names after axis=1 concat and are handled separately via dispatch
|
|
325
|
+
# mappings. Only write time-series DataFrames (MultiIndex columns).
|
|
326
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
for name, df in combined_dfs.items():
|
|
328
|
+
if df.empty:
|
|
329
|
+
continue
|
|
330
|
+
if not isinstance(df.columns, pd.MultiIndex):
|
|
331
|
+
continue
|
|
332
|
+
write_lean_parquet(df, output_dir / f"{name}.parquet")
|
|
333
|
+
|
|
334
|
+
# 5. Write metadata
|
|
335
|
+
meta = {"scenarios": list(scenario_folders.keys())}
|
|
336
|
+
with open(output_dir / "_metadata.json", "w") as f:
|
|
337
|
+
json.dump(meta, f)
|
|
338
|
+
|
|
339
|
+
# 6. Merge timeline breaks from all scenarios
|
|
340
|
+
break_times: set[str] = set()
|
|
341
|
+
for scenario_name in scenario_folders:
|
|
342
|
+
tb_path = parquet_base / scenario_name / "timeline_breaks.parquet"
|
|
343
|
+
if tb_path.exists():
|
|
344
|
+
try:
|
|
345
|
+
tb_df = read_lean_parquet(tb_path)
|
|
346
|
+
if "time" in tb_df.columns:
|
|
347
|
+
break_times.update(tb_df["time"].astype(str))
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
if break_times:
|
|
351
|
+
bt_df = pd.DataFrame({"break_time": sorted(break_times)})
|
|
352
|
+
write_lean_parquet(bt_df, output_dir / "timeline_breaks.parquet", index=False)
|
|
353
|
+
|
|
354
|
+
written_count = sum(
|
|
355
|
+
1 for df in combined_dfs.values()
|
|
356
|
+
if not df.empty and isinstance(df.columns, pd.MultiIndex)
|
|
357
|
+
)
|
|
358
|
+
print(
|
|
359
|
+
f"Wrote comparison parquets for {len(scenario_folders)} scenarios "
|
|
360
|
+
f"({written_count} variables) to: {output_dir}"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# 7. Compute plot plans and availability for the comparison data.
|
|
364
|
+
# Read the merged single-mode YAML and derive comparison configs from
|
|
365
|
+
# each entry's ``scenario_rule`` (or an explicit
|
|
366
|
+
# ``comparison_overrides.map_dimensions_for_plots``); entries with
|
|
367
|
+
# neither have no comparison rendering and are dropped.
|
|
368
|
+
try:
|
|
369
|
+
import yaml
|
|
370
|
+
from flextool.plot_outputs.orchestrator import compute_all_plot_plans
|
|
371
|
+
from flextool.plot_outputs.color_template import resolve_plot_settings_path
|
|
372
|
+
from flextool.plot_outputs.config import flatten_new_format
|
|
373
|
+
from flextool.scenario_comparison.orchestrator import (
|
|
374
|
+
_derive_comparison_settings,
|
|
375
|
+
)
|
|
376
|
+
from flextool.gui.project_utils import get_projects_dir
|
|
377
|
+
|
|
378
|
+
config_path = get_projects_dir().parent / "templates" / "default_plots.yaml"
|
|
379
|
+
if config_path.is_file():
|
|
380
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
381
|
+
settings = yaml.safe_load(f)
|
|
382
|
+
plot_settings = _derive_comparison_settings(
|
|
383
|
+
flatten_new_format(settings.get("plots", {}))
|
|
384
|
+
)
|
|
385
|
+
bt = break_times if break_times else None
|
|
386
|
+
compute_all_plot_plans(
|
|
387
|
+
combined_dfs, plot_settings, output_dir,
|
|
388
|
+
plot_rows=(0, 167), break_times=bt,
|
|
389
|
+
strip_scenario_level=False,
|
|
390
|
+
color_path=resolve_plot_settings_path(project_path),
|
|
391
|
+
)
|
|
392
|
+
except Exception as exc:
|
|
393
|
+
import logging
|
|
394
|
+
logging.getLogger(__name__).warning(
|
|
395
|
+
"Comparison plot plan computation failed (non-fatal): %s", exc
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# 7. Return the output directory
|
|
399
|
+
return output_dir
|