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,1095 @@
|
|
|
1
|
+
"""Δ.31 — In-memory replacement for the CSV-based ``read_sets``.
|
|
2
|
+
|
|
3
|
+
Translates the polars ``FlexData`` set-frames into the pandas
|
|
4
|
+
:class:`pd.Index` / :class:`pd.MultiIndex` shapes the downstream
|
|
5
|
+
``out_*`` modules consume. CSV reads are gone entirely — every
|
|
6
|
+
attribute on the returned :class:`SimpleNamespace` maps to a FlexData
|
|
7
|
+
field (or, for a small handful of derived sets, a simple in-memory
|
|
8
|
+
projection of one or more FlexData fields).
|
|
9
|
+
|
|
10
|
+
Failure mode: every helper raises loudly when a FlexData field is
|
|
11
|
+
absent or has an unexpected schema. Empty sets are returned as
|
|
12
|
+
empty :class:`pd.Index` / :class:`pd.MultiIndex` with the correct
|
|
13
|
+
``names`` / ``dtype`` so downstream consumers can call
|
|
14
|
+
``.intersection`` / ``.get_level_values`` etc. without crashing.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from types import SimpleNamespace
|
|
19
|
+
from typing import TYPE_CHECKING, Sequence
|
|
20
|
+
|
|
21
|
+
import pandas as pd
|
|
22
|
+
import polars as pl
|
|
23
|
+
|
|
24
|
+
from flextool.process_outputs._inmemory_helpers import (
|
|
25
|
+
empty_index,
|
|
26
|
+
empty_multi_index,
|
|
27
|
+
long_dim,
|
|
28
|
+
to_index,
|
|
29
|
+
to_multi_index,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from polar_high import Solution
|
|
34
|
+
|
|
35
|
+
from flextool.engine_polars.input import FlexData
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Per-set helpers
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _index_or_empty(
|
|
44
|
+
frame_pl: "pl.DataFrame | None",
|
|
45
|
+
*,
|
|
46
|
+
dim: str,
|
|
47
|
+
name: str | None = None,
|
|
48
|
+
) -> pd.Index:
|
|
49
|
+
"""Return an :class:`pd.Index` for ``frame_pl[dim]`` or an empty Index."""
|
|
50
|
+
name = name if name is not None else long_dim(dim)
|
|
51
|
+
if frame_pl is None or frame_pl.height == 0 or dim not in frame_pl.columns:
|
|
52
|
+
return empty_index(name=name)
|
|
53
|
+
return to_index(frame_pl, dim=dim, name=name)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _multi_index_or_empty(
|
|
57
|
+
frame_pl: "pl.DataFrame | None",
|
|
58
|
+
*,
|
|
59
|
+
dims: Sequence[str],
|
|
60
|
+
names: Sequence[str] | None = None,
|
|
61
|
+
) -> pd.MultiIndex:
|
|
62
|
+
"""Return a :class:`pd.MultiIndex` for ``frame_pl[*dims]`` or an
|
|
63
|
+
empty MultiIndex with the given names."""
|
|
64
|
+
if names is None:
|
|
65
|
+
names = [long_dim(d) for d in dims]
|
|
66
|
+
if frame_pl is None or frame_pl.height == 0:
|
|
67
|
+
return empty_multi_index(list(names))
|
|
68
|
+
if not all(d in frame_pl.columns for d in dims):
|
|
69
|
+
return empty_multi_index(list(names))
|
|
70
|
+
return to_multi_index(frame_pl, dims=dims, names=names)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _multi_index_with_solve(
|
|
74
|
+
frame_pl: "pl.DataFrame | None",
|
|
75
|
+
*,
|
|
76
|
+
solve_name: str,
|
|
77
|
+
dims: Sequence[str],
|
|
78
|
+
names: Sequence[str],
|
|
79
|
+
) -> pd.MultiIndex:
|
|
80
|
+
"""Like :func:`_multi_index_or_empty` but prepend a constant
|
|
81
|
+
``solve`` level. Used for sets whose legacy CSV layout was
|
|
82
|
+
``solve, period, …`` but FlexData drops the solve column.
|
|
83
|
+
"""
|
|
84
|
+
if frame_pl is None or frame_pl.height == 0:
|
|
85
|
+
return empty_multi_index(["solve"] + list(names))
|
|
86
|
+
if not all(d in frame_pl.columns for d in dims):
|
|
87
|
+
return empty_multi_index(["solve"] + list(names))
|
|
88
|
+
pdf = frame_pl.with_columns(pl.lit(solve_name).alias("solve")).select(
|
|
89
|
+
"solve", *list(dims)
|
|
90
|
+
).to_pandas()
|
|
91
|
+
return pd.MultiIndex.from_frame(pdf, names=["solve"] + list(names))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _commodity_node_from_flex(
|
|
95
|
+
flex_data: "FlexData",
|
|
96
|
+
*,
|
|
97
|
+
attrs: Sequence[tuple[str, str]],
|
|
98
|
+
) -> pd.MultiIndex:
|
|
99
|
+
"""Derive a ``(commodity, node)`` MultiIndex from FlexData flow_* frames.
|
|
100
|
+
|
|
101
|
+
Each entry of ``attrs`` is a ``(field_name, node_column)`` tuple where
|
|
102
|
+
``field_name`` is a ``flex_data`` attribute that — when present — is a
|
|
103
|
+
polars frame with at least columns ``c`` and ``node_column``. The
|
|
104
|
+
union of ``(c, node_column)`` across all listed frames is returned
|
|
105
|
+
as a deterministic, deduplicated ``pd.MultiIndex`` with names
|
|
106
|
+
``("commodity", "node")``. Returns an empty MultiIndex when no
|
|
107
|
+
listed field is populated.
|
|
108
|
+
|
|
109
|
+
All Enum columns are cast to Utf8 before crossing the pandas
|
|
110
|
+
boundary to avoid Enum-vs-Utf8 join-key mismatches downstream
|
|
111
|
+
(cf. ``specs/enum_dtype_refactor_handoff.md``).
|
|
112
|
+
"""
|
|
113
|
+
pieces: list[pl.DataFrame] = []
|
|
114
|
+
for field_name, node_col in attrs:
|
|
115
|
+
fr = getattr(flex_data, field_name, None)
|
|
116
|
+
if fr is None or fr.height == 0 or node_col not in fr.columns:
|
|
117
|
+
continue
|
|
118
|
+
c_col = "c" if "c" in fr.columns else "commodity"
|
|
119
|
+
if c_col not in fr.columns:
|
|
120
|
+
continue
|
|
121
|
+
pieces.append(
|
|
122
|
+
fr.select(
|
|
123
|
+
pl.col(c_col).cast(pl.Utf8).alias("commodity"),
|
|
124
|
+
pl.col(node_col).cast(pl.Utf8).alias("node"),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
if not pieces:
|
|
128
|
+
return empty_multi_index(["commodity", "node"])
|
|
129
|
+
df = (
|
|
130
|
+
pl.concat(pieces, how="vertical_relaxed")
|
|
131
|
+
.unique()
|
|
132
|
+
.sort("commodity", "node")
|
|
133
|
+
.to_pandas()
|
|
134
|
+
)
|
|
135
|
+
if df.empty:
|
|
136
|
+
return empty_multi_index(["commodity", "node"])
|
|
137
|
+
return pd.MultiIndex.from_frame(df, names=["commodity", "node"])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# Emitting-flow frames that contribute to the CO2 emissions set, as
|
|
141
|
+
# ``(field_name, node_column)`` pairs. The union covers CO2-priced,
|
|
142
|
+
# period-capped and multi-period-total-capped flows. A process that sits in
|
|
143
|
+
# several CO2 groups appears in several of these frames; the union is
|
|
144
|
+
# deduplicated on ``(process, commodity, node)`` so the per-process system
|
|
145
|
+
# total (``calc_costs.emissions_co2_d``) counts each emitting flow exactly
|
|
146
|
+
# once. Omitting the ``*_total`` pair made ``co2_method = total`` models
|
|
147
|
+
# report 0 CO2.
|
|
148
|
+
_CO2_EMISSION_FLOW_ATTRS: tuple[tuple[str, str], ...] = (
|
|
149
|
+
("flow_from_co2_priced", "source"),
|
|
150
|
+
("flow_from_co2_priced_noEff", "source"),
|
|
151
|
+
("flow_from_co2_capped", "source"),
|
|
152
|
+
("flow_from_co2_capped_noEff", "source"),
|
|
153
|
+
("flow_from_co2_capped_total", "source"),
|
|
154
|
+
("flow_from_co2_capped_total_noEff", "source"),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _process_commodity_node_from_flex(
|
|
159
|
+
flex_data: "FlexData",
|
|
160
|
+
*,
|
|
161
|
+
attrs: Sequence[tuple[str, str]],
|
|
162
|
+
) -> pd.MultiIndex:
|
|
163
|
+
"""Derive a ``(process, commodity, node)`` MultiIndex from FlexData
|
|
164
|
+
flow_* frames.
|
|
165
|
+
|
|
166
|
+
Each entry of ``attrs`` is a ``(field_name, node_column)`` tuple. The
|
|
167
|
+
union of ``(p, c, node_column)`` rows across all listed frames is
|
|
168
|
+
returned as a deterministic, deduplicated ``pd.MultiIndex`` with
|
|
169
|
+
names ``("process", "commodity", "node")``.
|
|
170
|
+
"""
|
|
171
|
+
pieces: list[pl.DataFrame] = []
|
|
172
|
+
for field_name, node_col in attrs:
|
|
173
|
+
fr = getattr(flex_data, field_name, None)
|
|
174
|
+
if fr is None or fr.height == 0 or node_col not in fr.columns:
|
|
175
|
+
continue
|
|
176
|
+
c_col = "c" if "c" in fr.columns else "commodity"
|
|
177
|
+
p_col = "p" if "p" in fr.columns else "process"
|
|
178
|
+
if c_col not in fr.columns or p_col not in fr.columns:
|
|
179
|
+
continue
|
|
180
|
+
pieces.append(
|
|
181
|
+
fr.select(
|
|
182
|
+
pl.col(p_col).cast(pl.Utf8).alias("process"),
|
|
183
|
+
pl.col(c_col).cast(pl.Utf8).alias("commodity"),
|
|
184
|
+
pl.col(node_col).cast(pl.Utf8).alias("node"),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
if not pieces:
|
|
188
|
+
return empty_multi_index(["process", "commodity", "node"])
|
|
189
|
+
df = (
|
|
190
|
+
pl.concat(pieces, how="vertical_relaxed")
|
|
191
|
+
.unique()
|
|
192
|
+
.sort("process", "commodity", "node")
|
|
193
|
+
.to_pandas()
|
|
194
|
+
)
|
|
195
|
+
if df.empty:
|
|
196
|
+
return empty_multi_index(["process", "commodity", "node"])
|
|
197
|
+
return pd.MultiIndex.from_frame(
|
|
198
|
+
df, names=["process", "commodity", "node"],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _node_types(flex_data: "FlexData") -> dict[str, str]:
|
|
203
|
+
r"""Return ``{node: p_node_type}`` for every node, defaulting to
|
|
204
|
+
``balance``.
|
|
205
|
+
|
|
206
|
+
FlexData carries the type-discrimination through dedicated set
|
|
207
|
+
frames (``nodeBalance``, ``nodeState``). We synthesize the
|
|
208
|
+
``p_node_type`` semantics from those:
|
|
209
|
+
|
|
210
|
+
* ``storage`` — node in nodeState
|
|
211
|
+
* ``balance`` — node in nodeBalance \ nodeState
|
|
212
|
+
* ``balance_within_period`` — node in nodeBalance with the
|
|
213
|
+
``balance_within_period`` flag (no FlexData field carries this
|
|
214
|
+
flag standalone; treat as default for now)
|
|
215
|
+
* ``commodity`` — node attached to a commodity (via
|
|
216
|
+
``commodity_node``)
|
|
217
|
+
"""
|
|
218
|
+
nodes = []
|
|
219
|
+
if (flex_data.nodeBalance is not None
|
|
220
|
+
and flex_data.nodeBalance.height > 0):
|
|
221
|
+
nodes = flex_data.nodeBalance["n"].to_list()
|
|
222
|
+
state_nodes = set()
|
|
223
|
+
if (flex_data.nodeState is not None
|
|
224
|
+
and flex_data.nodeState.height > 0):
|
|
225
|
+
state_nodes = set(flex_data.nodeState["n"].to_list())
|
|
226
|
+
|
|
227
|
+
types: dict[str, str] = {}
|
|
228
|
+
for n in nodes:
|
|
229
|
+
if n in state_nodes:
|
|
230
|
+
types[n] = "storage"
|
|
231
|
+
else:
|
|
232
|
+
types[n] = "balance"
|
|
233
|
+
return types
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Main entry point
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def read_sets(
|
|
242
|
+
flex_data: "FlexData",
|
|
243
|
+
solution: "Solution",
|
|
244
|
+
*,
|
|
245
|
+
solve_name: str = "solve",
|
|
246
|
+
) -> SimpleNamespace:
|
|
247
|
+
"""Translate ``FlexData`` set frames into the legacy ``s`` namespace.
|
|
248
|
+
|
|
249
|
+
Parameters
|
|
250
|
+
----------
|
|
251
|
+
flex_data : FlexData
|
|
252
|
+
The polars input bundle the LP was built from.
|
|
253
|
+
solution : polar_high.Solution
|
|
254
|
+
Currently unused — kept for signature symmetry with
|
|
255
|
+
:func:`read_parameters` and to allow future post-solve sets.
|
|
256
|
+
solve_name : str, optional
|
|
257
|
+
The active solve identifier; injected as the leading level on
|
|
258
|
+
sets whose legacy layout was ``(solve, period, …)``.
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
SimpleNamespace
|
|
263
|
+
Sets as :class:`pd.Index` / :class:`pd.MultiIndex` /
|
|
264
|
+
:class:`pd.Series` matching the legacy CSV-path signature.
|
|
265
|
+
"""
|
|
266
|
+
s = SimpleNamespace()
|
|
267
|
+
|
|
268
|
+
# ─── Process / entity sets ────────────────────────────────────────────
|
|
269
|
+
# entity = nodes ∪ processes
|
|
270
|
+
nodes = []
|
|
271
|
+
if (flex_data.nodeBalance is not None
|
|
272
|
+
and flex_data.nodeBalance.height > 0):
|
|
273
|
+
nodes = flex_data.nodeBalance["n"].to_list()
|
|
274
|
+
|
|
275
|
+
processes = []
|
|
276
|
+
if (flex_data.process_source_sink is not None
|
|
277
|
+
and flex_data.process_source_sink.height > 0):
|
|
278
|
+
processes = (flex_data.process_source_sink.select("p")
|
|
279
|
+
.unique().to_pandas()["p"].tolist())
|
|
280
|
+
|
|
281
|
+
s.entity = pd.Index(nodes + processes, name="entity")
|
|
282
|
+
s.node = pd.Index(nodes, name="node")
|
|
283
|
+
s.process = pd.Index(processes, name="process")
|
|
284
|
+
|
|
285
|
+
# entityInvest / entityDivest — entities with a non-empty invest/divest set.
|
|
286
|
+
invest_entities = []
|
|
287
|
+
if (flex_data.ed_invest_set is not None
|
|
288
|
+
and flex_data.ed_invest_set.height > 0):
|
|
289
|
+
invest_entities = (flex_data.ed_invest_set.select("e")
|
|
290
|
+
.unique().to_pandas()["e"].tolist())
|
|
291
|
+
s.entityInvest = pd.Index(invest_entities, name="entity")
|
|
292
|
+
|
|
293
|
+
divest_entities = []
|
|
294
|
+
if (flex_data.ed_divest_set is not None
|
|
295
|
+
and flex_data.ed_divest_set.height > 0):
|
|
296
|
+
divest_entities = (flex_data.ed_divest_set.select("e")
|
|
297
|
+
.unique().to_pandas()["e"].tolist())
|
|
298
|
+
s.entityDivest = pd.Index(divest_entities, name="entity")
|
|
299
|
+
|
|
300
|
+
# process_unit / process_connection — partition of processes.
|
|
301
|
+
unit_set = set()
|
|
302
|
+
if (flex_data.process_unit is not None
|
|
303
|
+
and flex_data.process_unit.height > 0):
|
|
304
|
+
unit_set = set(flex_data.process_unit["p"].to_list())
|
|
305
|
+
s.process_unit = pd.Index(sorted(unit_set), name="process")
|
|
306
|
+
s.process_connection = pd.Index(
|
|
307
|
+
[p for p in processes if p not in unit_set],
|
|
308
|
+
name="process",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# process_profile — processes with a profile method (any of the
|
|
312
|
+
# profile_upper / lower / fixed sets).
|
|
313
|
+
profile_processes = set()
|
|
314
|
+
for fld in (flex_data.process_profile_upper,
|
|
315
|
+
flex_data.process_profile_lower,
|
|
316
|
+
flex_data.process_profile_fixed):
|
|
317
|
+
if fld is not None and fld.height > 0 and "p" in fld.columns:
|
|
318
|
+
profile_processes.update(fld["p"].to_list())
|
|
319
|
+
s.process_profile = pd.Index(sorted(profile_processes), name="process")
|
|
320
|
+
|
|
321
|
+
# process_online / process_online_integer / process_online_linear —
|
|
322
|
+
# processes with unit commitment.
|
|
323
|
+
s.process_online = _index_or_empty(
|
|
324
|
+
flex_data.process_online, dim="p", name="process",
|
|
325
|
+
)
|
|
326
|
+
s.process_online_integer = _index_or_empty(
|
|
327
|
+
flex_data.process_online_integer, dim="p", name="process",
|
|
328
|
+
)
|
|
329
|
+
s.process_online_linear = _index_or_empty(
|
|
330
|
+
flex_data.process_online_linear, dim="p", name="process",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# process_VRE — processes whose source is via a profile_upper
|
|
334
|
+
# method on the source side. Flextool emits this set distinctly;
|
|
335
|
+
# in FlexData it's encoded via process_profile_upper rows where
|
|
336
|
+
# source ∉ sink (i.e., upstream profile drives the flow). We
|
|
337
|
+
# leave this as an approximation: union of profile-upper processes
|
|
338
|
+
# constrained to (process, node) pairs.
|
|
339
|
+
if (flex_data.process_profile_upper is not None
|
|
340
|
+
and flex_data.process_profile_upper.height > 0):
|
|
341
|
+
# process_VRE: (process, node) pairs where the process has a
|
|
342
|
+
# profile_upper on its sink (output) arc. ``calc_storage_vre``
|
|
343
|
+
# filters via ``process_VRE.isin(process_sink)`` and indexes
|
|
344
|
+
# flow_dt / potentialVREgen_dt columns by (process, sink) — so
|
|
345
|
+
# the node
|
|
346
|
+
# half must be the OUTPUT side (sink), matching the convention
|
|
347
|
+
# used by ``process__node__profile__profile_method`` below.
|
|
348
|
+
cols = flex_data.process_profile_upper.columns
|
|
349
|
+
if "sink" in cols:
|
|
350
|
+
ent_dim = "sink"
|
|
351
|
+
elif "source" in cols:
|
|
352
|
+
ent_dim = "source"
|
|
353
|
+
else:
|
|
354
|
+
ent_dim = cols[1] if len(cols) > 1 else cols[0]
|
|
355
|
+
pdf = (flex_data.process_profile_upper.select("p", ent_dim)
|
|
356
|
+
.unique().to_pandas())
|
|
357
|
+
s.process_VRE = pd.MultiIndex.from_frame(
|
|
358
|
+
pdf, names=["process", "node"],
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
s.process_VRE = empty_multi_index(["process", "node"])
|
|
362
|
+
|
|
363
|
+
# process_source_sink: (process, source, sink) tuples.
|
|
364
|
+
s.process_source_sink = _multi_index_or_empty(
|
|
365
|
+
flex_data.process_source_sink, dims=("p", "source", "sink"),
|
|
366
|
+
names=["process", "source", "sink"],
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# process_method_sources_sinks — derived (method/orig_source/orig_sink/
|
|
370
|
+
# always_source/always_sink). FlexData doesn't carry these explicit
|
|
371
|
+
# fields, so we run a 3-way join.
|
|
372
|
+
#
|
|
373
|
+
# The ``always_*`` form keeps the process as one endpoint for every
|
|
374
|
+
# arc (so each direct-method unit has one source-side row pointing
|
|
375
|
+
# to ``(source → p)`` and one sink-side row pointing to ``(p → sink)``).
|
|
376
|
+
# The downstream slope branch in calc_capacity_flows expects this
|
|
377
|
+
# split — without it, the source-side fuel-flow column is missing and
|
|
378
|
+
# commodity costs are computed against the output-side flow (the
|
|
379
|
+
# e89bf53e regression).
|
|
380
|
+
#
|
|
381
|
+
# Method label: any value in ``s.method_1var_per_way`` (defined below)
|
|
382
|
+
# triggers the slope multiplier in calc_capacity_flows. Connections
|
|
383
|
+
# are 1var-per-way by default in flextool; indirect units have already
|
|
384
|
+
# been split into ``(p, source, p)`` / ``(p, p, sink)`` rows by
|
|
385
|
+
# ``process_source_sink_canonical`` upstream, so always=orig is correct
|
|
386
|
+
# for those rows.
|
|
387
|
+
if (flex_data.process_source_sink is not None
|
|
388
|
+
and flex_data.process_source_sink.height > 0):
|
|
389
|
+
pss_pdf = flex_data.process_source_sink.select(
|
|
390
|
+
"p", "source", "sink",
|
|
391
|
+
).to_pandas()
|
|
392
|
+
|
|
393
|
+
rows: list[tuple[str, str, str, str, str, str]] = []
|
|
394
|
+
for p, src, snk in zip(
|
|
395
|
+
pss_pdf["p"], pss_pdf["source"], pss_pdf["sink"],
|
|
396
|
+
):
|
|
397
|
+
if src == p or snk == p:
|
|
398
|
+
# Indirect / noConversion form — already split, always=orig.
|
|
399
|
+
rows.append((p, "method_1way_1var_LP", src, snk, src, snk))
|
|
400
|
+
else:
|
|
401
|
+
# Direct-method arc — emit BOTH always-process variants so
|
|
402
|
+
# downstream consumers get a source-side and a sink-side
|
|
403
|
+
# column in r.flow_dt (with slope applied on the source
|
|
404
|
+
# side). See process_arc_unions write_process_arc_unions
|
|
405
|
+
# at L248-263 for the legacy equivalent.
|
|
406
|
+
rows.append((p, "method_1way_1var_LP", src, snk, src, p))
|
|
407
|
+
rows.append((p, "method_1way_1var_LP", src, snk, p, snk))
|
|
408
|
+
rows = list(dict.fromkeys(rows))
|
|
409
|
+
cols = ["process", "method", "orig_source", "orig_sink",
|
|
410
|
+
"always_source", "always_sink"]
|
|
411
|
+
s.process_method_sources_sinks = pd.MultiIndex.from_frame(
|
|
412
|
+
pd.DataFrame(rows, columns=cols),
|
|
413
|
+
names=cols,
|
|
414
|
+
)
|
|
415
|
+
else:
|
|
416
|
+
s.process_method_sources_sinks = empty_multi_index(
|
|
417
|
+
["process", "method", "orig_source", "orig_sink",
|
|
418
|
+
"always_source", "always_sink"],
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# process_method — variable-style classification (1var_LP, 2var_LP,
|
|
422
|
+
# …). Currently approximated as ``method_1way_1var_LP`` for every
|
|
423
|
+
# process that appears in ``process_source_sink``; this is the
|
|
424
|
+
# legacy seed the rest of the reader has always carried.
|
|
425
|
+
if (flex_data.process_source_sink is not None
|
|
426
|
+
and flex_data.process_source_sink.height > 0):
|
|
427
|
+
pdf = flex_data.process_source_sink.select("p").unique().to_pandas()
|
|
428
|
+
pdf["method"] = "method_1way_1var_LP"
|
|
429
|
+
# Tag method_2way_1var_off processes correctly (the .mod's
|
|
430
|
+
# single-signed-flow 2-way method). ``calc_connections`` keys on
|
|
431
|
+
# this label to KEEP the sign of the flow (positive = node_1→node_2,
|
|
432
|
+
# negative = the reverse) instead of clipping it at 0 like a 2-var
|
|
433
|
+
# connection. Without this, a forced reverse flow would be clipped
|
|
434
|
+
# away. ``process_source_sink_2way_1var`` is the engine-side set of
|
|
435
|
+
# those arcs (DC + non-DC).
|
|
436
|
+
twoway1var = getattr(flex_data, "process_source_sink_2way_1var", None)
|
|
437
|
+
if twoway1var is not None and twoway1var.height > 0:
|
|
438
|
+
tw_procs = set(twoway1var.select("p").unique().to_pandas()["p"])
|
|
439
|
+
pdf.loc[pdf["p"].isin(tw_procs), "method"] = \
|
|
440
|
+
"method_2way_1var_off"
|
|
441
|
+
s.process_method = pd.MultiIndex.from_frame(
|
|
442
|
+
pdf, names=["process", "method"],
|
|
443
|
+
)
|
|
444
|
+
else:
|
|
445
|
+
s.process_method = empty_multi_index(["process", "method"])
|
|
446
|
+
|
|
447
|
+
# process__ct_method — conversion-time classification, orthogonal
|
|
448
|
+
# to ``process_method``. Values that the downstream calculators
|
|
449
|
+
# check for include ``"min_load_efficiency"``,
|
|
450
|
+
# ``"constant_efficiency"``, ``"min_load"`` etc. The only field
|
|
451
|
+
# FlexData carries on the conversion-time axis is
|
|
452
|
+
# ``process_min_load_eff`` (the subset of processes with
|
|
453
|
+
# ct_method=min_load_efficiency); we materialise those rows so the
|
|
454
|
+
# membership check ``(p, 'min_load_efficiency') in
|
|
455
|
+
# s.process__ct_method`` in calc_capacity_flows resolves to True
|
|
456
|
+
# for the right processes. Processes that fall outside that
|
|
457
|
+
# subset simply don't appear in the index — which is correct: no
|
|
458
|
+
# downstream consumer keys on any other ct_method value via this
|
|
459
|
+
# set.
|
|
460
|
+
pmle = getattr(flex_data, "process_min_load_eff", None)
|
|
461
|
+
if pmle is not None and pmle.height > 0 and "p" in pmle.columns:
|
|
462
|
+
pdf = pmle.select("p").unique().to_pandas()
|
|
463
|
+
pdf["method"] = "min_load_efficiency"
|
|
464
|
+
s.process__ct_method = pd.MultiIndex.from_frame(
|
|
465
|
+
pdf, names=["process", "method"],
|
|
466
|
+
)
|
|
467
|
+
else:
|
|
468
|
+
s.process__ct_method = empty_multi_index(["process", "method"])
|
|
469
|
+
|
|
470
|
+
# method_1var_per_way / method_nvar — flextool's known-method names.
|
|
471
|
+
s.method_1var_per_way = pd.Index(
|
|
472
|
+
["method_1way_1var_off", "method_1way_1var_LP",
|
|
473
|
+
"method_1way_1var_MIP", "method_2way_1var_off",
|
|
474
|
+
"method_2way_1var_LP", "method_2way_1var_MIP"],
|
|
475
|
+
name="method",
|
|
476
|
+
)
|
|
477
|
+
s.method_nvar = pd.Index(
|
|
478
|
+
["method_1way_nvar_off", "method_1way_nvar_LP",
|
|
479
|
+
"method_1way_nvar_MIP", "method_2way_nvar_off",
|
|
480
|
+
"method_2way_nvar_LP", "method_2way_nvar_MIP"],
|
|
481
|
+
name="method",
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# ─── Time-related sets (per-solve) ────────────────────────────────────
|
|
485
|
+
# period — (solve, period). Derived from dt's distinct d values.
|
|
486
|
+
if flex_data.dt is not None and flex_data.dt.height > 0:
|
|
487
|
+
periods_pl = flex_data.dt.select("d").unique()
|
|
488
|
+
period_list = periods_pl.to_pandas()["d"].tolist()
|
|
489
|
+
s.period = pd.MultiIndex.from_arrays(
|
|
490
|
+
[[solve_name] * len(period_list), period_list],
|
|
491
|
+
names=["solve", "period"],
|
|
492
|
+
)
|
|
493
|
+
s.dt = pd.MultiIndex.from_frame(
|
|
494
|
+
flex_data.dt.with_columns(pl.lit(solve_name).alias("solve"))
|
|
495
|
+
.select("solve", "d", "t").to_pandas(),
|
|
496
|
+
names=["solve", "period", "time"],
|
|
497
|
+
)
|
|
498
|
+
else:
|
|
499
|
+
s.period = empty_multi_index(["solve", "period"])
|
|
500
|
+
s.dt = empty_multi_index(["solve", "period", "time"])
|
|
501
|
+
|
|
502
|
+
# d_realized_period / d_realize_invest / dt_realize_dispatch /
|
|
503
|
+
# d_realize_dispatch_or_invest — these are subsets of period / dt.
|
|
504
|
+
# Prefer the explicit ``flex_data.realized_dispatch`` (period, step)
|
|
505
|
+
# frame when supplied — this restricts dispatch-time CSVs to the
|
|
506
|
+
# realized window and excludes foresight rows from rolling solves.
|
|
507
|
+
# Falls back to ``period`` / ``dt`` when no realized_dispatch is
|
|
508
|
+
# available (single-period fixtures where every period is realized).
|
|
509
|
+
if (flex_data.realized_dispatch is not None
|
|
510
|
+
and flex_data.realized_dispatch.height > 0):
|
|
511
|
+
rd_pl = flex_data.realized_dispatch
|
|
512
|
+
s.dt_realize_dispatch = pd.MultiIndex.from_frame(
|
|
513
|
+
rd_pl.with_columns(pl.lit(solve_name).alias("solve"))
|
|
514
|
+
.select("solve", "period", "step").to_pandas(),
|
|
515
|
+
names=["solve", "period", "time"],
|
|
516
|
+
)
|
|
517
|
+
# Preserve the canonical period order from ``flex_data.dt`` —
|
|
518
|
+
# ``realized_dispatch`` uniqueness can reorder periods, which
|
|
519
|
+
# breaks period-indexed CSV row order (e.g. ``unit_capacity__d``).
|
|
520
|
+
rd_periods_unordered = set(
|
|
521
|
+
rd_pl.select("period").unique().to_pandas()["period"].tolist()
|
|
522
|
+
)
|
|
523
|
+
if flex_data.dt is not None and flex_data.dt.height > 0:
|
|
524
|
+
dt_period_order = (flex_data.dt.select("d")
|
|
525
|
+
.unique(maintain_order=True)
|
|
526
|
+
.to_pandas()["d"].tolist())
|
|
527
|
+
rd_periods = [p for p in dt_period_order if p in rd_periods_unordered]
|
|
528
|
+
else:
|
|
529
|
+
rd_periods = list(rd_periods_unordered)
|
|
530
|
+
s.d_realized_period = pd.MultiIndex.from_arrays(
|
|
531
|
+
[[solve_name] * len(rd_periods), rd_periods],
|
|
532
|
+
names=["solve", "period"],
|
|
533
|
+
)
|
|
534
|
+
else:
|
|
535
|
+
# Without explicit ``realized_dispatch`` we cannot tell which
|
|
536
|
+
# rows of ``dt`` are realized vs foresight. Emit empty sets so
|
|
537
|
+
# this solve contributes no realized rows to ``read_sets_multi``
|
|
538
|
+
# unions. Single-solve modern fixtures always populate
|
|
539
|
+
# ``flex_data.realized_dispatch``; legacy/empty cases will
|
|
540
|
+
# surface as empty output which is still cleaner than leaking
|
|
541
|
+
# the full per-solve ``dt`` (including foresight horizons).
|
|
542
|
+
s.d_realized_period = empty_multi_index(["solve", "period"])
|
|
543
|
+
s.dt_realize_dispatch = empty_multi_index(
|
|
544
|
+
["solve", "period", "time"],
|
|
545
|
+
)
|
|
546
|
+
s.dt_fix_storage_timesteps = empty_multi_index(
|
|
547
|
+
["solve", "period", "time"],
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# d_realize_invest — periods where invest *or divest* decisions are
|
|
551
|
+
# realized. Use the union of ed_invest_set and ed_divest_set "d"
|
|
552
|
+
# columns: scenarios that only divest (e.g. ``coal_retire``) have
|
|
553
|
+
# empty ed_invest_set but non-empty ed_divest_set, and we still need
|
|
554
|
+
# those periods in d_realize_invest so the drop_levels inner-join
|
|
555
|
+
# against ed_divest doesn't collapse to empty (which was leaving
|
|
556
|
+
# ``divested`` as NaN in ``unit_capacity__d.csv``).
|
|
557
|
+
#
|
|
558
|
+
# Intersect with ``realized_dispatch`` periods: ed_invest_set covers
|
|
559
|
+
# CANDIDATE (e, d) pairs which include lookahead periods (e.g.
|
|
560
|
+
# test_a_lot_but_not_multi_year has p2020 realized + p2025 lookahead;
|
|
561
|
+
# ed_invest_set spans both, but only p2020 is a REALIZED invest
|
|
562
|
+
# period). Without this filter, ``unit_capacity__d`` emitted both
|
|
563
|
+
# p2020 and p2025 rows instead of just p2020.
|
|
564
|
+
invest_periods_list: list = []
|
|
565
|
+
seen: set = set()
|
|
566
|
+
realized_period_set: set = set(
|
|
567
|
+
s.d_realized_period.get_level_values("period").tolist()
|
|
568
|
+
) if len(s.d_realized_period) > 0 else set()
|
|
569
|
+
for src in (flex_data.ed_invest_set, flex_data.ed_divest_set):
|
|
570
|
+
if (src is not None and src.height > 0
|
|
571
|
+
and "d" in src.columns):
|
|
572
|
+
for d in src.select("d").unique().to_pandas()["d"].tolist():
|
|
573
|
+
if d in seen:
|
|
574
|
+
continue
|
|
575
|
+
if realized_period_set and d not in realized_period_set:
|
|
576
|
+
continue
|
|
577
|
+
seen.add(d)
|
|
578
|
+
invest_periods_list.append(d)
|
|
579
|
+
invest_periods = invest_periods_list
|
|
580
|
+
s.d_realize_invest = pd.MultiIndex.from_arrays(
|
|
581
|
+
[[solve_name] * len(invest_periods), invest_periods],
|
|
582
|
+
names=["solve", "period"],
|
|
583
|
+
)
|
|
584
|
+
# d_realize_dispatch_or_invest = d_realized_period ∪ d_realize_invest.
|
|
585
|
+
# Use realized-dispatch periods (not all dt periods) so foresight
|
|
586
|
+
# periods don't leak into invest/period-aggregate outputs. Reuse
|
|
587
|
+
# the canonically ordered ``d_realized_period`` we just built.
|
|
588
|
+
realized_periods: list = list(
|
|
589
|
+
s.d_realized_period.get_level_values("period")
|
|
590
|
+
) if len(s.d_realized_period) > 0 else []
|
|
591
|
+
union_periods = list(dict.fromkeys(realized_periods + invest_periods))
|
|
592
|
+
s.d_realize_dispatch_or_invest = pd.MultiIndex.from_arrays(
|
|
593
|
+
[[solve_name] * len(union_periods), union_periods],
|
|
594
|
+
names=["solve", "period"],
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
# ed_invest / ed_divest / edd_invest — (entity, period) /
|
|
598
|
+
# (entity, period_invest, period).
|
|
599
|
+
s.ed_invest = _multi_index_with_solve(
|
|
600
|
+
flex_data.ed_invest_set, solve_name=solve_name,
|
|
601
|
+
dims=("e", "d"), names=["entity", "period"],
|
|
602
|
+
)
|
|
603
|
+
s.ed_divest = _multi_index_with_solve(
|
|
604
|
+
flex_data.ed_divest_set, solve_name=solve_name,
|
|
605
|
+
dims=("e", "d"), names=["entity", "period"],
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
# edd_invest may have either (e, d_invest, d) or (e, d) dims.
|
|
609
|
+
if (flex_data.edd_invest_set is not None
|
|
610
|
+
and flex_data.edd_invest_set.height > 0):
|
|
611
|
+
cols = flex_data.edd_invest_set.columns
|
|
612
|
+
if len(cols) >= 3:
|
|
613
|
+
pdf = flex_data.edd_invest_set.with_columns(
|
|
614
|
+
pl.lit(solve_name).alias("solve")
|
|
615
|
+
).select("solve", cols[0], cols[1], cols[2]).to_pandas()
|
|
616
|
+
s.edd_invest = pd.MultiIndex.from_frame(
|
|
617
|
+
pdf, names=["solve", "entity", "period_invest", "period"],
|
|
618
|
+
)
|
|
619
|
+
else:
|
|
620
|
+
s.edd_invest = empty_multi_index(
|
|
621
|
+
["solve", "entity", "period_invest", "period"]
|
|
622
|
+
)
|
|
623
|
+
else:
|
|
624
|
+
s.edd_invest = empty_multi_index(
|
|
625
|
+
["solve", "entity", "period_invest", "period"]
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
# process__node__profile__profile_method — DataFrame from FlexData
|
|
629
|
+
# process_profile_upper / lower / fixed.
|
|
630
|
+
pieces: list[pd.DataFrame] = []
|
|
631
|
+
for fld, method in (
|
|
632
|
+
(flex_data.process_profile_upper, "upper_limit"),
|
|
633
|
+
(flex_data.process_profile_lower, "lower_limit"),
|
|
634
|
+
(flex_data.process_profile_fixed, "equality"),
|
|
635
|
+
):
|
|
636
|
+
if fld is not None and fld.height > 0:
|
|
637
|
+
pdf = fld.to_pandas().copy()
|
|
638
|
+
cols = pdf.columns.tolist()
|
|
639
|
+
# canonical: (p, source, sink, f) → (process, node, profile, method)
|
|
640
|
+
if "f" in cols:
|
|
641
|
+
pdf = pdf.rename(columns={"p": "process", "f": "profile"})
|
|
642
|
+
# node = sink (output) by convention
|
|
643
|
+
if "sink" in pdf.columns:
|
|
644
|
+
pdf["node"] = pdf["sink"]
|
|
645
|
+
elif "source" in pdf.columns:
|
|
646
|
+
pdf["node"] = pdf["source"]
|
|
647
|
+
else:
|
|
648
|
+
pdf["node"] = ""
|
|
649
|
+
pdf["profile_method"] = method
|
|
650
|
+
pieces.append(pdf[["process", "node", "profile", "profile_method"]])
|
|
651
|
+
if pieces:
|
|
652
|
+
all_df = pd.concat(pieces, ignore_index=True)
|
|
653
|
+
s.process__node__profile__profile_method = pd.MultiIndex.from_frame(
|
|
654
|
+
all_df,
|
|
655
|
+
names=["process", "node", "profile", "profile_method"],
|
|
656
|
+
)
|
|
657
|
+
else:
|
|
658
|
+
s.process__node__profile__profile_method = empty_multi_index(
|
|
659
|
+
["process", "node", "profile", "profile_method"]
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# ─── Time helpers ─────────────────────────────────────────────────────
|
|
663
|
+
if (flex_data.dtttdt is not None and flex_data.dtttdt.height > 0):
|
|
664
|
+
cols = flex_data.dtttdt.columns
|
|
665
|
+
# Expected: (d, t, t_previous, t_previous_within_timeset, d_previous, t_previous_within_solve)
|
|
666
|
+
pdf = flex_data.dtttdt.with_columns(
|
|
667
|
+
pl.lit(solve_name).alias("solve")
|
|
668
|
+
).to_pandas()
|
|
669
|
+
# Map flex names → legacy names
|
|
670
|
+
rename = {"d": "period", "t": "time"}
|
|
671
|
+
pdf = pdf.rename(columns=rename)
|
|
672
|
+
# Order: solve, period, time, t_previous, t_previous_within_timeset,
|
|
673
|
+
# d_previous, t_previous_within_solve
|
|
674
|
+
legacy_names = ["solve", "period", "time", "t_previous",
|
|
675
|
+
"t_previous_within_timeset", "d_previous",
|
|
676
|
+
"t_previous_within_solve"]
|
|
677
|
+
# Only keep columns we have
|
|
678
|
+
use_cols = ["solve", "period", "time"] + [
|
|
679
|
+
c for c in legacy_names[3:] if c in pdf.columns
|
|
680
|
+
]
|
|
681
|
+
s.dtttdt = pd.MultiIndex.from_frame(
|
|
682
|
+
pdf[use_cols], names=use_cols,
|
|
683
|
+
)
|
|
684
|
+
else:
|
|
685
|
+
s.dtttdt = empty_multi_index(
|
|
686
|
+
["solve", "period", "time", "t_previous"]
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
# dtt — typically (d, t, t_previous). Derive from dtttdt if available.
|
|
690
|
+
# Sort by (solve, period, time, t_previous) so the resulting
|
|
691
|
+
# MultiIndex follows the canonical iteration order — without
|
|
692
|
+
# this, downstream droplevel('t_previous') retains polars'
|
|
693
|
+
# ``unique()`` hash-order and writers like ``unit_ramps`` emit time
|
|
694
|
+
# columns out of order (e.g. coal_ramp_limit).
|
|
695
|
+
if (flex_data.dtttdt is not None and flex_data.dtttdt.height > 0):
|
|
696
|
+
pdf = (flex_data.dtttdt.with_columns(
|
|
697
|
+
pl.lit(solve_name).alias("solve"),
|
|
698
|
+
)
|
|
699
|
+
.select("solve", "d", "t", "t_previous")
|
|
700
|
+
.unique()
|
|
701
|
+
.sort(["solve", "d", "t", "t_previous"])
|
|
702
|
+
.to_pandas())
|
|
703
|
+
s.dtt = pd.MultiIndex.from_frame(
|
|
704
|
+
pdf, names=["solve", "period", "time", "t_previous"],
|
|
705
|
+
)
|
|
706
|
+
else:
|
|
707
|
+
s.dtt = empty_multi_index(
|
|
708
|
+
["solve", "period", "time", "t_previous"]
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
s.period__time_first = empty_multi_index(["solve", "period", "time"])
|
|
712
|
+
s.period_first_of_solve = empty_multi_index(["solve", "period"])
|
|
713
|
+
s.period_in_use = (
|
|
714
|
+
_multi_index_with_solve(
|
|
715
|
+
flex_data.period_in_use_set, solve_name=solve_name,
|
|
716
|
+
dims=("d",), names=["period"],
|
|
717
|
+
)
|
|
718
|
+
if flex_data.period_in_use_set is not None
|
|
719
|
+
else s.period
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# ─── Node-related sets ────────────────────────────────────────────────
|
|
723
|
+
node_types = _node_types(flex_data)
|
|
724
|
+
s.node_state = pd.Index(
|
|
725
|
+
[n for n, t in node_types.items() if t == "storage"], name="node",
|
|
726
|
+
)
|
|
727
|
+
s.node_balance = pd.Index(
|
|
728
|
+
[n for n, t in node_types.items() if t in ("balance", "storage")],
|
|
729
|
+
name="node",
|
|
730
|
+
)
|
|
731
|
+
s.node_balance_period = pd.Index(
|
|
732
|
+
[n for n, t in node_types.items() if t == "balance_within_period"],
|
|
733
|
+
name="node",
|
|
734
|
+
)
|
|
735
|
+
s.node_commodity = pd.Index([], name="node", dtype="object")
|
|
736
|
+
if (flex_data.flow_to_commodity is not None
|
|
737
|
+
and flex_data.flow_to_commodity.height > 0):
|
|
738
|
+
# flow_to_commodity rows imply the sink node is a commodity node
|
|
739
|
+
cols = flex_data.flow_to_commodity.columns
|
|
740
|
+
sink_col = "sink" if "sink" in cols else None
|
|
741
|
+
if sink_col is not None:
|
|
742
|
+
s.node_commodity = pd.Index(
|
|
743
|
+
flex_data.flow_to_commodity.select(sink_col).unique()
|
|
744
|
+
.to_pandas()[sink_col].tolist(),
|
|
745
|
+
name="node",
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
# node_self_discharge — nodes with non-zero self-discharge.
|
|
749
|
+
if (flex_data.p_state_self_discharge is not None
|
|
750
|
+
and flex_data.p_state_self_discharge.frame.height > 0):
|
|
751
|
+
s.node_self_discharge = pd.Index(
|
|
752
|
+
(flex_data.p_state_self_discharge.frame
|
|
753
|
+
.filter(pl.col("value") != 0.0)
|
|
754
|
+
.select("n").unique().to_pandas()["n"].tolist()),
|
|
755
|
+
name="node",
|
|
756
|
+
)
|
|
757
|
+
else:
|
|
758
|
+
s.node_self_discharge = pd.Index([], name="node", dtype="object")
|
|
759
|
+
|
|
760
|
+
# node__storage_binding_method — (node, method).
|
|
761
|
+
#
|
|
762
|
+
# Walk every storage_bind_* projection exposed by FlexData. The four
|
|
763
|
+
# frames below mirror the entries in ``_projection_params.py:1858-1861``
|
|
764
|
+
# and the FlexData declarations in ``input.py:579-582``. Pre-fix this
|
|
765
|
+
# block walked only ``storage_bind_within_solve`` and
|
|
766
|
+
# ``storage_bind_forward_only`` (audit §5.2), which silently dropped the
|
|
767
|
+
# ``bind_within_timeblock`` and ``bind_within_solve_blended_weights`` methods and
|
|
768
|
+
# broke ``calc_storage_vre.py`` for nodes carrying them.
|
|
769
|
+
#
|
|
770
|
+
# Orthogonal gap (per audit §4): the v54 value list also accepts
|
|
771
|
+
# ``bind_within_period`` and ``bind_intraperiod_blocks``, but
|
|
772
|
+
# ``nodeBalance_eq`` (model.py:615) has no constraint branch for either,
|
|
773
|
+
# and FlexData carries no projection attribute for them. Those methods
|
|
774
|
+
# are accepted at ingestion but currently produce no constraint term —
|
|
775
|
+
# tracked separately, not in scope here.
|
|
776
|
+
binding_pieces: list[tuple[str, str]] = []
|
|
777
|
+
bind_projections: tuple[tuple[str, str], ...] = (
|
|
778
|
+
("storage_bind_within_solve", "bind_within_solve"),
|
|
779
|
+
("storage_bind_forward_only", "bind_forward_only"),
|
|
780
|
+
("storage_bind_within_timeblock", "bind_within_timeblock"),
|
|
781
|
+
("storage_bind_within_solve_blended_weights", "bind_within_solve_blended_weights"),
|
|
782
|
+
# Phase D — added with the constraint wiring.
|
|
783
|
+
("storage_bind_forward_only_blended_weights",
|
|
784
|
+
"bind_forward_only_blended_weights"),
|
|
785
|
+
# Phase E — per-period cyclic-closure variant.
|
|
786
|
+
("storage_bind_within_period_blended_weights",
|
|
787
|
+
"bind_within_period_blended_weights"),
|
|
788
|
+
)
|
|
789
|
+
for attr_name, method_string in bind_projections:
|
|
790
|
+
frame = getattr(flex_data, attr_name, None)
|
|
791
|
+
if frame is not None and frame.height > 0:
|
|
792
|
+
for n in frame["n"].to_list():
|
|
793
|
+
binding_pieces.append((n, method_string))
|
|
794
|
+
if binding_pieces:
|
|
795
|
+
s.node__storage_binding_method = pd.MultiIndex.from_tuples(
|
|
796
|
+
binding_pieces, names=["node", "method"],
|
|
797
|
+
)
|
|
798
|
+
else:
|
|
799
|
+
s.node__storage_binding_method = empty_multi_index(["node", "method"])
|
|
800
|
+
|
|
801
|
+
s.node__storage_start_end_method = empty_multi_index(["node", "method"])
|
|
802
|
+
s.node__inflow_method = empty_multi_index(["node", "method"])
|
|
803
|
+
s.node__storage_nested_fix_method = empty_multi_index(["node", "method"])
|
|
804
|
+
|
|
805
|
+
# process_source / process_sink — (process, source) / (process, sink).
|
|
806
|
+
#
|
|
807
|
+
# Must contain exactly one canonical (process, node) per input/output arc:
|
|
808
|
+
# • units : unit__inputNode nodes for source, unit__outputNode for sink
|
|
809
|
+
# • connections : node_1 of connection__node__node for source, node_2 for sink
|
|
810
|
+
#
|
|
811
|
+
# The collapsed process_source_sink includes reverse arcs for 2-way
|
|
812
|
+
# connections AND intermediate (p, p) arcs for indirect units, so it
|
|
813
|
+
# cannot be projected directly. Use the pre-computed canonical fields
|
|
814
|
+
# (populated in input.py from the raw entity tables) when available; fall
|
|
815
|
+
# back to a pss-based derivation that filters out process-as-node arcs.
|
|
816
|
+
if (flex_data.process_source_canonical is not None
|
|
817
|
+
and flex_data.process_source_canonical.height > 0):
|
|
818
|
+
s.process_source = pd.MultiIndex.from_frame(
|
|
819
|
+
flex_data.process_source_canonical.to_pandas(),
|
|
820
|
+
names=["process", "source"],
|
|
821
|
+
)
|
|
822
|
+
elif (flex_data.process_source_sink is not None
|
|
823
|
+
and flex_data.process_source_sink.height > 0):
|
|
824
|
+
pss_pl = flex_data.process_source_sink
|
|
825
|
+
src_df = (pss_pl.filter(pl.col("source") != pl.col("p"))
|
|
826
|
+
.select("p", "source").unique().to_pandas())
|
|
827
|
+
s.process_source = pd.MultiIndex.from_frame(src_df, names=["process", "source"])
|
|
828
|
+
else:
|
|
829
|
+
s.process_source = empty_multi_index(["process", "source"])
|
|
830
|
+
|
|
831
|
+
if (flex_data.process_sink_canonical is not None
|
|
832
|
+
and flex_data.process_sink_canonical.height > 0):
|
|
833
|
+
s.process_sink = pd.MultiIndex.from_frame(
|
|
834
|
+
flex_data.process_sink_canonical.to_pandas(),
|
|
835
|
+
names=["process", "sink"],
|
|
836
|
+
)
|
|
837
|
+
elif (flex_data.process_source_sink is not None
|
|
838
|
+
and flex_data.process_source_sink.height > 0):
|
|
839
|
+
pss_pl = flex_data.process_source_sink
|
|
840
|
+
snk_df = (pss_pl.filter(pl.col("sink") != pl.col("p"))
|
|
841
|
+
.select("p", "sink").unique().to_pandas())
|
|
842
|
+
s.process_sink = pd.MultiIndex.from_frame(snk_df, names=["process", "sink"])
|
|
843
|
+
else:
|
|
844
|
+
s.process_sink = empty_multi_index(["process", "sink"])
|
|
845
|
+
|
|
846
|
+
# process__source__sink__profile__profile_method — DataFrame for VRE etc.
|
|
847
|
+
s.process__source__sink__profile__profile_method = pd.DataFrame(
|
|
848
|
+
columns=["process", "source", "sink", "profile", "profile_method"],
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
# ─── Commodity-related sets ───────────────────────────────────────────
|
|
852
|
+
# Recover the four sets that the legacy CSV path obtained from
|
|
853
|
+
# ``solve_data/commodity_node.csv`` / ``commodity_node_co2.csv`` /
|
|
854
|
+
# ``process__commodity__node.csv`` / ``process__commodity__node_co2.csv``
|
|
855
|
+
# by deriving from the FlexData flow_* frames the model already builds.
|
|
856
|
+
# Each flow_* frame is a join of (p, source, sink) arcs onto either
|
|
857
|
+
# ``commodity__node.csv`` (commodity-priced) or a CO2-priced/capped
|
|
858
|
+
# (g, c, n) projection, so the union of source/sink-side commodity
|
|
859
|
+
# nodes across the frames covers exactly the (c, n) pairs the LP
|
|
860
|
+
# objective and downstream cost calculation reference.
|
|
861
|
+
# Regression introduced by e89bf53e (Δ.31 piece 2, in-memory readers).
|
|
862
|
+
s.commodity_node = _commodity_node_from_flex(
|
|
863
|
+
flex_data,
|
|
864
|
+
attrs=(
|
|
865
|
+
("flow_from_commodity_eff", "source"),
|
|
866
|
+
("flow_from_commodity_noEff", "source"),
|
|
867
|
+
("flow_to_commodity", "sink"),
|
|
868
|
+
),
|
|
869
|
+
)
|
|
870
|
+
s.commodity_node_co2 = _commodity_node_from_flex(
|
|
871
|
+
flex_data,
|
|
872
|
+
attrs=(
|
|
873
|
+
("flow_from_co2_priced", "source"),
|
|
874
|
+
("flow_from_co2_priced_noEff", "source"),
|
|
875
|
+
("flow_from_co2_capped", "source"),
|
|
876
|
+
("flow_from_co2_capped_noEff", "source"),
|
|
877
|
+
),
|
|
878
|
+
)
|
|
879
|
+
s.process__commodity__node = _process_commodity_node_from_flex(
|
|
880
|
+
flex_data,
|
|
881
|
+
attrs=(
|
|
882
|
+
("flow_from_commodity_eff", "source"),
|
|
883
|
+
("flow_from_commodity_noEff", "source"),
|
|
884
|
+
("flow_to_commodity", "sink"),
|
|
885
|
+
),
|
|
886
|
+
)
|
|
887
|
+
s.process__commodity__node_co2 = _process_commodity_node_from_flex(
|
|
888
|
+
flex_data, attrs=_CO2_EMISSION_FLOW_ATTRS,
|
|
889
|
+
)
|
|
890
|
+
s.group_co2_price = pd.Index([], name="group", dtype="object")
|
|
891
|
+
s.group_co2_limit = pd.Index([], name="group", dtype="object")
|
|
892
|
+
if (flex_data.p_co2_price is not None
|
|
893
|
+
and flex_data.p_co2_price.frame.height > 0):
|
|
894
|
+
s.group_co2_price = pd.Index(
|
|
895
|
+
flex_data.p_co2_price.frame.select("g").unique()
|
|
896
|
+
.to_pandas()["g"].tolist(),
|
|
897
|
+
name="group",
|
|
898
|
+
)
|
|
899
|
+
# Groups limited by either the per-period cap (``co2_max_period``) or the
|
|
900
|
+
# multi-period total cap (``co2_max_total``). Both feed the group-level
|
|
901
|
+
# CO2 attribution; omitting the total cap dropped ``co2_method = total``
|
|
902
|
+
# groups from the group CO2 reports.
|
|
903
|
+
_limit_groups: list = []
|
|
904
|
+
for _cap in (flex_data.group_co2_max_period, flex_data.group_co2_max_total):
|
|
905
|
+
if _cap is not None and _cap.height > 0:
|
|
906
|
+
_gcol = _cap.columns[0]
|
|
907
|
+
_limit_groups += (_cap.select(_gcol).unique()
|
|
908
|
+
.to_pandas()[_gcol].tolist())
|
|
909
|
+
if _limit_groups:
|
|
910
|
+
s.group_co2_limit = pd.Index(
|
|
911
|
+
list(dict.fromkeys(_limit_groups)), name="group")
|
|
912
|
+
|
|
913
|
+
# ─── Group-related sets ───────────────────────────────────────────────
|
|
914
|
+
s.groupInertia = _index_or_empty(flex_data.groupInertia, dim="g", name="group")
|
|
915
|
+
s.groupNonSync = _index_or_empty(flex_data.groupNonSync, dim="g", name="group")
|
|
916
|
+
s.groupCapacityMargin = _index_or_empty(
|
|
917
|
+
flex_data.groupCapacityMargin, dim="g", name="group",
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
# Aggregator / dispatch group sets — empty by default; populated
|
|
921
|
+
# when nodeGroupDispatch features are configured. No FlexData
|
|
922
|
+
# field carries these directly today.
|
|
923
|
+
s.nodeGroupDispatch = pd.Index([], name="group", dtype="object")
|
|
924
|
+
s.nodeGroupIndicators = pd.Index([], name="group", dtype="object")
|
|
925
|
+
s.flowGroupIndicators = pd.Index([], name="group", dtype="object")
|
|
926
|
+
s.nodeGroupDispatch__connection_Not_in_aggregate = empty_multi_index(
|
|
927
|
+
["group", "connection"]
|
|
928
|
+
)
|
|
929
|
+
s.nodeGroupDispatch__process__unit__to_node_Not_in_aggregate = empty_multi_index(
|
|
930
|
+
["group", "process", "unit", "node"]
|
|
931
|
+
)
|
|
932
|
+
s.nodeGroupDispatch__process__node__to_unit_Not_in_aggregate = empty_multi_index(
|
|
933
|
+
["group", "process", "node", "unit"]
|
|
934
|
+
)
|
|
935
|
+
s.nodeGroupDispatch__process__connection__to_node_Not_in_aggregate = empty_multi_index(
|
|
936
|
+
["group", "process", "connection", "node"]
|
|
937
|
+
)
|
|
938
|
+
s.nodeGroupDispatch__process__node__to_connection_Not_in_aggregate = empty_multi_index(
|
|
939
|
+
["group", "process", "node", "connection"]
|
|
940
|
+
)
|
|
941
|
+
s.nodeGroupDispatch__processGroup_Unit_to_group = empty_multi_index(
|
|
942
|
+
["group", "group_aggregate"]
|
|
943
|
+
)
|
|
944
|
+
s.nodeGroupDispatch__processGroup__process__unit__to_node = empty_multi_index(
|
|
945
|
+
["group", "group_aggregate", "process", "unit", "node"]
|
|
946
|
+
)
|
|
947
|
+
s.nodeGroupDispatch__processGroup_Group_to_unit = empty_multi_index(
|
|
948
|
+
["group", "group_aggregate"]
|
|
949
|
+
)
|
|
950
|
+
s.nodeGroupDispatch__processGroup__process__node__to_unit = empty_multi_index(
|
|
951
|
+
["group", "group_aggregate", "process", "node", "unit"]
|
|
952
|
+
)
|
|
953
|
+
s.nodeGroupDispatch__processGroup_Connection = empty_multi_index(
|
|
954
|
+
["group", "group_aggregate"]
|
|
955
|
+
)
|
|
956
|
+
s.nodeGroupDispatch__processGroup__process__connection__to_node = empty_multi_index(
|
|
957
|
+
["group", "group_aggregate", "process", "connection", "node"]
|
|
958
|
+
)
|
|
959
|
+
s.nodeGroupDispatch__processGroup__process__node__to_connection = empty_multi_index(
|
|
960
|
+
["group", "group_aggregate", "process", "node", "connection"]
|
|
961
|
+
)
|
|
962
|
+
s.nodeGroupDispatch__process_fully_inside = empty_multi_index(
|
|
963
|
+
["group", "process"]
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
# group_node — (group, node) from group_node FlexData field.
|
|
967
|
+
if (flex_data.group_node is not None
|
|
968
|
+
and flex_data.group_node.height > 0):
|
|
969
|
+
cols = flex_data.group_node.columns
|
|
970
|
+
s.group_node = pd.MultiIndex.from_frame(
|
|
971
|
+
flex_data.group_node.to_pandas(),
|
|
972
|
+
names=[long_dim(c) for c in cols],
|
|
973
|
+
)
|
|
974
|
+
else:
|
|
975
|
+
s.group_node = empty_multi_index(["group", "node"])
|
|
976
|
+
s.group_process = empty_multi_index(["group", "process"])
|
|
977
|
+
s.group_process_node = empty_multi_index(["group", "process", "node"])
|
|
978
|
+
|
|
979
|
+
# upDown — flextool's known up/down set.
|
|
980
|
+
s.upDown = pd.Index(["up", "down"], name="updown")
|
|
981
|
+
|
|
982
|
+
# enable_optional_outputs — read from FlexData if available. Empty set OK.
|
|
983
|
+
s.enable_optional_outputs = set()
|
|
984
|
+
|
|
985
|
+
# ─── DC power flow sets ───────────────────────────────────────────────
|
|
986
|
+
s.node_dc_power_flow = _index_or_empty(
|
|
987
|
+
flex_data.node_dc_power_flow, dim="n", name="node",
|
|
988
|
+
)
|
|
989
|
+
s.connection_dc_power_flow = _index_or_empty(
|
|
990
|
+
flex_data.connection_dc_power_flow, dim="p", name="connection",
|
|
991
|
+
)
|
|
992
|
+
if (flex_data.p_connection_susceptance is not None
|
|
993
|
+
and flex_data.p_connection_susceptance.frame.height > 0):
|
|
994
|
+
df = flex_data.p_connection_susceptance.frame.to_pandas()
|
|
995
|
+
s.connection_susceptance = df.set_index("p")["value"].astype(float)
|
|
996
|
+
s.connection_susceptance.index.name = "connection"
|
|
997
|
+
else:
|
|
998
|
+
s.connection_susceptance = pd.Series(dtype=float)
|
|
999
|
+
|
|
1000
|
+
return s
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
# ---------------------------------------------------------------------------
|
|
1004
|
+
# Multi-solve wrapper
|
|
1005
|
+
# ---------------------------------------------------------------------------
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _multi_index_has_solve(obj) -> bool:
|
|
1009
|
+
if isinstance(obj, pd.MultiIndex):
|
|
1010
|
+
return "solve" in (obj.names or ())
|
|
1011
|
+
return False
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _series_or_df_has_solve(obj) -> bool:
|
|
1015
|
+
if isinstance(obj, (pd.DataFrame, pd.Series)):
|
|
1016
|
+
idx = obj.index
|
|
1017
|
+
if isinstance(idx, pd.MultiIndex):
|
|
1018
|
+
return "solve" in (idx.names or ())
|
|
1019
|
+
return False
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def read_sets_multi(
|
|
1023
|
+
steps: "list[tuple[str, FlexData, Solution]] | list[tuple[str, FlexData]]",
|
|
1024
|
+
solution: "Solution | None" = None,
|
|
1025
|
+
) -> SimpleNamespace:
|
|
1026
|
+
"""Multi-solve variant of :func:`read_sets`.
|
|
1027
|
+
|
|
1028
|
+
See :func:`flextool.process_outputs.read_parameters.read_parameters_multi`
|
|
1029
|
+
for the rationale. Per-sub-solve ``(solve, period, …)``-keyed sets
|
|
1030
|
+
(``period``, ``dt``, ``d_realized_period``, ``dt_realize_dispatch``,
|
|
1031
|
+
``d_realize_invest``, ``ed_invest``, ``dtttdt``, etc.) are unioned
|
|
1032
|
+
across sub-solves; static set attributes (``node``, ``process``,
|
|
1033
|
+
``upDown``, …) are taken from the last step (invariant).
|
|
1034
|
+
"""
|
|
1035
|
+
if not steps:
|
|
1036
|
+
raise ValueError("read_sets_multi: steps must be non-empty")
|
|
1037
|
+
|
|
1038
|
+
def _step_solution(s):
|
|
1039
|
+
if len(s) >= 3:
|
|
1040
|
+
return s[2]
|
|
1041
|
+
if solution is None:
|
|
1042
|
+
raise ValueError(
|
|
1043
|
+
"read_sets_multi: step is a 2-tuple but no fallback "
|
|
1044
|
+
"solution was supplied"
|
|
1045
|
+
)
|
|
1046
|
+
return solution
|
|
1047
|
+
|
|
1048
|
+
per_step = [
|
|
1049
|
+
(s[0], read_sets(s[1], _step_solution(s), solve_name=s[0]))
|
|
1050
|
+
for s in steps
|
|
1051
|
+
]
|
|
1052
|
+
last_ns = per_step[-1][1]
|
|
1053
|
+
if len(per_step) == 1:
|
|
1054
|
+
return last_ns
|
|
1055
|
+
|
|
1056
|
+
out = SimpleNamespace()
|
|
1057
|
+
attr_names = list(vars(last_ns).keys())
|
|
1058
|
+
for attr in attr_names:
|
|
1059
|
+
pieces = [getattr(ns, attr) for _, ns in per_step]
|
|
1060
|
+
first = pieces[0]
|
|
1061
|
+
if isinstance(first, pd.MultiIndex):
|
|
1062
|
+
if _multi_index_has_solve(first):
|
|
1063
|
+
# Concat MultiIndex rows from each sub-solve. Non-empty
|
|
1064
|
+
# pieces only — empty MultiIndex with mismatching dtypes
|
|
1065
|
+
# can perturb the result's dtype.
|
|
1066
|
+
non_empty = [p for p in pieces if len(p) > 0]
|
|
1067
|
+
if not non_empty:
|
|
1068
|
+
setattr(out, attr, first)
|
|
1069
|
+
continue
|
|
1070
|
+
# Build per-level Python lists and reassemble (works
|
|
1071
|
+
# regardless of dtype quirks at the empty-frame
|
|
1072
|
+
# boundary).
|
|
1073
|
+
names = non_empty[0].names
|
|
1074
|
+
# All pieces share the same names by construction.
|
|
1075
|
+
level_lists = [[] for _ in names]
|
|
1076
|
+
for p in non_empty:
|
|
1077
|
+
for i in range(len(names)):
|
|
1078
|
+
level_lists[i].extend(list(p.get_level_values(i)))
|
|
1079
|
+
setattr(out, attr, pd.MultiIndex.from_arrays(
|
|
1080
|
+
level_lists, names=names,
|
|
1081
|
+
))
|
|
1082
|
+
else:
|
|
1083
|
+
setattr(out, attr, getattr(last_ns, attr))
|
|
1084
|
+
elif _series_or_df_has_solve(first):
|
|
1085
|
+
non_empty = [p for p in pieces if len(p) > 0]
|
|
1086
|
+
if not non_empty:
|
|
1087
|
+
setattr(out, attr, pieces[-1])
|
|
1088
|
+
continue
|
|
1089
|
+
merged = pd.concat(non_empty, axis=0)
|
|
1090
|
+
if hasattr(pieces[-1], "columns") and hasattr(merged, "columns"):
|
|
1091
|
+
merged.columns.name = pieces[-1].columns.name
|
|
1092
|
+
setattr(out, attr, merged)
|
|
1093
|
+
else:
|
|
1094
|
+
setattr(out, attr, getattr(last_ns, attr))
|
|
1095
|
+
return out
|