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,553 @@
|
|
|
1
|
+
"""Read solver variable outputs into a namespace.
|
|
2
|
+
|
|
3
|
+
Supports two pathways:
|
|
4
|
+
|
|
5
|
+
* **CSV** — ``output_raw/*.csv`` (legacy path). Kept alive for
|
|
6
|
+
``--use-old-raw-csv`` runs.
|
|
7
|
+
* **Parquet** — per-solve parquets in
|
|
8
|
+
``output_raw/<var>__{solve}.parquet`` written by
|
|
9
|
+
:mod:`flextool.process_outputs.read_highs_solution`. The parquets
|
|
10
|
+
carry full MultiIndex information in compact ``flextool`` metadata,
|
|
11
|
+
so they round-trip to the same DataFrame shape the CSV path
|
|
12
|
+
produces (see ``lean_parquet``).
|
|
13
|
+
|
|
14
|
+
Both pathways use the same ``output_raw/`` folder. Dispatch is by
|
|
15
|
+
file presence: if any ``v_flow__*.parquet`` exists and no
|
|
16
|
+
``v_flow.csv`` does, the parquet pathway is used; otherwise the CSV
|
|
17
|
+
pathway.
|
|
18
|
+
"""
|
|
19
|
+
import ast
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import tempfile
|
|
23
|
+
from types import SimpleNamespace
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Sequence
|
|
26
|
+
import pandas as pd
|
|
27
|
+
import pyarrow as pa
|
|
28
|
+
import pyarrow.parquet as pq
|
|
29
|
+
|
|
30
|
+
from flextool.process_outputs.read_highs_solution import empty_variable_frame
|
|
31
|
+
from flextool.process_outputs.solve_order import load_solve_order
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_variables(output_dir):
|
|
35
|
+
"""Read all variable outputs into a :class:`SimpleNamespace`.
|
|
36
|
+
|
|
37
|
+
``output_dir`` is the ``output_raw/`` path. ``input/`` is resolved
|
|
38
|
+
as a sibling folder (needed for ``group_entity_invest.csv``).
|
|
39
|
+
"""
|
|
40
|
+
output_path = Path(output_dir)
|
|
41
|
+
work_folder = output_path.parent
|
|
42
|
+
input_path = work_folder / "input"
|
|
43
|
+
|
|
44
|
+
# Prefer the CSV pathway when its output is present. Fall back to
|
|
45
|
+
# the per-solve parquets when ``v_flow.csv`` is absent.
|
|
46
|
+
if (output_path / "v_flow.csv").exists():
|
|
47
|
+
return _read_from_csv(output_path, input_path)
|
|
48
|
+
if any(output_path.glob("v_flow__*.parquet")):
|
|
49
|
+
return _read_from_parquet(output_path, input_path)
|
|
50
|
+
return _read_from_csv(output_path, input_path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _stream_concat_parts(parts: list[Path]) -> pd.DataFrame:
|
|
54
|
+
"""Concatenate per-solve parquet *parts* without materialising them
|
|
55
|
+
all at once.
|
|
56
|
+
|
|
57
|
+
The previous implementation built ``frames = [read_lean_parquet(p)
|
|
58
|
+
for p in parts]`` then ``pd.concat(frames).fillna(0.0)``. On 72-roll
|
|
59
|
+
SouthAfrica that peaked at ~50 GB for ``v_flow`` alone — the Python
|
|
60
|
+
list held every per-solve frame simultaneously, then ``pd.concat``
|
|
61
|
+
produced a second dense copy of the union.
|
|
62
|
+
|
|
63
|
+
Phase E2 strategy:
|
|
64
|
+
|
|
65
|
+
1. Footer-only pass over each part to gather the union of physical
|
|
66
|
+
column names, the per-column canonical type (preferring concrete
|
|
67
|
+
types over ``null`` from empty parts), and the row-index level
|
|
68
|
+
info from the first non-empty part's ``flextool`` metadata.
|
|
69
|
+
2. Open a single ``ParquetWriter`` on a temp file with the union
|
|
70
|
+
schema. For each part read its table, promote (add nulls / drop)
|
|
71
|
+
columns to align with the union schema, and write it as one
|
|
72
|
+
row-group. Drop the table reference before reading the next
|
|
73
|
+
part so we never hold more than ONE part's arrow data in memory
|
|
74
|
+
during the write phase.
|
|
75
|
+
3. Read the consolidated temp parquet back as a single pandas
|
|
76
|
+
DataFrame via ``pq.read_table(...).to_pandas(self_destruct=True)``.
|
|
77
|
+
arrow buffers are released column-by-column during the
|
|
78
|
+
conversion.
|
|
79
|
+
4. Apply the historical fixups (1-level MultiIndex collapse +
|
|
80
|
+
``.fillna(0.0)``). The zero-fill matters for downstream
|
|
81
|
+
consumers (e.g. ``calc_storage_vre`` uses
|
|
82
|
+
``v.state[n].reindex(...)``; a NaN there would break the
|
|
83
|
+
arithmetic).
|
|
84
|
+
|
|
85
|
+
Memory profile (per variable), where ``F = N × per_part`` is the
|
|
86
|
+
union frame size:
|
|
87
|
+
|
|
88
|
+
old : peak ≈ N × per_part + 2 × F (Python list of N frames
|
|
89
|
+
+ ``pd.concat`` temp + final)
|
|
90
|
+
new : peak ≈ max(2 × per_part, (write phase: one part
|
|
91
|
+
2 × F) + writer buffer)
|
|
92
|
+
(read phase: arrow table
|
|
93
|
+
+ pandas frame during
|
|
94
|
+
column-by-column convert)
|
|
95
|
+
|
|
96
|
+
Concretely: the spec-cited 50 GB SouthAfrica peak (dominated by
|
|
97
|
+
the N-frame list term) drops to roughly ``2 × F`` — the read-back
|
|
98
|
+
floor. When ``F ≈ N × per_part`` (parts share most columns) the
|
|
99
|
+
win is ~halving peak; when parts share *no* columns (each part
|
|
100
|
+
contributes disjoint entities) ``F`` shrinks proportionally and
|
|
101
|
+
the win grows.
|
|
102
|
+
|
|
103
|
+
The temp file lives in the same directory as the parts so it stays
|
|
104
|
+
on the workspace filesystem (predictable disk locality, no
|
|
105
|
+
cross-filesystem rename surprises).
|
|
106
|
+
"""
|
|
107
|
+
# Filter out parts that wrote zero rows AND zero columns. The old
|
|
108
|
+
# path kept these only if they were the *only* parts; we mirror
|
|
109
|
+
# that fallback here so an all-empty variable still returns the
|
|
110
|
+
# same shape it did before.
|
|
111
|
+
non_empty_parts: list[Path] = []
|
|
112
|
+
for p in parts:
|
|
113
|
+
pf = pq.ParquetFile(str(p))
|
|
114
|
+
if pf.metadata.num_rows == 0 and pf.metadata.num_columns == 0:
|
|
115
|
+
continue
|
|
116
|
+
non_empty_parts.append(p)
|
|
117
|
+
use_parts = non_empty_parts or parts
|
|
118
|
+
|
|
119
|
+
if not use_parts:
|
|
120
|
+
# Defensive — caller guarantees ``parts`` is non-empty.
|
|
121
|
+
return pd.DataFrame()
|
|
122
|
+
|
|
123
|
+
# Pass 1: union of columns + per-column canonical type + row-index
|
|
124
|
+
# level info from the first non-empty part's metadata. Reading
|
|
125
|
+
# just the schema costs one footer read per file (cheap; KB not GB).
|
|
126
|
+
union_cols: list[str] = []
|
|
127
|
+
seen: set[str] = set()
|
|
128
|
+
flextool_info: bytes | None = None
|
|
129
|
+
null_type = pa.null()
|
|
130
|
+
name_to_type: dict[str, pa.DataType] = {}
|
|
131
|
+
for p in use_parts:
|
|
132
|
+
pf = pq.ParquetFile(str(p))
|
|
133
|
+
sch = pf.schema_arrow
|
|
134
|
+
meta = sch.metadata or {}
|
|
135
|
+
if flextool_info is None and b"flextool" in meta:
|
|
136
|
+
flextool_info = meta[b"flextool"]
|
|
137
|
+
for field in sch:
|
|
138
|
+
if field.name not in seen:
|
|
139
|
+
seen.add(field.name)
|
|
140
|
+
union_cols.append(field.name)
|
|
141
|
+
name_to_type[field.name] = field.type
|
|
142
|
+
else:
|
|
143
|
+
# Prefer any concrete type over ``null`` — a part with
|
|
144
|
+
# zero rows may emit a column of ``null`` type, and
|
|
145
|
+
# arrow can't cast a later concrete column *into* that.
|
|
146
|
+
if (
|
|
147
|
+
name_to_type[field.name] == null_type
|
|
148
|
+
and field.type != null_type
|
|
149
|
+
):
|
|
150
|
+
name_to_type[field.name] = field.type
|
|
151
|
+
|
|
152
|
+
union_fields = [pa.field(c, name_to_type[c]) for c in union_cols]
|
|
153
|
+
# Preserve flextool metadata so the temp file is readable via
|
|
154
|
+
# ``read_lean_parquet`` (round-trips the index / columns names).
|
|
155
|
+
schema_meta = {b"flextool": flextool_info} if flextool_info else None
|
|
156
|
+
union_schema = pa.schema(union_fields, metadata=schema_meta)
|
|
157
|
+
|
|
158
|
+
# Pass 2: stream-write each part as a single row-group.
|
|
159
|
+
parent = use_parts[0].parent
|
|
160
|
+
# ``mkstemp`` creates the file with O_EXCL — no collision with
|
|
161
|
+
# concurrent runs writing into the same folder.
|
|
162
|
+
fd, tmp_path_str = tempfile.mkstemp(
|
|
163
|
+
prefix=f".{use_parts[0].stem.split('__')[0]}__concat_", suffix=".parquet",
|
|
164
|
+
dir=str(parent),
|
|
165
|
+
)
|
|
166
|
+
os.close(fd) # ParquetWriter will reopen by path.
|
|
167
|
+
tmp_path = Path(tmp_path_str)
|
|
168
|
+
try:
|
|
169
|
+
with pq.ParquetWriter(str(tmp_path), union_schema) as writer:
|
|
170
|
+
for p in use_parts:
|
|
171
|
+
table = pq.read_table(str(p))
|
|
172
|
+
# Strip per-part metadata so the writer doesn't reject
|
|
173
|
+
# the row-group on schema mismatch — only the file-level
|
|
174
|
+
# ``union_schema`` carries the canonical ``flextool``
|
|
175
|
+
# blob.
|
|
176
|
+
table = table.replace_schema_metadata(None)
|
|
177
|
+
# Align the part's columns to ``union_cols`` order,
|
|
178
|
+
# filling absent columns with a null array (cheap —
|
|
179
|
+
# arrow stores a single null reference, not a dense
|
|
180
|
+
# ndarray).
|
|
181
|
+
n_rows = table.num_rows
|
|
182
|
+
arrays: list[pa.ChunkedArray | pa.Array] = []
|
|
183
|
+
part_cols = {f.name: i for i, f in enumerate(table.schema)}
|
|
184
|
+
for col_name in union_cols:
|
|
185
|
+
if col_name in part_cols:
|
|
186
|
+
arr = table.column(part_cols[col_name])
|
|
187
|
+
target_type = name_to_type[col_name]
|
|
188
|
+
if arr.type != target_type:
|
|
189
|
+
if arr.type == null_type:
|
|
190
|
+
# All-null part column → emit a properly
|
|
191
|
+
# typed null array of the same length.
|
|
192
|
+
arr = pa.nulls(arr.length(), type=target_type)
|
|
193
|
+
else:
|
|
194
|
+
# Concrete → target (e.g. int32 → int64).
|
|
195
|
+
arr = arr.cast(target_type)
|
|
196
|
+
arrays.append(arr)
|
|
197
|
+
else:
|
|
198
|
+
arrays.append(
|
|
199
|
+
pa.nulls(n_rows, type=name_to_type[col_name])
|
|
200
|
+
)
|
|
201
|
+
aligned = pa.Table.from_arrays(
|
|
202
|
+
arrays, schema=union_schema,
|
|
203
|
+
)
|
|
204
|
+
writer.write_table(aligned)
|
|
205
|
+
# Free part-level references before reading the next.
|
|
206
|
+
del table, aligned, arrays
|
|
207
|
+
|
|
208
|
+
# Pass 3: single read-back as pandas. ``self_destruct=True``
|
|
209
|
+
# discards arrow buffers as columns are materialised, so peak
|
|
210
|
+
# during this conversion is one buffer + the growing pandas
|
|
211
|
+
# frame, not two complete copies. The temp file's row-group
|
|
212
|
+
# layout is preserved (one per part) — multi-row-group reads
|
|
213
|
+
# are still single-frame from the consumer's perspective.
|
|
214
|
+
result_table = pq.read_table(str(tmp_path))
|
|
215
|
+
df = result_table.to_pandas(self_destruct=True)
|
|
216
|
+
del result_table
|
|
217
|
+
finally:
|
|
218
|
+
# Best-effort cleanup — never let a stray temp file accumulate.
|
|
219
|
+
try:
|
|
220
|
+
tmp_path.unlink()
|
|
221
|
+
except OSError:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
# --- Rebuild row index from the flextool metadata ---
|
|
225
|
+
# ``read_lean_parquet`` does this for a single file; we replicate
|
|
226
|
+
# the logic against the union schema's metadata so the streaming
|
|
227
|
+
# writer is interchangeable with the old eager path.
|
|
228
|
+
if flextool_info is not None:
|
|
229
|
+
info = json.loads(flextool_info)
|
|
230
|
+
idx_names = info.get("idx")
|
|
231
|
+
if idx_names:
|
|
232
|
+
col_lookup: list[str] = []
|
|
233
|
+
for i, lname in enumerate(idx_names):
|
|
234
|
+
col_lookup.append(
|
|
235
|
+
f"__index_level_{i}__" if lname is None else lname
|
|
236
|
+
)
|
|
237
|
+
present = [c for c in col_lookup if c in df.columns]
|
|
238
|
+
if present:
|
|
239
|
+
df = df.set_index(present)
|
|
240
|
+
if isinstance(df.index, pd.MultiIndex):
|
|
241
|
+
df.index.names = idx_names
|
|
242
|
+
else:
|
|
243
|
+
df.index.name = idx_names[0]
|
|
244
|
+
|
|
245
|
+
col_level_names = info.get("col")
|
|
246
|
+
if col_level_names:
|
|
247
|
+
tuples = [ast.literal_eval(c) for c in df.columns]
|
|
248
|
+
df.columns = pd.MultiIndex.from_tuples(
|
|
249
|
+
tuples, names=col_level_names,
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
single_col_name = info.get("col_name")
|
|
253
|
+
if single_col_name is not None:
|
|
254
|
+
df.columns.name = single_col_name
|
|
255
|
+
|
|
256
|
+
# ``astype(float)`` matched the old behaviour — values are already
|
|
257
|
+
# float64 from the writer side, but this preserves the historical
|
|
258
|
+
# promotion for any oddly-typed (e.g. int-zero) all-null columns.
|
|
259
|
+
df = df.astype(float)
|
|
260
|
+
|
|
261
|
+
# Collapse 1-level MultiIndex columns to plain Index — same fixup
|
|
262
|
+
# the old eager path applied (the CSV pathway produces a plain
|
|
263
|
+
# Index here; downstream ``DataFrame.mul(..., level=0)`` treats
|
|
264
|
+
# MultiIndex-with-one-level differently).
|
|
265
|
+
if isinstance(df.columns, pd.MultiIndex) and df.columns.nlevels == 1:
|
|
266
|
+
df.columns = pd.Index(
|
|
267
|
+
[c[0] for c in df.columns], name=df.columns.names[0],
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return df.fillna(0.0)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _read_from_parquet(parquet_dir: Path, input_path: Path) -> SimpleNamespace:
|
|
274
|
+
"""New pathway: concat per-solve parquets for each variable.
|
|
275
|
+
|
|
276
|
+
Per-solve parquets are concatenated in solve **creation** order —
|
|
277
|
+
the same order the CSV pathway sees rows appended by phase-1
|
|
278
|
+
printfs. Reading in creation order avoids any post-concat sort;
|
|
279
|
+
downstream ``DataFrame.mul(axis=1, level=0)`` aligns operands by
|
|
280
|
+
row index, and both reader pathways producing the same row order
|
|
281
|
+
is enough.
|
|
282
|
+
"""
|
|
283
|
+
v = SimpleNamespace()
|
|
284
|
+
work_folder = parquet_dir.parent
|
|
285
|
+
solve_order = load_solve_order(work_folder)
|
|
286
|
+
|
|
287
|
+
def _solve_from_filename(name: str, path: Path) -> str:
|
|
288
|
+
# ``<output_name>__<solve>.parquet`` — strip prefix + extension.
|
|
289
|
+
return path.name[len(name) + 2:-len(".parquet")]
|
|
290
|
+
|
|
291
|
+
def _read(
|
|
292
|
+
name: str,
|
|
293
|
+
col_names: Sequence[str],
|
|
294
|
+
*,
|
|
295
|
+
has_period: bool = True,
|
|
296
|
+
has_time: bool = True,
|
|
297
|
+
) -> pd.DataFrame:
|
|
298
|
+
# Parquet carries full float64 precision; no rounding — that
|
|
299
|
+
# belongs only at the final user-facing CSV write boundary.
|
|
300
|
+
#
|
|
301
|
+
# When no parquet exists (writer was never called for this variable),
|
|
302
|
+
# fall back to a typed empty frame via ``empty_variable_frame`` so
|
|
303
|
+
# downstream calc code still sees the expected ``columns.name`` /
|
|
304
|
+
# row-index ``names``.
|
|
305
|
+
parts = list(parquet_dir.glob(f"{name}__*.parquet"))
|
|
306
|
+
if not parts:
|
|
307
|
+
return empty_variable_frame(
|
|
308
|
+
solve_name="", col_names=col_names,
|
|
309
|
+
has_period=has_period, has_time=has_time,
|
|
310
|
+
)
|
|
311
|
+
# Order per-solve parts by solve creation order so the concat
|
|
312
|
+
# result is already in the correct row order — no post-concat
|
|
313
|
+
# sort needed. Solves missing from ``solve_order`` sort first
|
|
314
|
+
# (shouldn't happen normally; defensive).
|
|
315
|
+
parts.sort(key=lambda p: solve_order.get(_solve_from_filename(name, p), -1))
|
|
316
|
+
# Phase E2 — stream per-solve parts through pyarrow as one
|
|
317
|
+
# row-group each, so we never hold all frames in a Python list
|
|
318
|
+
# nor pay for ``pd.concat``'s second-copy. Reader contract is
|
|
319
|
+
# unchanged: the public return is still a ``pd.DataFrame`` with
|
|
320
|
+
# the same row order, column union, MultiIndex/columns-name
|
|
321
|
+
# semantics, and zero-filled missing cells.
|
|
322
|
+
return _stream_concat_parts(parts)
|
|
323
|
+
|
|
324
|
+
v.obj = _read("v_obj", ("objective",), has_period=False, has_time=False)
|
|
325
|
+
v.flow = _read("v_flow", ("process", "source", "sink"))
|
|
326
|
+
# Reverse-flow auxiliary for method_2way_1var_off arcs (empty frame
|
|
327
|
+
# when no such arc is present). Folded into the signed net flow by
|
|
328
|
+
# calc_capacity_flows.
|
|
329
|
+
v.flow_back = _read("v_flow_back", ("process", "source", "sink"))
|
|
330
|
+
v.ramp = _read("v_ramp", ("process", "source", "sink"))
|
|
331
|
+
v.reserve = _read("v_reserve", ("process", "reserve", "updown", "node"))
|
|
332
|
+
v.state = _read("v_state", ("node",))
|
|
333
|
+
v.online_linear = _read("v_online_linear", ("process",))
|
|
334
|
+
v.startup_linear = _read("v_startup_linear", ("process",))
|
|
335
|
+
v.shutdown_linear = _read("v_shutdown_linear", ("process",))
|
|
336
|
+
v.online_integer = _read("v_online_integer", ("process",))
|
|
337
|
+
v.startup_integer = _read("v_startup_integer", ("process",))
|
|
338
|
+
v.shutdown_integer = _read("v_shutdown_integer", ("process",))
|
|
339
|
+
v.q_state_up = _read("vq_state_up", ("node",))
|
|
340
|
+
v.q_state_down = _read("vq_state_down", ("node",))
|
|
341
|
+
v.q_reserve = _read("vq_reserve", ("reserve", "updown", "node_group"))
|
|
342
|
+
v.q_inertia = _read("vq_inertia", ("group",))
|
|
343
|
+
v.q_non_synchronous = _read("vq_non_synchronous", ("group",))
|
|
344
|
+
v.q_state_up_group = _read("vq_state_up_group", ("group",))
|
|
345
|
+
v.q_capacity_margin = _read("vq_capacity_margin", ("group",), has_time=False)
|
|
346
|
+
v.invest = _read("v_invest", ("entity",), has_time=False)
|
|
347
|
+
v.divest = _read("v_divest", ("entity",), has_time=False)
|
|
348
|
+
v.dual_node_balance = _read("v_dual_node_balance", ("node",))
|
|
349
|
+
v.dual_reserve_balance = _read(
|
|
350
|
+
"v_dual_reserve__upDown__group__period__t",
|
|
351
|
+
("reserve", "updown", "node_group"),
|
|
352
|
+
)
|
|
353
|
+
v.angle = _read("v_angle", ("node",))
|
|
354
|
+
v.dual_invest_unit = _read("v_dual_invest_unit", ("unit",), has_time=False)
|
|
355
|
+
v.dual_invest_connection = _read("v_dual_invest_connection", ("connection",), has_time=False)
|
|
356
|
+
v.dual_invest_node = _read("v_dual_invest_node", ("node",), has_time=False)
|
|
357
|
+
v.dual_maxInvest_period = _read("v_dual_maxInvest_period", ("entity",), has_time=False)
|
|
358
|
+
v.dual_maxInvest_total = _read("v_dual_maxInvest_total", ("entity",), has_time=False)
|
|
359
|
+
v.dual_maxCumulative = _read("v_dual_maxCumulative", ("entity",), has_time=False)
|
|
360
|
+
v.dual_maxInvestGroup_period = _read("v_dual_maxInvestGroup_period", ("group",), has_time=False)
|
|
361
|
+
v.dual_maxInvestGroup_total = _read("v_dual_maxInvestGroup_total", ("group",), has_time=False)
|
|
362
|
+
v.dual_maxInvestGroup_cumulative = _read("v_dual_maxInvestGroup_cumulative", ("group",), has_time=False)
|
|
363
|
+
# Investment-floor (min-side) duals — mirror the max-side readers above.
|
|
364
|
+
v.dual_minInvest_period = _read("v_dual_minInvest_period", ("entity",), has_time=False)
|
|
365
|
+
v.dual_minInvest_total = _read("v_dual_minInvest_total", ("entity",), has_time=False)
|
|
366
|
+
v.dual_minCumulative = _read("v_dual_minCumulative", ("entity",), has_time=False)
|
|
367
|
+
v.dual_minInvestGroup_period = _read("v_dual_minInvestGroup_period", ("group",), has_time=False)
|
|
368
|
+
v.dual_minInvestGroup_total = _read("v_dual_minInvestGroup_total", ("group",), has_time=False)
|
|
369
|
+
v.dual_minInvestGroup_cumulative = _read("v_dual_minInvestGroup_cumulative", ("group",), has_time=False)
|
|
370
|
+
v.dual_co2_max_period = _read("v_dual_co2_max_period", ("group",), has_time=False)
|
|
371
|
+
v.dual_co2_max_total = _read("v_dual_co2_max_total", ("group",), has_period=False, has_time=False)
|
|
372
|
+
|
|
373
|
+
# ``group_entity_invest`` is a static map (solveFirst-only) written
|
|
374
|
+
# during phase 1 directly to ``input/`` — same as the CSV pathway.
|
|
375
|
+
# Δ.31: tolerate absence (the polars cascade doesn't always emit
|
|
376
|
+
# this file; downstream code only consumes it for the
|
|
377
|
+
# ``maxInvestGroup`` family which is empty without group invest).
|
|
378
|
+
gei_path = input_path / "group_entity_invest.csv"
|
|
379
|
+
if gei_path.exists():
|
|
380
|
+
v.group_entity_invest = pd.read_csv(gei_path)
|
|
381
|
+
else:
|
|
382
|
+
v.group_entity_invest = pd.DataFrame(columns=["group", "entity"])
|
|
383
|
+
return v
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _read_from_csv(output_path: Path, input_path: Path) -> SimpleNamespace:
|
|
387
|
+
"""CSV pathway: read ``output_raw/<var>.csv`` files."""
|
|
388
|
+
v = SimpleNamespace()
|
|
389
|
+
|
|
390
|
+
# Variables with (solve, period, time) index
|
|
391
|
+
v.obj = pd.read_csv(output_path / 'v_obj.csv', header=[0], index_col=[0]).astype(float)
|
|
392
|
+
v.flow = pd.read_csv(output_path / 'v_flow.csv', header=[0, 1, 2], index_col=[0, 1, 2]).astype(float)
|
|
393
|
+
# Reverse-flow auxiliary for method_2way_1var_off arcs. The legacy CSV
|
|
394
|
+
# writer only emits this file when the variable exists; mirror v_flow's
|
|
395
|
+
# shape with an empty frame otherwise.
|
|
396
|
+
_flow_back_csv = output_path / 'v_flow_back.csv'
|
|
397
|
+
if _flow_back_csv.exists():
|
|
398
|
+
v.flow_back = pd.read_csv(_flow_back_csv, header=[0, 1, 2], index_col=[0, 1, 2]).astype(float)
|
|
399
|
+
else:
|
|
400
|
+
v.flow_back = pd.DataFrame(index=v.flow.index, columns=v.flow.columns[:0])
|
|
401
|
+
v.ramp = pd.read_csv(output_path / 'v_ramp.csv', header=[0, 1, 2], index_col=[0, 1, 2]).astype(float)
|
|
402
|
+
v.reserve = pd.read_csv(output_path / 'v_reserve.csv', header=[0, 1, 2, 3], index_col=[0, 1, 2]).astype(float)
|
|
403
|
+
v.state = pd.read_csv(output_path / 'v_state.csv', index_col=[0, 1, 2]).astype(float)
|
|
404
|
+
v.online_linear = pd.read_csv(output_path / 'v_online_linear.csv', index_col=[0, 1, 2]).astype(float)
|
|
405
|
+
v.startup_linear = pd.read_csv(output_path / 'v_startup_linear.csv', index_col=[0, 1, 2]).astype(float)
|
|
406
|
+
v.shutdown_linear = pd.read_csv(output_path / 'v_shutdown_linear.csv', index_col=[0, 1, 2]).astype(float)
|
|
407
|
+
v.online_integer = pd.read_csv(output_path / 'v_online_integer.csv', index_col=[0, 1, 2]).astype(float)
|
|
408
|
+
v.startup_integer = pd.read_csv(output_path / 'v_startup_integer.csv', index_col=[0, 1, 2]).astype(float)
|
|
409
|
+
v.shutdown_integer = pd.read_csv(output_path / 'v_shutdown_integer.csv', index_col=[0, 1, 2]).astype(float)
|
|
410
|
+
v.q_state_up = pd.read_csv(output_path / 'vq_state_up.csv', index_col=[0, 1, 2]).astype(float)
|
|
411
|
+
v.q_state_down = pd.read_csv(output_path / 'vq_state_down.csv', index_col=[0, 1, 2]).astype(float)
|
|
412
|
+
v.q_reserve = pd.read_csv(output_path / 'vq_reserve.csv', header=[0, 1, 2], index_col=[0, 1, 2]).astype(float)
|
|
413
|
+
v.q_inertia = pd.read_csv(output_path / 'vq_inertia.csv', index_col=[0, 1, 2]).astype(float)
|
|
414
|
+
v.q_non_synchronous = pd.read_csv(output_path / 'vq_non_synchronous.csv', index_col=[0, 1, 2]).astype(float)
|
|
415
|
+
v.q_state_up_group = pd.read_csv(output_path / 'vq_state_up_group.csv', index_col=[0, 1, 2]).astype(float)
|
|
416
|
+
v.q_capacity_margin = pd.read_csv(output_path / 'vq_capacity_margin.csv', index_col=[0, 1]).astype(float)
|
|
417
|
+
v.invest = pd.read_csv(output_path / 'v_invest.csv', index_col=[0, 1]).astype(float)
|
|
418
|
+
v.divest = pd.read_csv(output_path / 'v_divest.csv', index_col=[0, 1]).astype(float)
|
|
419
|
+
v.dual_node_balance = pd.read_csv(output_path / 'v_dual_node_balance.csv', index_col=[0, 1, 2]).astype(float)
|
|
420
|
+
v.dual_reserve_balance = pd.read_csv(output_path / 'v_dual_reserve__upDown__group__period__t.csv', header=[0, 1, 2], index_col=[0, 1, 2]).astype(float)
|
|
421
|
+
|
|
422
|
+
# DC power flow voltage angles (may be empty when no DC PF nodes exist)
|
|
423
|
+
angle_path = output_path / 'v_angle.csv'
|
|
424
|
+
if angle_path.exists():
|
|
425
|
+
v.angle = pd.read_csv(angle_path, index_col=[0, 1, 2]).astype(float)
|
|
426
|
+
if v.angle.empty:
|
|
427
|
+
v.angle = pd.DataFrame()
|
|
428
|
+
else:
|
|
429
|
+
v.angle.index.names = ['solve', 'period', 'time']
|
|
430
|
+
v.angle.columns.name = 'node'
|
|
431
|
+
else:
|
|
432
|
+
v.angle = pd.DataFrame()
|
|
433
|
+
|
|
434
|
+
v.dual_invest_unit = pd.read_csv(output_path / 'v_dual_invest_unit.csv', index_col=[0, 1]).astype(float)
|
|
435
|
+
v.dual_invest_connection = pd.read_csv(output_path / 'v_dual_invest_connection.csv', index_col=[0, 1]).astype(float)
|
|
436
|
+
v.dual_invest_node = pd.read_csv(output_path / 'v_dual_invest_node.csv', index_col=[0, 1]).astype(float)
|
|
437
|
+
# Investment constraint duals (per MW, unscaled)
|
|
438
|
+
v.dual_maxInvest_period = pd.read_csv(output_path / 'v_dual_maxInvest_period.csv', index_col=[0, 1]).astype(float)
|
|
439
|
+
v.dual_maxInvest_total = pd.read_csv(output_path / 'v_dual_maxInvest_total.csv', index_col=[0, 1]).astype(float)
|
|
440
|
+
v.dual_maxCumulative = pd.read_csv(output_path / 'v_dual_maxCumulative.csv', index_col=[0, 1]).astype(float)
|
|
441
|
+
v.dual_maxInvestGroup_period = pd.read_csv(output_path / 'v_dual_maxInvestGroup_period.csv', index_col=[0, 1]).astype(float)
|
|
442
|
+
v.dual_maxInvestGroup_total = pd.read_csv(output_path / 'v_dual_maxInvestGroup_total.csv', index_col=[0, 1]).astype(float)
|
|
443
|
+
v.dual_maxInvestGroup_cumulative = pd.read_csv(output_path / 'v_dual_maxInvestGroup_cumulative.csv', index_col=[0, 1]).astype(float)
|
|
444
|
+
# Investment-floor (min-side) duals (per MW, unscaled)
|
|
445
|
+
v.dual_minInvest_period = pd.read_csv(output_path / 'v_dual_minInvest_period.csv', index_col=[0, 1]).astype(float)
|
|
446
|
+
v.dual_minInvest_total = pd.read_csv(output_path / 'v_dual_minInvest_total.csv', index_col=[0, 1]).astype(float)
|
|
447
|
+
v.dual_minCumulative = pd.read_csv(output_path / 'v_dual_minCumulative.csv', index_col=[0, 1]).astype(float)
|
|
448
|
+
v.dual_minInvestGroup_period = pd.read_csv(output_path / 'v_dual_minInvestGroup_period.csv', index_col=[0, 1]).astype(float)
|
|
449
|
+
v.dual_minInvestGroup_total = pd.read_csv(output_path / 'v_dual_minInvestGroup_total.csv', index_col=[0, 1]).astype(float)
|
|
450
|
+
v.dual_minInvestGroup_cumulative = pd.read_csv(output_path / 'v_dual_minInvestGroup_cumulative.csv', index_col=[0, 1]).astype(float)
|
|
451
|
+
# CO2 emission-cap duals (raw dual is per /1000-scaled RHS; downstream * 1000 for tCO2)
|
|
452
|
+
v.dual_co2_max_period = pd.read_csv(output_path / 'v_dual_co2_max_period.csv', index_col=[0, 1]).astype(float)
|
|
453
|
+
v.dual_co2_max_total = pd.read_csv(output_path / 'v_dual_co2_max_total.csv', index_col=[0]).astype(float)
|
|
454
|
+
# group_entity_invest lives in input/ (solveFirst-gated static).
|
|
455
|
+
v.group_entity_invest = pd.read_csv(input_path / 'group_entity_invest.csv')
|
|
456
|
+
|
|
457
|
+
v.flow.index.names = ['solve', 'period', 'time']
|
|
458
|
+
v.ramp.index.names = ['solve', 'period', 'time']
|
|
459
|
+
v.reserve.index.names = ['solve', 'period', 'time']
|
|
460
|
+
v.state.index.names = ['solve', 'period', 'time']
|
|
461
|
+
v.online_linear.index.names = ['solve', 'period', 'time']
|
|
462
|
+
v.startup_linear.index.names = ['solve', 'period', 'time']
|
|
463
|
+
v.shutdown_linear.index.names = ['solve', 'period', 'time']
|
|
464
|
+
v.online_integer.index.names = ['solve', 'period', 'time']
|
|
465
|
+
v.startup_integer.index.names = ['solve', 'period', 'time']
|
|
466
|
+
v.shutdown_integer.index.names = ['solve', 'period', 'time']
|
|
467
|
+
v.q_state_up.index.names = ['solve', 'period', 'time']
|
|
468
|
+
v.q_state_down.index.names = ['solve', 'period', 'time']
|
|
469
|
+
v.q_reserve.index.names = ['solve', 'period', 'time']
|
|
470
|
+
v.q_inertia.index.names = ['solve', 'period', 'time']
|
|
471
|
+
v.q_non_synchronous.index.names = ['solve', 'period', 'time']
|
|
472
|
+
v.q_state_up_group.index.names = ['solve', 'period', 'time']
|
|
473
|
+
v.q_capacity_margin.index.names = ['solve', 'period']
|
|
474
|
+
v.invest.index.names = ['solve', 'period']
|
|
475
|
+
v.divest.index.names = ['solve', 'period']
|
|
476
|
+
v.dual_node_balance.index.names = ['solve', 'period', 'time']
|
|
477
|
+
v.dual_reserve_balance.index.names = ['solve', 'period', 'time']
|
|
478
|
+
v.dual_invest_unit.index.names = ['solve', 'period']
|
|
479
|
+
v.dual_invest_connection.index.names = ['solve', 'period']
|
|
480
|
+
v.dual_invest_node.index.names = ['solve', 'period']
|
|
481
|
+
v.dual_maxInvest_period.index.names = ['solve', 'period']
|
|
482
|
+
v.dual_maxInvest_total.index.names = ['solve', 'period']
|
|
483
|
+
v.dual_maxCumulative.index.names = ['solve', 'period']
|
|
484
|
+
v.dual_maxInvestGroup_period.index.names = ['solve', 'period']
|
|
485
|
+
v.dual_maxInvestGroup_total.index.names = ['solve', 'period']
|
|
486
|
+
v.dual_maxInvestGroup_cumulative.index.names = ['solve', 'period']
|
|
487
|
+
v.dual_minInvest_period.index.names = ['solve', 'period']
|
|
488
|
+
v.dual_minInvest_total.index.names = ['solve', 'period']
|
|
489
|
+
v.dual_minCumulative.index.names = ['solve', 'period']
|
|
490
|
+
v.dual_minInvestGroup_period.index.names = ['solve', 'period']
|
|
491
|
+
v.dual_minInvestGroup_total.index.names = ['solve', 'period']
|
|
492
|
+
v.dual_minInvestGroup_cumulative.index.names = ['solve', 'period']
|
|
493
|
+
v.dual_co2_max_period.index.names = ['solve', 'period']
|
|
494
|
+
v.dual_co2_max_total.index.name = 'solve'
|
|
495
|
+
|
|
496
|
+
# Create multi-index for variables with single header row
|
|
497
|
+
v.state.columns.name = 'node'
|
|
498
|
+
v.online_linear.columns.name = 'process'
|
|
499
|
+
v.startup_linear.columns.name = 'process'
|
|
500
|
+
v.shutdown_linear.columns.name = 'process'
|
|
501
|
+
v.online_integer.columns.name = 'process'
|
|
502
|
+
v.startup_integer.columns.name = 'process'
|
|
503
|
+
v.shutdown_integer.columns.name = 'process'
|
|
504
|
+
v.q_state_up.columns.name = 'node'
|
|
505
|
+
v.q_state_down.columns.name = 'node'
|
|
506
|
+
v.q_inertia.columns.name = 'group'
|
|
507
|
+
v.q_non_synchronous.columns.name = 'group'
|
|
508
|
+
v.q_state_up_group.columns.name = 'group'
|
|
509
|
+
v.q_capacity_margin.columns.name = 'group'
|
|
510
|
+
v.invest.columns.name = 'entity'
|
|
511
|
+
v.divest.columns.name = 'entity'
|
|
512
|
+
v.dual_node_balance.columns.name = 'node'
|
|
513
|
+
v.dual_invest_unit.columns.name = 'unit'
|
|
514
|
+
v.dual_invest_connection.columns.name = 'connection'
|
|
515
|
+
v.dual_invest_node.columns.name = 'node'
|
|
516
|
+
v.dual_maxInvest_period.columns.name = 'entity'
|
|
517
|
+
v.dual_maxInvest_total.columns.name = 'entity'
|
|
518
|
+
v.dual_maxCumulative.columns.name = 'entity'
|
|
519
|
+
v.dual_maxInvestGroup_period.columns.name = 'group'
|
|
520
|
+
v.dual_maxInvestGroup_total.columns.name = 'group'
|
|
521
|
+
v.dual_maxInvestGroup_cumulative.columns.name = 'group'
|
|
522
|
+
v.dual_minInvest_period.columns.name = 'entity'
|
|
523
|
+
v.dual_minInvest_total.columns.name = 'entity'
|
|
524
|
+
v.dual_minCumulative.columns.name = 'entity'
|
|
525
|
+
v.dual_minInvestGroup_period.columns.name = 'group'
|
|
526
|
+
v.dual_minInvestGroup_total.columns.name = 'group'
|
|
527
|
+
v.dual_minInvestGroup_cumulative.columns.name = 'group'
|
|
528
|
+
v.dual_co2_max_period.columns.name = 'group'
|
|
529
|
+
v.dual_co2_max_total.columns.name = 'group'
|
|
530
|
+
|
|
531
|
+
# Add multi-index to variables with multiple header rows (this multi-index creation works also when the dataframe is empty)
|
|
532
|
+
v.flow.columns = pd.MultiIndex.from_tuples(
|
|
533
|
+
[(col[0], col[1], col[2]) for col in v.flow.columns],
|
|
534
|
+
names=['process', 'source', 'sink']
|
|
535
|
+
)
|
|
536
|
+
v.ramp.columns = pd.MultiIndex.from_tuples(
|
|
537
|
+
[(col[0], col[1], col[2]) for col in v.ramp.columns],
|
|
538
|
+
names=['process', 'source', 'sink']
|
|
539
|
+
)
|
|
540
|
+
v.reserve.columns = pd.MultiIndex.from_tuples(
|
|
541
|
+
[(col[0], col[1], col[2], col[3]) for col in v.reserve.columns],
|
|
542
|
+
names=['process', 'reserve', 'updown', 'node']
|
|
543
|
+
)
|
|
544
|
+
v.q_reserve.columns = pd.MultiIndex.from_tuples(
|
|
545
|
+
[(col[0], col[1], col[2]) for col in v.q_reserve.columns],
|
|
546
|
+
names=['reserve', 'updown', 'node_group']
|
|
547
|
+
)
|
|
548
|
+
v.dual_reserve_balance.columns = pd.MultiIndex.from_tuples(
|
|
549
|
+
[(col[0], col[1], col[2]) for col in v.dual_reserve_balance.columns],
|
|
550
|
+
names=['reserve', 'updown', 'node_group']
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
return v
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Solve creation-order helper.
|
|
2
|
+
|
|
3
|
+
Reads ``solve_data/solve__p_entity_pre_existing.csv`` (a per-solve parameter
|
|
4
|
+
that's appended on every solve and that's present for every realistic
|
|
5
|
+
model) to recover the order in which solves were executed.
|
|
6
|
+
|
|
7
|
+
Used by :mod:`read_variables._read_from_parquet` to concatenate per-solve
|
|
8
|
+
parquets in solve creation order, so the resulting row order matches
|
|
9
|
+
the CSV-reader pathway (where rows are naturally in creation order via
|
|
10
|
+
phase-1 ``for {s in solve_current, ...}`` printf appends). Matching
|
|
11
|
+
row order at the source avoids needing any post-concat sort or
|
|
12
|
+
per-call-site reindex.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Creation-order manifest written incrementally by the per-roll output
|
|
22
|
+
# hook (one line per sub-solve, in execution order). This is the
|
|
23
|
+
# authoritative order source on the default ``keep_solutions=False`` flow,
|
|
24
|
+
# where ``solve_data/solve__p_entity_pre_existing.csv`` is absent.
|
|
25
|
+
_MANIFEST_NAME = "_solve_order.txt"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def append_solve_order(output_dir: Path | str, solve_name: str) -> None:
|
|
29
|
+
"""Append ``solve_name`` to the creation-order manifest in ``output_dir``.
|
|
30
|
+
|
|
31
|
+
Idempotent within a run only in the sense that re-running the cascade
|
|
32
|
+
into the same folder must start from a clean manifest — callers that
|
|
33
|
+
re-run must clear ``output_dir`` first (the cascade does). Called
|
|
34
|
+
once per sub-solve, in execution order, so the file's line order is
|
|
35
|
+
the cascade creation order both the variable reader and the stage-3
|
|
36
|
+
param/set union read back via :func:`load_solve_order`.
|
|
37
|
+
"""
|
|
38
|
+
out = Path(output_dir)
|
|
39
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with (out / _MANIFEST_NAME).open("a", encoding="utf-8") as f:
|
|
41
|
+
f.write(f"{solve_name}\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_from_manifest(output_dir: Path) -> dict[str, int]:
|
|
45
|
+
path = output_dir / _MANIFEST_NAME
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return {}
|
|
48
|
+
seen: list[str] = []
|
|
49
|
+
seen_set: set[str] = set()
|
|
50
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
51
|
+
name = line.strip()
|
|
52
|
+
if name and name not in seen_set:
|
|
53
|
+
seen_set.add(name)
|
|
54
|
+
seen.append(name)
|
|
55
|
+
return {s: i for i, s in enumerate(seen)}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_solve_order(work_folder: Path | str) -> dict[str, int]:
|
|
59
|
+
"""Map solve names to their creation order (0-indexed).
|
|
60
|
+
|
|
61
|
+
Sources, in priority:
|
|
62
|
+
|
|
63
|
+
1. ``output_raw/_solve_order.txt`` — the per-roll manifest appended by
|
|
64
|
+
the output hook (:func:`append_solve_order`). Authoritative on the
|
|
65
|
+
default ``keep_solutions=False`` flow.
|
|
66
|
+
2. ``solve_data/solve__p_entity_pre_existing.csv`` — legacy per-solve
|
|
67
|
+
appendage; unique solves in row order = creation order.
|
|
68
|
+
|
|
69
|
+
Returns ``{}`` if neither source is present.
|
|
70
|
+
"""
|
|
71
|
+
work = Path(work_folder)
|
|
72
|
+
manifest = _load_from_manifest(work / "output_raw")
|
|
73
|
+
if manifest:
|
|
74
|
+
return manifest
|
|
75
|
+
|
|
76
|
+
path = work / "solve_data" / "solve__p_entity_pre_existing.csv"
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return {}
|
|
79
|
+
df = pd.read_csv(path, usecols=["solve"])
|
|
80
|
+
solves = df["solve"].astype(str).drop_duplicates().tolist()
|
|
81
|
+
return {s: i for i, s in enumerate(solves)}
|