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,1799 @@
|
|
|
1
|
+
"""In-memory ``read_parameters`` — polars ``FlexData`` → pandas wide.
|
|
2
|
+
|
|
3
|
+
Translates the polars :class:`FlexData` and
|
|
4
|
+
:class:`polar_high.Solution` objects directly into the pandas wide
|
|
5
|
+
format the downstream ``out_*`` modules consume. Every attribute on
|
|
6
|
+
the returned :class:`SimpleNamespace` maps to a FlexData field (or,
|
|
7
|
+
for ``entity_all_capacity``, a post-solve derivation from FlexData +
|
|
8
|
+
``solution.value("v_invest")`` + ``solution.value("v_divest")``).
|
|
9
|
+
|
|
10
|
+
Properties:
|
|
11
|
+
|
|
12
|
+
* No CSV round-trip — empty multi-header CSVs no longer silently drop
|
|
13
|
+
column dim names.
|
|
14
|
+
* Post-solve derived attributes (``entity_all_capacity`` and the three
|
|
15
|
+
sister capacity tables) use the live ``solution`` directly.
|
|
16
|
+
* Dim names are set via the central
|
|
17
|
+
:data:`flextool.process_outputs._inmemory_helpers.DIM_NAMES`
|
|
18
|
+
translation table (no per-CSV ``.columns.name = '...'`` patchwork).
|
|
19
|
+
|
|
20
|
+
Failure mode: every helper raises loudly when a FlexData field is
|
|
21
|
+
absent or has an unexpected shape — authoring bugs surface at the
|
|
22
|
+
call-site instead of producing silently-empty outputs.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from types import SimpleNamespace
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
import pandas as pd
|
|
30
|
+
import polars as pl
|
|
31
|
+
|
|
32
|
+
from flextool.process_outputs._inmemory_helpers import (
|
|
33
|
+
series_with_index,
|
|
34
|
+
series_with_multi_index,
|
|
35
|
+
wide_multi_col,
|
|
36
|
+
wide_per_entity,
|
|
37
|
+
with_solve_column,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from polar_high import Solution
|
|
42
|
+
|
|
43
|
+
from flextool.engine_polars.input import FlexData
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Per-attribute helpers
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _empty_pdtX_per_entity(
|
|
52
|
+
*,
|
|
53
|
+
flex_data: "FlexData | None",
|
|
54
|
+
solve_name: str,
|
|
55
|
+
col_name: str,
|
|
56
|
+
dtype=float,
|
|
57
|
+
) -> pd.DataFrame:
|
|
58
|
+
"""Return a ``(solve, period, time)``-indexed DataFrame with empty
|
|
59
|
+
named columns.
|
|
60
|
+
|
|
61
|
+
Used for the per-(solve, period, time) family when the underlying
|
|
62
|
+
FlexData field is None / empty. Critically, when ``flex_data``
|
|
63
|
+
is supplied, the row index is materialised over the active solve's
|
|
64
|
+
full ``(d, t)`` axis — matching the legacy CSV path which wrote
|
|
65
|
+
``solve,period,time`` rows for every timestep even when no data
|
|
66
|
+
columns were present. Without this, downstream broadcasts like
|
|
67
|
+
``v.state.mul(par.node_self_discharge_loss, axis=1, level=0)``
|
|
68
|
+
raise ``TypeError: Join on level between two MultiIndex objects
|
|
69
|
+
is ambiguous`` because pandas can't reconcile two empty MultiIndex
|
|
70
|
+
on join.
|
|
71
|
+
"""
|
|
72
|
+
if flex_data is not None and flex_data.dt is not None and flex_data.dt.height > 0:
|
|
73
|
+
dt_pdf = flex_data.dt.to_pandas()
|
|
74
|
+
idx = pd.MultiIndex.from_arrays(
|
|
75
|
+
[[solve_name] * len(dt_pdf), dt_pdf["d"].tolist(), dt_pdf["t"].tolist()],
|
|
76
|
+
names=["solve", "period", "time"],
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
idx = pd.MultiIndex.from_arrays(
|
|
80
|
+
[[], [], []], names=["solve", "period", "time"],
|
|
81
|
+
)
|
|
82
|
+
out = pd.DataFrame(index=idx, dtype=dtype)
|
|
83
|
+
out.columns = pd.Index([], dtype="object", name=col_name)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _pdtX_per_entity(
|
|
88
|
+
param,
|
|
89
|
+
*,
|
|
90
|
+
solve_name: str,
|
|
91
|
+
entity_dim: str,
|
|
92
|
+
col_name: str | None = None,
|
|
93
|
+
flex_data: "FlexData | None" = None,
|
|
94
|
+
) -> pd.DataFrame:
|
|
95
|
+
"""Pivot a Param with dims ``(<entity>, d, t)`` to pandas wide-format
|
|
96
|
+
indexed by ``(solve, period, time)`` × entity.
|
|
97
|
+
|
|
98
|
+
``param`` may be ``None`` / empty — returns an empty DataFrame
|
|
99
|
+
indexed over the full ``(solve, d, t)`` axis (taken from
|
|
100
|
+
``flex_data.dt`` when supplied) so downstream broadcast-multiply
|
|
101
|
+
ops align cleanly.
|
|
102
|
+
"""
|
|
103
|
+
if param is None or param.frame.height == 0:
|
|
104
|
+
return _empty_pdtX_per_entity(
|
|
105
|
+
flex_data=flex_data,
|
|
106
|
+
solve_name=solve_name,
|
|
107
|
+
col_name=col_name or entity_dim,
|
|
108
|
+
)
|
|
109
|
+
# Phase E.1: Param dims may be narrower than (entity, d, t) when
|
|
110
|
+
# the source was authored as scalar / 1d_map. The pandas pivot
|
|
111
|
+
# below requires explicit "d" and "t" columns, so promote via
|
|
112
|
+
# flex_data.dt when needed.
|
|
113
|
+
if "d" not in param.dims or "t" not in param.dims:
|
|
114
|
+
from flextool.engine_polars._param_shapes import promote_param_to_dt
|
|
115
|
+
if flex_data is None or flex_data.dt is None:
|
|
116
|
+
return _empty_pdtX_per_entity(
|
|
117
|
+
flex_data=flex_data,
|
|
118
|
+
solve_name=solve_name,
|
|
119
|
+
col_name=col_name or entity_dim,
|
|
120
|
+
)
|
|
121
|
+
pdt_frame = promote_param_to_dt(param, flex_data.dt).collect()
|
|
122
|
+
else:
|
|
123
|
+
pdt_frame = param.frame
|
|
124
|
+
pl_df = with_solve_column(pdt_frame, solve_name)
|
|
125
|
+
return wide_per_entity(
|
|
126
|
+
pl_df,
|
|
127
|
+
row_dims=("solve", "d", "t"),
|
|
128
|
+
col_dim=entity_dim,
|
|
129
|
+
row_names=("solve", "period", "time"),
|
|
130
|
+
col_name=col_name or entity_dim,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _pdX_per_entity(
|
|
135
|
+
param,
|
|
136
|
+
*,
|
|
137
|
+
solve_name: str,
|
|
138
|
+
entity_dim: str,
|
|
139
|
+
col_name: str | None = None,
|
|
140
|
+
flex_data: "FlexData | None" = None,
|
|
141
|
+
densify_entities: "list[str] | None" = None,
|
|
142
|
+
) -> pd.DataFrame:
|
|
143
|
+
"""Pivot a Param with dims ``(<entity>, d)`` to pandas wide-format
|
|
144
|
+
indexed by ``(solve, period)`` × entity.
|
|
145
|
+
|
|
146
|
+
``param`` may be ``None`` / empty — returns an empty DataFrame
|
|
147
|
+
indexed over the active solve's full ``(d,)`` axis (taken from
|
|
148
|
+
``flex_data.dt`` when supplied).
|
|
149
|
+
|
|
150
|
+
``densify_entities`` (optional) — when supplied, the result is
|
|
151
|
+
reindexed to include every entity in the list (zero-filling any
|
|
152
|
+
entity that doesn't appear in ``param``). The legacy CSV path
|
|
153
|
+
(``ed_fixed_cost.csv`` etc.) did this for the entity universe;
|
|
154
|
+
downstream consumers like
|
|
155
|
+
``calc_costs.py:cost_entity_fixed_invested`` index the result
|
|
156
|
+
by ``v.invest.columns`` and expect every invest-entity column to
|
|
157
|
+
be present.
|
|
158
|
+
"""
|
|
159
|
+
if flex_data is not None and flex_data.dt is not None and flex_data.dt.height > 0:
|
|
160
|
+
d_pdf = flex_data.dt.select("d").unique().to_pandas()
|
|
161
|
+
periods_full = d_pdf["d"].tolist()
|
|
162
|
+
else:
|
|
163
|
+
periods_full = []
|
|
164
|
+
|
|
165
|
+
if param is None or param.frame.height == 0:
|
|
166
|
+
if periods_full:
|
|
167
|
+
idx = pd.MultiIndex.from_arrays(
|
|
168
|
+
[[solve_name] * len(periods_full), periods_full],
|
|
169
|
+
names=["solve", "period"],
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
idx = pd.MultiIndex.from_arrays([[], []], names=["solve", "period"])
|
|
173
|
+
out = pd.DataFrame(index=idx, dtype=float)
|
|
174
|
+
out.columns = pd.Index([], dtype="object", name=col_name or entity_dim)
|
|
175
|
+
else:
|
|
176
|
+
pl_df = with_solve_column(param.frame, solve_name)
|
|
177
|
+
out = wide_per_entity(
|
|
178
|
+
pl_df,
|
|
179
|
+
row_dims=("solve", "d"),
|
|
180
|
+
col_dim=entity_dim,
|
|
181
|
+
row_names=("solve", "period"),
|
|
182
|
+
col_name=col_name or entity_dim,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if densify_entities:
|
|
186
|
+
# Add missing entities (zero-filled) and apply the stable column
|
|
187
|
+
# ordering in a single reindex. Inserting columns one at a time
|
|
188
|
+
# (``out[e] = 0.0`` in a loop) fragments the frame and triggers a
|
|
189
|
+
# PerformanceWarning once per insert — a single reindex avoids it.
|
|
190
|
+
ordered = list(densify_entities) + [
|
|
191
|
+
c for c in out.columns if c not in densify_entities
|
|
192
|
+
]
|
|
193
|
+
out = out.reindex(columns=ordered, fill_value=0.0)
|
|
194
|
+
out.columns.name = col_name or entity_dim
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _pX_per_entity(
|
|
199
|
+
param,
|
|
200
|
+
*,
|
|
201
|
+
entity_dim: str,
|
|
202
|
+
col_name: str | None = None,
|
|
203
|
+
) -> pd.Series:
|
|
204
|
+
"""Per-entity scalar Param ``(<entity>,)`` → pandas Series with
|
|
205
|
+
the entity as the index name.
|
|
206
|
+
"""
|
|
207
|
+
if param is None or param.frame.height == 0:
|
|
208
|
+
return pd.Series(dtype=float, name="value", index=pd.Index([], name=col_name or entity_dim))
|
|
209
|
+
return series_with_index(
|
|
210
|
+
param.frame, dim=entity_dim, name=col_name or entity_dim,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _pd_series_solve_period(
|
|
215
|
+
param,
|
|
216
|
+
*,
|
|
217
|
+
solve_name: str,
|
|
218
|
+
) -> pd.Series:
|
|
219
|
+
"""Per-period Param ``(d,)`` → pandas Series indexed by
|
|
220
|
+
``(solve, period)``.
|
|
221
|
+
"""
|
|
222
|
+
if param is None or param.frame.height == 0:
|
|
223
|
+
idx = pd.MultiIndex.from_arrays([[], []], names=["solve", "period"])
|
|
224
|
+
return pd.Series(dtype=float, name="value", index=idx)
|
|
225
|
+
pl_df = with_solve_column(param.frame, solve_name)
|
|
226
|
+
pdf = pl_df.to_pandas()
|
|
227
|
+
s = pdf.set_index(["solve", "d"])["value"].astype(float)
|
|
228
|
+
s.index.names = ["solve", "period"]
|
|
229
|
+
s.name = "value"
|
|
230
|
+
return s
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _pdt_series_solve_period_time(
|
|
234
|
+
param,
|
|
235
|
+
*,
|
|
236
|
+
solve_name: str,
|
|
237
|
+
) -> pd.Series:
|
|
238
|
+
"""Param ``(d, t)`` → pandas Series indexed by
|
|
239
|
+
``(solve, period, time)``.
|
|
240
|
+
"""
|
|
241
|
+
if param is None or param.frame.height == 0:
|
|
242
|
+
idx = pd.MultiIndex.from_arrays([[], [], []], names=["solve", "period", "time"])
|
|
243
|
+
return pd.Series(dtype=float, name="value", index=idx)
|
|
244
|
+
pl_df = with_solve_column(param.frame, solve_name)
|
|
245
|
+
pdf = pl_df.to_pandas()
|
|
246
|
+
s = pdf.set_index(["solve", "d", "t"])["value"].astype(float)
|
|
247
|
+
s.index.names = ["solve", "period", "time"]
|
|
248
|
+
s.name = "value"
|
|
249
|
+
return s
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _empty_pdtX_multi_col(
|
|
253
|
+
*, col_names: tuple[str, str, str],
|
|
254
|
+
) -> pd.DataFrame:
|
|
255
|
+
"""Empty DataFrame with ``(solve, period, time)`` row index and a
|
|
256
|
+
3-level empty column MultiIndex.
|
|
257
|
+
"""
|
|
258
|
+
idx = pd.MultiIndex.from_arrays(
|
|
259
|
+
[[], [], []], names=["solve", "period", "time"]
|
|
260
|
+
)
|
|
261
|
+
out = pd.DataFrame(index=idx, dtype=float)
|
|
262
|
+
out.columns = pd.MultiIndex.from_arrays([[], [], []], names=list(col_names))
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _pdtX_multi_col(
|
|
267
|
+
param,
|
|
268
|
+
*,
|
|
269
|
+
solve_name: str,
|
|
270
|
+
col_dims: tuple[str, str, str],
|
|
271
|
+
col_names: tuple[str, str, str],
|
|
272
|
+
densify_col_tuples: "pl.DataFrame | None" = None,
|
|
273
|
+
flex_data: "FlexData | None" = None,
|
|
274
|
+
) -> pd.DataFrame:
|
|
275
|
+
"""Param ``(*col_dims, d, t)`` → wide pandas with row
|
|
276
|
+
``(solve, period, time)`` × column MultiIndex over col_dims.
|
|
277
|
+
|
|
278
|
+
``densify_col_tuples`` (optional) — a polars frame whose first three
|
|
279
|
+
columns enumerate every ``(col_dims)`` tuple that must appear in the
|
|
280
|
+
result. Missing tuples are added as zero-valued columns; downstream
|
|
281
|
+
consumers (e.g. ``calc_slacks.q_reserves_dt``) broadcast-multiply
|
|
282
|
+
against ``v.q_reserve.columns`` which carries the full LP domain even
|
|
283
|
+
when the source ``reservation`` parameter is sparse.
|
|
284
|
+
"""
|
|
285
|
+
if param is None or param.frame.height == 0:
|
|
286
|
+
out = _empty_pdtX_multi_col(col_names=col_names)
|
|
287
|
+
else:
|
|
288
|
+
# Phase E.1: Param dims may be narrower than (*col_dims, d, t)
|
|
289
|
+
# when the source was authored as scalar / 1d_map. Promote via
|
|
290
|
+
# flex_data.dt so the pivot below sees explicit d/t columns.
|
|
291
|
+
if "d" not in param.dims or "t" not in param.dims:
|
|
292
|
+
from flextool.engine_polars._param_shapes import promote_param_to_dt
|
|
293
|
+
if flex_data is None or flex_data.dt is None:
|
|
294
|
+
out = _empty_pdtX_multi_col(col_names=col_names)
|
|
295
|
+
if densify_col_tuples is None:
|
|
296
|
+
return out
|
|
297
|
+
# Fall through to densify path.
|
|
298
|
+
pdt_frame = None
|
|
299
|
+
else:
|
|
300
|
+
pdt_frame = promote_param_to_dt(param, flex_data.dt).collect()
|
|
301
|
+
else:
|
|
302
|
+
pdt_frame = param.frame
|
|
303
|
+
if pdt_frame is not None:
|
|
304
|
+
pl_df = with_solve_column(pdt_frame, solve_name)
|
|
305
|
+
out = wide_multi_col(
|
|
306
|
+
pl_df,
|
|
307
|
+
row_dims=("solve", "d", "t"),
|
|
308
|
+
col_dims=col_dims,
|
|
309
|
+
row_names=("solve", "period", "time"),
|
|
310
|
+
col_names=col_names,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if densify_col_tuples is None:
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
gate_cols = densify_col_tuples.columns[: len(col_dims)]
|
|
317
|
+
gate_tuples = [
|
|
318
|
+
tuple(row)
|
|
319
|
+
for row in densify_col_tuples.select(gate_cols)
|
|
320
|
+
.sort(gate_cols)
|
|
321
|
+
.iter_rows()
|
|
322
|
+
]
|
|
323
|
+
existing = list(out.columns) if isinstance(out.columns, pd.MultiIndex) else []
|
|
324
|
+
ordered = list(gate_tuples) + [c for c in existing if c not in gate_tuples]
|
|
325
|
+
full_cols = pd.MultiIndex.from_tuples(ordered, names=list(col_names))
|
|
326
|
+
out = out.reindex(columns=full_cols, fill_value=0.0)
|
|
327
|
+
return out
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _build_pssdt_varCost_alwaysProcess(
|
|
331
|
+
flex_data: "FlexData",
|
|
332
|
+
*,
|
|
333
|
+
solve_name: str,
|
|
334
|
+
) -> pd.DataFrame:
|
|
335
|
+
"""Build the ``_alwaysProcess``-keyed varCost frame for post-processing.
|
|
336
|
+
|
|
337
|
+
The .mod's objective splits ``other_operational_cost`` into four
|
|
338
|
+
sums (``pssdt_varCost_noEff``, ``..._eff_unit_source``,
|
|
339
|
+
``..._eff_unit_sink``, ``..._eff_connection``). Python
|
|
340
|
+
post-processing collapses them into a single ``(p, source, sink)``
|
|
341
|
+
multiplication against ``r.flow_dt``; because ``r.flow_dt`` is
|
|
342
|
+
indexed by ``process_source_sink_alwaysProcess`` tuples, varCost
|
|
343
|
+
needs the same keying.
|
|
344
|
+
|
|
345
|
+
Reference: ``_emit_period_params._derive_varCost_pair`` (used to
|
|
346
|
+
write ``pdtProcess__source__sink__dt_varCost_alwaysProcess.csv``)
|
|
347
|
+
for the exact algebra. Replicated here directly on FlexData
|
|
348
|
+
Params (``p_pdt_varCost_source``, ``p_pdt_varCost_sink``,
|
|
349
|
+
``p_pdt_varCost_process``) so we don't need to round-trip via the
|
|
350
|
+
on-disk CSV.
|
|
351
|
+
|
|
352
|
+
Returns a wide ``DataFrame`` with row index ``(solve, period, time)``
|
|
353
|
+
and column MultiIndex ``(process, source, sink)``. Tuples where
|
|
354
|
+
every dt-row would be zero are dropped (matches the CSV writer's
|
|
355
|
+
``filter(value != 0.0)``).
|
|
356
|
+
"""
|
|
357
|
+
pss_frame = flex_data.process_source_sink
|
|
358
|
+
if pss_frame is None or pss_frame.height == 0:
|
|
359
|
+
return _empty_pdtX_multi_col(
|
|
360
|
+
col_names=("process", "source", "sink"),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Build alwaysProcess pss: each direct-method arc (src != p, snk != p)
|
|
364
|
+
# contributes BOTH ``(p, src, p)`` and ``(p, p, snk)``; arcs where p
|
|
365
|
+
# already appears as endpoint pass through unchanged. This mirrors
|
|
366
|
+
# the cascade-side derivation in
|
|
367
|
+
# ``flextool.process_outputs.read_sets`` ~ L380-390.
|
|
368
|
+
pss_pdf = pss_frame.select("p", "source", "sink").to_pandas()
|
|
369
|
+
always_rows: list[tuple[str, str, str]] = []
|
|
370
|
+
for p, src, snk in zip(
|
|
371
|
+
pss_pdf["p"], pss_pdf["source"], pss_pdf["sink"],
|
|
372
|
+
):
|
|
373
|
+
if src == p or snk == p:
|
|
374
|
+
always_rows.append((p, src, snk))
|
|
375
|
+
else:
|
|
376
|
+
always_rows.append((p, src, p))
|
|
377
|
+
always_rows.append((p, p, snk))
|
|
378
|
+
# Dedup preserving order.
|
|
379
|
+
always_rows = list(dict.fromkeys(always_rows))
|
|
380
|
+
|
|
381
|
+
# Membership sets used to gate per-side OOC contributions. Use the
|
|
382
|
+
# canonical (process, node) sets (input-arc nodes for source,
|
|
383
|
+
# output-arc nodes for sink); ``process_source_sink`` itself
|
|
384
|
+
# contains intermediate (p, p) entries for indirect units and so
|
|
385
|
+
# cannot be projected directly.
|
|
386
|
+
proc_src_pairs: set[tuple[str, str]] = set()
|
|
387
|
+
if (flex_data.process_source_canonical is not None
|
|
388
|
+
and flex_data.process_source_canonical.height > 0):
|
|
389
|
+
for p, n in flex_data.process_source_canonical.select(
|
|
390
|
+
"p", "source",
|
|
391
|
+
).iter_rows():
|
|
392
|
+
proc_src_pairs.add((str(p), str(n)))
|
|
393
|
+
proc_snk_pairs: set[tuple[str, str]] = set()
|
|
394
|
+
if (flex_data.process_sink_canonical is not None
|
|
395
|
+
and flex_data.process_sink_canonical.height > 0):
|
|
396
|
+
for p, n in flex_data.process_sink_canonical.select(
|
|
397
|
+
"p", "sink",
|
|
398
|
+
).iter_rows():
|
|
399
|
+
proc_snk_pairs.add((str(p), str(n)))
|
|
400
|
+
|
|
401
|
+
def _as_lookup_dt(
|
|
402
|
+
param, key_cols: tuple[str, ...],
|
|
403
|
+
):
|
|
404
|
+
"""Materialise a FlexData Param into a lookup ``f(*full_key) → value``.
|
|
405
|
+
|
|
406
|
+
Phase E.1: ``broadcast_to_period_time`` returns Params whose dims
|
|
407
|
+
depend on the authored Spine shape (SCALAR → ``(p, sink)``,
|
|
408
|
+
MAP_PERIOD → ``(p, sink, d)``, MAP_PERIOD_TIME → ``(p, sink, d, t)``).
|
|
409
|
+
Polar_high broadcasts the lower-dim Params lazily at constraint
|
|
410
|
+
emission, but the output writers consume frames directly — so we
|
|
411
|
+
key the dict by the Param's actual dim subset of ``key_cols`` and
|
|
412
|
+
project the requested ``full_key`` onto that subset on lookup.
|
|
413
|
+
"""
|
|
414
|
+
if param is None or param.frame.height == 0:
|
|
415
|
+
return lambda *_args: 0.0
|
|
416
|
+
present = tuple(c for c in key_cols if c in param.dims)
|
|
417
|
+
proj_idx = [key_cols.index(c) for c in present]
|
|
418
|
+
out: dict[tuple[str, ...], float] = {}
|
|
419
|
+
cols = [*present, "value"] if present else ["value"]
|
|
420
|
+
for row in param.frame.select(*cols).iter_rows():
|
|
421
|
+
if present:
|
|
422
|
+
*k, v = row
|
|
423
|
+
out[tuple(str(x) for x in k)] = float(v)
|
|
424
|
+
else:
|
|
425
|
+
# Pure scalar — single global value.
|
|
426
|
+
out[()] = float(row[0])
|
|
427
|
+
def _lookup(*full_key, _out=out, _idx=proj_idx):
|
|
428
|
+
return _out.get(tuple(full_key[i] for i in _idx), 0.0)
|
|
429
|
+
return _lookup
|
|
430
|
+
|
|
431
|
+
src_ooc = _as_lookup_dt(
|
|
432
|
+
flex_data.p_pdt_varCost_source, ("p", "source", "d", "t"),
|
|
433
|
+
)
|
|
434
|
+
snk_ooc = _as_lookup_dt(
|
|
435
|
+
flex_data.p_pdt_varCost_sink, ("p", "sink", "d", "t"),
|
|
436
|
+
)
|
|
437
|
+
proc_ooc = _as_lookup_dt(
|
|
438
|
+
flex_data.p_pdt_varCost_process, ("p", "d", "t"),
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# dt grid for the broadcast.
|
|
442
|
+
dt_frame = flex_data.dt
|
|
443
|
+
if dt_frame is None or dt_frame.height == 0:
|
|
444
|
+
return _empty_pdtX_multi_col(
|
|
445
|
+
col_names=("process", "source", "sink"),
|
|
446
|
+
)
|
|
447
|
+
dt_pairs = [
|
|
448
|
+
(str(d), str(t))
|
|
449
|
+
for d, t in dt_frame.select("d", "t").iter_rows()
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
# Build (solve, period, time, process, source, sink, value) long frame.
|
|
453
|
+
# For each alwaysProcess tuple + each (d, t), sum gated contributions.
|
|
454
|
+
rec_solve: list[str] = []
|
|
455
|
+
rec_d: list[str] = []
|
|
456
|
+
rec_t: list[str] = []
|
|
457
|
+
rec_p: list[str] = []
|
|
458
|
+
rec_src: list[str] = []
|
|
459
|
+
rec_snk: list[str] = []
|
|
460
|
+
rec_v: list[float] = []
|
|
461
|
+
for (p, src, snk) in always_rows:
|
|
462
|
+
# Track whether this tuple ever has a non-zero value — drop if
|
|
463
|
+
# universally zero (matches the CSV writer's filter).
|
|
464
|
+
any_nonzero = False
|
|
465
|
+
local_pairs: list[tuple[str, str, float]] = []
|
|
466
|
+
for (d, t) in dt_pairs:
|
|
467
|
+
v = 0.0
|
|
468
|
+
if (p, src) in proc_src_pairs:
|
|
469
|
+
v += src_ooc(p, src, d, t)
|
|
470
|
+
if (p, snk) in proc_snk_pairs:
|
|
471
|
+
v += snk_ooc(p, snk, d, t)
|
|
472
|
+
# alwaysProcess gating for the process-level OOC term:
|
|
473
|
+
# include only when one of the (always-process) endpoints is
|
|
474
|
+
# in process_source ∪ process_sink (matches
|
|
475
|
+
# ``_derive_varCost_pair`` ``if always: ... if (p, snk) in
|
|
476
|
+
# proc_snk or (p, snk) in proc_src``).
|
|
477
|
+
if (p, snk) in proc_snk_pairs or (p, snk) in proc_src_pairs:
|
|
478
|
+
v += proc_ooc(p, d, t)
|
|
479
|
+
if v != 0.0:
|
|
480
|
+
any_nonzero = True
|
|
481
|
+
local_pairs.append((d, t, v))
|
|
482
|
+
if not any_nonzero:
|
|
483
|
+
continue
|
|
484
|
+
for (d, t, v) in local_pairs:
|
|
485
|
+
rec_solve.append(solve_name)
|
|
486
|
+
rec_d.append(d)
|
|
487
|
+
rec_t.append(t)
|
|
488
|
+
rec_p.append(p)
|
|
489
|
+
rec_src.append(src)
|
|
490
|
+
rec_snk.append(snk)
|
|
491
|
+
rec_v.append(v)
|
|
492
|
+
|
|
493
|
+
if not rec_solve:
|
|
494
|
+
return _empty_pdtX_multi_col(
|
|
495
|
+
col_names=("process", "source", "sink"),
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
long_pl = pl.DataFrame({
|
|
499
|
+
"solve": rec_solve,
|
|
500
|
+
"d": rec_d,
|
|
501
|
+
"t": rec_t,
|
|
502
|
+
"p": rec_p,
|
|
503
|
+
"source": rec_src,
|
|
504
|
+
"sink": rec_snk,
|
|
505
|
+
"value": rec_v,
|
|
506
|
+
})
|
|
507
|
+
return wide_multi_col(
|
|
508
|
+
long_pl,
|
|
509
|
+
row_dims=("solve", "d", "t"),
|
|
510
|
+
col_dims=("p", "source", "sink"),
|
|
511
|
+
row_names=("solve", "period", "time"),
|
|
512
|
+
col_names=("process", "source", "sink"),
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _entity_all_capacity(
|
|
517
|
+
flex_data: "FlexData",
|
|
518
|
+
solution: "Solution",
|
|
519
|
+
*,
|
|
520
|
+
solve_name: str,
|
|
521
|
+
) -> pd.DataFrame:
|
|
522
|
+
"""Compute ``entity_all_capacity[(solve, period), entity]`` from
|
|
523
|
+
FlexData + the live solution.
|
|
524
|
+
|
|
525
|
+
This is the in-memory ``total``-capacity parameter consumed by the
|
|
526
|
+
rendering layer (``out_capacity`` etc.). It is independent of the
|
|
527
|
+
removed legacy ``handoff_writers`` capacity CSVs, using polars
|
|
528
|
+
long-form frames end-to-end.
|
|
529
|
+
|
|
530
|
+
Formula::
|
|
531
|
+
|
|
532
|
+
entity_all_capacity[e, d] =
|
|
533
|
+
existing[e, d]
|
|
534
|
+
+ sum over (e, d_inv, d) in edd_invest of
|
|
535
|
+
v_invest[e, d_inv] * unitsize[e]
|
|
536
|
+
- sum over (e, d_dv) in ed_divest with years[d_dv] <= years[d] of
|
|
537
|
+
v_divest[e, d_dv] * unitsize[e]
|
|
538
|
+
"""
|
|
539
|
+
# Collect base existing — start from p_entity_all_existing if
|
|
540
|
+
# available; else fall back to a (entity × period) zero frame
|
|
541
|
+
# spanning the active periods.
|
|
542
|
+
if (flex_data.p_entity_all_existing is not None
|
|
543
|
+
and flex_data.p_entity_all_existing.frame.height > 0):
|
|
544
|
+
existing_lf = flex_data.p_entity_all_existing.frame.lazy().select(
|
|
545
|
+
pl.col("e").cast(pl.Utf8),
|
|
546
|
+
pl.col("d").cast(pl.Utf8),
|
|
547
|
+
pl.col("value").alias("existing"),
|
|
548
|
+
)
|
|
549
|
+
else:
|
|
550
|
+
existing_lf = pl.DataFrame(
|
|
551
|
+
schema={"e": pl.Utf8, "d": pl.Utf8, "existing": pl.Float64},
|
|
552
|
+
).lazy()
|
|
553
|
+
|
|
554
|
+
# Unitsize map: process p_unitsize ∪ node p_state_unitsize.
|
|
555
|
+
unitsize_pieces: list[pl.LazyFrame] = []
|
|
556
|
+
if (flex_data.p_unitsize is not None
|
|
557
|
+
and flex_data.p_unitsize.frame.height > 0):
|
|
558
|
+
unitsize_pieces.append(
|
|
559
|
+
flex_data.p_unitsize.frame.lazy().select(
|
|
560
|
+
pl.col("p").cast(pl.Utf8).alias("e"),
|
|
561
|
+
pl.col("value").alias("unitsize"),
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
if (flex_data.p_state_unitsize is not None
|
|
565
|
+
and flex_data.p_state_unitsize.frame.height > 0):
|
|
566
|
+
unitsize_pieces.append(
|
|
567
|
+
flex_data.p_state_unitsize.frame.lazy().select(
|
|
568
|
+
pl.col("n").cast(pl.Utf8).alias("e"),
|
|
569
|
+
pl.col("value").alias("unitsize"),
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
if unitsize_pieces:
|
|
573
|
+
unitsize_lf = pl.concat(unitsize_pieces, how="vertical")
|
|
574
|
+
else:
|
|
575
|
+
unitsize_lf = pl.DataFrame(
|
|
576
|
+
schema={"e": pl.Utf8, "unitsize": pl.Float64},
|
|
577
|
+
).lazy()
|
|
578
|
+
|
|
579
|
+
# Active solution slices. ``solution.value`` returns long form
|
|
580
|
+
# ``(*dims, value)``. The polars LP splits invest/divest into
|
|
581
|
+
# process-side (``v_invest_p`` / ``v_divest_p``) and node-side
|
|
582
|
+
# (``v_invest_n`` / ``v_divest_n``) variables; we union them.
|
|
583
|
+
def _try_value(name: str) -> "pl.DataFrame":
|
|
584
|
+
if name not in solution._vars:
|
|
585
|
+
return pl.DataFrame(
|
|
586
|
+
schema={"e": pl.Utf8, "d": pl.Utf8, "value": pl.Float64},
|
|
587
|
+
)
|
|
588
|
+
df = solution.value(name)
|
|
589
|
+
# The polars LP uses ``p`` / ``n`` for the entity dim; rename
|
|
590
|
+
# to the canonical ``e``. Also accept ``entity`` / ``period``.
|
|
591
|
+
rename = {}
|
|
592
|
+
for src, dst in (("p", "e"), ("n", "e"),
|
|
593
|
+
("entity", "e"), ("period", "d")):
|
|
594
|
+
if src in df.columns and dst not in df.columns:
|
|
595
|
+
rename[src] = dst
|
|
596
|
+
if rename:
|
|
597
|
+
df = df.rename(rename)
|
|
598
|
+
return df.select(
|
|
599
|
+
pl.col("e").cast(pl.Utf8),
|
|
600
|
+
pl.col("d").cast(pl.Utf8),
|
|
601
|
+
"value",
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
invest_pieces = []
|
|
605
|
+
divest_pieces = []
|
|
606
|
+
for nm in ("v_invest", "v_invest_p", "v_invest_n"):
|
|
607
|
+
f = _try_value(nm)
|
|
608
|
+
if f.height > 0:
|
|
609
|
+
invest_pieces.append(f)
|
|
610
|
+
for nm in ("v_divest", "v_divest_p", "v_divest_n"):
|
|
611
|
+
f = _try_value(nm)
|
|
612
|
+
if f.height > 0:
|
|
613
|
+
divest_pieces.append(f)
|
|
614
|
+
# FlexData / solution dim columns may be Enum dtype (from
|
|
615
|
+
# ``cast_flexdata_axes`` at the end of ``load_flextool``); empty
|
|
616
|
+
# fallback frames declare Utf8. ``_try_value`` casts the
|
|
617
|
+
# populated side to Utf8 (see above) so every concat input shares
|
|
618
|
+
# the empty-fallback dtype — no per-input cast needed here.
|
|
619
|
+
invest = (pl.concat(invest_pieces, how="vertical") if invest_pieces
|
|
620
|
+
else pl.DataFrame(schema={"e": pl.Utf8, "d": pl.Utf8, "value": pl.Float64}))
|
|
621
|
+
divest = (pl.concat(divest_pieces, how="vertical") if divest_pieces
|
|
622
|
+
else pl.DataFrame(schema={"e": pl.Utf8, "d": pl.Utf8, "value": pl.Float64}))
|
|
623
|
+
|
|
624
|
+
# invest contribution per (e, d) — by edd_invest indirection
|
|
625
|
+
if (flex_data.edd_invest_set is not None
|
|
626
|
+
and flex_data.edd_invest_set.height > 0):
|
|
627
|
+
edd = flex_data.edd_invest_set.lazy()
|
|
628
|
+
cols = flex_data.edd_invest_set.columns
|
|
629
|
+
# canonical: (e, d_invest, d) — rename if dim names differ
|
|
630
|
+
rename = {}
|
|
631
|
+
if cols[0] not in ("e", "entity"):
|
|
632
|
+
rename[cols[0]] = "e"
|
|
633
|
+
elif cols[0] == "entity":
|
|
634
|
+
rename["entity"] = "e"
|
|
635
|
+
if len(cols) > 1 and cols[1] != "d_invest":
|
|
636
|
+
rename[cols[1]] = "d_invest"
|
|
637
|
+
if len(cols) > 2 and cols[2] != "d":
|
|
638
|
+
rename[cols[2]] = "d"
|
|
639
|
+
if rename:
|
|
640
|
+
edd = edd.rename(rename)
|
|
641
|
+
edd = edd.with_columns(
|
|
642
|
+
pl.col("e").cast(pl.Utf8),
|
|
643
|
+
pl.col("d_invest").cast(pl.Utf8),
|
|
644
|
+
pl.col("d").cast(pl.Utf8),
|
|
645
|
+
)
|
|
646
|
+
invest_contrib = (
|
|
647
|
+
edd.join(invest.lazy().rename({"d": "d_invest", "value": "v_inv"}),
|
|
648
|
+
on=["e", "d_invest"], how="inner")
|
|
649
|
+
.join(unitsize_lf, on="e", how="inner")
|
|
650
|
+
.select(
|
|
651
|
+
"e", "d",
|
|
652
|
+
invested=pl.col("v_inv") * pl.col("unitsize"),
|
|
653
|
+
)
|
|
654
|
+
.group_by(["e", "d"]).agg(pl.col("invested").sum())
|
|
655
|
+
)
|
|
656
|
+
else:
|
|
657
|
+
invest_contrib = pl.DataFrame(
|
|
658
|
+
schema={"e": pl.Utf8, "d": pl.Utf8, "invested": pl.Float64},
|
|
659
|
+
).lazy()
|
|
660
|
+
|
|
661
|
+
# divest contribution per (e, d) — for each (e, d_dv) in ed_divest_set,
|
|
662
|
+
# apply v_divest[e, d_dv] * unitsize[e] to every period d with
|
|
663
|
+
# years[d_dv] <= years[d].
|
|
664
|
+
if (flex_data.p_period_share is not None
|
|
665
|
+
and flex_data.p_period_share.frame.height > 0):
|
|
666
|
+
# We don't have years_from_start_d directly, but the divest
|
|
667
|
+
# carry-forward is monotone over period order; we approximate
|
|
668
|
+
# by using period name ordering (lexicographic). For
|
|
669
|
+
# production fidelity we want years_from_start_d — get it from
|
|
670
|
+
# the timeline. Instead rely on edd_divest_active which
|
|
671
|
+
# already gives the active (d_divest, d) pairs.
|
|
672
|
+
pass
|
|
673
|
+
|
|
674
|
+
if (flex_data.edd_divest_active is not None
|
|
675
|
+
and flex_data.edd_divest_active.height > 0):
|
|
676
|
+
edd_dv = flex_data.edd_divest_active.lazy()
|
|
677
|
+
cols = flex_data.edd_divest_active.columns
|
|
678
|
+
# canonical: (e, d_divest, d). edd_divest_active uses ``p`` for
|
|
679
|
+
# the entity dim — rename to ``e``.
|
|
680
|
+
rename = {}
|
|
681
|
+
if cols[0] not in ("e", "entity"):
|
|
682
|
+
rename[cols[0]] = "e"
|
|
683
|
+
elif cols[0] == "entity":
|
|
684
|
+
rename["entity"] = "e"
|
|
685
|
+
if len(cols) > 1 and cols[1] != "d_divest":
|
|
686
|
+
rename[cols[1]] = "d_divest"
|
|
687
|
+
if len(cols) > 2 and cols[2] != "d":
|
|
688
|
+
rename[cols[2]] = "d"
|
|
689
|
+
if rename:
|
|
690
|
+
edd_dv = edd_dv.rename(rename)
|
|
691
|
+
edd_dv = edd_dv.with_columns(
|
|
692
|
+
pl.col("e").cast(pl.Utf8),
|
|
693
|
+
pl.col("d_divest").cast(pl.Utf8),
|
|
694
|
+
pl.col("d").cast(pl.Utf8),
|
|
695
|
+
)
|
|
696
|
+
divest_contrib = (
|
|
697
|
+
edd_dv.join(divest.lazy().rename({"d": "d_divest", "value": "v_dv"}),
|
|
698
|
+
on=["e", "d_divest"], how="inner")
|
|
699
|
+
.join(unitsize_lf, on="e", how="inner")
|
|
700
|
+
.select(
|
|
701
|
+
"e", "d",
|
|
702
|
+
divested=pl.col("v_dv") * pl.col("unitsize"),
|
|
703
|
+
)
|
|
704
|
+
.group_by(["e", "d"]).agg(pl.col("divested").sum())
|
|
705
|
+
)
|
|
706
|
+
elif (flex_data.ed_divest_set is not None
|
|
707
|
+
and flex_data.ed_divest_set.height > 0):
|
|
708
|
+
# Fallback: each (e, d_dv) divests carry-over from d_dv onward
|
|
709
|
+
# within the same period set. Without years map, take only
|
|
710
|
+
# the divestment in the same period.
|
|
711
|
+
edd_dv = flex_data.ed_divest_set.lazy()
|
|
712
|
+
cols = flex_data.ed_divest_set.columns
|
|
713
|
+
rename = {}
|
|
714
|
+
if cols[0] not in ("e", "entity"):
|
|
715
|
+
rename[cols[0]] = "e"
|
|
716
|
+
elif cols[0] == "entity":
|
|
717
|
+
rename["entity"] = "e"
|
|
718
|
+
if len(cols) > 1 and cols[1] != "d":
|
|
719
|
+
rename[cols[1]] = "d"
|
|
720
|
+
if rename:
|
|
721
|
+
edd_dv = edd_dv.rename(rename)
|
|
722
|
+
edd_dv = edd_dv.with_columns(
|
|
723
|
+
pl.col("e").cast(pl.Utf8),
|
|
724
|
+
pl.col("d").cast(pl.Utf8),
|
|
725
|
+
)
|
|
726
|
+
divest_contrib = (
|
|
727
|
+
edd_dv.join(divest.lazy().rename({"value": "v_dv"}),
|
|
728
|
+
on=["e", "d"], how="inner")
|
|
729
|
+
.join(unitsize_lf, on="e", how="inner")
|
|
730
|
+
.select(
|
|
731
|
+
"e", "d",
|
|
732
|
+
divested=pl.col("v_dv") * pl.col("unitsize"),
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
else:
|
|
736
|
+
divest_contrib = pl.DataFrame(
|
|
737
|
+
schema={"e": pl.Utf8, "d": pl.Utf8, "divested": pl.Float64},
|
|
738
|
+
).lazy()
|
|
739
|
+
|
|
740
|
+
# Combine: outer-join on (e, d) and sum. ``how="full"`` requires
|
|
741
|
+
# explicit coalesce on the join keys. Every input has already
|
|
742
|
+
# been normalised to Utf8 ``e`` / ``d`` above (so Enum-typed
|
|
743
|
+
# populated frames don't clash with the Utf8 empty fallbacks).
|
|
744
|
+
j1 = existing_lf.join(
|
|
745
|
+
invest_contrib, on=["e", "d"], how="full", coalesce=True,
|
|
746
|
+
)
|
|
747
|
+
j2 = j1.join(divest_contrib, on=["e", "d"], how="full", coalesce=True)
|
|
748
|
+
combined = (
|
|
749
|
+
j2.with_columns(
|
|
750
|
+
existing=pl.col("existing").fill_null(0.0),
|
|
751
|
+
invested=pl.col("invested").fill_null(0.0),
|
|
752
|
+
divested=pl.col("divested").fill_null(0.0),
|
|
753
|
+
)
|
|
754
|
+
.with_columns(
|
|
755
|
+
total=pl.col("existing") + pl.col("invested") - pl.col("divested"),
|
|
756
|
+
)
|
|
757
|
+
.filter(pl.col("e").is_not_null() & pl.col("d").is_not_null())
|
|
758
|
+
.select("e", "d", "total")
|
|
759
|
+
.collect()
|
|
760
|
+
)
|
|
761
|
+
if combined.height == 0:
|
|
762
|
+
idx = pd.MultiIndex.from_arrays([[], []], names=["solve", "period"])
|
|
763
|
+
out = pd.DataFrame(index=idx, dtype=float)
|
|
764
|
+
out.columns.name = "entity"
|
|
765
|
+
return out
|
|
766
|
+
pl_df = combined.with_columns(pl.lit(solve_name).alias("solve"))
|
|
767
|
+
return wide_per_entity(
|
|
768
|
+
pl_df.rename({"total": "value"}),
|
|
769
|
+
row_dims=("solve", "d"),
|
|
770
|
+
col_dim="e",
|
|
771
|
+
row_names=("solve", "period"),
|
|
772
|
+
col_name="entity",
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _ensure_value_zero(df: pd.DataFrame, columns) -> pd.DataFrame:
|
|
777
|
+
"""Ensure ``df`` has columns with zero values. Used to create the
|
|
778
|
+
``p_node`` / ``p_process_source`` / ``p_process_sink`` row-by-param
|
|
779
|
+
legacy frames for which we no longer have a single source FlexData
|
|
780
|
+
field; downstream consumers test ``loc['inertia_constant']`` etc.
|
|
781
|
+
"""
|
|
782
|
+
if df is None:
|
|
783
|
+
return None
|
|
784
|
+
for c in columns:
|
|
785
|
+
if c not in df.columns:
|
|
786
|
+
df[c] = 0.0
|
|
787
|
+
return df
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
# ---------------------------------------------------------------------------
|
|
791
|
+
# Composite legacy frames
|
|
792
|
+
# ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _build_p_node(flex_data: "FlexData") -> pd.DataFrame:
|
|
796
|
+
"""Construct the legacy ``p_node`` wide table.
|
|
797
|
+
|
|
798
|
+
Legacy CSV layout (``solve_data/p_node.csv``)::
|
|
799
|
+
|
|
800
|
+
param,west
|
|
801
|
+
annual_flow,0
|
|
802
|
+
peak_inflow,0
|
|
803
|
+
|
|
804
|
+
Rows are parameter names; columns are nodes. The only consumer
|
|
805
|
+
today is :func:`out_node.node_summary` indirectly via
|
|
806
|
+
``r.node_inflow_d`` / ``s.node_balance``; the table is otherwise
|
|
807
|
+
unread. We build it from the available FlexData fields and fall
|
|
808
|
+
back to zeros for missing parameters.
|
|
809
|
+
"""
|
|
810
|
+
nodes = []
|
|
811
|
+
if (flex_data.nodeBalance is not None
|
|
812
|
+
and flex_data.nodeBalance.height > 0):
|
|
813
|
+
nodes = flex_data.nodeBalance["n"].to_list()
|
|
814
|
+
if not nodes:
|
|
815
|
+
out = pd.DataFrame(index=pd.Index([], name="param"), dtype=float)
|
|
816
|
+
out.columns.name = "node"
|
|
817
|
+
return out
|
|
818
|
+
# Known scalar-per-node params we can fill from FlexData.
|
|
819
|
+
rows: dict[str, dict[str, float]] = {
|
|
820
|
+
"annual_flow": {n: 0.0 for n in nodes},
|
|
821
|
+
"peak_inflow": {n: 0.0 for n in nodes},
|
|
822
|
+
}
|
|
823
|
+
out = pd.DataFrame(rows).T.astype(float)
|
|
824
|
+
out.index.name = "param"
|
|
825
|
+
out.columns.name = "node"
|
|
826
|
+
out = out.reindex(columns=nodes)
|
|
827
|
+
return out
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _build_p_process_per_arc(
|
|
831
|
+
flex_data: "FlexData",
|
|
832
|
+
*,
|
|
833
|
+
side: str, # "source" or "sink"
|
|
834
|
+
) -> pd.DataFrame:
|
|
835
|
+
"""Build the legacy ``p_process_<side>`` wide table.
|
|
836
|
+
|
|
837
|
+
Legacy CSV layout (``solve_data/p_process_source.csv``)::
|
|
838
|
+
|
|
839
|
+
process,p1,p2,...
|
|
840
|
+
source,s1,s2,...
|
|
841
|
+
efficiency,...,...
|
|
842
|
+
...
|
|
843
|
+
inertia_constant,...,...
|
|
844
|
+
|
|
845
|
+
Rows are parameter names (``efficiency``, ``ramp_speed_up``,
|
|
846
|
+
``inertia_constant``, …); columns are a MultiIndex of ``(process,
|
|
847
|
+
<side>)``. The downstream consumer (``out_ancillary``) reads
|
|
848
|
+
``loc['inertia_constant']`` only — we populate that row from the
|
|
849
|
+
relevant FlexData field and zero-fill the rest.
|
|
850
|
+
"""
|
|
851
|
+
if side == "source":
|
|
852
|
+
pss = flex_data.process_source_sink
|
|
853
|
+
ramp_up = flex_data.p_ramp_speed_up_source
|
|
854
|
+
ramp_down = flex_data.p_ramp_speed_down_source
|
|
855
|
+
inertia = flex_data.p_process_source_inertia_constant
|
|
856
|
+
side_dim = "source"
|
|
857
|
+
elif side == "sink":
|
|
858
|
+
pss = flex_data.process_source_sink
|
|
859
|
+
ramp_up = flex_data.p_ramp_speed_up_sink
|
|
860
|
+
ramp_down = flex_data.p_ramp_speed_down_sink
|
|
861
|
+
inertia = flex_data.p_process_sink_inertia_constant
|
|
862
|
+
side_dim = "sink"
|
|
863
|
+
else:
|
|
864
|
+
raise ValueError(f"side must be 'source' or 'sink', got {side!r}")
|
|
865
|
+
|
|
866
|
+
# Build the canonical row-by-param row list — same order, same names
|
|
867
|
+
# as the legacy CSV so downstream ``loc['inertia_constant']`` etc.
|
|
868
|
+
# work whether columns are empty or not.
|
|
869
|
+
row_names = [
|
|
870
|
+
"efficiency",
|
|
871
|
+
"efficiency_at_min_load",
|
|
872
|
+
"min_load",
|
|
873
|
+
"coefficient",
|
|
874
|
+
"flow_unitsize",
|
|
875
|
+
"other_operational_cost",
|
|
876
|
+
"ramp_cost",
|
|
877
|
+
"ramp_speed_up",
|
|
878
|
+
"ramp_speed_down",
|
|
879
|
+
"inertia_constant",
|
|
880
|
+
]
|
|
881
|
+
|
|
882
|
+
if pss is None or pss.height == 0:
|
|
883
|
+
cols = pd.MultiIndex.from_arrays(
|
|
884
|
+
[[], []], names=["process", side_dim]
|
|
885
|
+
)
|
|
886
|
+
out = pd.DataFrame(
|
|
887
|
+
index=pd.Index(row_names, name="param"), columns=cols, dtype=float,
|
|
888
|
+
)
|
|
889
|
+
return out
|
|
890
|
+
|
|
891
|
+
# Distinct (process, <side>) pairs. ``process_source_sink`` carries
|
|
892
|
+
# ``(p, source, sink)``; we want unique (process, source) for source
|
|
893
|
+
# and unique (process, sink) for sink.
|
|
894
|
+
pdf = pss.select("p", side_dim).unique().to_pandas()
|
|
895
|
+
pairs = list(zip(pdf["p"], pdf[side_dim]))
|
|
896
|
+
cols = pd.MultiIndex.from_tuples(
|
|
897
|
+
pairs, names=["process", side_dim]
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
# Build rows for known parameters. All zeros except where FlexData
|
|
901
|
+
# gives an explicit value. Order matches the legacy CSV.
|
|
902
|
+
out = pd.DataFrame(0.0, index=row_names, columns=cols, dtype=float)
|
|
903
|
+
out.index.name = "param"
|
|
904
|
+
|
|
905
|
+
def _fill_row(row: str, param) -> None:
|
|
906
|
+
if param is None or param.frame.height == 0:
|
|
907
|
+
return
|
|
908
|
+
# param.frame columns: (p, <side>, value)
|
|
909
|
+
pf = param.frame.to_pandas()
|
|
910
|
+
# Resolve the entity dim name: source | sink
|
|
911
|
+
ent_col = side_dim if side_dim in pf.columns else (
|
|
912
|
+
"name" if "name" in pf.columns else side_dim
|
|
913
|
+
)
|
|
914
|
+
for _, r in pf.iterrows():
|
|
915
|
+
key = (r["p"], r[ent_col])
|
|
916
|
+
if key in cols:
|
|
917
|
+
out.loc[row, key] = float(r["value"])
|
|
918
|
+
|
|
919
|
+
_fill_row("ramp_speed_up", ramp_up)
|
|
920
|
+
_fill_row("ramp_speed_down", ramp_down)
|
|
921
|
+
_fill_row("inertia_constant", inertia)
|
|
922
|
+
return out
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
# ---------------------------------------------------------------------------
|
|
926
|
+
# Main entry point
|
|
927
|
+
# ---------------------------------------------------------------------------
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _build_entity_universe(flex_data: "FlexData") -> list[str]:
|
|
931
|
+
"""Build the densify entity universe for the (entity, period) wide
|
|
932
|
+
frames (``entity_fixed_cost`` / ``entity_lifetime_fixed_cost*``).
|
|
933
|
+
|
|
934
|
+
``calc_costs.py:cost_entity_fixed_invested`` indexes those frames by
|
|
935
|
+
``v.invest.columns`` / ``v.divest.columns`` — the cross-solve column
|
|
936
|
+
union — so every column they reach must be present. The returned
|
|
937
|
+
list is the order-preserving, de-duplicated union of:
|
|
938
|
+
|
|
939
|
+
* ``nodeBalance`` nodes,
|
|
940
|
+
* ``process_source_sink`` processes + their source / sink nodes,
|
|
941
|
+
* every invest / divest variable entity (``ed_invest_set`` /
|
|
942
|
+
``ed_divest_set``), and
|
|
943
|
+
* the solve-invariant ``p_all_entity_unitsize`` carrier (unit ∪ node
|
|
944
|
+
∪ connection over the WHOLE-model cascade — the same superset that
|
|
945
|
+
backs ``entity_unitsize`` after 896263be).
|
|
946
|
+
|
|
947
|
+
The carrier is the crucial last source: the per-solve sets above only
|
|
948
|
+
see the entities active in THIS sub-solve, but ``v.invest`` /
|
|
949
|
+
``v.divest`` carry the cross-solve union. An entity that is
|
|
950
|
+
invest-eligible in some other roll (e.g. ``CaboVerde_wind``) is
|
|
951
|
+
otherwise absent from every step's universe and so missing from the
|
|
952
|
+
concatenated ``entity_lifetime_fixed_cost`` columns, raising
|
|
953
|
+
``KeyError`` when ``calc_costs`` indexes by ``v.invest.columns``
|
|
954
|
+
(calc_costs.py:168). Densifying over the carrier's full entity list
|
|
955
|
+
zero-fills those cells, matching the legacy CSV path's full-universe
|
|
956
|
+
coverage.
|
|
957
|
+
"""
|
|
958
|
+
universe: list[str] = []
|
|
959
|
+
if (flex_data.nodeBalance is not None
|
|
960
|
+
and flex_data.nodeBalance.height > 0):
|
|
961
|
+
universe.extend(flex_data.nodeBalance["n"].to_list())
|
|
962
|
+
if (flex_data.process_source_sink is not None
|
|
963
|
+
and flex_data.process_source_sink.height > 0):
|
|
964
|
+
universe.extend(
|
|
965
|
+
flex_data.process_source_sink.select("p").unique()
|
|
966
|
+
.to_pandas()["p"].tolist()
|
|
967
|
+
)
|
|
968
|
+
# Also include any commodity nodes that show up in process_source_sink
|
|
969
|
+
# (sources / sinks) — these can be invest entities too.
|
|
970
|
+
if (flex_data.process_source_sink is not None
|
|
971
|
+
and flex_data.process_source_sink.height > 0):
|
|
972
|
+
for col in ("source", "sink"):
|
|
973
|
+
extra = flex_data.process_source_sink.select(col).unique().to_pandas()[col].tolist()
|
|
974
|
+
universe.extend(extra)
|
|
975
|
+
# Every invest / divest variable entity. ``ed_invest_set`` /
|
|
976
|
+
# ``ed_divest_set`` carry the (e, d) variable index that becomes the
|
|
977
|
+
# ``v_invest`` / ``v_divest`` column set (read_sets.py:267-280 builds
|
|
978
|
+
# ``s.entityInvest`` / ``s.entityDivest`` from the same frames). An
|
|
979
|
+
# entity can be invest-eligible (``invest_method`` set) without
|
|
980
|
+
# appearing in ``process_source_sink`` — e.g. a connection that has
|
|
981
|
+
# invest parameters but no ``connection__node__node`` endpoints, so it
|
|
982
|
+
# carries no source/sink rows. It still produces a (degenerate,
|
|
983
|
+
# always-zero) ``v_invest`` column.
|
|
984
|
+
for invest_set in (flex_data.ed_invest_set, flex_data.ed_divest_set):
|
|
985
|
+
if invest_set is not None and invest_set.height > 0:
|
|
986
|
+
universe.extend(
|
|
987
|
+
invest_set.select("e").unique().to_pandas()["e"].tolist()
|
|
988
|
+
)
|
|
989
|
+
# Solve-invariant whole-model carrier (covers cross-solve invest
|
|
990
|
+
# candidates absent from this sub-solve's sets — see docstring).
|
|
991
|
+
carrier = getattr(flex_data, "p_all_entity_unitsize", None)
|
|
992
|
+
if (carrier is not None
|
|
993
|
+
and getattr(carrier, "frame", None) is not None
|
|
994
|
+
and carrier.frame.height > 0):
|
|
995
|
+
universe.extend(
|
|
996
|
+
carrier.frame.select("e").unique().to_pandas()["e"].tolist()
|
|
997
|
+
)
|
|
998
|
+
# Deduplicate while keeping insertion order.
|
|
999
|
+
return list(dict.fromkeys(universe))
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def read_parameters(
|
|
1003
|
+
flex_data: "FlexData",
|
|
1004
|
+
solution: "Solution",
|
|
1005
|
+
*,
|
|
1006
|
+
solve_name: str = "solve",
|
|
1007
|
+
) -> SimpleNamespace:
|
|
1008
|
+
"""Translate ``FlexData`` + a polars-LP :class:`polar_high.Solution`
|
|
1009
|
+
into the legacy ``par`` namespace consumed by the
|
|
1010
|
+
:mod:`flextool.process_outputs.out_*` modules.
|
|
1011
|
+
|
|
1012
|
+
Parameters
|
|
1013
|
+
----------
|
|
1014
|
+
flex_data : FlexData
|
|
1015
|
+
The polars input bundle the LP was built from.
|
|
1016
|
+
solution : polar_high.Solution
|
|
1017
|
+
The solved LP — used for the post-solve derived attributes
|
|
1018
|
+
(``entity_all_capacity`` and the three sister capacity tables).
|
|
1019
|
+
solve_name : str, optional
|
|
1020
|
+
The active solve identifier. Used to inject the leading
|
|
1021
|
+
``solve`` index level the downstream wide-format consumers
|
|
1022
|
+
expect. Defaults to ``"solve"`` for tests; production callers
|
|
1023
|
+
in :mod:`flextool.engine_polars._orchestration` pass the real
|
|
1024
|
+
``complete_solve_name``.
|
|
1025
|
+
|
|
1026
|
+
Returns
|
|
1027
|
+
-------
|
|
1028
|
+
SimpleNamespace
|
|
1029
|
+
With every attribute populated to match the legacy CSV-path
|
|
1030
|
+
signature. Failure to populate any required attribute raises
|
|
1031
|
+
:class:`KeyError` / :class:`ValueError` loudly.
|
|
1032
|
+
"""
|
|
1033
|
+
p = SimpleNamespace()
|
|
1034
|
+
|
|
1035
|
+
# Entity universe — used to densify the (entity, period) wide frames
|
|
1036
|
+
# so downstream consumers like
|
|
1037
|
+
# ``calc_costs.py:cost_entity_fixed_invested`` find every invest
|
|
1038
|
+
# entity in ``par.entity_lifetime_fixed_cost.columns`` (the legacy
|
|
1039
|
+
# CSV path emitted zero-filled rows for every entity).
|
|
1040
|
+
_entity_universe = _build_entity_universe(flex_data)
|
|
1041
|
+
|
|
1042
|
+
# ─── Write-once static params (entity-level scalars / per-arc tables) ──
|
|
1043
|
+
p.node = _build_p_node(flex_data)
|
|
1044
|
+
p.entity_unitsize = _build_entity_unitsize_series(
|
|
1045
|
+
flex_data, entity_universe=_entity_universe,
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
# commodity_co2_content — Series indexed by commodity (legacy used
|
|
1049
|
+
# to read this from input/p_commodity_co2_content.csv; in FlexData
|
|
1050
|
+
# it's ``p_co2_content`` keyed on ``c``).
|
|
1051
|
+
if (flex_data.p_co2_content is not None
|
|
1052
|
+
and flex_data.p_co2_content.frame.height > 0):
|
|
1053
|
+
p.commodity_co2_content = series_with_index(
|
|
1054
|
+
flex_data.p_co2_content.frame, dim="c", name="commodity",
|
|
1055
|
+
)
|
|
1056
|
+
else:
|
|
1057
|
+
p.commodity_co2_content = pd.Series(
|
|
1058
|
+
dtype=float,
|
|
1059
|
+
index=pd.Index([], name="commodity"),
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
# process_sink_conversion_flow_coeff /
|
|
1063
|
+
# process_source_conversion_flow_coeff — Series with MultiIndex
|
|
1064
|
+
# (process, sink|source). FlexData carries these as
|
|
1065
|
+
# ``p_process_sink_conversion_flow_coeff`` /
|
|
1066
|
+
# ``p_process_source_conversion_flow_coeff`` (dims ``(p, sink)`` /
|
|
1067
|
+
# ``(p, source)``).
|
|
1068
|
+
if (flex_data.p_process_sink_conversion_flow_coeff is not None
|
|
1069
|
+
and flex_data.p_process_sink_conversion_flow_coeff.frame.height > 0):
|
|
1070
|
+
p.process_sink_conversion_flow_coeff = series_with_multi_index(
|
|
1071
|
+
flex_data.p_process_sink_conversion_flow_coeff.frame,
|
|
1072
|
+
dims=("p", "sink"),
|
|
1073
|
+
names=["process", "sink"],
|
|
1074
|
+
)
|
|
1075
|
+
else:
|
|
1076
|
+
p.process_sink_conversion_flow_coeff = pd.Series(
|
|
1077
|
+
dtype=float,
|
|
1078
|
+
index=pd.MultiIndex.from_arrays(
|
|
1079
|
+
[[], []], names=["process", "sink"]
|
|
1080
|
+
),
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
if (flex_data.p_process_source_conversion_flow_coeff is not None
|
|
1084
|
+
and flex_data.p_process_source_conversion_flow_coeff.frame.height > 0):
|
|
1085
|
+
p.process_source_conversion_flow_coeff = series_with_multi_index(
|
|
1086
|
+
flex_data.p_process_source_conversion_flow_coeff.frame,
|
|
1087
|
+
dims=("p", "source"),
|
|
1088
|
+
names=["process", "source"],
|
|
1089
|
+
)
|
|
1090
|
+
else:
|
|
1091
|
+
p.process_source_conversion_flow_coeff = pd.Series(
|
|
1092
|
+
dtype=float,
|
|
1093
|
+
index=pd.MultiIndex.from_arrays(
|
|
1094
|
+
[[], []], names=["process", "source"]
|
|
1095
|
+
),
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
# reserve_upDown_group_penalty — Series with MultiIndex (reserve,
|
|
1099
|
+
# upDown, node_group).
|
|
1100
|
+
if (flex_data.p_reserve_upDown_group_penalty_reserve is not None
|
|
1101
|
+
and flex_data.p_reserve_upDown_group_penalty_reserve.frame.height > 0):
|
|
1102
|
+
p.reserve_upDown_group_penalty = series_with_multi_index(
|
|
1103
|
+
flex_data.p_reserve_upDown_group_penalty_reserve.frame,
|
|
1104
|
+
dims=("r", "ud", "g"),
|
|
1105
|
+
names=["reserve", "upDown", "node_group"],
|
|
1106
|
+
)
|
|
1107
|
+
else:
|
|
1108
|
+
p.reserve_upDown_group_penalty = pd.Series(
|
|
1109
|
+
dtype=float,
|
|
1110
|
+
index=pd.MultiIndex.from_arrays(
|
|
1111
|
+
[[], [], []], names=["reserve", "upDown", "node_group"]
|
|
1112
|
+
),
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
# ─── Per-(solve, period, time) parameters ───────────────────────────────
|
|
1116
|
+
p.step_duration = _pdt_series_solve_period_time(
|
|
1117
|
+
flex_data.p_step_duration, solve_name=solve_name,
|
|
1118
|
+
)
|
|
1119
|
+
p.timestep_weight = _pdt_series_solve_period_time(
|
|
1120
|
+
flex_data.p_timestep_weight, solve_name=solve_name,
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
# flow_min / flow_max — multi-column DataFrames. FlexData has
|
|
1124
|
+
# ``p_flow_upper`` ((p, source, sink, d, t)). The legacy CSV's
|
|
1125
|
+
# ``flow_min`` is structurally distinct; populate as empty
|
|
1126
|
+
# (consumers tolerate empty), and fill flow_max from p_flow_upper.
|
|
1127
|
+
p.flow_min = _empty_pdtX_multi_col(
|
|
1128
|
+
col_names=("process", "source", "sink"),
|
|
1129
|
+
)
|
|
1130
|
+
p.flow_max = _pdtX_multi_col(
|
|
1131
|
+
flex_data.p_flow_upper,
|
|
1132
|
+
solve_name=solve_name,
|
|
1133
|
+
col_dims=("p", "source", "sink"),
|
|
1134
|
+
col_names=("process", "source", "sink"),
|
|
1135
|
+
flex_data=flex_data,
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
# process_source / process_sink — wide rows-by-param frames.
|
|
1139
|
+
p.process_source = _build_p_process_per_arc(flex_data, side="source")
|
|
1140
|
+
p.process_sink = _build_p_process_per_arc(flex_data, side="sink")
|
|
1141
|
+
|
|
1142
|
+
# process_slope / process_section / process_availability —
|
|
1143
|
+
# (solve, period, time) × process.
|
|
1144
|
+
p.process_slope = _pdtX_per_entity(
|
|
1145
|
+
flex_data.p_slope, solve_name=solve_name,
|
|
1146
|
+
entity_dim="p", col_name="process", flex_data=flex_data,
|
|
1147
|
+
)
|
|
1148
|
+
p.process_section = _pdtX_per_entity(
|
|
1149
|
+
flex_data.p_section, solve_name=solve_name,
|
|
1150
|
+
entity_dim="p", col_name="process", flex_data=flex_data,
|
|
1151
|
+
)
|
|
1152
|
+
p.process_availability = _pdtX_per_entity(
|
|
1153
|
+
flex_data.p_process_availability, solve_name=solve_name,
|
|
1154
|
+
entity_dim="p", col_name="process", flex_data=flex_data,
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
# process_source_sink_varCost — (solve, period, time) × (process, source, sink).
|
|
1158
|
+
#
|
|
1159
|
+
# The post-processing in calc_costs.py multiplies this against
|
|
1160
|
+
# ``r.flow_dt`` whose columns are keyed by
|
|
1161
|
+
# ``process_source_sink_alwaysProcess`` tuples (each direct-method
|
|
1162
|
+
# unit contributes one ``(p, source, p)`` source-side column and one
|
|
1163
|
+
# ``(p, p, sink)`` sink-side column). Therefore the varCost frame
|
|
1164
|
+
# must use the same ``_alwaysProcess`` keying — NOT the LP's
|
|
1165
|
+
# pss-keyed ``p_pssdt_varCost`` Param (which the .mod uses only for
|
|
1166
|
+
# the ``pssdt_varCost_noEff`` objective term). For
|
|
1167
|
+
# ``min_load_efficiency`` (``eff_unit_source``) units the source-side
|
|
1168
|
+
# cost lives on the ``(p, source, p)`` tuple alongside the
|
|
1169
|
+
# slope+section fuel flow in ``r.flow_dt``; without the rekey, the
|
|
1170
|
+
# column intersection is empty and the bucket is silently zero
|
|
1171
|
+
# (regression guard:
|
|
1172
|
+
# ``tests/test_cost_aggregation_semantics.TestMinLoadEfficiencySectionTerm``).
|
|
1173
|
+
p.process_source_sink_varCost = _build_pssdt_varCost_alwaysProcess(
|
|
1174
|
+
flex_data, solve_name=solve_name,
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
# node_self_discharge_loss / node_penalty_up / node_penalty_down /
|
|
1178
|
+
# node_inflow / commodity_price / group_co2_price / profile.
|
|
1179
|
+
if (flex_data.p_state_self_discharge is not None
|
|
1180
|
+
and flex_data.p_state_self_discharge.frame.height > 0):
|
|
1181
|
+
# p_state_self_discharge has dims (n,). Broadcast to (solve, d, t)
|
|
1182
|
+
# via dt — matching the legacy ``pdtNode_self_discharge_loss.csv``
|
|
1183
|
+
# which had per-(solve, period, time) rows × node columns even
|
|
1184
|
+
# though the value is constant per node.
|
|
1185
|
+
n_frame = flex_data.p_state_self_discharge.frame
|
|
1186
|
+
dt_frame = flex_data.dt
|
|
1187
|
+
broadcast = (
|
|
1188
|
+
n_frame.lazy()
|
|
1189
|
+
.join(dt_frame.lazy(), how="cross")
|
|
1190
|
+
.with_columns(pl.lit(solve_name).alias("solve"))
|
|
1191
|
+
.select("solve", "d", "t", pl.col("n"), pl.col("value"))
|
|
1192
|
+
.collect()
|
|
1193
|
+
)
|
|
1194
|
+
p.node_self_discharge_loss = wide_per_entity(
|
|
1195
|
+
broadcast, row_dims=("solve", "d", "t"), col_dim="n",
|
|
1196
|
+
row_names=("solve", "period", "time"), col_name="node",
|
|
1197
|
+
)
|
|
1198
|
+
else:
|
|
1199
|
+
p.node_self_discharge_loss = _empty_pdtX_per_entity(
|
|
1200
|
+
flex_data=flex_data, solve_name=solve_name, col_name="node",
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
p.node_penalty_up = _pdtX_per_entity(
|
|
1204
|
+
flex_data.p_penalty_up, solve_name=solve_name,
|
|
1205
|
+
entity_dim="n", col_name="node", flex_data=flex_data,
|
|
1206
|
+
)
|
|
1207
|
+
p.node_penalty_down = _pdtX_per_entity(
|
|
1208
|
+
flex_data.p_penalty_down, solve_name=solve_name,
|
|
1209
|
+
entity_dim="n", col_name="node", flex_data=flex_data,
|
|
1210
|
+
)
|
|
1211
|
+
p.node_inflow = _pdtX_per_entity(
|
|
1212
|
+
flex_data.p_inflow, solve_name=solve_name,
|
|
1213
|
+
entity_dim="n", col_name="node", flex_data=flex_data,
|
|
1214
|
+
)
|
|
1215
|
+
p.commodity_price = _pdtX_per_entity(
|
|
1216
|
+
flex_data.p_commodity_price, solve_name=solve_name,
|
|
1217
|
+
entity_dim="c", col_name="commodity", flex_data=flex_data,
|
|
1218
|
+
)
|
|
1219
|
+
p.group_co2_price = _pdtX_per_entity(
|
|
1220
|
+
flex_data.p_co2_price, solve_name=solve_name,
|
|
1221
|
+
entity_dim="g", col_name="group", flex_data=flex_data,
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
# reserve_upDown_group_reservation — multi-column (r, ud, g).
|
|
1225
|
+
p.reserve_upDown_group_reservation = _pdtX_multi_col(
|
|
1226
|
+
flex_data.pdtReserve_upDown_group_reservation, solve_name=solve_name,
|
|
1227
|
+
col_dims=("r", "ud", "g"),
|
|
1228
|
+
col_names=("reserve", "upDown", "node_group"),
|
|
1229
|
+
densify_col_tuples=getattr(flex_data, "reserve_upDown_group", None),
|
|
1230
|
+
flex_data=flex_data,
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
# profile — (solve, period, time) × profile.
|
|
1234
|
+
p.profile = _pdtX_per_entity(
|
|
1235
|
+
flex_data.p_profile_value, solve_name=solve_name,
|
|
1236
|
+
entity_dim="f", col_name="profile", flex_data=flex_data,
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
# ─── Per-(solve, period) scalar params ──────────────────────────────────
|
|
1240
|
+
# years_from_start_d / years_represented_d — Series. FlexData
|
|
1241
|
+
# carries ``p_years_represented_d`` (Param, (d,) → R width sum;
|
|
1242
|
+
# populated by ``_derived_params.apply_derived_a`` from
|
|
1243
|
+
# ``solve.years_represented``). ``years_from_start_d`` is still
|
|
1244
|
+
# defaulted to 0.0 (used only for divest carry-forward; the active
|
|
1245
|
+
# divest cascade uses ``edd_divest_active`` directly).
|
|
1246
|
+
if (flex_data.p_period_share is not None
|
|
1247
|
+
and flex_data.p_period_share.frame.height > 0):
|
|
1248
|
+
# p_period_share is (d,) → use d set as the period axis.
|
|
1249
|
+
periods = flex_data.p_period_share.frame.select("d").to_pandas()["d"].tolist()
|
|
1250
|
+
else:
|
|
1251
|
+
periods = []
|
|
1252
|
+
if periods:
|
|
1253
|
+
idx = pd.MultiIndex.from_arrays(
|
|
1254
|
+
[[solve_name] * len(periods), periods], names=["solve", "period"],
|
|
1255
|
+
)
|
|
1256
|
+
p.years_from_start_d = pd.Series([0.0] * len(periods), index=idx, name="value")
|
|
1257
|
+
# Per-period R width sum from FlexData when populated; fall back
|
|
1258
|
+
# to 1.0 per period when the source carries no
|
|
1259
|
+
# ``solve.years_represented`` rows (single-year fixtures).
|
|
1260
|
+
yr_widths = [1.0] * len(periods)
|
|
1261
|
+
yr_param = flex_data.p_years_represented_d
|
|
1262
|
+
if yr_param is not None and yr_param.frame.height > 0:
|
|
1263
|
+
yr_map = {
|
|
1264
|
+
str(d): float(v)
|
|
1265
|
+
for d, v in zip(
|
|
1266
|
+
yr_param.frame["d"].to_list(),
|
|
1267
|
+
yr_param.frame["value"].to_list(),
|
|
1268
|
+
)
|
|
1269
|
+
}
|
|
1270
|
+
yr_widths = [yr_map.get(str(d), 1.0) for d in periods]
|
|
1271
|
+
p.years_represented_d = pd.Series(yr_widths, index=idx, name="value")
|
|
1272
|
+
else:
|
|
1273
|
+
empty_idx = pd.MultiIndex.from_arrays([[], []], names=["solve", "period"])
|
|
1274
|
+
p.years_from_start_d = pd.Series(dtype=float, index=empty_idx, name="value")
|
|
1275
|
+
p.years_represented_d = pd.Series(dtype=float, index=empty_idx, name="value")
|
|
1276
|
+
|
|
1277
|
+
# entity_max_units / entity_all_existing / entity_pre_existing —
|
|
1278
|
+
# (solve, period) × entity. Densify across the entity universe
|
|
1279
|
+
# so downstream lookups by ``v.invest.columns`` find every entity.
|
|
1280
|
+
p.entity_max_units = _pdX_per_entity(
|
|
1281
|
+
flex_data.p_entity_max_units, solve_name=solve_name,
|
|
1282
|
+
entity_dim="e", col_name="entity", flex_data=flex_data,
|
|
1283
|
+
densify_entities=_entity_universe,
|
|
1284
|
+
)
|
|
1285
|
+
p.entity_all_existing = _pdX_per_entity(
|
|
1286
|
+
flex_data.p_entity_all_existing, solve_name=solve_name,
|
|
1287
|
+
entity_dim="e", col_name="entity", flex_data=flex_data,
|
|
1288
|
+
densify_entities=_entity_universe,
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
# entity_pre_existing — distinct from entity_all_existing on
|
|
1292
|
+
# multi-solve runs (``pre_existing`` is the same as ``all_existing``
|
|
1293
|
+
# on first solve; differs only when prior solves invested). We
|
|
1294
|
+
# use ``p_entity_previously_invested_capacity`` if available, else
|
|
1295
|
+
# fall back to ``entity_all_existing`` minus the previously
|
|
1296
|
+
# invested. Single-solve: identical to entity_all_existing.
|
|
1297
|
+
if (flex_data.p_entity_previously_invested_capacity is not None
|
|
1298
|
+
and flex_data.p_entity_previously_invested_capacity.frame.height > 0):
|
|
1299
|
+
# Subtract from p_entity_all_existing if both populated.
|
|
1300
|
+
all_lf = flex_data.p_entity_all_existing.frame.lazy() if (
|
|
1301
|
+
flex_data.p_entity_all_existing is not None
|
|
1302
|
+
and flex_data.p_entity_all_existing.frame.height > 0
|
|
1303
|
+
) else None
|
|
1304
|
+
prev_lf = flex_data.p_entity_previously_invested_capacity.frame.lazy()
|
|
1305
|
+
if all_lf is not None:
|
|
1306
|
+
pre_existing = (
|
|
1307
|
+
all_lf.join(
|
|
1308
|
+
prev_lf.rename({"value": "prev"}), on=["e", "d"], how="left",
|
|
1309
|
+
)
|
|
1310
|
+
.with_columns(
|
|
1311
|
+
value=pl.col("value") - pl.col("prev").fill_null(0.0),
|
|
1312
|
+
)
|
|
1313
|
+
.select("e", "d", "value")
|
|
1314
|
+
.collect()
|
|
1315
|
+
)
|
|
1316
|
+
else:
|
|
1317
|
+
pre_existing = prev_lf.select("e", "d", "value").collect()
|
|
1318
|
+
from polar_high import Param # local import — Param is heavy
|
|
1319
|
+
p.entity_pre_existing = _pdX_per_entity(
|
|
1320
|
+
Param(("e", "d"), pre_existing), solve_name=solve_name,
|
|
1321
|
+
entity_dim="e", col_name="entity", flex_data=flex_data,
|
|
1322
|
+
densify_entities=_entity_universe,
|
|
1323
|
+
)
|
|
1324
|
+
else:
|
|
1325
|
+
p.entity_pre_existing = _pdX_per_entity(
|
|
1326
|
+
flex_data.p_entity_all_existing, solve_name=solve_name,
|
|
1327
|
+
entity_dim="e", col_name="entity", flex_data=flex_data,
|
|
1328
|
+
densify_entities=_entity_universe,
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
# entity_all_capacity — post-solve derived.
|
|
1332
|
+
p.entity_all_capacity = _entity_all_capacity(
|
|
1333
|
+
flex_data, solution, solve_name=solve_name,
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
# process_startup_cost — (solve, period) × process.
|
|
1337
|
+
p.process_startup_cost = _pdX_per_entity(
|
|
1338
|
+
flex_data.p_startup_cost, solve_name=solve_name,
|
|
1339
|
+
entity_dim="p", col_name="process", flex_data=flex_data,
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
# entity_fixed_cost / entity_lifetime_fixed_cost / entity_lifetime_fixed_cost_divest.
|
|
1343
|
+
# Densify across the full entity universe — calc_costs.py:154-155
|
|
1344
|
+
# indexes these by ``v.invest.columns`` / ``v.divest.columns``,
|
|
1345
|
+
# which can include any entity.
|
|
1346
|
+
p.entity_fixed_cost = _pdX_per_entity(
|
|
1347
|
+
flex_data.p_ed_fixed_cost, solve_name=solve_name,
|
|
1348
|
+
entity_dim="e", col_name="entity", flex_data=flex_data,
|
|
1349
|
+
densify_entities=_entity_universe,
|
|
1350
|
+
)
|
|
1351
|
+
p.entity_lifetime_fixed_cost = _pdX_per_entity(
|
|
1352
|
+
flex_data.ed_lifetime_fixed_cost, solve_name=solve_name,
|
|
1353
|
+
entity_dim="e", col_name="entity", flex_data=flex_data,
|
|
1354
|
+
densify_entities=_entity_universe,
|
|
1355
|
+
)
|
|
1356
|
+
p.entity_lifetime_fixed_cost_divest = _pdX_per_entity(
|
|
1357
|
+
flex_data.ed_lifetime_fixed_cost_divest, solve_name=solve_name,
|
|
1358
|
+
entity_dim="e", col_name="entity", flex_data=flex_data,
|
|
1359
|
+
densify_entities=_entity_universe,
|
|
1360
|
+
)
|
|
1361
|
+
|
|
1362
|
+
# node_annual_flow — Series((solve, period), node).
|
|
1363
|
+
p.node_annual_flow = _empty_solve_period_per_entity(
|
|
1364
|
+
col_name="node",
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
# group_penalty_inertia / group_penalty_non_synchronous /
|
|
1368
|
+
# group_penalty_capacity_margin / group_inertia_limit / group_capacity_margin.
|
|
1369
|
+
#
|
|
1370
|
+
# SCEN-6: ``pdGroup_penalty_capacity_margin`` is derived via
|
|
1371
|
+
# ``_entity_period_scalar`` which drops null rows. When a group has
|
|
1372
|
+
# ``capacity_margin > 0`` but ``penalty_capacity_margin`` left blank
|
|
1373
|
+
# (a common template pattern — e.g. xlsx ``Commodity_nodes``), the
|
|
1374
|
+
# group lands in ``groupCapacityMargin`` (built from non-empty
|
|
1375
|
+
# ``capacity_margin`` rows) but is absent from the penalty frame.
|
|
1376
|
+
# ``calc_slacks.py`` indexes ``par.group_penalty_capacity_margin
|
|
1377
|
+
# [s.groupCapacityMargin]`` and raises ``KeyError`` on the gap.
|
|
1378
|
+
# Densify here with the same zero-fill convention used elsewhere for
|
|
1379
|
+
# parity emitters: missing penalty ⇒ 0 (no slack-cost contribution,
|
|
1380
|
+
# matching the LP: the slack term in
|
|
1381
|
+
# ``_group_slack.compute_slack_costs`` is itself gated on a
|
|
1382
|
+
# non-null ``pdGroup_penalty_capacity_margin``).
|
|
1383
|
+
if (flex_data.groupCapacityMargin is not None
|
|
1384
|
+
and flex_data.groupCapacityMargin.height > 0):
|
|
1385
|
+
_group_cap_margin_universe = (
|
|
1386
|
+
flex_data.groupCapacityMargin.select("g").unique()
|
|
1387
|
+
.to_pandas()["g"].tolist()
|
|
1388
|
+
)
|
|
1389
|
+
else:
|
|
1390
|
+
_group_cap_margin_universe = []
|
|
1391
|
+
p.group_penalty_inertia = _pdX_per_entity(
|
|
1392
|
+
flex_data.pdGroup_penalty_inertia, solve_name=solve_name,
|
|
1393
|
+
entity_dim="g", col_name="group", flex_data=flex_data,
|
|
1394
|
+
)
|
|
1395
|
+
p.group_penalty_non_synchronous = _pdX_per_entity(
|
|
1396
|
+
flex_data.pdGroup_penalty_non_synchronous, solve_name=solve_name,
|
|
1397
|
+
entity_dim="g", col_name="group", flex_data=flex_data,
|
|
1398
|
+
)
|
|
1399
|
+
p.group_penalty_capacity_margin = _pdX_per_entity(
|
|
1400
|
+
flex_data.pdGroup_penalty_capacity_margin, solve_name=solve_name,
|
|
1401
|
+
entity_dim="g", col_name="group", flex_data=flex_data,
|
|
1402
|
+
densify_entities=_group_cap_margin_universe,
|
|
1403
|
+
)
|
|
1404
|
+
p.group_inertia_limit = _pdX_per_entity(
|
|
1405
|
+
flex_data.pdGroup_inertia_limit, solve_name=solve_name,
|
|
1406
|
+
entity_dim="g", col_name="group", flex_data=flex_data,
|
|
1407
|
+
)
|
|
1408
|
+
p.group_capacity_margin = _pdX_per_entity(
|
|
1409
|
+
flex_data.pdGroup_capacity_margin, solve_name=solve_name,
|
|
1410
|
+
entity_dim="g", col_name="group", flex_data=flex_data,
|
|
1411
|
+
)
|
|
1412
|
+
|
|
1413
|
+
# entity_annuity / entity_annual_discounted / entity_annual_divest_discounted.
|
|
1414
|
+
# entity_annuity is undiscounted; we use discounted as a stand-in
|
|
1415
|
+
# (only consumed in debug mode; the legacy distinction is preserved
|
|
1416
|
+
# for compatibility).
|
|
1417
|
+
p.entity_annuity = _pdX_per_entity(
|
|
1418
|
+
flex_data.ed_entity_annual_discounted, solve_name=solve_name,
|
|
1419
|
+
entity_dim="e", col_name="entity", flex_data=flex_data,
|
|
1420
|
+
densify_entities=_entity_universe,
|
|
1421
|
+
)
|
|
1422
|
+
p.entity_annual_discounted = _pdX_per_entity(
|
|
1423
|
+
flex_data.ed_entity_annual_discounted, solve_name=solve_name,
|
|
1424
|
+
entity_dim="e", col_name="entity", flex_data=flex_data,
|
|
1425
|
+
densify_entities=_entity_universe,
|
|
1426
|
+
)
|
|
1427
|
+
p.entity_annual_divest_discounted = _pdX_per_entity(
|
|
1428
|
+
flex_data.ed_entity_annual_divest_discounted, solve_name=solve_name,
|
|
1429
|
+
entity_dim="e", col_name="entity", flex_data=flex_data,
|
|
1430
|
+
densify_entities=_entity_universe,
|
|
1431
|
+
)
|
|
1432
|
+
|
|
1433
|
+
# inflation factors — Series((solve, period)).
|
|
1434
|
+
p.inflation_factor_operations_yearly = _pd_series_solve_period(
|
|
1435
|
+
flex_data.p_inflation_op, solve_name=solve_name,
|
|
1436
|
+
)
|
|
1437
|
+
# No FlexData equivalent for "investment yearly"; reuse operations
|
|
1438
|
+
# (the rate differs only when discount_rate ≠ 0; for fixtures
|
|
1439
|
+
# without explicit rates they're identical).
|
|
1440
|
+
p.inflation_factor_investment_yearly = _pd_series_solve_period(
|
|
1441
|
+
flex_data.p_inflation_op, solve_name=solve_name,
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
# node_capacity_for_scaling / group_capacity_for_scaling.
|
|
1445
|
+
# Densify nodes for downstream slack-scaling lookups in
|
|
1446
|
+
# ``out_node.py`` / ``calc_slacks.py``.
|
|
1447
|
+
# The slack vars span ``nodeBalance ∪ nodeBalancePeriod`` (period nodes
|
|
1448
|
+
# also carry ``vq_state_up/down``), so the densify universe must cover
|
|
1449
|
+
# both — otherwise period-node ``q_state`` columns are missing from the
|
|
1450
|
+
# output frame. De-duplicate while preserving order; when there are no
|
|
1451
|
+
# period nodes the universe is exactly ``nodeBalance`` (byte-identical).
|
|
1452
|
+
nodes_universe = []
|
|
1453
|
+
if (flex_data.nodeBalance is not None
|
|
1454
|
+
and flex_data.nodeBalance.height > 0):
|
|
1455
|
+
nodes_universe = flex_data.nodeBalance["n"].to_list()
|
|
1456
|
+
if (flex_data.nodeBalancePeriod is not None
|
|
1457
|
+
and flex_data.nodeBalancePeriod.height > 0):
|
|
1458
|
+
seen = set(nodes_universe)
|
|
1459
|
+
for n in flex_data.nodeBalancePeriod["n"].to_list():
|
|
1460
|
+
if n not in seen:
|
|
1461
|
+
nodes_universe.append(n)
|
|
1462
|
+
seen.add(n)
|
|
1463
|
+
p.node_capacity_for_scaling = _pdX_per_entity(
|
|
1464
|
+
flex_data.p_node_capacity_for_scaling, solve_name=solve_name,
|
|
1465
|
+
entity_dim="n", col_name="node", flex_data=flex_data,
|
|
1466
|
+
densify_entities=nodes_universe,
|
|
1467
|
+
)
|
|
1468
|
+
p.group_capacity_for_scaling = _pdX_per_entity(
|
|
1469
|
+
flex_data.p_group_capacity_for_scaling, solve_name=solve_name,
|
|
1470
|
+
entity_dim="g", col_name="group", flex_data=flex_data,
|
|
1471
|
+
)
|
|
1472
|
+
|
|
1473
|
+
# complete_period_share_of_year — Series((solve, period)).
|
|
1474
|
+
p.complete_period_share_of_year = _pd_series_solve_period(
|
|
1475
|
+
flex_data.p_period_share, solve_name=solve_name,
|
|
1476
|
+
)
|
|
1477
|
+
|
|
1478
|
+
# nested_model — DataFrame indexed by ``param`` with ``value`` column.
|
|
1479
|
+
# Reflects ``solveFirst`` / ``solveLast`` / ``contains_solve``.
|
|
1480
|
+
rows = {"solveFirst": 1.0 if (flex_data.p_nested_solve_first is None
|
|
1481
|
+
or bool(flex_data.p_nested_solve_first)) else 0.0}
|
|
1482
|
+
p.nested_model = pd.DataFrame(
|
|
1483
|
+
{"value": list(rows.values())},
|
|
1484
|
+
index=pd.Index(list(rows.keys()), name="param"),
|
|
1485
|
+
)
|
|
1486
|
+
|
|
1487
|
+
return p
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _empty_solve_period_per_entity(*, col_name: str) -> pd.DataFrame:
|
|
1491
|
+
"""Empty ``(solve, period)``-indexed DataFrame with named empty columns."""
|
|
1492
|
+
idx = pd.MultiIndex.from_arrays([[], []], names=["solve", "period"])
|
|
1493
|
+
out = pd.DataFrame(index=idx, dtype=float)
|
|
1494
|
+
out.columns = pd.Index([], dtype="object", name=col_name)
|
|
1495
|
+
return out
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _build_entity_unitsize_series(
|
|
1499
|
+
flex_data: "FlexData",
|
|
1500
|
+
*,
|
|
1501
|
+
entity_universe: "list[str] | None" = None,
|
|
1502
|
+
) -> pd.Series:
|
|
1503
|
+
"""Per-entity unitsize Series indexed by entity name.
|
|
1504
|
+
|
|
1505
|
+
Preferred source — the solve-invariant
|
|
1506
|
+
``flex_data.p_all_entity_unitsize`` carrier (a Param ``(e,)`` over ALL
|
|
1507
|
+
entities: unit ∪ node ∪ connection) built input-side with the cascade
|
|
1508
|
+
``virtual_unitsize OR existing OR 1000.0``. This covers every entity
|
|
1509
|
+
in the model, including invest candidates absent from the LAST solve's
|
|
1510
|
+
pss/invest sets — which the pss-filtered ``p_unitsize`` /
|
|
1511
|
+
``p_state_unitsize`` reconstruction below misses, causing a ``KeyError``
|
|
1512
|
+
in ``calc_costs`` when ``v.invest`` (the cross-solve column union) is
|
|
1513
|
+
indexed against it.
|
|
1514
|
+
|
|
1515
|
+
Fallback (CSV/fixture paths lacking the carrier) — combines the
|
|
1516
|
+
per-solve ``p_unitsize`` (process side) and ``p_state_unitsize`` (node
|
|
1517
|
+
side), defaulting to ``1000.0`` for any entity in the nodeBalance /
|
|
1518
|
+
process_source_sink / invest universe that doesn't appear in either
|
|
1519
|
+
Param, matching the slow path's preprocessing default at
|
|
1520
|
+
``preprocessing/entity_period_calc_params.py:191``.
|
|
1521
|
+
"""
|
|
1522
|
+
_carrier = getattr(flex_data, "p_all_entity_unitsize", None)
|
|
1523
|
+
if _carrier is not None and getattr(_carrier, "frame", None) is not None \
|
|
1524
|
+
and _carrier.frame.height > 0:
|
|
1525
|
+
df = _carrier.frame.to_pandas()
|
|
1526
|
+
s = df.set_index("e")["value"].astype(float)
|
|
1527
|
+
s = s[~s.index.duplicated(keep="first")]
|
|
1528
|
+
s.index.name = "entity"
|
|
1529
|
+
s.name = "entity"
|
|
1530
|
+
return s
|
|
1531
|
+
|
|
1532
|
+
parts: list[pd.Series] = []
|
|
1533
|
+
if (flex_data.p_unitsize is not None
|
|
1534
|
+
and flex_data.p_unitsize.frame.height > 0):
|
|
1535
|
+
df = flex_data.p_unitsize.frame.to_pandas()
|
|
1536
|
+
s = df.set_index("p")["value"].astype(float)
|
|
1537
|
+
s.index.name = "entity"
|
|
1538
|
+
parts.append(s)
|
|
1539
|
+
if (flex_data.p_state_unitsize is not None
|
|
1540
|
+
and flex_data.p_state_unitsize.frame.height > 0):
|
|
1541
|
+
df = flex_data.p_state_unitsize.frame.to_pandas()
|
|
1542
|
+
s = df.set_index("n")["value"].astype(float)
|
|
1543
|
+
s.index.name = "entity"
|
|
1544
|
+
parts.append(s)
|
|
1545
|
+
if parts:
|
|
1546
|
+
out = pd.concat(parts)
|
|
1547
|
+
# Deduplicate (unlikely; node/process sets are disjoint).
|
|
1548
|
+
out = out[~out.index.duplicated(keep="first")]
|
|
1549
|
+
else:
|
|
1550
|
+
out = pd.Series(dtype=float, index=pd.Index([], name="entity"))
|
|
1551
|
+
out.name = "entity"
|
|
1552
|
+
# Densify with default 1000.0 (preprocessing default at
|
|
1553
|
+
# entity_period_calc_params.py:191).
|
|
1554
|
+
if entity_universe:
|
|
1555
|
+
for e in entity_universe:
|
|
1556
|
+
if e not in out.index:
|
|
1557
|
+
out[e] = 1000.0
|
|
1558
|
+
return out
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
# ---------------------------------------------------------------------------
|
|
1562
|
+
# Multi-solve wrapper
|
|
1563
|
+
# ---------------------------------------------------------------------------
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def _has_solve_level(obj) -> bool:
|
|
1567
|
+
"""True if ``obj`` is a pandas DataFrame/Series whose row MultiIndex
|
|
1568
|
+
has a ``solve`` level — i.e. it varies per sub-solve and rows from
|
|
1569
|
+
multiple sub-solves should be concatenated rather than overwritten.
|
|
1570
|
+
"""
|
|
1571
|
+
if isinstance(obj, (pd.DataFrame, pd.Series)):
|
|
1572
|
+
idx = obj.index
|
|
1573
|
+
if isinstance(idx, pd.MultiIndex):
|
|
1574
|
+
return "solve" in (idx.names or ())
|
|
1575
|
+
return False
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def read_parameters_multi(
|
|
1579
|
+
steps: "list[tuple[str, FlexData, Solution]] | list[tuple[str, FlexData]]",
|
|
1580
|
+
solution: "Solution | None" = None,
|
|
1581
|
+
) -> SimpleNamespace:
|
|
1582
|
+
"""Multi-solve variant of :func:`read_parameters`.
|
|
1583
|
+
|
|
1584
|
+
``steps`` is a list of ``(solve_name, flex_data)`` pairs covering
|
|
1585
|
+
every sub-solve (roll) of the orchestration's cascade. Per-(solve,
|
|
1586
|
+
period, time) and per-(solve, period) outputs are built per sub-
|
|
1587
|
+
solve and concatenated along the row axis so the resulting ``par``
|
|
1588
|
+
namespace covers the FULL union of every sub-solve's dt axis —
|
|
1589
|
+
matching the union ``v`` carries from parquet aggregation in
|
|
1590
|
+
:mod:`flextool.process_outputs.read_variables`.
|
|
1591
|
+
|
|
1592
|
+
Per-entity static attributes (entity_unitsize, commodity_co2_content,
|
|
1593
|
+
etc.) are invariant across sub-solves and are taken from the LAST
|
|
1594
|
+
step (arbitrary; they're identical across rolls).
|
|
1595
|
+
|
|
1596
|
+
Rationale: rolling-solve orchestration carries per-sub-solve
|
|
1597
|
+
``flex_data`` already; the output path must reflect the union of
|
|
1598
|
+
every sub-solve's parameter values, NOT just the last roll's. See
|
|
1599
|
+
the structural diagnosis: ``par`` was built from the last
|
|
1600
|
+
``flex_data.dt`` (one period) while ``v`` carried the union of
|
|
1601
|
+
every parquet (all periods), so downstream ``mul`` / ``join`` ops
|
|
1602
|
+
crashed on row-MultiIndex mismatches.
|
|
1603
|
+
"""
|
|
1604
|
+
if not steps:
|
|
1605
|
+
raise ValueError("read_parameters_multi: steps must be non-empty")
|
|
1606
|
+
|
|
1607
|
+
# Accept both (solve_name, flex_data, solution) and
|
|
1608
|
+
# (solve_name, flex_data) tuples for backwards compatibility. The
|
|
1609
|
+
# 3-tuple form is REQUIRED for ``_entity_all_capacity`` to see each
|
|
1610
|
+
# roll's own ``v_invest`` / ``v_divest`` — passing only the last
|
|
1611
|
+
# roll's solution makes ``total`` lag by one period across the
|
|
1612
|
+
# chain (see 2026-05-14 unit_capacity__d.csv diagnosis).
|
|
1613
|
+
def _step_solution(s):
|
|
1614
|
+
if len(s) >= 3:
|
|
1615
|
+
return s[2]
|
|
1616
|
+
if solution is None:
|
|
1617
|
+
raise ValueError(
|
|
1618
|
+
"read_parameters_multi: step is a 2-tuple but no "
|
|
1619
|
+
"fallback solution was supplied"
|
|
1620
|
+
)
|
|
1621
|
+
return solution
|
|
1622
|
+
|
|
1623
|
+
per_step = [
|
|
1624
|
+
(s[0], read_parameters(s[1], _step_solution(s), solve_name=s[0]))
|
|
1625
|
+
for s in steps
|
|
1626
|
+
]
|
|
1627
|
+
last_ns = per_step[-1][1]
|
|
1628
|
+
if len(per_step) == 1:
|
|
1629
|
+
return last_ns
|
|
1630
|
+
|
|
1631
|
+
# Pre-filter per-step ``entity_lifetime_fixed_cost`` /
|
|
1632
|
+
# ``entity_lifetime_fixed_cost_divest`` to only the step's REALIZED
|
|
1633
|
+
# periods (``flex_data.realized_dispatch`` — same source as
|
|
1634
|
+
# ``read_sets.d_realized_period``). In a rolling/nested cascade
|
|
1635
|
+
# each step holds a period-d "active" lifetime value in the row
|
|
1636
|
+
# where d is the step's realized period, plus forward-discounted
|
|
1637
|
+
# lookahead values for lookahead periods (e.g. y2020 step has
|
|
1638
|
+
# p2020=827152 active + p2025=373153 lookahead). ``v_invest[(solve,
|
|
1639
|
+
# d)]`` is non-zero only for the committing step's realized period,
|
|
1640
|
+
# so the cost in ``calc_costs.compute_costs`` (line 165, ``v.invest
|
|
1641
|
+
# × unitsize × entity_lifetime_fixed_cost``) must use the value
|
|
1642
|
+
# from that same committing step. Filtering each piece pre-concat
|
|
1643
|
+
# means ``drop_levels`` sees one row per period (no dedup
|
|
1644
|
+
# ambiguity). Without this, ``_PAR_DEDUP keep='first'`` picks the
|
|
1645
|
+
# earliest step's lookahead value for every period after the first
|
|
1646
|
+
# (e.g. p2025 → y2020 lookahead 373153 instead of y2025 active
|
|
1647
|
+
# 827152), giving ``costs_discounted.csv:fixed cost invested``
|
|
1648
|
+
# 120.6 instead of the LP-true 267.4. ``ed_invest_set`` /
|
|
1649
|
+
# ``ed_divest_set`` cover both realized + lookahead invest
|
|
1650
|
+
# candidates, so we use ``realized_dispatch`` instead.
|
|
1651
|
+
_lifetime_attrs = ("entity_lifetime_fixed_cost",
|
|
1652
|
+
"entity_lifetime_fixed_cost_divest")
|
|
1653
|
+
for (sn, ns), step in zip(per_step, steps):
|
|
1654
|
+
flex_data = step[1]
|
|
1655
|
+
rd = getattr(flex_data, "realized_dispatch", None)
|
|
1656
|
+
if rd is None or getattr(rd, "height", 0) == 0:
|
|
1657
|
+
continue
|
|
1658
|
+
realized_periods = set(
|
|
1659
|
+
rd.select("period").unique().to_pandas()["period"].tolist()
|
|
1660
|
+
)
|
|
1661
|
+
if not realized_periods:
|
|
1662
|
+
continue
|
|
1663
|
+
for attr in _lifetime_attrs:
|
|
1664
|
+
obj = getattr(ns, attr, None)
|
|
1665
|
+
if obj is None or not _has_solve_level(obj):
|
|
1666
|
+
continue
|
|
1667
|
+
periods = obj.index.get_level_values("period")
|
|
1668
|
+
mask = periods.isin(realized_periods)
|
|
1669
|
+
setattr(ns, attr, obj[mask])
|
|
1670
|
+
|
|
1671
|
+
# Per-step ``entity_all_existing`` carries the period-d existing-
|
|
1672
|
+
# capacity chain (pre_existing on solve_first, later_existing
|
|
1673
|
+
# otherwise). In a NESTED cascade the parent invest step and
|
|
1674
|
+
# every child dispatch step both densify the (period, entity)
|
|
1675
|
+
# grid: invest_24h@(wind_plant, p2020) = 1000 (pre_existing,
|
|
1676
|
+
# solve_first=True) but dispatch_..._roll_17@(wind_plant, p2020)
|
|
1677
|
+
# = 1288.83 (later_existing folding the parent's v_invest back
|
|
1678
|
+
# in). drop_levels' ``keep='last'`` dedup picks the dispatch
|
|
1679
|
+
# row, blowing up the ``existing`` column in unit_capacity__d.csv
|
|
1680
|
+
# to the cumulative-with-current-invest value (HEAD shows 1288.83
|
|
1681
|
+
# for the same cell v3.32.0 emits as 1000). v3.32.0's mod-side
|
|
1682
|
+
# writer only emits rows from the FIRST solve that owns each
|
|
1683
|
+
# period via ``period_capacity`` deduplication (flextool.mod
|
|
1684
|
+
# L5993-6002): in nested that's the parent invest step.
|
|
1685
|
+
#
|
|
1686
|
+
# Fix: filter each step's ``entity_all_existing`` to ONLY the
|
|
1687
|
+
# periods that step realizes (``flex_data.realized_dispatch`` ∪
|
|
1688
|
+
# ``ed_invest_set/ed_divest_set`` "d"s). Dispatch-only children
|
|
1689
|
+
# in a nested cascade drop their lookahead rows; the parent
|
|
1690
|
+
# invest step's per-period values are the sole contributor and
|
|
1691
|
+
# win regardless of dedup direction. In 4-solve / multi_year
|
|
1692
|
+
# invest cascades each step realizes a disjoint period so no
|
|
1693
|
+
# collision arises either way — the filter is a no-op there.
|
|
1694
|
+
for (sn, ns), step in zip(per_step, steps):
|
|
1695
|
+
flex_data = step[1]
|
|
1696
|
+
step_periods: set[str] = set()
|
|
1697
|
+
rd = getattr(flex_data, "realized_dispatch", None)
|
|
1698
|
+
if rd is not None and getattr(rd, "height", 0) > 0:
|
|
1699
|
+
step_periods.update(
|
|
1700
|
+
rd.select("period").unique().to_pandas()["period"].tolist()
|
|
1701
|
+
)
|
|
1702
|
+
for src_attr in ("ed_invest_set", "ed_divest_set"):
|
|
1703
|
+
src = getattr(flex_data, src_attr, None)
|
|
1704
|
+
if src is not None and getattr(src, "height", 0) > 0 and "d" in src.columns:
|
|
1705
|
+
step_periods.update(
|
|
1706
|
+
src.select("d").unique().to_pandas()["d"].tolist()
|
|
1707
|
+
)
|
|
1708
|
+
if not step_periods:
|
|
1709
|
+
# Step realizes nothing — leave as-is. This is the legacy
|
|
1710
|
+
# behaviour for fixtures that don't populate realized_*.
|
|
1711
|
+
continue
|
|
1712
|
+
for attr in ("entity_all_existing",):
|
|
1713
|
+
obj = getattr(ns, attr, None)
|
|
1714
|
+
if obj is None or not _has_solve_level(obj):
|
|
1715
|
+
continue
|
|
1716
|
+
periods = obj.index.get_level_values("period")
|
|
1717
|
+
mask = periods.isin(step_periods)
|
|
1718
|
+
setattr(ns, attr, obj[mask])
|
|
1719
|
+
|
|
1720
|
+
# Per-step ``entity_annual_discounted`` /
|
|
1721
|
+
# ``entity_annual_divest_discounted`` carry the invest-side NPV
|
|
1722
|
+
# coefficients used by ``calc_costs.compute_costs`` to derive
|
|
1723
|
+
# ``cost_entity_invest_d = v_invest × unitsize × annual_discounted``.
|
|
1724
|
+
# The polars derivation builds these from ``period_invest``: a
|
|
1725
|
+
# nested-dispatch child solve has empty ``period_invest`` and the
|
|
1726
|
+
# frame is therefore empty, but ``_pdX_per_entity(densify_entities=
|
|
1727
|
+
# _entity_universe)`` zero-fills it to the full entity × period
|
|
1728
|
+
# grid. In a nested cascade, the parent ``invest`` step holds the
|
|
1729
|
+
# real coefficients (e.g. 4.18 for ``wind_plant @ p2020``) while
|
|
1730
|
+
# every child dispatch step densifies the same (period, entity)
|
|
1731
|
+
# cell to 0. Without intervention, ``drop_levels`` dedup with
|
|
1732
|
+
# ``keep='last'`` picks the last child step's zero, zeroing out
|
|
1733
|
+
# ``r.cost_entity_invest_d`` and dropping ``unit investment &
|
|
1734
|
+
# retirement`` from ``costs_discounted.csv`` (HEAD shows 0,
|
|
1735
|
+
# v3.32.0 shows 471.28). Fix: detect a "dispatch-only" step
|
|
1736
|
+
# (``flex_data.ed_entity_annual_discounted`` empty/None) and clear
|
|
1737
|
+
# the densified-zero per-step frame so it doesn't compete with the
|
|
1738
|
+
# parent invest step's real values.
|
|
1739
|
+
_annual_attrs = ("entity_annual_discounted",
|
|
1740
|
+
"entity_annual_divest_discounted",
|
|
1741
|
+
"entity_annuity")
|
|
1742
|
+
_src_field = {
|
|
1743
|
+
"entity_annual_discounted": "ed_entity_annual_discounted",
|
|
1744
|
+
"entity_annuity": "ed_entity_annual_discounted",
|
|
1745
|
+
"entity_annual_divest_discounted":
|
|
1746
|
+
"ed_entity_annual_divest_discounted",
|
|
1747
|
+
}
|
|
1748
|
+
for (sn, ns), step in zip(per_step, steps):
|
|
1749
|
+
flex_data = step[1]
|
|
1750
|
+
for attr in _annual_attrs:
|
|
1751
|
+
src_attr = _src_field[attr]
|
|
1752
|
+
src_param = getattr(flex_data, src_attr, None)
|
|
1753
|
+
src_empty = (
|
|
1754
|
+
src_param is None
|
|
1755
|
+
or getattr(src_param, "frame", None) is None
|
|
1756
|
+
or getattr(src_param.frame, "height", 0) == 0
|
|
1757
|
+
)
|
|
1758
|
+
if not src_empty:
|
|
1759
|
+
continue
|
|
1760
|
+
obj = getattr(ns, attr, None)
|
|
1761
|
+
if obj is None or not _has_solve_level(obj):
|
|
1762
|
+
continue
|
|
1763
|
+
# Empty out this step's rows (preserve the index/columns
|
|
1764
|
+
# frame for the concat path; head(0) keeps the schema).
|
|
1765
|
+
setattr(ns, attr, obj.iloc[0:0])
|
|
1766
|
+
|
|
1767
|
+
out = SimpleNamespace()
|
|
1768
|
+
attr_names = [a for a in vars(last_ns).keys()]
|
|
1769
|
+
for attr in attr_names:
|
|
1770
|
+
pieces = [getattr(ns, attr) for _, ns in per_step]
|
|
1771
|
+
# If the attribute has a ``solve`` level on its row index, the
|
|
1772
|
+
# union over sub-solves is the correct semantic; concat rows.
|
|
1773
|
+
if any(_has_solve_level(p) for p in pieces):
|
|
1774
|
+
non_empty = [p for p in pieces if len(p) > 0]
|
|
1775
|
+
if not non_empty:
|
|
1776
|
+
setattr(out, attr, pieces[-1])
|
|
1777
|
+
continue
|
|
1778
|
+
# ``pd.concat`` preserves dtypes and row order. Each piece
|
|
1779
|
+
# carries a distinct ``solve`` value so no de-duplication
|
|
1780
|
+
# is needed.
|
|
1781
|
+
merged = pd.concat(non_empty, axis=0)
|
|
1782
|
+
# Preserve column dtype/name (concat keeps it; defensive).
|
|
1783
|
+
if hasattr(pieces[-1], "columns") and hasattr(merged, "columns"):
|
|
1784
|
+
merged.columns.name = pieces[-1].columns.name
|
|
1785
|
+
setattr(out, attr, merged)
|
|
1786
|
+
else:
|
|
1787
|
+
# Static / per-entity / not-solve-keyed: invariant across
|
|
1788
|
+
# sub-solves; pick the last step's value.
|
|
1789
|
+
#
|
|
1790
|
+
# ``entity_unitsize`` is one such attribute. It is now sourced
|
|
1791
|
+
# from the input-side invariant ``input/p_all_entity_unitsize``
|
|
1792
|
+
# (the ``flex_data.p_all_entity_unitsize`` carrier), which covers
|
|
1793
|
+
# ALL entities and is identical for every step — so the last-step
|
|
1794
|
+
# pick is sound and, unlike the old pss-filtered reconstruction,
|
|
1795
|
+
# it includes invest candidates absent from the final sub-solve.
|
|
1796
|
+
# No union/concat is needed (and would be wrong: there is no
|
|
1797
|
+
# ``solve`` level to disambiguate rows).
|
|
1798
|
+
setattr(out, attr, getattr(last_ns, attr))
|
|
1799
|
+
return out
|