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,412 @@
|
|
|
1
|
+
"""Reconstruct the minimal engine ``s`` (sets) namespace needed by the
|
|
2
|
+
``spinedb`` writer from PROCESSED ``output_parquet`` results alone.
|
|
3
|
+
|
|
4
|
+
This supports the parquet-replay path of the ``spinedb`` output write-method:
|
|
5
|
+
build the results SpineDB from a previously processed ``output_parquet``
|
|
6
|
+
bundle with NO re-solve (and without ``output_raw`` or the input DB).
|
|
7
|
+
|
|
8
|
+
``write_spinedb`` consumes exactly five attributes of ``s`` (verified in
|
|
9
|
+
``write_spinedb.py``):
|
|
10
|
+
|
|
11
|
+
* ``s.solve_period_time`` — ``pd.MultiIndex(['solve','period','time'])``,
|
|
12
|
+
read as a bare attribute in ``_ensure_solve_level`` (:737) to attach a
|
|
13
|
+
``solve`` level to every ``_dt`` frame lacking one.
|
|
14
|
+
* ``s.solve_period`` — ``pd.MultiIndex(['solve','period'])``, read as a bare
|
|
15
|
+
attribute in ``_ensure_solve_level`` (:750-751) and ``_discount_factor_map``
|
|
16
|
+
(:864) to attach a ``solve`` level to ``_d`` frames.
|
|
17
|
+
* ``s.process_unit`` — ``pd.Index(name='process')``, via ``getattr`` in
|
|
18
|
+
``_iter_columns`` (:560); the set of unit process names.
|
|
19
|
+
* ``s.process_connection`` — ``pd.Index(name='process')``, via ``getattr`` in
|
|
20
|
+
``_iter_columns`` (:561) and ``_connection_triple_lookup`` (:782); the set
|
|
21
|
+
of connection process names.
|
|
22
|
+
* ``s.process_source_sink`` — ``pd.MultiIndex(['process','source','sink'])``,
|
|
23
|
+
iterated as ``(process, source, sink)`` tuples in
|
|
24
|
+
``_connection_triple_lookup`` (:781) to form ``connection__node__node``
|
|
25
|
+
bynames.
|
|
26
|
+
|
|
27
|
+
All structures are rebuilt purely from the ``results`` dict (pure function,
|
|
28
|
+
no randomness) and sorted canonically for cross-process determinism.
|
|
29
|
+
|
|
30
|
+
The two discount-factor params (#33/#34) are NOT recoverable from the
|
|
31
|
+
processed bundle; on the replay path ``par`` is ``None`` and the writer
|
|
32
|
+
skips them with a debug log.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import logging
|
|
38
|
+
from types import SimpleNamespace
|
|
39
|
+
|
|
40
|
+
import pandas as pd
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Frames whose first column level (or, for ``_ed_p`` capacity frames, the
|
|
46
|
+
# entity index level) carries the unit / connection process name.
|
|
47
|
+
_UNIT_PREFIX = "unit_"
|
|
48
|
+
_CONNECTION_PREFIX = "connection_"
|
|
49
|
+
|
|
50
|
+
# Net-flow frames (column = bare connection name, value = signed net flow,
|
|
51
|
+
# positive = first→second node) used to detect bidirectional (2-way)
|
|
52
|
+
# connections on the replay path.
|
|
53
|
+
_NET_FLOW_KEYS = ("connection_dt_eee", "connection_d_eee")
|
|
54
|
+
# Net-flow magnitude below which a sign is treated as numerical noise (not a
|
|
55
|
+
# genuine reverse flow).
|
|
56
|
+
_FLOW_SIGN_EPS = 1e-9
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _frame_column_labels(df) -> list:
|
|
60
|
+
"""Return the column labels of ``df`` as a flat list, transparently
|
|
61
|
+
handling the squeezed-``Series`` case.
|
|
62
|
+
|
|
63
|
+
``read_lean_parquet`` (via ``write_outputs``) squeezes any frame whose
|
|
64
|
+
column axis is a single level / single column down to a ``pandas.Series``;
|
|
65
|
+
such a Series has NO ``.columns`` attribute but preserves the original
|
|
66
|
+
single column label in ``.name`` (a scalar for a 1-level frame, or a tuple
|
|
67
|
+
for a multi-level frame squeezed to one column). Scanning only
|
|
68
|
+
``df.columns`` would therefore silently drop the process carried by such a
|
|
69
|
+
squeezed frame. This helper yields the label list for both shapes so the
|
|
70
|
+
process set / node map can never under-cover."""
|
|
71
|
+
cols = getattr(df, "columns", None)
|
|
72
|
+
if cols is not None:
|
|
73
|
+
return list(cols)
|
|
74
|
+
# Squeezed Series — the (only) column label survives in ``.name``.
|
|
75
|
+
name = getattr(df, "name", None)
|
|
76
|
+
if name is None:
|
|
77
|
+
return []
|
|
78
|
+
return [name]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_replay_s(results: dict) -> SimpleNamespace:
|
|
82
|
+
"""Build the minimal ``s`` shim for ``write_spinedb`` from ``results``.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
results : dict[str, pandas.DataFrame | pandas.Series]
|
|
87
|
+
Processed results keyed by RAW engine table name, exactly as the
|
|
88
|
+
``read_parquet_dir`` branch of ``write_outputs`` rebuilds them
|
|
89
|
+
(``scenario`` column level already stripped / single levels
|
|
90
|
+
squeezed).
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
types.SimpleNamespace
|
|
95
|
+
Object exposing ``solve_period_time``, ``solve_period``,
|
|
96
|
+
``process_unit``, ``process_connection`` and ``process_source_sink``
|
|
97
|
+
with the exact shapes ``write_spinedb`` expects, plus a
|
|
98
|
+
``replay_solve_name`` convenience attribute (the canonical solve
|
|
99
|
+
label recovered from ``node_prices_dt_e``, or ``None`` when no solve
|
|
100
|
+
axis is recoverable) so the replay caller can pass the real solve
|
|
101
|
+
name to ``write_spinedb`` for the ``collapse_solve`` frames whose
|
|
102
|
+
single ``solve`` Map key is taken from ``solve_name``.
|
|
103
|
+
"""
|
|
104
|
+
solve_period_time, solve_period = _build_solve_indexes(results)
|
|
105
|
+
process_unit = _collect_process_names(results, _UNIT_PREFIX)
|
|
106
|
+
process_connection = _collect_process_names(results, _CONNECTION_PREFIX)
|
|
107
|
+
process_source_sink = _build_process_source_sink(results)
|
|
108
|
+
replay_solve_name = _last_solve_name(solve_period)
|
|
109
|
+
|
|
110
|
+
return SimpleNamespace(
|
|
111
|
+
solve_period_time=solve_period_time,
|
|
112
|
+
solve_period=solve_period,
|
|
113
|
+
process_unit=process_unit,
|
|
114
|
+
process_connection=process_connection,
|
|
115
|
+
process_source_sink=process_source_sink,
|
|
116
|
+
replay_solve_name=replay_solve_name,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _last_solve_name(solve_period: pd.MultiIndex):
|
|
121
|
+
"""Return the last solve label in ``solve_period`` (creation order), or
|
|
122
|
+
``None`` when the index is empty.
|
|
123
|
+
|
|
124
|
+
Frames written under ``collapse_solve`` (``costs_discounted_d_p``) carry
|
|
125
|
+
no per-row solve axis, so the writer wraps the whole frame under a single
|
|
126
|
+
``solve_name`` key. On the native path that key is the final cascade
|
|
127
|
+
``solve_name``; the final solve label in ``node_prices`` (= last creating
|
|
128
|
+
solve, on-disk row order) reproduces it, so the parquet path emits the
|
|
129
|
+
same ``solve`` Map key."""
|
|
130
|
+
if solve_period is None or len(solve_period) == 0:
|
|
131
|
+
return None
|
|
132
|
+
return str(solve_period.get_level_values("solve")[-1])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Solve reconstruction from node_prices_dt_e
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def _build_solve_indexes(results: dict):
|
|
140
|
+
"""Return ``(solve_period_time, solve_period)`` MultiIndexes from the
|
|
141
|
+
``(solve, period, time)`` index of ``node_prices_dt_e``.
|
|
142
|
+
|
|
143
|
+
Native ``s.solve_period_time`` / ``s.solve_period`` are the realized
|
|
144
|
+
``(solve, period, time)`` / ``(solve, period)`` index deduped on
|
|
145
|
+
``(period, time)`` / ``period`` with ``keep='last'`` (the last creating
|
|
146
|
+
solve wins). ``node_prices_dt_e`` retains the same pre-dedup
|
|
147
|
+
``(solve, period, time)`` index in solve-creation row order, so deduping
|
|
148
|
+
it ``keep='last'`` reproduces the native winner exactly.
|
|
149
|
+
|
|
150
|
+
When ``node_prices_dt_e`` is absent or empty, return empty MultiIndexes;
|
|
151
|
+
the writer then falls back to wrapping every frame under the single
|
|
152
|
+
``solve_name`` key (``_series_to_map`` :669-673).
|
|
153
|
+
"""
|
|
154
|
+
empty_spt = pd.MultiIndex.from_arrays(
|
|
155
|
+
[[], [], []], names=["solve", "period", "time"])
|
|
156
|
+
empty_sp = pd.MultiIndex.from_arrays(
|
|
157
|
+
[[], []], names=["solve", "period"])
|
|
158
|
+
|
|
159
|
+
obj = results.get("node_prices_dt_e")
|
|
160
|
+
if obj is None:
|
|
161
|
+
return empty_spt, empty_sp
|
|
162
|
+
idx = obj.index
|
|
163
|
+
if idx is None or len(idx) == 0:
|
|
164
|
+
return empty_spt, empty_sp
|
|
165
|
+
names = list(idx.names)
|
|
166
|
+
if not {"solve", "period", "time"} <= set(names):
|
|
167
|
+
logger.debug(
|
|
168
|
+
"spinedb replay: node_prices_dt_e index %s lacks "
|
|
169
|
+
"(solve, period, time) — solve recon degraded to single wrap",
|
|
170
|
+
names)
|
|
171
|
+
return empty_spt, empty_sp
|
|
172
|
+
|
|
173
|
+
solve = idx.get_level_values("solve")
|
|
174
|
+
period = idx.get_level_values("period")
|
|
175
|
+
time = idx.get_level_values("time")
|
|
176
|
+
|
|
177
|
+
# solve_period_time: dedup on (period, time), keep='last'.
|
|
178
|
+
spt = pd.MultiIndex.from_arrays(
|
|
179
|
+
[solve, period, time], names=["solve", "period", "time"])
|
|
180
|
+
pt = pd.MultiIndex.from_arrays([period, time], names=["period", "time"])
|
|
181
|
+
keep_spt = ~pt.duplicated(keep="last")
|
|
182
|
+
solve_period_time = spt[keep_spt]
|
|
183
|
+
|
|
184
|
+
# solve_period: collapse to (solve, period), dedup on period, keep='last'.
|
|
185
|
+
sp_full = pd.MultiIndex.from_arrays(
|
|
186
|
+
[solve, period], names=["solve", "period"])
|
|
187
|
+
keep_sp = ~period.duplicated(keep="last")
|
|
188
|
+
solve_period = sp_full[keep_sp]
|
|
189
|
+
|
|
190
|
+
return solve_period_time, solve_period
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# Unit / connection process-name sets
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def _collect_process_names(results: dict, prefix: str) -> pd.Index:
|
|
198
|
+
"""Union the process names across all ``results`` frames whose key starts
|
|
199
|
+
with ``prefix`` (``unit_`` or ``connection_``).
|
|
200
|
+
|
|
201
|
+
The process name is the FIRST column level of the (scenario-stripped)
|
|
202
|
+
frame — named ``unit`` / ``connection`` / ``process`` depending on the
|
|
203
|
+
frame — or, for ``_ed_p`` capacity frames where the entity sits in the
|
|
204
|
+
index, the index level named ``unit`` / ``connection``. Returns a
|
|
205
|
+
canonically sorted ``pd.Index(name='process')`` (matching native
|
|
206
|
+
``s.process_unit`` / ``s.process_connection``).
|
|
207
|
+
"""
|
|
208
|
+
entity_level = "unit" if prefix == _UNIT_PREFIX else "connection"
|
|
209
|
+
names: set[str] = set()
|
|
210
|
+
|
|
211
|
+
for key, df in results.items():
|
|
212
|
+
if not key.startswith(prefix):
|
|
213
|
+
continue
|
|
214
|
+
if df is None:
|
|
215
|
+
continue
|
|
216
|
+
# Capacity / indicator frames carry the entity in the index level.
|
|
217
|
+
idx_names = list(getattr(df.index, "names", []) or [])
|
|
218
|
+
if entity_level in idx_names:
|
|
219
|
+
names.update(df.index.get_level_values(entity_level).tolist())
|
|
220
|
+
continue
|
|
221
|
+
# Otherwise the entity is the first column level — read it via
|
|
222
|
+
# ``_frame_column_labels`` so a squeezed single-column Series (whose
|
|
223
|
+
# label lives in ``.name``, not ``.columns``) still contributes its
|
|
224
|
+
# process and the set never silently under-covers.
|
|
225
|
+
for col in _frame_column_labels(df):
|
|
226
|
+
first = col[0] if isinstance(col, tuple) else col
|
|
227
|
+
names.add(first)
|
|
228
|
+
|
|
229
|
+
return pd.Index(sorted(names), name="process")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Connection (process, source, sink) triple
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def _build_process_source_sink(results: dict) -> pd.MultiIndex:
|
|
237
|
+
"""Reconstruct ``(process, source, sink)`` triples for connections from
|
|
238
|
+
the ``connection_leftward_*`` and ``connection_rightward_*`` column
|
|
239
|
+
bynames.
|
|
240
|
+
|
|
241
|
+
Node-order convention (verified against ``calc_connections.py`` and real
|
|
242
|
+
parquet):
|
|
243
|
+
|
|
244
|
+
* ``connection_leftward_*`` columns are ``(process, first_node)`` —
|
|
245
|
+
``first_node`` is the **source** in ``s.process_source_sink``.
|
|
246
|
+
* ``connection_rightward_*`` columns are ``(process, second_node)`` —
|
|
247
|
+
``second_node`` is the **sink**.
|
|
248
|
+
|
|
249
|
+
So the triple is ``(process, left_node, right_node)`` =
|
|
250
|
+
``(process, source, sink)``, matching the native
|
|
251
|
+
``_connection_triple_lookup`` which builds ``(process, source, sink)``
|
|
252
|
+
from ``s.process_source_sink``.
|
|
253
|
+
|
|
254
|
+
The ``_dt`` and ``_d`` variants carry identical ``(process, node)``
|
|
255
|
+
columns; both are scanned and unioned so a missing variant cannot drop a
|
|
256
|
+
connection. Returns a canonically sorted
|
|
257
|
+
``pd.MultiIndex(['process','source','sink'])``.
|
|
258
|
+
"""
|
|
259
|
+
left_map = _process_node_map(results, "leftward") # process -> source
|
|
260
|
+
right_map = _process_node_map(results, "rightward") # process -> sink
|
|
261
|
+
|
|
262
|
+
# Warn about any genuinely bidirectional (2-way) connection BEFORE the
|
|
263
|
+
# triple is committed: its (source, sink) ordering is not recoverable
|
|
264
|
+
# from the processed bundle (see ``_warn_two_way_connections``).
|
|
265
|
+
_warn_two_way_connections(results, set(left_map) | set(right_map))
|
|
266
|
+
|
|
267
|
+
triples: set[tuple[str, str, str]] = set()
|
|
268
|
+
for process in set(left_map) | set(right_map):
|
|
269
|
+
source = left_map.get(process)
|
|
270
|
+
sink = right_map.get(process)
|
|
271
|
+
if source is None or sink is None:
|
|
272
|
+
logger.debug(
|
|
273
|
+
"spinedb replay: connection '%s' missing %s node — triple "
|
|
274
|
+
"skipped", process,
|
|
275
|
+
"left/source" if source is None else "right/sink")
|
|
276
|
+
continue
|
|
277
|
+
triples.add((process, source, sink))
|
|
278
|
+
|
|
279
|
+
if not triples:
|
|
280
|
+
return pd.MultiIndex.from_arrays(
|
|
281
|
+
[[], [], []], names=["process", "source", "sink"])
|
|
282
|
+
|
|
283
|
+
ordered = sorted(triples)
|
|
284
|
+
return pd.MultiIndex.from_tuples(
|
|
285
|
+
ordered, names=["process", "source", "sink"])
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _process_node_map(results: dict, direction: str) -> dict:
|
|
289
|
+
"""Map ``process -> node`` from every ``connection_<direction>_*`` frame
|
|
290
|
+
(``leftward`` / ``rightward``). Columns are ``(process, node)``.
|
|
291
|
+
|
|
292
|
+
Each connection has exactly one left node and one right node, so a single
|
|
293
|
+
mapping per process is correct; the first encountered wins (frames agree).
|
|
294
|
+
"""
|
|
295
|
+
needle = f"connection_{direction}_"
|
|
296
|
+
out: dict[str, str] = {}
|
|
297
|
+
for key, df in results.items():
|
|
298
|
+
if not key.startswith(needle):
|
|
299
|
+
continue
|
|
300
|
+
if df is None:
|
|
301
|
+
continue
|
|
302
|
+
# ``_frame_column_labels`` covers the squeezed single-column Series
|
|
303
|
+
# case (label in ``.name``), so a connection whose only directional
|
|
304
|
+
# frame was squeezed still maps its node.
|
|
305
|
+
for col in _frame_column_labels(df):
|
|
306
|
+
if not isinstance(col, tuple) or len(col) < 2:
|
|
307
|
+
# Expected (process, node); skip malformed.
|
|
308
|
+
continue
|
|
309
|
+
process, node = col[0], col[1]
|
|
310
|
+
out.setdefault(process, node)
|
|
311
|
+
return out
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _warn_two_way_connections(results: dict, connections: set) -> None:
|
|
315
|
+
"""Emit a runtime ``logging.warning`` for every BIDIRECTIONAL (2-way)
|
|
316
|
+
connection reconstructed on the replay path, naming it.
|
|
317
|
+
|
|
318
|
+
Why this matters
|
|
319
|
+
----------------
|
|
320
|
+
For a 1-way connection the ``(source, sink)`` ordering is exact on the
|
|
321
|
+
replay path: native ``s.process_source_sink`` holds the single arc
|
|
322
|
+
``(left_node, right_node)`` and the reconstruction reproduces it verbatim.
|
|
323
|
+
|
|
324
|
+
For a TRUE 2-way connection native ``s.process_source_sink`` carries BOTH
|
|
325
|
+
arcs ``(a, b)`` and ``(b, a)``; ``_connection_triple_lookup`` keeps the
|
|
326
|
+
lexicographically-first. The processed parquet retains only the
|
|
327
|
+
``(left_node, right_node)`` *geometry* (``connection_leftward_*`` /
|
|
328
|
+
``connection_rightward_*`` each name a single node) with NO surviving
|
|
329
|
+
signal for the arc the writer would have picked, so the reconstructed
|
|
330
|
+
``connection__node__node`` byname may be SILENTLY swapped relative to the
|
|
331
|
+
native DB. We do not attempt to make it exact here (that needs either the
|
|
332
|
+
input DB or a new persisted artifact — a decision still pending with the
|
|
333
|
+
maintainer); we only make the possible swap LOUD.
|
|
334
|
+
|
|
335
|
+
Detection predicate (parquet-only)
|
|
336
|
+
----------------------------------
|
|
337
|
+
2-way-ness is not visible in the directional geometry alone — for a 1-way
|
|
338
|
+
connection ``connection_leftward_*`` / ``connection_rightward_*`` BOTH
|
|
339
|
+
carry the (loss-driven) directional flow components, so "appears in both
|
|
340
|
+
roles" matches 1-way connections too and cannot be the test. The one
|
|
341
|
+
signal that survives into the processed bundle is the NET flow
|
|
342
|
+
(``connection_dt_eee`` / ``connection_d_eee``, column = bare connection,
|
|
343
|
+
value positive = first→second node): a genuinely bidirectional connection
|
|
344
|
+
flows BOTH ways across the horizon, so its net flow takes BOTH signs,
|
|
345
|
+
whereas a 1-way connection is strictly single-signed. We therefore warn
|
|
346
|
+
exactly for connections whose net flow has both a value ``> +eps`` and a
|
|
347
|
+
value ``< -eps``.
|
|
348
|
+
|
|
349
|
+
Known residual edge: a 2-way connection that happens to flow in only ONE
|
|
350
|
+
direction across the entire realized horizon is indistinguishable from a
|
|
351
|
+
1-way connection here (single-signed net flow) and is NOT warned — but in
|
|
352
|
+
that case its single realized direction is exactly what the reconstruction
|
|
353
|
+
emits, so the byname is at least self-consistent with the observed flow.
|
|
354
|
+
Making 2-way fully exact is out of scope (no input-DB read, no new
|
|
355
|
+
persisted artifact).
|
|
356
|
+
"""
|
|
357
|
+
two_way = sorted(
|
|
358
|
+
c for c in connections if _connection_is_bidirectional(results, c)
|
|
359
|
+
)
|
|
360
|
+
if not two_way:
|
|
361
|
+
return
|
|
362
|
+
# The maintainer has accepted the parquet-derived (node_1, node_2)
|
|
363
|
+
# geometry as the canonical (source, sink) ordering on the replay path,
|
|
364
|
+
# so this is the intended convention — not a defect. Emit a quiet debug
|
|
365
|
+
# breadcrumb (the ordering may differ from a native solve's
|
|
366
|
+
# lexicographic pick for these connections) without spamming normal runs.
|
|
367
|
+
logger.debug(
|
|
368
|
+
"spinedb replay: %d bidirectional (2-way) connection(s) (%s) use the "
|
|
369
|
+
"parquet (node_1, node_2) geometry for their 'connection__node__node' "
|
|
370
|
+
"(source, sink) byname; this may differ from a native solve's "
|
|
371
|
+
"ordering. 1-way connections are exact.",
|
|
372
|
+
len(two_way), ", ".join(two_way),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _connection_is_bidirectional(results: dict, connection: str) -> bool:
|
|
377
|
+
"""True when ``connection``'s net flow takes BOTH signs in any net-flow
|
|
378
|
+
frame (``connection_dt_eee`` / ``connection_d_eee``) — the parquet-only
|
|
379
|
+
signature of a genuinely 2-way connection (see
|
|
380
|
+
``_warn_two_way_connections``)."""
|
|
381
|
+
for key in _NET_FLOW_KEYS:
|
|
382
|
+
df = results.get(key)
|
|
383
|
+
if df is None:
|
|
384
|
+
continue
|
|
385
|
+
series = _net_flow_series(df, connection)
|
|
386
|
+
if series is None or len(series) == 0:
|
|
387
|
+
continue
|
|
388
|
+
has_pos = bool((series > _FLOW_SIGN_EPS).any())
|
|
389
|
+
has_neg = bool((series < -_FLOW_SIGN_EPS).any())
|
|
390
|
+
if has_pos and has_neg:
|
|
391
|
+
return True
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _net_flow_series(df, connection: str):
|
|
396
|
+
"""Extract the net-flow Series for ``connection`` from a net-flow frame.
|
|
397
|
+
|
|
398
|
+
Net-flow frames are keyed by the bare connection name (column level
|
|
399
|
+
``process``). Handles the squeezed single-column ``Series`` case (label
|
|
400
|
+
in ``.name``) and the multi-column frame case (select the column whose
|
|
401
|
+
first level is ``connection``)."""
|
|
402
|
+
cols = getattr(df, "columns", None)
|
|
403
|
+
if cols is None:
|
|
404
|
+
# Squeezed Series — usable only if its name identifies this connection.
|
|
405
|
+
name = getattr(df, "name", None)
|
|
406
|
+
first = name[0] if isinstance(name, tuple) else name
|
|
407
|
+
return df if first == connection else None
|
|
408
|
+
for col in cols:
|
|
409
|
+
first = col[0] if isinstance(col, tuple) else col
|
|
410
|
+
if first == connection:
|
|
411
|
+
return df[col]
|
|
412
|
+
return None
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Union per-roll realized param/set slices at output time (stage 3).
|
|
2
|
+
|
|
3
|
+
Multi-sub-solve runs persist each roll's realized param/set slice to
|
|
4
|
+
``output_raw/<attr>__<solve>.parquet`` while the Solution is still live
|
|
5
|
+
(:mod:`flextool.process_outputs.persist_realized_slice`, stage 2). This
|
|
6
|
+
module is the read side: it reconstructs the full ``par`` / ``s``
|
|
7
|
+
namespaces by UNIONING those per-roll parquets, reproducing exactly what
|
|
8
|
+
the proven-correct in-memory oracle
|
|
9
|
+
(:func:`...read_parameters.read_parameters_multi` /
|
|
10
|
+
:func:`...read_sets.read_sets_multi`) produces — but sourcing the
|
|
11
|
+
per-roll frames from disk instead of holding every roll's
|
|
12
|
+
``flex_data`` / ``solution`` in memory (which the default
|
|
13
|
+
``keep_solutions=False`` flow nulls on all but the last step).
|
|
14
|
+
|
|
15
|
+
Contract (identical to the in-memory multi readers):
|
|
16
|
+
|
|
17
|
+
* **Solve-keyed attrs** (carry a ``solve`` index level) — the union over
|
|
18
|
+
rolls. The persisted slices are ALREADY realized-filtered and
|
|
19
|
+
post-hack (``persist_realized_slice`` applies the three
|
|
20
|
+
``entity_lifetime_fixed_cost`` / ``entity_all_existing`` /
|
|
21
|
+
``entity_annual_*`` hacks + the realized intersect at persist time),
|
|
22
|
+
so the union is a plain row concat — exactly the ``pd.concat`` in
|
|
23
|
+
``read_parameters_multi`` / the per-level MultiIndex concat in
|
|
24
|
+
``read_sets_multi``. No fillna, no dedup (each roll carries a distinct
|
|
25
|
+
``solve`` value); ``drop_levels`` does the realized-intersect + dedup +
|
|
26
|
+
solve-drop downstream, just as it does for the in-memory path.
|
|
27
|
+
* **Static (solve-invariant) attrs** — taken once from the last step's
|
|
28
|
+
in-memory namespace (``read_parameters`` / ``read_sets`` on the last
|
|
29
|
+
roll's ``flex_data`` / ``solution``). These carry no ``solve`` level
|
|
30
|
+
and are identical across rolls; they are NOT persisted per-roll.
|
|
31
|
+
* **Output-dead solve-keyed params** (``flow_min``, ``flow_max``,
|
|
32
|
+
``years_from_start_d``, ``entity_max_units``, ``node_annual_flow``,
|
|
33
|
+
``group_capacity_margin``) are not persisted (``persist_realized_slice
|
|
34
|
+
._DEAD_PARAMS``) and have no consumer; the last step's value is kept so
|
|
35
|
+
the namespace stays shape-complete.
|
|
36
|
+
|
|
37
|
+
Per-roll parquets are unioned in solve CREATION order
|
|
38
|
+
(:func:`...solve_order.load_solve_order`) — the same order the oracle's
|
|
39
|
+
``solve_steps`` (``steps.items()``) carries and the same order the
|
|
40
|
+
variable reader uses, so the assembled row order matches.
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from types import SimpleNamespace
|
|
46
|
+
from typing import Sequence
|
|
47
|
+
|
|
48
|
+
import pandas as pd
|
|
49
|
+
|
|
50
|
+
from flextool.lean_parquet import read_lean_parquet
|
|
51
|
+
from flextool.process_outputs.read_parameters import _has_solve_level
|
|
52
|
+
from flextool.process_outputs.read_sets import (
|
|
53
|
+
_multi_index_has_solve,
|
|
54
|
+
_series_or_df_has_solve,
|
|
55
|
+
)
|
|
56
|
+
from flextool.process_outputs.solve_order import load_solve_order
|
|
57
|
+
|
|
58
|
+
# Sentinel prefixes that are persisted ONLY by the per-roll multi-solve
|
|
59
|
+
# writer (a solve-keyed core set + a solve-keyed core param). Their
|
|
60
|
+
# presence in ``output_raw/`` means the stage-3 union path is live.
|
|
61
|
+
_SENTINEL_PREFIXES: tuple[str, ...] = ("dt", "step_duration")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def has_persisted_slices(output_dir: Path | str) -> bool:
|
|
65
|
+
"""True iff MULTI-roll param/set slices were persisted to ``output_dir``.
|
|
66
|
+
|
|
67
|
+
Detection mirrors the variable reader's ``v_flow__*.parquet`` probe:
|
|
68
|
+
look for the per-roll ``<sentinel>__*.parquet`` files the stage-2
|
|
69
|
+
writer emits. Both sentinels must be present (one set, one param),
|
|
70
|
+
AND there must be MORE THAN ONE distinct solve label — a genuine
|
|
71
|
+
multi-sub-solve cascade. A single-solve scenario also persists these
|
|
72
|
+
parquets, but its outputs are produced by the in-memory single-
|
|
73
|
+
``flex_data`` path (which is NOT realized-filtered); activating the
|
|
74
|
+
union path only for true multi-roll runs keeps single-solve byte-
|
|
75
|
+
identical.
|
|
76
|
+
"""
|
|
77
|
+
out = Path(output_dir)
|
|
78
|
+
if not all(
|
|
79
|
+
any(out.glob(f"{prefix}__*.parquet")) for prefix in _SENTINEL_PREFIXES
|
|
80
|
+
):
|
|
81
|
+
return False
|
|
82
|
+
sentinel = _SENTINEL_PREFIXES[0]
|
|
83
|
+
solves = {
|
|
84
|
+
p.name[len(sentinel) + 2 : -len(".parquet")]
|
|
85
|
+
for p in out.glob(f"{sentinel}__*.parquet")
|
|
86
|
+
}
|
|
87
|
+
return len(solves) > 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _ordered_parts(output_dir: Path, attr: str, solve_order: dict[str, int]):
|
|
91
|
+
"""Per-roll parquet paths for ``attr`` in solve creation order.
|
|
92
|
+
|
|
93
|
+
``<attr>__<solve>.parquet`` — the solve label is everything after the
|
|
94
|
+
first ``__``. Parts whose solve is unknown to ``solve_order`` sort
|
|
95
|
+
first (defensive; shouldn't happen).
|
|
96
|
+
"""
|
|
97
|
+
parts = list(output_dir.glob(f"{attr}__*.parquet"))
|
|
98
|
+
|
|
99
|
+
def _solve(path: Path) -> str:
|
|
100
|
+
return path.name[len(attr) + 2 : -len(".parquet")]
|
|
101
|
+
|
|
102
|
+
parts.sort(key=lambda p: solve_order.get(_solve(p), -1))
|
|
103
|
+
return parts
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _union_param_attr(parts: Sequence[Path], template) -> object:
|
|
107
|
+
"""Union one solve-keyed param attr from its per-roll parquets.
|
|
108
|
+
|
|
109
|
+
``template`` is the last step's value for this attr (a ``pd.Series``
|
|
110
|
+
or ``pd.DataFrame``) — used only to decide the output shape and to
|
|
111
|
+
restore the ``columns.name`` / Series name. Mirrors the
|
|
112
|
+
``read_parameters_multi`` per-attr concat: drop empty pieces, concat
|
|
113
|
+
rows, restore the column metadata; if every piece is empty fall back
|
|
114
|
+
to the (empty) template so the namespace stays shape-complete.
|
|
115
|
+
"""
|
|
116
|
+
frames = [read_lean_parquet(p) for p in parts]
|
|
117
|
+
non_empty = [f for f in frames if len(f) > 0]
|
|
118
|
+
if not non_empty:
|
|
119
|
+
return template
|
|
120
|
+
merged = pd.concat(non_empty, axis=0)
|
|
121
|
+
if isinstance(template, pd.Series):
|
|
122
|
+
# Series were persisted as a one-column ``value`` frame.
|
|
123
|
+
series = merged.iloc[:, 0]
|
|
124
|
+
series.name = template.name
|
|
125
|
+
return series
|
|
126
|
+
# DataFrame — restore the columns name (concat keeps it, but be
|
|
127
|
+
# explicit to match ``read_parameters_multi``).
|
|
128
|
+
if hasattr(template, "columns"):
|
|
129
|
+
merged.columns.name = template.columns.name
|
|
130
|
+
return merged
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def union_params(
|
|
134
|
+
last_par: SimpleNamespace, output_dir: Path | str
|
|
135
|
+
) -> SimpleNamespace:
|
|
136
|
+
"""Reconstruct the full ``par`` namespace by unioning per-roll slices.
|
|
137
|
+
|
|
138
|
+
For every attr carrying a ``solve`` level in ``last_par`` whose
|
|
139
|
+
per-roll parquets exist, replace it with the union; static attrs (and
|
|
140
|
+
the output-dead solve-keyed params that are never persisted) keep the
|
|
141
|
+
last step's value. Byte-equivalent to ``read_parameters_multi`` for
|
|
142
|
+
the consumed attrs.
|
|
143
|
+
"""
|
|
144
|
+
output_dir = Path(output_dir)
|
|
145
|
+
solve_order = load_solve_order(output_dir.parent)
|
|
146
|
+
|
|
147
|
+
out = SimpleNamespace()
|
|
148
|
+
for attr, value in vars(last_par).items():
|
|
149
|
+
if not _has_solve_level(value):
|
|
150
|
+
# Static / invariant — last-step value (identical per roll).
|
|
151
|
+
setattr(out, attr, value)
|
|
152
|
+
continue
|
|
153
|
+
parts = _ordered_parts(output_dir, attr, solve_order)
|
|
154
|
+
if not parts:
|
|
155
|
+
# Output-dead solve-keyed param (never persisted) — keep the
|
|
156
|
+
# last step's value; it has no downstream consumer.
|
|
157
|
+
setattr(out, attr, value)
|
|
158
|
+
continue
|
|
159
|
+
setattr(out, attr, _union_param_attr(parts, value))
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _union_set_multiindex(parts: Sequence[Path], template: pd.MultiIndex):
|
|
164
|
+
"""Union one solve-keyed set's per-roll MultiIndex parquets.
|
|
165
|
+
|
|
166
|
+
Each set parquet stores the (Multi)Index as the row index of a
|
|
167
|
+
one-column ``_SET_MARKER_COL`` frame. Drop the marker column and
|
|
168
|
+
rebuild the MultiIndex via the same per-level concat
|
|
169
|
+
``read_sets_multi`` uses (robust to empty-frame dtype quirks). If
|
|
170
|
+
every roll is empty, fall back to the template (empty) MultiIndex.
|
|
171
|
+
"""
|
|
172
|
+
indices: list[pd.MultiIndex] = []
|
|
173
|
+
for p in parts:
|
|
174
|
+
df = read_lean_parquet(p)
|
|
175
|
+
idx = df.index
|
|
176
|
+
if len(idx) > 0:
|
|
177
|
+
indices.append(idx)
|
|
178
|
+
if not indices:
|
|
179
|
+
return template
|
|
180
|
+
names = indices[0].names
|
|
181
|
+
level_lists: list[list] = [[] for _ in names]
|
|
182
|
+
for idx in indices:
|
|
183
|
+
for i in range(len(names)):
|
|
184
|
+
level_lists[i].extend(list(idx.get_level_values(i)))
|
|
185
|
+
return pd.MultiIndex.from_arrays(level_lists, names=names)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def union_sets(
|
|
189
|
+
last_s: SimpleNamespace, output_dir: Path | str
|
|
190
|
+
) -> SimpleNamespace:
|
|
191
|
+
"""Reconstruct the full ``s`` namespace by unioning per-roll slices.
|
|
192
|
+
|
|
193
|
+
Solve-keyed sets (``period``, ``dt``, ``dtt``, ``dtttdt``, the
|
|
194
|
+
``ed_*`` invest sets, …) are unioned across rolls; static topology
|
|
195
|
+
sets (``node``, ``process``, ``upDown``, …) keep the last step's
|
|
196
|
+
value. Byte-equivalent to ``read_sets_multi``.
|
|
197
|
+
"""
|
|
198
|
+
output_dir = Path(output_dir)
|
|
199
|
+
solve_order = load_solve_order(output_dir.parent)
|
|
200
|
+
|
|
201
|
+
out = SimpleNamespace()
|
|
202
|
+
for attr, value in vars(last_s).items():
|
|
203
|
+
if isinstance(value, pd.MultiIndex) and _multi_index_has_solve(value):
|
|
204
|
+
parts = _ordered_parts(output_dir, attr, solve_order)
|
|
205
|
+
if not parts:
|
|
206
|
+
setattr(out, attr, value)
|
|
207
|
+
continue
|
|
208
|
+
setattr(out, attr, _union_set_multiindex(parts, value))
|
|
209
|
+
elif _series_or_df_has_solve(value):
|
|
210
|
+
# Defensive: no varying set is a DataFrame/Series with a solve
|
|
211
|
+
# level today, but the structural test admits one. Reuse the
|
|
212
|
+
# param-side concat.
|
|
213
|
+
parts = _ordered_parts(output_dir, attr, solve_order)
|
|
214
|
+
if not parts:
|
|
215
|
+
setattr(out, attr, value)
|
|
216
|
+
continue
|
|
217
|
+
setattr(out, attr, _union_param_attr(parts, value))
|
|
218
|
+
else:
|
|
219
|
+
# Static topology set — last-step value (invariant per roll).
|
|
220
|
+
setattr(out, attr, value)
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
__all__ = ["has_persisted_slices", "union_params", "union_sets"]
|