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,1286 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
import warnings
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import matplotlib
|
|
11
|
+
matplotlib.use('Agg') # Use non-interactive backend; must precede any pyplot import
|
|
12
|
+
import pandas as pd
|
|
13
|
+
import yaml
|
|
14
|
+
from spinedb_api import DatabaseMapping, from_database, Array
|
|
15
|
+
|
|
16
|
+
from flextool.lean_parquet import read_lean_parquet, write_lean_parquet
|
|
17
|
+
from flextool.process_outputs._output_meta import (
|
|
18
|
+
datapackage_resource,
|
|
19
|
+
derive_column_meta,
|
|
20
|
+
output_metadata_rows,
|
|
21
|
+
)
|
|
22
|
+
from flextool.plot_outputs.orchestrator import plot_dict_of_dataframes
|
|
23
|
+
from flextool.plot_outputs.color_template import resolve_plot_settings_path
|
|
24
|
+
from flextool.process_outputs.out_ancillary import (
|
|
25
|
+
connection, connection_wards, connection_cf,
|
|
26
|
+
reserves, investment_duals, inertia_results,
|
|
27
|
+
slack_variables, input_sets, dc_power_flow,
|
|
28
|
+
co2_duals,
|
|
29
|
+
)
|
|
30
|
+
from flextool.process_outputs.out_capacity import unit_capacity, connection_capacity, node_capacity
|
|
31
|
+
from flextool.process_outputs.out_costs import (
|
|
32
|
+
generic, cost_summaries, cost_breakdown_by_entity, CO2,
|
|
33
|
+
)
|
|
34
|
+
from flextool.process_outputs.out_flowgroup import flowGroup_indicators
|
|
35
|
+
from flextool.process_outputs.out_flows import (
|
|
36
|
+
unit_outputNode, unit_inputNode,
|
|
37
|
+
unit_cf_outputNode, unit_cf_inputNode,
|
|
38
|
+
unit_VRE_curtailment_and_potential, unit_ramps,
|
|
39
|
+
unit_online_and_startup,
|
|
40
|
+
)
|
|
41
|
+
from flextool.process_outputs.out_group import (
|
|
42
|
+
nodeGroup_indicators, nodeGroup_VRE_share,
|
|
43
|
+
nodeGroup_total_inflow, nodeGroup_flows,
|
|
44
|
+
)
|
|
45
|
+
from flextool.process_outputs.out_node import node_summary, node_additional_results
|
|
46
|
+
from flextool.process_outputs.process_results import post_process_results
|
|
47
|
+
from flextool.process_outputs.read_parameters import (
|
|
48
|
+
read_parameters,
|
|
49
|
+
read_parameters_multi,
|
|
50
|
+
)
|
|
51
|
+
from flextool.process_outputs.read_sets import (
|
|
52
|
+
read_sets,
|
|
53
|
+
read_sets_multi,
|
|
54
|
+
)
|
|
55
|
+
from flextool.process_outputs.read_variables import read_variables
|
|
56
|
+
from flextool.process_outputs.union_realized_slice import (
|
|
57
|
+
has_persisted_slices,
|
|
58
|
+
union_params,
|
|
59
|
+
union_sets,
|
|
60
|
+
)
|
|
61
|
+
from flextool.process_outputs.write_spinedb import write_spinedb
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_rename_entry(entry) -> tuple[str, bool]:
|
|
65
|
+
"""Parse a rename map value: ``[display_name, export_to_excel]``.
|
|
66
|
+
|
|
67
|
+
Returns (display_name, export_to_excel).
|
|
68
|
+
"""
|
|
69
|
+
if isinstance(entry, list) and len(entry) >= 2:
|
|
70
|
+
return str(entry[0]), bool(entry[1])
|
|
71
|
+
# Bare string (legacy) — treat as name with export=True
|
|
72
|
+
return str(entry), True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _frame_axes(df) -> tuple[list, list]:
|
|
76
|
+
"""Return ``(index_level_names, measure_columns)`` for a DataFrame or
|
|
77
|
+
Series — the dimension + measure axes the CSV datapackage describes.
|
|
78
|
+
|
|
79
|
+
A Series (e.g. a single-value summary row) has its ``name`` as its one
|
|
80
|
+
measure column, ``'value'`` when unnamed.
|
|
81
|
+
"""
|
|
82
|
+
index_names = list(df.index.names)
|
|
83
|
+
if isinstance(df, pd.Series):
|
|
84
|
+
return index_names, [df.name if df.name is not None else 'value']
|
|
85
|
+
return index_names, list(df.columns)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write_excel_with_metadata(excel_path, sheets) -> None:
|
|
89
|
+
"""Write ``sheets`` to ``excel_path`` with embedded output metadata.
|
|
90
|
+
|
|
91
|
+
``sheets`` is a list of ``(sheet_name, raw_output_key, df)``. Each frame
|
|
92
|
+
is written to its own sheet; per-column unit/semantics (derived from the
|
|
93
|
+
metadata catalog via the raw output key) are surfaced two ways:
|
|
94
|
+
|
|
95
|
+
* header-cell **comments** (hover notes) on the measure columns of
|
|
96
|
+
single-level-column frames, and
|
|
97
|
+
* a trailing ``_output_metadata`` index sheet listing every documented
|
|
98
|
+
``(sheet, output, column, unit, semantics, tooltip, formula)``.
|
|
99
|
+
|
|
100
|
+
The data grid itself is untouched — metadata rides alongside it.
|
|
101
|
+
"""
|
|
102
|
+
meta_rows: list[dict] = []
|
|
103
|
+
with pd.ExcelWriter(excel_path, engine='xlsxwriter') as writer:
|
|
104
|
+
for sheet_name, raw_key, df in sheets:
|
|
105
|
+
df.to_excel(writer, sheet_name=sheet_name)
|
|
106
|
+
_, measure_cols = _frame_axes(df)
|
|
107
|
+
rows = output_metadata_rows(raw_key, measure_cols)
|
|
108
|
+
for r in rows:
|
|
109
|
+
meta_rows.append({"sheet": sheet_name, **r})
|
|
110
|
+
# Header comments only for single-level DataFrame columns — pandas
|
|
111
|
+
# writes multi-level column headers across several rows (plus an
|
|
112
|
+
# index-name row), which the simple (0, nidx+j) anchor below would
|
|
113
|
+
# not address correctly; the _output_metadata sheet still covers
|
|
114
|
+
# those (and Series-valued sheets).
|
|
115
|
+
if rows and isinstance(df, pd.DataFrame) and df.columns.nlevels == 1:
|
|
116
|
+
worksheet = writer.sheets[sheet_name]
|
|
117
|
+
nidx = df.index.nlevels
|
|
118
|
+
by_col = {r["column"]: r for r in rows}
|
|
119
|
+
for j, col in enumerate(df.columns):
|
|
120
|
+
r = by_col.get(str(col))
|
|
121
|
+
if r is None:
|
|
122
|
+
continue
|
|
123
|
+
note = [f"unit: {r['unit'] or '—'}",
|
|
124
|
+
f"semantics: {r['semantics']}"]
|
|
125
|
+
if r["tooltip"]:
|
|
126
|
+
note.append(r["tooltip"])
|
|
127
|
+
if r["formula"]:
|
|
128
|
+
note.append(f"formula: {r['formula']}")
|
|
129
|
+
worksheet.write_comment(0, nidx + j, "\n".join(note))
|
|
130
|
+
if meta_rows:
|
|
131
|
+
meta_df = pd.DataFrame(
|
|
132
|
+
meta_rows,
|
|
133
|
+
columns=["sheet", "output", "column", "unit",
|
|
134
|
+
"semantics", "tooltip", "long", "formula"],
|
|
135
|
+
)
|
|
136
|
+
meta_df.to_excel(writer, sheet_name="_output_metadata", index=False)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _provider_lookup_df(provider, parent: str, stem: str):
|
|
140
|
+
"""Return the polars frame for ``<parent>/<stem>`` from *provider*,
|
|
141
|
+
or ``None`` when the Provider doesn't carry it.
|
|
142
|
+
|
|
143
|
+
Provider key convention matches the emit_* writers' ``_emit(...)``
|
|
144
|
+
callsites — ``"<parent>/<basename-without-csv>"`` (see
|
|
145
|
+
:mod:`flextool.engine_polars._emit_provider_io`). The cascade keeps
|
|
146
|
+
these frames in memory only (no on-disk fallback), so a ``None``
|
|
147
|
+
*provider* yields ``None`` for every key.
|
|
148
|
+
"""
|
|
149
|
+
if provider is None:
|
|
150
|
+
return None
|
|
151
|
+
name = f"{parent}/{stem}" if parent else stem
|
|
152
|
+
if provider.has(name):
|
|
153
|
+
return provider.get(name)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _backfill_group_indicator_sets(s, output_dir, *, provider=None):
|
|
158
|
+
"""Populate ``nodeGroupDispatch`` / ``*Indicators`` from cascade outputs.
|
|
159
|
+
|
|
160
|
+
These three sets are derived from per-group user parameters
|
|
161
|
+
(``output_nodeGroup_dispatch`` / ``output_nodeGroup_indicators`` /
|
|
162
|
+
``output_flowGroup_indicators``). No FlexData field carries them
|
|
163
|
+
today; ``read_sets`` therefore hardcodes them empty. Without this
|
|
164
|
+
backfill, every ``nodeGroup_flows`` / ``flowGroup_indicators`` writer
|
|
165
|
+
short-circuits to "empty" and the group_flows__dt / group_flows__d /
|
|
166
|
+
flowGroup CSVs go missing.
|
|
167
|
+
|
|
168
|
+
Also backfills ``s.group_process_node`` — the flowGroup membership
|
|
169
|
+
MultiIndex consumed by ``out_flowgroup.flowGroup_indicators`` — from the
|
|
170
|
+
Provider key ``input/flowGroup__process__node``. ``read_sets`` leaves it
|
|
171
|
+
empty (no FlexData field carries it), so without this backfill the
|
|
172
|
+
flowGroup indicators sum nothing and emit all-zero rows in production.
|
|
173
|
+
|
|
174
|
+
Also backfills the 12 ``nodeGroupDispatch__*`` arc-union MultiIndex
|
|
175
|
+
sets emitted by ``flextool/engine_polars/_emit_arc_unions.py``.
|
|
176
|
+
Without these, ``calc_group_flows`` finds zero rows for the unit /
|
|
177
|
+
connection aggregator joins, and ``out_group.nodeGroup_flows`` emits
|
|
178
|
+
only the slack/inflow/loss column families — group_flows__dt loses
|
|
179
|
+
its ``from_unitGroup`` / ``from_unit`` / ``from_connectionGroup`` /
|
|
180
|
+
``to_connectionGroup`` / per-connection ``internal_losses`` columns.
|
|
181
|
+
|
|
182
|
+
Provider-only: reads come from the per-sub-solve
|
|
183
|
+
``FlexDataProvider`` keyed under ``"input/<basename>"`` /
|
|
184
|
+
``"solve_data/<basename>"`` — the writer→emitter refactor no longer
|
|
185
|
+
flushes these to disk on the default cascade path, so there is no
|
|
186
|
+
on-disk fallback (matching the B6 ``_emit_arc_unions`` precedent).
|
|
187
|
+
|
|
188
|
+
``provider`` contract (STRICT): on an in-memory output path a
|
|
189
|
+
Provider MUST exist. This function is only reached on the in-memory
|
|
190
|
+
read path (the disk/parquet re-plot path short-circuits earlier), so
|
|
191
|
+
a ``None`` *provider* is a wiring bug — it would silently drop the
|
|
192
|
+
group-flow output families (``from_unitGroup`` /
|
|
193
|
+
``from_connectionGroup`` / ``from_unit`` / ``to_connectionGroup``)
|
|
194
|
+
from ``group_flows__dt.csv``. We therefore RAISE rather than warn-
|
|
195
|
+
and-continue.
|
|
196
|
+
|
|
197
|
+
Emptiness is legitimate, but it must be *stated* by a Provider that
|
|
198
|
+
yields nothing for the group keys, NOT by a missing Provider. An
|
|
199
|
+
in-memory caller with no groups must pass an explicit empty
|
|
200
|
+
``FlexDataProvider`` — the keyed frames are then absent → the sets
|
|
201
|
+
stay empty without any error, which is the correct tolerant
|
|
202
|
+
behaviour for a genuinely group-less model.
|
|
203
|
+
"""
|
|
204
|
+
if provider is None:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
"_backfill_group_indicator_sets: no flex_data_provider was "
|
|
207
|
+
"wired through write_outputs on an in-memory output path. On "
|
|
208
|
+
"an in-memory path a Provider is REQUIRED: empty group sets "
|
|
209
|
+
"must be stated by an (empty) FlexDataProvider, not by a "
|
|
210
|
+
"missing one. Production callers must forward "
|
|
211
|
+
"last_step.flex_data_provider; an in-memory run with no groups "
|
|
212
|
+
"must pass an explicit empty FlexDataProvider()."
|
|
213
|
+
)
|
|
214
|
+
for attr, fname in (
|
|
215
|
+
('nodeGroupDispatch', 'nodeGroupDispatch.csv'),
|
|
216
|
+
('nodeGroupIndicators', 'nodeGroupIndicators.csv'),
|
|
217
|
+
('flowGroupIndicators', 'flowGroupIndicators.csv'),
|
|
218
|
+
):
|
|
219
|
+
stem = fname[:-4]
|
|
220
|
+
df_pl = _provider_lookup_df(provider, "input", stem)
|
|
221
|
+
if df_pl is None:
|
|
222
|
+
continue
|
|
223
|
+
if df_pl.is_empty() or df_pl.width == 0:
|
|
224
|
+
continue
|
|
225
|
+
col0 = df_pl.columns[0]
|
|
226
|
+
vals = df_pl.select(col0).to_series().drop_nulls().to_list()
|
|
227
|
+
if not vals:
|
|
228
|
+
continue
|
|
229
|
+
setattr(s, attr, pd.Index(vals, name='group'))
|
|
230
|
+
|
|
231
|
+
# --- flowGroup membership (group_process_node) ---------------------------
|
|
232
|
+
# ``read_sets`` hardcodes ``s.group_process_node`` empty (no FlexData
|
|
233
|
+
# field carries it), so without this backfill ``out_flowgroup`` sums
|
|
234
|
+
# nothing and emits all-zero rows. Source = the cascade Provider key
|
|
235
|
+
# ``input/flowGroup__process__node`` (3 cols: flowGroup, process, node).
|
|
236
|
+
# ``solve_data/group_process_node`` is ABSENT on the cascade path. A
|
|
237
|
+
# missing key yields ``None`` here → the set stays empty, which is the
|
|
238
|
+
# correct tolerant behaviour for a group-less model.
|
|
239
|
+
gpn_pl = _provider_lookup_df(provider, "input", "flowGroup__process__node")
|
|
240
|
+
if gpn_pl is not None and not gpn_pl.is_empty() and gpn_pl.width >= 3:
|
|
241
|
+
gpn = gpn_pl.to_pandas()
|
|
242
|
+
gpn.columns = ['group', 'process', 'node']
|
|
243
|
+
s.group_process_node = pd.MultiIndex.from_frame(
|
|
244
|
+
gpn[['group', 'process', 'node']], names=['group', 'process', 'node'])
|
|
245
|
+
|
|
246
|
+
# --- 12 nodeGroupDispatch__* arc-union sets (from solve_data/) -----------
|
|
247
|
+
# Each entry: (attr-on-s, filename, csv→multi-index column map).
|
|
248
|
+
# The column map is ``[(csv_col, level_name), ...]`` — same CSV column may
|
|
249
|
+
# appear under multiple level names (e.g. ``unit`` populates both
|
|
250
|
+
# ``process`` and ``unit`` in the unit-aggregator sets where process==unit).
|
|
251
|
+
dispatch_specs = (
|
|
252
|
+
# 4-col / 2-col Not-in-aggregate sets — CSV columns map 1:1 to levels.
|
|
253
|
+
('nodeGroupDispatch__process_fully_inside',
|
|
254
|
+
'nodeGroupDispatch__process_fully_inside.csv',
|
|
255
|
+
(('group', 'group'), ('process', 'process'))),
|
|
256
|
+
('nodeGroupDispatch__process__unit__to_node_Not_in_aggregate',
|
|
257
|
+
'nodeGroupDispatch__process__unit__to_node_Not_in_aggregate.csv',
|
|
258
|
+
(('group', 'group'), ('process', 'process'),
|
|
259
|
+
('unit', 'unit'), ('node', 'node'))),
|
|
260
|
+
('nodeGroupDispatch__process__node__to_unit_Not_in_aggregate',
|
|
261
|
+
'nodeGroupDispatch__process__node__to_unit_Not_in_aggregate.csv',
|
|
262
|
+
(('group', 'group'), ('process', 'process'),
|
|
263
|
+
('node', 'node'), ('unit', 'unit'))),
|
|
264
|
+
('nodeGroupDispatch__process__connection__to_node_Not_in_aggregate',
|
|
265
|
+
'nodeGroupDispatch__process__connection__to_node_Not_in_aggregate.csv',
|
|
266
|
+
(('group', 'group'), ('process', 'process'),
|
|
267
|
+
('connection', 'connection'), ('node', 'node'))),
|
|
268
|
+
('nodeGroupDispatch__process__node__to_connection_Not_in_aggregate',
|
|
269
|
+
'nodeGroupDispatch__process__node__to_connection_Not_in_aggregate.csv',
|
|
270
|
+
(('group', 'group'), ('process', 'process'),
|
|
271
|
+
('node', 'node'), ('connection', 'connection'))),
|
|
272
|
+
('nodeGroupDispatch__connection_Not_in_aggregate',
|
|
273
|
+
'nodeGroupDispatch__connection_Not_in_aggregate.csv',
|
|
274
|
+
(('group', 'group'), ('connection', 'connection'))),
|
|
275
|
+
# Unit aggregator sets: CSV stores (group, group_aggregate, unit,
|
|
276
|
+
# source, sink); a unit's process name equals its unit name, and the
|
|
277
|
+
# node is the sink for to-node side / source for to-unit side.
|
|
278
|
+
('nodeGroupDispatch__processGroup__process__unit__to_node',
|
|
279
|
+
'nodeGroupDispatch__group_aggregate__process__unit__to_node.csv',
|
|
280
|
+
(('group', 'group'), ('group_aggregate', 'group_aggregate'),
|
|
281
|
+
('unit', 'process'), ('unit', 'unit'), ('sink', 'node'))),
|
|
282
|
+
('nodeGroupDispatch__processGroup__process__node__to_unit',
|
|
283
|
+
'nodeGroupDispatch__group_aggregate__process__node__to_unit.csv',
|
|
284
|
+
(('group', 'group'), ('group_aggregate', 'group_aggregate'),
|
|
285
|
+
('unit', 'process'), ('source', 'node'), ('unit', 'unit'))),
|
|
286
|
+
# Connection aggregator sets: CSV stores (group, group_aggregate,
|
|
287
|
+
# connection, source, sink); process==connection, node side depends.
|
|
288
|
+
('nodeGroupDispatch__processGroup__process__connection__to_node',
|
|
289
|
+
'nodeGroupDispatch__group_aggregate__process__connection__to_node.csv',
|
|
290
|
+
(('group', 'group'), ('group_aggregate', 'group_aggregate'),
|
|
291
|
+
('connection', 'process'), ('connection', 'connection'),
|
|
292
|
+
('sink', 'node'))),
|
|
293
|
+
('nodeGroupDispatch__processGroup__process__node__to_connection',
|
|
294
|
+
'nodeGroupDispatch__group_aggregate__process__node__to_connection.csv',
|
|
295
|
+
(('group', 'group'), ('group_aggregate', 'group_aggregate'),
|
|
296
|
+
('connection', 'process'), ('source', 'node'),
|
|
297
|
+
('connection', 'connection'))),
|
|
298
|
+
# 2-col projection sets — (group, group_aggregate).
|
|
299
|
+
('nodeGroupDispatch__processGroup_Unit_to_group',
|
|
300
|
+
'nodeGroupDispatch__group_aggregate_Unit_to_group.csv',
|
|
301
|
+
(('group', 'group'), ('group_aggregate', 'group_aggregate'))),
|
|
302
|
+
('nodeGroupDispatch__processGroup_Group_to_unit',
|
|
303
|
+
'nodeGroupDispatch__group_aggregate_Group_to_unit.csv',
|
|
304
|
+
(('group', 'group'), ('group_aggregate', 'group_aggregate'))),
|
|
305
|
+
('nodeGroupDispatch__processGroup_Connection',
|
|
306
|
+
'nodeGroupDispatch__group_aggregate_Connection.csv',
|
|
307
|
+
(('group', 'group'), ('group_aggregate', 'group_aggregate'))),
|
|
308
|
+
)
|
|
309
|
+
for attr, fname, col_map in dispatch_specs:
|
|
310
|
+
stem = fname[:-4]
|
|
311
|
+
df_pl = _provider_lookup_df(provider, "solve_data", stem)
|
|
312
|
+
if df_pl is None or df_pl.is_empty():
|
|
313
|
+
continue
|
|
314
|
+
df = df_pl.to_pandas()
|
|
315
|
+
# Skip if frame is missing any required source column.
|
|
316
|
+
csv_cols = [c for c, _ in col_map]
|
|
317
|
+
if not all(c in df.columns for c in csv_cols):
|
|
318
|
+
continue
|
|
319
|
+
level_names = [lvl for _, lvl in col_map]
|
|
320
|
+
arrays = [df[c].tolist() for c, _ in col_map]
|
|
321
|
+
mi = pd.MultiIndex.from_arrays(arrays, names=level_names)
|
|
322
|
+
setattr(s, attr, mi)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _read_outputs(
|
|
326
|
+
output_dir,
|
|
327
|
+
*,
|
|
328
|
+
flex_data=None,
|
|
329
|
+
solution=None,
|
|
330
|
+
solve_name=None,
|
|
331
|
+
solve_steps=None,
|
|
332
|
+
flex_data_provider=None,
|
|
333
|
+
):
|
|
334
|
+
"""Read solver output files and return (par, s, v).
|
|
335
|
+
|
|
336
|
+
``flex_data`` + ``solution`` are required for the in-memory
|
|
337
|
+
parameter / set reading. Variables (parquets) are read from
|
|
338
|
+
``output_raw/`` where the polars-LP write path emits them.
|
|
339
|
+
|
|
340
|
+
``solve_steps`` (multi-solve): an ordered list of
|
|
341
|
+
``(solve_name, FlexData)`` pairs covering every sub-solve (roll)
|
|
342
|
+
of the orchestration cascade. When supplied, ``par`` / ``s`` are
|
|
343
|
+
built as the union over every sub-solve's dt axis — matching the
|
|
344
|
+
``solve``-axis union ``v`` carries from per-sub-solve parquet
|
|
345
|
+
aggregation. Falls back to the single-``flex_data`` path when
|
|
346
|
+
not supplied (single-solve scenarios).
|
|
347
|
+
"""
|
|
348
|
+
# Multi-solve output union (stage 3) — when the per-roll realized
|
|
349
|
+
# param/set slices have been persisted to ``output_dir`` (the
|
|
350
|
+
# ``keep_solutions=False`` memory-flat normal flow,
|
|
351
|
+
# ``persist_realized_slice``), assemble ``par`` / ``s`` by unioning
|
|
352
|
+
# those parquets instead of holding every roll's flex_data/solution
|
|
353
|
+
# in memory. This is byte-equivalent to the in-memory
|
|
354
|
+
# ``read_parameters_multi`` / ``read_sets_multi`` oracle (the persisted
|
|
355
|
+
# slices are already realized-filtered + post-hack), but works on the
|
|
356
|
+
# default flow where prior steps' flex_data/solution are nulled — so
|
|
357
|
+
# it replaces the broken single-flex_data last-roll fallback. The
|
|
358
|
+
# last step's ``flex_data`` / ``solution`` supply the static
|
|
359
|
+
# (solve-invariant) attrs + the per-attr shape template.
|
|
360
|
+
if (
|
|
361
|
+
solve_steps is None
|
|
362
|
+
and flex_data is not None
|
|
363
|
+
and solution is not None
|
|
364
|
+
and has_persisted_slices(output_dir)
|
|
365
|
+
):
|
|
366
|
+
last_par = read_parameters(
|
|
367
|
+
flex_data, solution, solve_name=solve_name or "solve"
|
|
368
|
+
)
|
|
369
|
+
last_s = read_sets(
|
|
370
|
+
flex_data, solution, solve_name=solve_name or "solve"
|
|
371
|
+
)
|
|
372
|
+
p = union_params(last_par, output_dir)
|
|
373
|
+
s = union_sets(last_s, output_dir)
|
|
374
|
+
_backfill_group_indicator_sets(s, output_dir, provider=flex_data_provider)
|
|
375
|
+
v = read_variables(output_dir)
|
|
376
|
+
return p, s, v
|
|
377
|
+
if solve_steps is not None:
|
|
378
|
+
if solution is None:
|
|
379
|
+
raise ValueError(
|
|
380
|
+
"_read_outputs requires solution alongside solve_steps."
|
|
381
|
+
)
|
|
382
|
+
p = read_parameters_multi(solve_steps, solution)
|
|
383
|
+
s = read_sets_multi(solve_steps, solution)
|
|
384
|
+
_backfill_group_indicator_sets(s, output_dir, provider=flex_data_provider)
|
|
385
|
+
v = read_variables(output_dir)
|
|
386
|
+
return p, s, v
|
|
387
|
+
if flex_data is None or solution is None:
|
|
388
|
+
raise ValueError(
|
|
389
|
+
"_read_outputs requires flex_data and solution; "
|
|
390
|
+
"the CSV-based read_parameters / read_sets path is gone."
|
|
391
|
+
)
|
|
392
|
+
p = read_parameters(flex_data, solution, solve_name=solve_name or "solve")
|
|
393
|
+
s = read_sets(flex_data, solution, solve_name=solve_name or "solve")
|
|
394
|
+
_backfill_group_indicator_sets(s, output_dir, provider=flex_data_provider)
|
|
395
|
+
v = read_variables(output_dir)
|
|
396
|
+
return p, s, v
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def log_time(log_string, start, timing_recorder=None):
|
|
400
|
+
"""Print and record one ``write_outputs`` sub-phase.
|
|
401
|
+
|
|
402
|
+
Feeds the ``TimingRecorder`` when supplied. The ``print(...)`` line
|
|
403
|
+
is kept for human-readable stdout — that's the file most operators
|
|
404
|
+
eyeball.
|
|
405
|
+
"""
|
|
406
|
+
elapsed = time.perf_counter() - start
|
|
407
|
+
print(f"---{log_string}: {elapsed:.4f} seconds")
|
|
408
|
+
if timing_recorder is not None:
|
|
409
|
+
timing_recorder.record(
|
|
410
|
+
'write_outputs',
|
|
411
|
+
subphase=log_string,
|
|
412
|
+
seconds=elapsed,
|
|
413
|
+
t_start=start,
|
|
414
|
+
)
|
|
415
|
+
return time.perf_counter()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def print_namespace_structure(namespace, name='r', max_items=3, output_file='namespace_structure.txt'):
|
|
419
|
+
import pandas as pd
|
|
420
|
+
import sys
|
|
421
|
+
|
|
422
|
+
def format_list(items, max_n=max_items):
|
|
423
|
+
items_list = list(items)
|
|
424
|
+
if len(items_list) <= max_n:
|
|
425
|
+
return items_list
|
|
426
|
+
return items_list[:max_n] + [f'... ({len(items_list)} total)']
|
|
427
|
+
|
|
428
|
+
with open(output_file, 'a') as f:
|
|
429
|
+
original_stdout = sys.stdout
|
|
430
|
+
sys.stdout = f
|
|
431
|
+
|
|
432
|
+
for attr_name in dir(namespace):
|
|
433
|
+
if attr_name.startswith('_'):
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
obj = getattr(namespace, attr_name)
|
|
437
|
+
print(f"\n{name}.{attr_name}")
|
|
438
|
+
|
|
439
|
+
if isinstance(obj, pd.DataFrame):
|
|
440
|
+
print("Type: DataFrame")
|
|
441
|
+
print(f"Shape: {obj.shape}")
|
|
442
|
+
print(f"Index: {obj.index.names if hasattr(obj.index, 'names') else 'default'}")
|
|
443
|
+
print(f"Columns: {format_list(obj.columns)}")
|
|
444
|
+
print(f"Dtypes:\n{obj.dtypes}")
|
|
445
|
+
|
|
446
|
+
elif isinstance(obj, pd.Series):
|
|
447
|
+
print("Type: Series")
|
|
448
|
+
print(f"Shape: {obj.shape}")
|
|
449
|
+
print(f"Index: {obj.index.name or 'default'}")
|
|
450
|
+
print(f"Dtype: {obj.dtype}")
|
|
451
|
+
|
|
452
|
+
elif isinstance(obj, pd.Index):
|
|
453
|
+
print("Type: Index")
|
|
454
|
+
print(f"Name: {obj.name}")
|
|
455
|
+
print(f"Values: {format_list(obj)}")
|
|
456
|
+
print(f"Dtype: {obj.dtype}")
|
|
457
|
+
|
|
458
|
+
sys.stdout = original_stdout
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def write_summary_csv(par, s, v, r, csv_dir):
|
|
462
|
+
"""Write summary CSV file matching the GNU MathProg format"""
|
|
463
|
+
import os
|
|
464
|
+
|
|
465
|
+
# Output file path
|
|
466
|
+
fn_summary = os.path.join(csv_dir, 'summary_solve.csv')
|
|
467
|
+
|
|
468
|
+
# Get common parameters - these are Series indexed by period
|
|
469
|
+
p_inflation_factor_operations_yearly = par.inflation_factor_operations_yearly
|
|
470
|
+
p_inflation_factor_investment_yearly = par.inflation_factor_investment_yearly
|
|
471
|
+
complete_period_share_of_year = par.complete_period_share_of_year
|
|
472
|
+
|
|
473
|
+
# Get period sets
|
|
474
|
+
period_in_use = complete_period_share_of_year.index
|
|
475
|
+
d_realized_period = s.d_realized_period
|
|
476
|
+
|
|
477
|
+
# Open file and write all content
|
|
478
|
+
with open(fn_summary, 'w') as f:
|
|
479
|
+
# Header with timestamp
|
|
480
|
+
timestamp = datetime.now(timezone.utc)
|
|
481
|
+
f.write(f'"Diagnostic results from all solves. Output at (UTC): {timestamp}"\n\n')
|
|
482
|
+
|
|
483
|
+
# Total cost from solver (M CUR). Iterate solve names in
|
|
484
|
+
# natural-sort order (so ``roll_0, roll_1, ..., roll_10, ...``
|
|
485
|
+
# not ``roll_1, roll_10, ...``). ``v.obj.index`` carries the
|
|
486
|
+
# parquet-concat order, which depends on
|
|
487
|
+
# ``solve_data/solve__p_entity_pre_existing.csv`` — written as a
|
|
488
|
+
# header-only stub by the engine cascade for some rolling-solve
|
|
489
|
+
# paths, leaving the sort key unstable. Natural-sort here is
|
|
490
|
+
# the writer-side guard that matches v3.32.0's numeric solve
|
|
491
|
+
# ordering.
|
|
492
|
+
f.write('\n')
|
|
493
|
+
f.write('"Solve","Objective","Total cost from solver, includes all penalty costs"\n')
|
|
494
|
+
sorted_solves = sorted(
|
|
495
|
+
v.obj.index,
|
|
496
|
+
key=lambda s: [int(t) if t.isdigit() else t
|
|
497
|
+
for t in re.split(r"(\d+)", str(s))],
|
|
498
|
+
)
|
|
499
|
+
for row_idx in sorted_solves:
|
|
500
|
+
f.write(f'{row_idx},{v.obj.loc[row_idx, "objective"] / 1000000:.12g}\n')
|
|
501
|
+
|
|
502
|
+
# Total cost (calculated) full horizon (M CUR). Mirrors the LP
|
|
503
|
+
# objective: oper + penalty + invest + divest + fixed (pre-existing
|
|
504
|
+
# + invested + divested). Fixed-cost terms are in the LP obj at
|
|
505
|
+
# flextool.mod:2107-2126; omitting them here would under-report
|
|
506
|
+
# the objective for any scenario with non-zero existing capacity
|
|
507
|
+
# or a non-zero lifetime_fixed_cost on invest/divest entities.
|
|
508
|
+
total_cost_full = (
|
|
509
|
+
r.costOper_and_penalty_d
|
|
510
|
+
.add(r.costInvest_d, fill_value=0.0)
|
|
511
|
+
.add(r.costDivest_d, fill_value=0.0)
|
|
512
|
+
.add(r.costFixedPreExisting_d, fill_value=0.0)
|
|
513
|
+
.add(r.costFixedInvested_d, fill_value=0.0)
|
|
514
|
+
.add(r.costFixedDivested_d, fill_value=0.0)
|
|
515
|
+
).sum(axis=0) / 1000000
|
|
516
|
+
|
|
517
|
+
f.write(f'"Total cost (calculated) full horizon (M CUR)",{total_cost_full:.12g},"Annualized operational, penalty and investment costs"\n')
|
|
518
|
+
f.write(f'"Total cost (calculated) realized periods (M CUR)",{total_cost_full:.12g}\n')
|
|
519
|
+
|
|
520
|
+
# Operational costs for realized periods (M CUR)
|
|
521
|
+
operational_costs = r.costOper_d.sum(axis=0) / 1000000
|
|
522
|
+
f.write(f'"Operational costs for realized periods (M CUR)",{operational_costs:.12g}\n')
|
|
523
|
+
|
|
524
|
+
# Investment costs for realized periods (M CUR)
|
|
525
|
+
investment_costs = r.costInvest_d.sum(axis=0) / 1000000
|
|
526
|
+
f.write(f'"Investment costs for realized periods (M CUR)",{investment_costs:.12g}\n')
|
|
527
|
+
|
|
528
|
+
# Retirement costs / salvage revenue for realized periods (M CUR).
|
|
529
|
+
# Sign follows the user's salvage_value input: positive salvage
|
|
530
|
+
# (scrap revenue) shows as a negative cost; negative salvage
|
|
531
|
+
# (decommissioning cost) shows as a positive cost. Included in
|
|
532
|
+
# the solver objective since v_divest is a decision variable.
|
|
533
|
+
retirement_costs = r.costDivest_d.sum(axis=0) / 1000000
|
|
534
|
+
f.write(f'"Retirement costs / salvage revenue for realized periods (M CUR)",{retirement_costs:.12g}\n')
|
|
535
|
+
|
|
536
|
+
# Fixed costs for existing entities (M CUR)
|
|
537
|
+
fixed_costs_pre_existing = r.costFixedPreExisting_d.sum(axis=0) / 1000000
|
|
538
|
+
fixed_costs_invested = r.costFixedInvested_d.sum(axis=0) / 1000000
|
|
539
|
+
fixed_costs_divested = r.costFixedDivested_d.sum(axis=0) / 1000000
|
|
540
|
+
|
|
541
|
+
f.write(f'"Fixed costs for pre-existing entities (M CUR)",{fixed_costs_pre_existing:.12g}\n')
|
|
542
|
+
f.write(f'"Fixed costs for invested entities (M CUR)",{fixed_costs_invested:.12g}\n')
|
|
543
|
+
f.write(f'"Fixed cost removal due to divested entities (M CUR)",{fixed_costs_divested:.12g}\n')
|
|
544
|
+
|
|
545
|
+
# Penalty (slack) costs for realized periods (M CUR)
|
|
546
|
+
penalty_costs = r.costPenalty_d.sum(axis=0) / 1000000
|
|
547
|
+
f.write(f'"Penalty (slack) costs for realized periods (M CUR)",{penalty_costs:.12g}\n')
|
|
548
|
+
|
|
549
|
+
# Period information table
|
|
550
|
+
f.write('\nPeriod')
|
|
551
|
+
for d in period_in_use:
|
|
552
|
+
f.write(f',{d}')
|
|
553
|
+
f.write('\n')
|
|
554
|
+
|
|
555
|
+
# Time in use in years
|
|
556
|
+
f.write('"Time in use in years"')
|
|
557
|
+
for d in period_in_use:
|
|
558
|
+
f.write(f',{complete_period_share_of_year[d]:.12g}')
|
|
559
|
+
f.write('\n')
|
|
560
|
+
|
|
561
|
+
# Operational discount factor
|
|
562
|
+
f.write('"Operational discount factor"')
|
|
563
|
+
for d in period_in_use:
|
|
564
|
+
f.write(f',{p_inflation_factor_operations_yearly[d]:.12g}')
|
|
565
|
+
f.write('\n')
|
|
566
|
+
|
|
567
|
+
# Investment discount factor — iterate ``period_in_use`` (the
|
|
568
|
+
# same axis as the "Operational discount factor" / "Time in use
|
|
569
|
+
# in years" rows above) so the per-period values align under
|
|
570
|
+
# the period header. Earlier HEAD iterated ``s.d_realize_invest``
|
|
571
|
+
# which (a) emits no values for scenarios without realized
|
|
572
|
+
# invest decisions (e.g. ``capacity_margin``, where v3.32.0
|
|
573
|
+
# still wrote ``,1`` against the single p2020 period) and
|
|
574
|
+
# (b) carries a different period order on multi-period invest
|
|
575
|
+
# scenarios than the surrounding rows. v3.32.0 wrote one
|
|
576
|
+
# value per period in ``period_in_use``.
|
|
577
|
+
f.write('"Investment discount factor"')
|
|
578
|
+
for d in period_in_use:
|
|
579
|
+
f.write(f',{p_inflation_factor_investment_yearly[d]:.12g}')
|
|
580
|
+
f.write('\n\n')
|
|
581
|
+
|
|
582
|
+
# Emissions section
|
|
583
|
+
f.write('Emissions\n')
|
|
584
|
+
co2_total = r.emissions_co2_d.sum(axis=0) / 1000000
|
|
585
|
+
f.write(f'"CO2 [Mt]",{co2_total:.6g},"System-wide annualized CO2 emissions for realized periods"\n')
|
|
586
|
+
|
|
587
|
+
# Slack variables section
|
|
588
|
+
f.write('\n"Slack variables multiplied by timestep duration (creating or removing energy/matter, ')
|
|
589
|
+
f.write('creating inertia, adding synchronous generation, decreasing capacity margin, creating reserve)"\n')
|
|
590
|
+
|
|
591
|
+
# Node state slack - upward (creating energy)
|
|
592
|
+
for node in r.upward_node_slack_d_not_annualized.columns:
|
|
593
|
+
for period in d_realized_period:
|
|
594
|
+
if period in r.upward_node_slack_d_not_annualized.index and r.upward_node_slack_d_not_annualized.loc[period, node] > 0:
|
|
595
|
+
f.write(f'Created, {node}, {period}, {r.upward_node_slack_d_not_annualized.loc[period, node]:.5g}\n')
|
|
596
|
+
|
|
597
|
+
# Node state slack - downward (removing energy)
|
|
598
|
+
for node in r.downward_node_slack_d_not_annualized.columns:
|
|
599
|
+
for period in d_realized_period:
|
|
600
|
+
if period in r.downward_node_slack_d_not_annualized.index and r.downward_node_slack_d_not_annualized.loc[period, node] > 0:
|
|
601
|
+
f.write(f'Removed, {node}, {period}, {r.downward_node_slack_d_not_annualized.loc[period, node]:.5g}\n')
|
|
602
|
+
|
|
603
|
+
# Inertia slack
|
|
604
|
+
for group in r.q_inertia_d_not_annualized.columns:
|
|
605
|
+
for period in d_realized_period:
|
|
606
|
+
if period in r.q_inertia_d_not_annualized.index and r.q_inertia_d_not_annualized.loc[period, group] > 0:
|
|
607
|
+
f.write(f'Inertia, {group}, {period}, {r.q_inertia_d_not_annualized.loc[period, group]:.5g}\n')
|
|
608
|
+
|
|
609
|
+
# Non-synchronous slack
|
|
610
|
+
for group in r.q_non_synchronous_d_not_annualized.columns:
|
|
611
|
+
for period in d_realized_period:
|
|
612
|
+
if period in r.q_non_synchronous_d_not_annualized.index and r.q_non_synchronous_d_not_annualized.loc[period, group] > 0:
|
|
613
|
+
f.write(f'NonSync, {group}, {period}, {r.q_non_synchronous_d_not_annualized.loc[period, group]:.5g}\n')
|
|
614
|
+
|
|
615
|
+
# Capacity margin slack — iterate realized periods rather than
|
|
616
|
+
# d_realize_invest. The capacity-margin constraint is defined
|
|
617
|
+
# per realized period and is independent of invest/divest
|
|
618
|
+
# entities; scenarios such as ``capacity_margin`` have no
|
|
619
|
+
# invest entities, so d_realize_invest is empty and this loop
|
|
620
|
+
# would silently drop legitimate slack rows.
|
|
621
|
+
for group in r.q_capacity_margin_d_not_annualized.columns:
|
|
622
|
+
for period in d_realized_period:
|
|
623
|
+
if period in r.q_capacity_margin_d_not_annualized.index and r.q_capacity_margin_d_not_annualized.loc[period, group] > 0:
|
|
624
|
+
f.write(f'CapMargin, {group}, {period}, {r.q_capacity_margin_d_not_annualized.loc[period, group]:.5g}\n')
|
|
625
|
+
|
|
626
|
+
# Reserve slack
|
|
627
|
+
for group in r.q_reserves_d_not_annualized.columns:
|
|
628
|
+
for period in d_realized_period:
|
|
629
|
+
if period in r.q_reserves_d_not_annualized.index and r.q_reserves_d_not_annualized.loc[period, group] > 0:
|
|
630
|
+
f.write(f'Reserve, {group}, {period}, {r.q_reserves_d_not_annualized.loc[period, group]:.5g}\n')
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# List of all output functions
|
|
634
|
+
ALL_OUTPUTS = [
|
|
635
|
+
input_sets,
|
|
636
|
+
generic,
|
|
637
|
+
cost_summaries,
|
|
638
|
+
cost_breakdown_by_entity,
|
|
639
|
+
reserves,
|
|
640
|
+
unit_online_and_startup,
|
|
641
|
+
node_summary,
|
|
642
|
+
node_additional_results,
|
|
643
|
+
investment_duals,
|
|
644
|
+
inertia_results,
|
|
645
|
+
slack_variables,
|
|
646
|
+
unit_capacity,
|
|
647
|
+
connection_capacity,
|
|
648
|
+
node_capacity,
|
|
649
|
+
nodeGroup_indicators,
|
|
650
|
+
nodeGroup_VRE_share,
|
|
651
|
+
flowGroup_indicators,
|
|
652
|
+
CO2,
|
|
653
|
+
co2_duals,
|
|
654
|
+
nodeGroup_flows,
|
|
655
|
+
unit_outputNode,
|
|
656
|
+
unit_inputNode,
|
|
657
|
+
connection,
|
|
658
|
+
connection_wards,
|
|
659
|
+
nodeGroup_total_inflow,
|
|
660
|
+
connection_cf,
|
|
661
|
+
unit_cf_outputNode,
|
|
662
|
+
unit_cf_inputNode,
|
|
663
|
+
unit_VRE_curtailment_and_potential,
|
|
664
|
+
unit_ramps,
|
|
665
|
+
dc_power_flow,
|
|
666
|
+
]
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _resolve_settings(write_methods, output_config_path, active_configs, plot_rows,
|
|
670
|
+
output_location, plot_file_format, settings_db_url,
|
|
671
|
+
fallback_output_location, results_db_url=None):
|
|
672
|
+
"""Resolve output settings: explicit args > settings DB > hardcoded defaults.
|
|
673
|
+
|
|
674
|
+
``output-spinedb`` from the settings DB is honored on BOTH the native
|
|
675
|
+
solve path and the parquet-replay path: a True flag writes the results
|
|
676
|
+
SpineDB either way (the run produces it from the live solve; re-create
|
|
677
|
+
rebuilds/augments it from parquet without re-solving).
|
|
678
|
+
"""
|
|
679
|
+
db_reachable = False
|
|
680
|
+
if settings_db_url:
|
|
681
|
+
if settings_db_url.startswith('sqlite:///'):
|
|
682
|
+
db_reachable = os.path.exists(settings_db_url.replace('sqlite:///', ''))
|
|
683
|
+
else:
|
|
684
|
+
db_reachable = True # HTTP or other remote URLs — let DatabaseMapping handle errors
|
|
685
|
+
if db_reachable:
|
|
686
|
+
with DatabaseMapping(settings_db_url) as settings_db:
|
|
687
|
+
settings_entities = settings_db.get_entity_items(entity_class_name="settings")
|
|
688
|
+
if len(settings_entities) == 1:
|
|
689
|
+
settings_name = settings_entities[0]["name"]
|
|
690
|
+
settings_params: dict = {}
|
|
691
|
+
for pv in settings_db.get_parameter_value_items(entity_class_name="settings"):
|
|
692
|
+
if pv["entity_byname"] == (settings_name,):
|
|
693
|
+
settings_params[pv["parameter_definition_name"]] = from_database(pv["value"], pv["type"])
|
|
694
|
+
logging.debug(f"Settings DB parameters: {settings_params}")
|
|
695
|
+
|
|
696
|
+
if write_methods is None:
|
|
697
|
+
method_keys = [f'output-{m}' for m in ['plot', 'parquet', 'csv', 'excel', 'spinedb']]
|
|
698
|
+
if any(k in settings_params for k in method_keys):
|
|
699
|
+
write_methods = [m for m in ['plot', 'parquet', 'csv', 'excel', 'spinedb']
|
|
700
|
+
if settings_params.get(f'output-{m}', False)]
|
|
701
|
+
|
|
702
|
+
# CLI-supplied results_db_url wins; otherwise honor the
|
|
703
|
+
# optional settings value (mirrors the write_methods
|
|
704
|
+
# CLI-vs-settings precedence above).
|
|
705
|
+
if results_db_url is None and 'output-results-db-url' in settings_params:
|
|
706
|
+
results_db_url = str(settings_params['output-results-db-url'])
|
|
707
|
+
|
|
708
|
+
if output_config_path is None and 'output-config-path' in settings_params:
|
|
709
|
+
output_config_path = str(settings_params['output-config-path'])
|
|
710
|
+
|
|
711
|
+
if active_configs is None and 'active-output-configs' in settings_params:
|
|
712
|
+
val = settings_params['active-output-configs']
|
|
713
|
+
if isinstance(val, str):
|
|
714
|
+
active_configs = [val]
|
|
715
|
+
elif isinstance(val, Array):
|
|
716
|
+
active_configs = list(val.values)
|
|
717
|
+
else:
|
|
718
|
+
active_configs = list(val)
|
|
719
|
+
|
|
720
|
+
if plot_rows is None:
|
|
721
|
+
first = settings_params.get('plot_first_timestep')
|
|
722
|
+
duration = settings_params.get('plot_duration')
|
|
723
|
+
if first is not None and duration is not None:
|
|
724
|
+
plot_rows = (int(first), int(first) + int(duration))
|
|
725
|
+
|
|
726
|
+
if output_location is None and 'output-location' in settings_params:
|
|
727
|
+
output_location = str(settings_params['output-location'])
|
|
728
|
+
|
|
729
|
+
if plot_file_format is None and 'plot-file-format' in settings_params:
|
|
730
|
+
plot_file_format = str(settings_params['plot-file-format'])
|
|
731
|
+
|
|
732
|
+
# Apply hardcoded defaults for anything still unset
|
|
733
|
+
from flextool._resources import package_data_path
|
|
734
|
+
_default_plots = str(package_data_path("schemas/default_plots.yaml"))
|
|
735
|
+
if write_methods is None:
|
|
736
|
+
write_methods = ['plot', 'parquet', 'excel']
|
|
737
|
+
if output_config_path is None:
|
|
738
|
+
output_config_path = _default_plots
|
|
739
|
+
elif not os.path.isabs(output_config_path):
|
|
740
|
+
# Legacy: a settings DB may carry a repo-relative override like
|
|
741
|
+
# ``templates/default_plots.yaml``. Treat such relative paths as
|
|
742
|
+
# references to the bundled default — installing from a wheel no
|
|
743
|
+
# longer has a repo root to resolve against.
|
|
744
|
+
if os.path.basename(output_config_path) in {
|
|
745
|
+
'default_plots.yaml', 'default_comparison_plots.yaml'
|
|
746
|
+
}:
|
|
747
|
+
output_config_path = _default_plots
|
|
748
|
+
else:
|
|
749
|
+
output_config_path = os.path.abspath(output_config_path)
|
|
750
|
+
# Self-heal for stale settings DBs that still point at the deleted
|
|
751
|
+
# ``default_comparison_plots.yaml`` (comparison rules now live inside
|
|
752
|
+
# default_plots.yaml via per-leaf ``scenario_rule``).
|
|
753
|
+
if (os.path.basename(output_config_path) == 'default_comparison_plots.yaml'
|
|
754
|
+
or not os.path.isfile(output_config_path)):
|
|
755
|
+
if output_config_path != _default_plots:
|
|
756
|
+
logging.info(
|
|
757
|
+
"output-config-path %s not found or superseded — using "
|
|
758
|
+
"bundled %s instead.", output_config_path, _default_plots,
|
|
759
|
+
)
|
|
760
|
+
output_config_path = _default_plots
|
|
761
|
+
if active_configs is None:
|
|
762
|
+
active_configs = ['default']
|
|
763
|
+
if plot_rows is None:
|
|
764
|
+
plot_rows = (0, 167)
|
|
765
|
+
if output_location is None:
|
|
766
|
+
output_location = fallback_output_location or ''
|
|
767
|
+
if plot_file_format is None:
|
|
768
|
+
plot_file_format = 'png'
|
|
769
|
+
|
|
770
|
+
return write_methods, output_config_path, active_configs, plot_rows, output_location, plot_file_format, results_db_url
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def write_outputs(scenario_name, output_config_path=None, active_configs=None, output_funcs=None, output_location=None, subdir=None, read_parquet_dir=False, write_methods=None, plot_rows=None, debug=False, single_result=None, settings_db_url=None, fallback_output_location=None, plot_file_format=None, raw_output_dir=None, only_first_file=False, timing_recorder=None, flex_data=None, solution=None, solve_name=None, solve_steps=None, flex_data_provider=None, results_db_url=None):
|
|
774
|
+
"""
|
|
775
|
+
Write FlexTool outputs to various formats.
|
|
776
|
+
|
|
777
|
+
Args:
|
|
778
|
+
scenario_name: Name of the scenario
|
|
779
|
+
output_config_path: Path to YAML configuration file defining outputs
|
|
780
|
+
active_configs: output_config yaml can contain multiple plot configurations for same data, choose which ones to use. Defaults to 'default' only.
|
|
781
|
+
output_funcs: list of functions to run, or None for ALL_OUTPUTS
|
|
782
|
+
subdir: Subdirectory for outputs
|
|
783
|
+
read_parquet_dir: Directory to read existing parquet files from
|
|
784
|
+
write_methods: List of output methods ('plot', 'parquet', 'excel', 'csv')
|
|
785
|
+
plot_rows: Tuple of first and last row to plot in a time series plots. Default is (0, 167).
|
|
786
|
+
debug: Enable debug output
|
|
787
|
+
single_result: Tuple of (key, csv_name, plot_name, plot_type, subplots_per_row, legend_position)
|
|
788
|
+
for processing a single result. Overrides config file.
|
|
789
|
+
settings_db_url: URL of the settings database (optional, fills in unset params)
|
|
790
|
+
fallback_output_location: Used as output_location if not set by caller or settings DB
|
|
791
|
+
raw_output_dir: Path to the directory containing solver raw output CSV files
|
|
792
|
+
(default: 'output_raw' relative to CWD)
|
|
793
|
+
flex_data: FlexData (polars input bundle) — required for the
|
|
794
|
+
in-memory parameter / set reading. Pass-through from the
|
|
795
|
+
orchestration step's last solve.
|
|
796
|
+
solution: polar_high.Solution — required; used by the in-memory
|
|
797
|
+
read_parameters for entity_all_capacity (and friends)
|
|
798
|
+
derivation.
|
|
799
|
+
solve_name: complete solve identifier (e.g. ``"y2020_2day_dispatch"``)
|
|
800
|
+
used as the leading ``solve`` index level on the wide-format
|
|
801
|
+
params / sets. Defaults to ``scenario_name``.
|
|
802
|
+
solve_steps: optional ordered list of ``(solve_name, FlexData)``
|
|
803
|
+
pairs covering every sub-solve (roll) of the orchestration
|
|
804
|
+
cascade. Multi-solve / rolling scenarios MUST supply this
|
|
805
|
+
so ``par`` and ``s`` are unioned across every sub-solve's
|
|
806
|
+
dt axis — otherwise they would only carry the last roll's
|
|
807
|
+
(d, t) rows while ``v`` carries the union from per-sub-
|
|
808
|
+
solve parquet aggregation, and pandas joins/muls would
|
|
809
|
+
mismatch. Single-solve scenarios can ignore.
|
|
810
|
+
flex_data_provider: optional per-sub-solve
|
|
811
|
+
:class:`flextool.engine_polars._flex_data_provider.FlexDataProvider`
|
|
812
|
+
from the orchestration step (typically ``last_step.flex_data_provider``).
|
|
813
|
+
Used by :func:`_backfill_group_indicator_sets` to read the
|
|
814
|
+
``input/nodeGroup*`` + ``solve_data/nodeGroupDispatch__*``
|
|
815
|
+
frames in-memory — the writer→emitter refactor no longer
|
|
816
|
+
flushes these to disk on the default cascade path, so
|
|
817
|
+
without a Provider here the ``group_flows__dt.csv`` /
|
|
818
|
+
``flowGroup__*.csv`` outputs would silently disappear.
|
|
819
|
+
results_db_url: optional target SpineDB URL for the ``spinedb``
|
|
820
|
+
write-method. When omitted, defaults to a ``results.sqlite``
|
|
821
|
+
file next to the other outputs (``output_location``). Ignored
|
|
822
|
+
unless ``'spinedb'`` is in ``write_methods``. The ``spinedb``
|
|
823
|
+
write-method runs on BOTH paths and ``output-spinedb`` from the
|
|
824
|
+
settings DB is honored on both: on the native solve path it uses
|
|
825
|
+
the live ``s`` / ``par`` namespaces, while on the
|
|
826
|
+
``read_parquet_dir`` REPLAY path it rebuilds the ``s`` shim from
|
|
827
|
+
the processed parquet (``build_replay_s``, with ``par=None`` —
|
|
828
|
+
the two discount/inflation params are dropped on the replay path).
|
|
829
|
+
"""
|
|
830
|
+
write_methods, output_config_path, active_configs, plot_rows, output_location, plot_file_format, results_db_url = _resolve_settings(
|
|
831
|
+
write_methods, output_config_path, active_configs, plot_rows,
|
|
832
|
+
output_location, plot_file_format, settings_db_url, fallback_output_location,
|
|
833
|
+
results_db_url,
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
logging.debug(
|
|
837
|
+
f"Resolved output settings: write_methods={write_methods}, "
|
|
838
|
+
f"output_config_path={output_config_path}, active_configs={active_configs}, "
|
|
839
|
+
f"plot_rows={plot_rows}, output_location={output_location}"
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
warnings.filterwarnings('ignore', category=pd.errors.PerformanceWarning)
|
|
843
|
+
start = time.perf_counter()
|
|
844
|
+
|
|
845
|
+
# Load output configuration from YAML or create from single_result
|
|
846
|
+
if single_result:
|
|
847
|
+
# Parse single_result tuple
|
|
848
|
+
key, csv_name, plot_name, plot_type, subplots_per_row, legend_position = single_result
|
|
849
|
+
|
|
850
|
+
# Convert string "null" to None
|
|
851
|
+
def parse_value(val):
|
|
852
|
+
if val == "null" or val == "None":
|
|
853
|
+
return None
|
|
854
|
+
# Try to convert to int if it's a numeric string
|
|
855
|
+
try:
|
|
856
|
+
return int(val)
|
|
857
|
+
except (ValueError, TypeError):
|
|
858
|
+
return val
|
|
859
|
+
|
|
860
|
+
csv_name = parse_value(csv_name)
|
|
861
|
+
plot_name = parse_value(plot_name)
|
|
862
|
+
plot_type = parse_value(plot_type)
|
|
863
|
+
subplots_per_row = parse_value(subplots_per_row)
|
|
864
|
+
legend_position = parse_value(legend_position)
|
|
865
|
+
|
|
866
|
+
# Create single-entry settings dict
|
|
867
|
+
settings = {
|
|
868
|
+
"plots": {
|
|
869
|
+
key: [plot_name, plot_type, subplots_per_row, legend_position]
|
|
870
|
+
},
|
|
871
|
+
"rename": {
|
|
872
|
+
key: csv_name
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
else:
|
|
876
|
+
# Load output configuration from YAML
|
|
877
|
+
with open(output_config_path, 'r', encoding='utf-8') as f:
|
|
878
|
+
settings = yaml.safe_load(f)
|
|
879
|
+
|
|
880
|
+
if subdir:
|
|
881
|
+
parquet_dir = os.path.join(output_location, 'output_parquet', subdir)
|
|
882
|
+
csv_dir = os.path.join(output_location, 'output_csv', subdir)
|
|
883
|
+
plot_dir = os.path.join(output_location, 'output_plots', subdir)
|
|
884
|
+
else:
|
|
885
|
+
parquet_dir = os.path.join(output_location, 'output_parquet')
|
|
886
|
+
csv_dir = os.path.join(output_location, 'output_csv')
|
|
887
|
+
plot_dir = os.path.join(output_location, 'output_plots')
|
|
888
|
+
|
|
889
|
+
# Read and process data
|
|
890
|
+
start = log_time("Read configuration files", start, timing_recorder)
|
|
891
|
+
|
|
892
|
+
# If results already exist as parquet files, read them
|
|
893
|
+
if read_parquet_dir:
|
|
894
|
+
results = {}
|
|
895
|
+
for filename in os.listdir(parquet_dir):
|
|
896
|
+
if filename.endswith('.parquet') and filename != 'timeline_breaks.parquet':
|
|
897
|
+
key = filename[:-8] # Remove '.parquet' extension
|
|
898
|
+
filepath = os.path.join(parquet_dir, filename)
|
|
899
|
+
results[key] = read_lean_parquet(filepath)
|
|
900
|
+
if len(results[key].columns.names) == 1:
|
|
901
|
+
results[key] = results[key].squeeze()
|
|
902
|
+
else:
|
|
903
|
+
results[key] = results[key].droplevel('scenario', axis=1)
|
|
904
|
+
start = log_time("Read parquet files", start, timing_recorder)
|
|
905
|
+
|
|
906
|
+
# Read original raw outputs from FlexTool
|
|
907
|
+
else:
|
|
908
|
+
par, s, v = _read_outputs(
|
|
909
|
+
raw_output_dir or 'output_raw',
|
|
910
|
+
flex_data=flex_data,
|
|
911
|
+
solution=solution,
|
|
912
|
+
solve_name=solve_name or scenario_name,
|
|
913
|
+
solve_steps=solve_steps,
|
|
914
|
+
flex_data_provider=flex_data_provider,
|
|
915
|
+
)
|
|
916
|
+
start = log_time("Read flextool outputs", start, timing_recorder)
|
|
917
|
+
|
|
918
|
+
# Pre-process results to be closer to what needed for output writing
|
|
919
|
+
r = post_process_results(par, s, v)
|
|
920
|
+
start = log_time("Post-processed outputs", start, timing_recorder)
|
|
921
|
+
|
|
922
|
+
# Call the final processing functions for each category of outputs
|
|
923
|
+
# and make a dict of dataframes to hold final results
|
|
924
|
+
output_funcs = output_funcs or ALL_OUTPUTS
|
|
925
|
+
|
|
926
|
+
all_results = {}
|
|
927
|
+
for func in output_funcs:
|
|
928
|
+
func_results = func(par, s, v, r, debug)
|
|
929
|
+
if not func_results:
|
|
930
|
+
continue
|
|
931
|
+
|
|
932
|
+
# Handle both single result (wrapped in list) and multiple results
|
|
933
|
+
if not isinstance(func_results, list):
|
|
934
|
+
func_results = [func_results]
|
|
935
|
+
|
|
936
|
+
for result_df, table_name in func_results:
|
|
937
|
+
# Use excel_sheet as the key to allow multiple outputs per function
|
|
938
|
+
all_results[table_name] = result_df
|
|
939
|
+
|
|
940
|
+
results = all_results
|
|
941
|
+
start = log_time("Formatted for output", start, timing_recorder)
|
|
942
|
+
|
|
943
|
+
# Write files for debugging purposes
|
|
944
|
+
if debug:
|
|
945
|
+
open('namespace_structure.txt', 'w').close()
|
|
946
|
+
print_namespace_structure(r, 'r')
|
|
947
|
+
print_namespace_structure(s, 's')
|
|
948
|
+
print_namespace_structure(v, 'v')
|
|
949
|
+
print_namespace_structure(par, 'par')
|
|
950
|
+
start = log_time("Wrote debugging files", start, timing_recorder)
|
|
951
|
+
|
|
952
|
+
# Write to parquet
|
|
953
|
+
if 'parquet' in write_methods and not read_parquet_dir:
|
|
954
|
+
os.makedirs(parquet_dir, exist_ok=True)
|
|
955
|
+
if not os.path.exists(parquet_dir):
|
|
956
|
+
os.makedirs(parquet_dir)
|
|
957
|
+
|
|
958
|
+
# Empty the scenario's parquet dir before writing so retired/renamed
|
|
959
|
+
# outputs don't linger (a stale file would be re-read by the
|
|
960
|
+
# scenario-comparison combine and trip the "not registered in
|
|
961
|
+
# TimeSeriesResults" warning). Done here — as late as possible, after
|
|
962
|
+
# the solve and post-processing have produced ``results`` — so a
|
|
963
|
+
# failed solve/post-process leaves the previous run's outputs intact.
|
|
964
|
+
# Removes top-level files only; the ``plot_plans/`` subdir and the
|
|
965
|
+
# regenerated ``_dispatch_metadata.json`` are rewritten later in this
|
|
966
|
+
# same call. Mirrors the csv-dir cleanup below, including the
|
|
967
|
+
# single-result guard (partial runs must not wipe sibling outputs).
|
|
968
|
+
if not single_result:
|
|
969
|
+
for filename in os.listdir(parquet_dir):
|
|
970
|
+
file_path = os.path.join(parquet_dir, filename)
|
|
971
|
+
if os.path.isfile(file_path):
|
|
972
|
+
os.remove(file_path)
|
|
973
|
+
|
|
974
|
+
for name, df in results.items():
|
|
975
|
+
col_meta = None
|
|
976
|
+
if isinstance(df, (pd.MultiIndex, pd.Index)):
|
|
977
|
+
df = df.to_frame(index=False)
|
|
978
|
+
df.insert(0, 'scenario', scenario_name)
|
|
979
|
+
df.set_index(list(df.columns)).index
|
|
980
|
+
else:
|
|
981
|
+
# Derive per-column unit/semantics from the leaf columns
|
|
982
|
+
# (before the scenario level is prepended) so the keys match
|
|
983
|
+
# the logical column names, not arrow's flattened tuples.
|
|
984
|
+
# Series-valued outputs have no columns — skip them for now.
|
|
985
|
+
if isinstance(df, pd.DataFrame):
|
|
986
|
+
derived = derive_column_meta(name, df.columns)
|
|
987
|
+
if derived:
|
|
988
|
+
col_meta = {k: v.as_dict() for k, v in derived.items()}
|
|
989
|
+
df = pd.concat({scenario_name: df}, axis=1, names=['scenario'])
|
|
990
|
+
write_lean_parquet(df, f'{parquet_dir}/{name}.parquet',
|
|
991
|
+
column_metadata=col_meta)
|
|
992
|
+
|
|
993
|
+
# Copy timeline_breaks from solve_data to parquet dir.
|
|
994
|
+
raw_dir = raw_output_dir or 'output_raw'
|
|
995
|
+
work_dir = os.path.dirname(raw_dir) or '.'
|
|
996
|
+
breaks_csv = os.path.join(work_dir, 'solve_data', 'timeline_breaks.csv')
|
|
997
|
+
if os.path.exists(breaks_csv):
|
|
998
|
+
breaks_df = pd.read_csv(breaks_csv)
|
|
999
|
+
write_lean_parquet(breaks_df, f'{parquet_dir}/timeline_breaks.parquet', index=False)
|
|
1000
|
+
|
|
1001
|
+
start = log_time("Wrote to parquet", start, timing_recorder)
|
|
1002
|
+
|
|
1003
|
+
# Compute plot plans for the viewer (always, when parquets exist)
|
|
1004
|
+
if 'parquet' in write_methods or read_parquet_dir:
|
|
1005
|
+
try:
|
|
1006
|
+
from flextool.plot_outputs.orchestrator import compute_all_plot_plans
|
|
1007
|
+
from flextool.plot_outputs.format_helpers import load_timeline_breaks
|
|
1008
|
+
plan_break_times = load_timeline_breaks(parquet_dir)
|
|
1009
|
+
plan_results = {k: v.to_frame() if isinstance(v, pd.Series) else v for k, v in results.items()}
|
|
1010
|
+
single_plot_settings = settings.get('plots', {})
|
|
1011
|
+
compute_all_plot_plans(
|
|
1012
|
+
plan_results, single_plot_settings, parquet_dir,
|
|
1013
|
+
active_settings=active_configs, plot_rows=plot_rows,
|
|
1014
|
+
break_times=plan_break_times,
|
|
1015
|
+
color_path=resolve_plot_settings_path(Path(output_location)),
|
|
1016
|
+
)
|
|
1017
|
+
start = log_time("Computed plot plans", start, timing_recorder)
|
|
1018
|
+
except Exception as exc:
|
|
1019
|
+
logging.warning("Plot plan computation failed (non-fatal): %s", exc)
|
|
1020
|
+
|
|
1021
|
+
# Phase D: write per-scenario dispatch metadata so the viewer can
|
|
1022
|
+
# union ylims across scenarios on demand (without waiting for the
|
|
1023
|
+
# cross-scenario combine). Loads the just-written parquets back
|
|
1024
|
+
# via the existing readers, restricted to this single scenario.
|
|
1025
|
+
try:
|
|
1026
|
+
import json as _json
|
|
1027
|
+
from flextool.scenario_comparison.db_reader import (
|
|
1028
|
+
build_scenario_folders_from_dir,
|
|
1029
|
+
collect_parquet_files,
|
|
1030
|
+
combine_parquet_files,
|
|
1031
|
+
)
|
|
1032
|
+
from flextool.scenario_comparison.dispatch_mappings import (
|
|
1033
|
+
combine_dispatch_mappings,
|
|
1034
|
+
resolve_data_scenario_tag,
|
|
1035
|
+
)
|
|
1036
|
+
from flextool.scenario_comparison.data_models import TimeSeriesResults
|
|
1037
|
+
from flextool.scenario_comparison.dispatch_plots import (
|
|
1038
|
+
compute_dispatch_metadata_for_scenario,
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
# parquet_dir == <output_location>/output_parquet/<scenario>/
|
|
1042
|
+
# The folder name (subdir) drives folder discovery, but the
|
|
1043
|
+
# dispatch data is tagged with the model scenario name, which
|
|
1044
|
+
# may differ (GUI run-index suffix: folder S2_Dry_1 → tag
|
|
1045
|
+
# S2_Dry). Keep the two roles separate.
|
|
1046
|
+
scenario_pq_dir = os.path.dirname(parquet_dir) if subdir else parquet_dir
|
|
1047
|
+
scenario_folder_name = subdir or scenario_name
|
|
1048
|
+
|
|
1049
|
+
sc_folders = build_scenario_folders_from_dir(
|
|
1050
|
+
scenario_pq_dir, [scenario_folder_name],
|
|
1051
|
+
)
|
|
1052
|
+
if sc_folders:
|
|
1053
|
+
files_by_name = collect_parquet_files(sc_folders, output_subdir="")
|
|
1054
|
+
combined = combine_parquet_files(files_by_name, num_scenarios=1)
|
|
1055
|
+
ts_results = TimeSeriesResults.from_dict(combined)
|
|
1056
|
+
dispatch_mappings = combine_dispatch_mappings(sc_folders, "")
|
|
1057
|
+
# Slice by the in-data scenario tag, not the folder name.
|
|
1058
|
+
data_scenario_tag = resolve_data_scenario_tag(
|
|
1059
|
+
dispatch_mappings, scenario_name,
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
if plot_rows and len(plot_rows) >= 2:
|
|
1063
|
+
meta_timeline = (int(plot_rows[0]), int(plot_rows[1]) + 1)
|
|
1064
|
+
else:
|
|
1065
|
+
meta_timeline = (0, 168)
|
|
1066
|
+
|
|
1067
|
+
disp_meta = compute_dispatch_metadata_for_scenario(
|
|
1068
|
+
ts_results, dispatch_mappings,
|
|
1069
|
+
data_scenario_tag, meta_timeline,
|
|
1070
|
+
)
|
|
1071
|
+
else:
|
|
1072
|
+
disp_meta = {"nodeGroups": {}}
|
|
1073
|
+
|
|
1074
|
+
meta_path = os.path.join(parquet_dir, "_dispatch_metadata.json")
|
|
1075
|
+
with open(meta_path, "w", encoding="utf-8") as f:
|
|
1076
|
+
_json.dump(disp_meta, f, indent=2)
|
|
1077
|
+
start = log_time("Wrote dispatch metadata", start, timing_recorder)
|
|
1078
|
+
except Exception as exc:
|
|
1079
|
+
logging.warning(
|
|
1080
|
+
"Per-scenario dispatch metadata computation failed (non-fatal): %s",
|
|
1081
|
+
exc,
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
# Plot results
|
|
1085
|
+
if 'plot' in write_methods:
|
|
1086
|
+
os.makedirs(plot_dir, exist_ok=True)
|
|
1087
|
+
if not os.path.exists(plot_dir):
|
|
1088
|
+
os.makedirs(plot_dir)
|
|
1089
|
+
# Don't delete existing plots when processing single result
|
|
1090
|
+
delete_plots = not bool(single_result)
|
|
1091
|
+
results = {k: v.to_frame() if isinstance(v, pd.Series) else v for k, v in results.items()}
|
|
1092
|
+
# Load timeline breaks for visual gaps in time-series plots
|
|
1093
|
+
from flextool.plot_outputs.format_helpers import load_timeline_breaks
|
|
1094
|
+
break_times = load_timeline_breaks(parquet_dir)
|
|
1095
|
+
plot_dict_of_dataframes(results, plot_dir, settings['plots'], active_settings=active_configs, plot_rows=plot_rows, delete_existing_plots=delete_plots, plot_file_format=plot_file_format, only_first_file=only_first_file, break_times=break_times, color_path=resolve_plot_settings_path(Path(output_location)))
|
|
1096
|
+
|
|
1097
|
+
start = log_time('Plotted figures', start, timing_recorder)
|
|
1098
|
+
|
|
1099
|
+
# Write to csv
|
|
1100
|
+
if 'csv' in write_methods:
|
|
1101
|
+
os.makedirs(csv_dir, exist_ok=True)
|
|
1102
|
+
if not os.path.exists(csv_dir):
|
|
1103
|
+
os.makedirs(csv_dir)
|
|
1104
|
+
|
|
1105
|
+
# Only empty csv dir when not processing single result
|
|
1106
|
+
if not single_result:
|
|
1107
|
+
for filename in os.listdir(csv_dir):
|
|
1108
|
+
file_path = os.path.join(csv_dir, filename)
|
|
1109
|
+
if os.path.isfile(file_path):
|
|
1110
|
+
os.remove(file_path)
|
|
1111
|
+
|
|
1112
|
+
# Accumulate Frictionless resources for the sidecar datapackage.json
|
|
1113
|
+
# (units + semantics per column; CSVs themselves stay byte-identical).
|
|
1114
|
+
dp_resources: list[dict] = []
|
|
1115
|
+
|
|
1116
|
+
# Different CSV writing logic depending on data source
|
|
1117
|
+
if read_parquet_dir:
|
|
1118
|
+
# Simplified CSV writing from parquet (no par,s,v,r available)
|
|
1119
|
+
rename_raw = settings.get('rename', {})
|
|
1120
|
+
for table_name, df in results.items():
|
|
1121
|
+
display_name, _ = _parse_rename_entry(rename_raw.get(table_name, table_name))
|
|
1122
|
+
csv_filename = display_name + '.csv'
|
|
1123
|
+
csv_path = os.path.join(csv_dir, csv_filename)
|
|
1124
|
+
dp_resources.append(datapackage_resource(
|
|
1125
|
+
table_name, csv_filename, *_frame_axes(df)))
|
|
1126
|
+
df_copy = df.reset_index()
|
|
1127
|
+
df_copy.columns.names = [None] * df_copy.columns.nlevels
|
|
1128
|
+
df_copy.to_csv(csv_path, index=False, float_format='%.8g')
|
|
1129
|
+
else:
|
|
1130
|
+
# Full CSV writing from output_raw (par,s,v,r available)
|
|
1131
|
+
write_summary_csv(par, s, v, r, csv_dir)
|
|
1132
|
+
|
|
1133
|
+
rename_raw = settings.get('rename', {})
|
|
1134
|
+
for table_name, df in results.items():
|
|
1135
|
+
if isinstance(df, (pd.MultiIndex, pd.Index)):
|
|
1136
|
+
df = df.to_frame(index=False)
|
|
1137
|
+
if 'solve' not in df.index.names and 'period' in df.index.names:
|
|
1138
|
+
if 'time' in df.index.names:
|
|
1139
|
+
# Use per-timestep mapping for dispatch data (correct per-roll solve names)
|
|
1140
|
+
# Build a dict from (period, time) → solve to avoid sort-merge reordering
|
|
1141
|
+
spt = s.solve_period_time
|
|
1142
|
+
if spt.droplevel('solve').duplicated().any():
|
|
1143
|
+
spt = spt[~spt.droplevel('solve').duplicated(keep='last')]
|
|
1144
|
+
pt_to_solve = dict(zip(spt.droplevel('solve'), spt.get_level_values('solve')))
|
|
1145
|
+
# Key off the named 'period'/'time' levels, not positional
|
|
1146
|
+
# index slots — entity-first indexes (e.g. (unit, period, time))
|
|
1147
|
+
# otherwise read the wrong levels and miss every lookup.
|
|
1148
|
+
period_level = df.index.get_level_values('period')
|
|
1149
|
+
time_level = df.index.get_level_values('time')
|
|
1150
|
+
solve_vals = [pt_to_solve.get((p, t), '') for p, t in zip(period_level, time_level)]
|
|
1151
|
+
df.index = pd.MultiIndex.from_arrays(
|
|
1152
|
+
[solve_vals] + [df.index.get_level_values(n) for n in df.index.names],
|
|
1153
|
+
names=['solve'] + list(df.index.names)
|
|
1154
|
+
)
|
|
1155
|
+
else:
|
|
1156
|
+
# For period-only data, use solve_period to add solve column
|
|
1157
|
+
# Use dict lookup to preserve index order (join reorders alphabetically)
|
|
1158
|
+
unique_sp = s.solve_period[~s.solve_period.droplevel('solve').duplicated(keep='last')]
|
|
1159
|
+
period_to_solve = dict(zip(unique_sp.get_level_values('period'), unique_sp.get_level_values('solve')))
|
|
1160
|
+
if df.index.nlevels == 1:
|
|
1161
|
+
solve_vals = df.index.map(lambda p: period_to_solve.get(p, ''))
|
|
1162
|
+
df.index = pd.MultiIndex.from_arrays(
|
|
1163
|
+
[solve_vals, df.index],
|
|
1164
|
+
names=['solve', df.index.name]
|
|
1165
|
+
)
|
|
1166
|
+
else:
|
|
1167
|
+
period_level = df.index.get_level_values('period')
|
|
1168
|
+
solve_vals = period_level.map(lambda p: period_to_solve.get(p, ''))
|
|
1169
|
+
df.index = pd.MultiIndex.from_arrays(
|
|
1170
|
+
[solve_vals] + [df.index.get_level_values(n) for n in df.index.names],
|
|
1171
|
+
names=['solve'] + list(df.index.names)
|
|
1172
|
+
)
|
|
1173
|
+
names = list(df.index.names)
|
|
1174
|
+
solve_pos = names.index('solve')
|
|
1175
|
+
period_pos = names.index('period')
|
|
1176
|
+
names.pop(solve_pos)
|
|
1177
|
+
if solve_pos < period_pos:
|
|
1178
|
+
period_pos -= 1
|
|
1179
|
+
names.insert(period_pos, 'solve')
|
|
1180
|
+
df.index = df.index.reorder_levels(order=names)
|
|
1181
|
+
|
|
1182
|
+
display_name, _ = _parse_rename_entry(rename_raw.get(table_name, table_name))
|
|
1183
|
+
csv_filename = display_name + '.csv'
|
|
1184
|
+
csv_path = os.path.join(csv_dir, csv_filename)
|
|
1185
|
+
dp_resources.append(datapackage_resource(
|
|
1186
|
+
table_name, csv_filename, *_frame_axes(df)))
|
|
1187
|
+
df = df.reset_index()
|
|
1188
|
+
df.columns.names = [None] * df.columns.nlevels
|
|
1189
|
+
df.to_csv(csv_path, index=False, float_format='%.8g')
|
|
1190
|
+
|
|
1191
|
+
# Sidecar metadata descriptor for the CSV bundle (units + semantics).
|
|
1192
|
+
if dp_resources:
|
|
1193
|
+
dp = {
|
|
1194
|
+
"name": "flextool-output-csv",
|
|
1195
|
+
"profile": "tabular-data-package",
|
|
1196
|
+
"title": "FlexTool processed outputs",
|
|
1197
|
+
"resources": sorted(dp_resources, key=lambda r: r["path"]),
|
|
1198
|
+
}
|
|
1199
|
+
with open(os.path.join(csv_dir, 'datapackage.json'),
|
|
1200
|
+
'w', encoding='utf-8') as fh:
|
|
1201
|
+
json.dump(dp, fh, indent=2)
|
|
1202
|
+
|
|
1203
|
+
start = log_time('Wrote to csv', start, timing_recorder)
|
|
1204
|
+
|
|
1205
|
+
# Write to excel
|
|
1206
|
+
if 'excel' in write_methods:
|
|
1207
|
+
rename_raw = settings.get('rename', {})
|
|
1208
|
+
excel_dir = os.path.join(output_location, 'output_excel')
|
|
1209
|
+
os.makedirs(excel_dir, exist_ok=True)
|
|
1210
|
+
excel_path = os.path.join(excel_dir, 'output_' + scenario_name + '.xlsx')
|
|
1211
|
+
# Build list of (sheet_name, raw_key, df) sorted alphabetically. The
|
|
1212
|
+
# raw key is kept so unit/semantics metadata can be derived per sheet.
|
|
1213
|
+
sheets: list[tuple[str, str, pd.DataFrame]] = []
|
|
1214
|
+
used_names: set[str] = set()
|
|
1215
|
+
for name, df in results.items():
|
|
1216
|
+
display_name, export = _parse_rename_entry(
|
|
1217
|
+
rename_raw.get(name, name)
|
|
1218
|
+
)
|
|
1219
|
+
if not export:
|
|
1220
|
+
continue
|
|
1221
|
+
if isinstance(df, (pd.MultiIndex, pd.Index)):
|
|
1222
|
+
df = df.to_frame(index=False)
|
|
1223
|
+
if (not df.empty) & (len(df) > 0):
|
|
1224
|
+
sheet_name = display_name[:31]
|
|
1225
|
+
if sheet_name in used_names:
|
|
1226
|
+
suffix = 1
|
|
1227
|
+
while f"{sheet_name[:28]}_{suffix}" in used_names:
|
|
1228
|
+
suffix += 1
|
|
1229
|
+
sheet_name = f"{sheet_name[:28]}_{suffix}"
|
|
1230
|
+
used_names.add(sheet_name)
|
|
1231
|
+
sheets.append((sheet_name, name, df))
|
|
1232
|
+
sheets.sort(key=lambda x: x[0].lower())
|
|
1233
|
+
|
|
1234
|
+
write_excel_with_metadata(excel_path, sheets)
|
|
1235
|
+
|
|
1236
|
+
start = log_time('Wrote to Excel', start, timing_recorder)
|
|
1237
|
+
|
|
1238
|
+
# Write to SpineDB results database
|
|
1239
|
+
if 'spinedb' in write_methods:
|
|
1240
|
+
db_url = results_db_url or (
|
|
1241
|
+
'sqlite:///' + os.path.join(output_location or '.', 'results.sqlite')
|
|
1242
|
+
)
|
|
1243
|
+
if read_parquet_dir:
|
|
1244
|
+
# Replay path: ``s`` / ``par`` are UNDEFINED here (assigned only
|
|
1245
|
+
# in the native ``else`` branch above). Rebuild the minimal
|
|
1246
|
+
# ``s`` shim the writer needs purely from the processed
|
|
1247
|
+
# ``results`` (no output_raw, no input DB). ``par`` is None, so
|
|
1248
|
+
# the two discount-factor params (#33/#34) are intentionally
|
|
1249
|
+
# dropped — the writer degrades gracefully and logs that.
|
|
1250
|
+
from flextool.process_outputs.spinedb_replay import build_replay_s
|
|
1251
|
+
s_shim = build_replay_s(results)
|
|
1252
|
+
# Prefer the real solve label recovered from node_prices_dt_e so
|
|
1253
|
+
# the ``collapse_solve`` frame (cost_discounted_solve) gets the
|
|
1254
|
+
# same single ``solve`` Map key the native path used; fall back
|
|
1255
|
+
# to the caller's ``solve_name`` / scenario name when no solve
|
|
1256
|
+
# axis is recoverable.
|
|
1257
|
+
replay_solve_name = (
|
|
1258
|
+
s_shim.replay_solve_name or solve_name or scenario_name
|
|
1259
|
+
)
|
|
1260
|
+
write_spinedb(
|
|
1261
|
+
results, s_shim, db_url, scenario_name,
|
|
1262
|
+
replay_solve_name, par=None,
|
|
1263
|
+
)
|
|
1264
|
+
else:
|
|
1265
|
+
write_spinedb(results, s, db_url, scenario_name, solve_name or scenario_name, par=par)
|
|
1266
|
+
start = log_time('Wrote to SpineDB', start, timing_recorder)
|
|
1267
|
+
|
|
1268
|
+
# Phase F — write manifest.json describing the output bundle.
|
|
1269
|
+
# Idempotent (safe to call multiple times); rooted at the work
|
|
1270
|
+
# folder (parent of output_raw/). Failure is non-fatal so it can't
|
|
1271
|
+
# block a successful cascade.
|
|
1272
|
+
#
|
|
1273
|
+
# Skip on the parquet-replay path: the manifest documents the raw
|
|
1274
|
+
# SOLVE bundle (output_raw/ LP-variable shards), which does not exist
|
|
1275
|
+
# when re-creating outputs from processed parquet — running it there
|
|
1276
|
+
# only emits a stray output_raw/ scaffold and warns about every
|
|
1277
|
+
# (legitimately absent) raw shard.
|
|
1278
|
+
if not read_parquet_dir:
|
|
1279
|
+
try:
|
|
1280
|
+
from flextool.engine_polars._parquet_bundle import write_manifest
|
|
1281
|
+
raw_dir = raw_output_dir or 'output_raw'
|
|
1282
|
+
bundle_root = os.path.dirname(raw_dir) or '.'
|
|
1283
|
+
write_manifest(bundle_root)
|
|
1284
|
+
start = log_time('Wrote manifest.json', start, timing_recorder)
|
|
1285
|
+
except Exception as exc: # noqa: BLE001
|
|
1286
|
+
logging.warning("Manifest write failed (non-fatal): %s", exc)
|