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,1479 @@
|
|
|
1
|
+
"""BlockLayout — multi-resolution timestep grouping (Δ.2).
|
|
2
|
+
|
|
3
|
+
Native ``engine_polars`` port of
|
|
4
|
+
``flextool/engine_polars/_blocks.py`` (1368 LOC). Produces, in one
|
|
5
|
+
pass, every per-solve block-related frame consumed downstream:
|
|
6
|
+
|
|
7
|
+
* ``entity_block`` — ``(entity, block)``
|
|
8
|
+
* ``process_side_block`` — ``(process, side, block)``
|
|
9
|
+
* ``process_block`` — ``(process, block)`` — process-unified
|
|
10
|
+
* ``block_step_duration`` — ``(block, period, step, step_duration)``
|
|
11
|
+
* ``overlap_set`` — ``(period, block_coarse, step_coarse,
|
|
12
|
+
block_fine, step_fine, fraction)``
|
|
13
|
+
* ``block_step_previous`` — per-block predecessor 7-tuples
|
|
14
|
+
* ``block_period_time_first/_last`` — per-block period boundaries
|
|
15
|
+
|
|
16
|
+
The algorithm is a 1:1 port of the reference (multi-resolution
|
|
17
|
+
alignment is genuinely intricate; the port preserves every quirk of
|
|
18
|
+
the original — including the two-block-deep limit and the
|
|
19
|
+
aligned-subsets-only assumption). Internal bookkeeping uses Python
|
|
20
|
+
dicts/tuples (the input data is small — O(entities + arcs)) and the
|
|
21
|
+
output surface is polars DataFrames so consumers get a uniform shape.
|
|
22
|
+
|
|
23
|
+
Key decisions:
|
|
24
|
+
|
|
25
|
+
* **Lazy polars at the rim**: the public ``BlockLayout`` exposes
|
|
26
|
+
``LazyFrame``-friendly polars frames; consumers ``.collect()`` once
|
|
27
|
+
at LP-build time.
|
|
28
|
+
* **Cache per-(b_coarse, b_fine) overlap rows** as the schematic
|
|
29
|
+
recommends — built once during ``derive_overlap_set`` and shared
|
|
30
|
+
across every arc that references the pair.
|
|
31
|
+
* **Two-block-deep limit accepted** — the reference assumes one
|
|
32
|
+
resolution-group block + the natural fine block; the port preserves
|
|
33
|
+
this. Lifting it is a model-design question.
|
|
34
|
+
* **No defensive gating** — degenerate single-block fixtures produce
|
|
35
|
+
identity-only overlap rows (matching pre-v51 behaviour); fixtures
|
|
36
|
+
with no resolution groups bypass the multi-block branches entirely.
|
|
37
|
+
* **Fail loud** — ambiguous group memberships and non-aligned subsets
|
|
38
|
+
raise ``FlexToolConfigError`` / ``NotImplementedError`` (matching
|
|
39
|
+
the reference).
|
|
40
|
+
|
|
41
|
+
Reference: ``flextool/engine_polars/_blocks.py`` (read-only).
|
|
42
|
+
"""
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
from collections import defaultdict
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from typing import TYPE_CHECKING, Iterable
|
|
48
|
+
|
|
49
|
+
import polars as pl
|
|
50
|
+
|
|
51
|
+
from flextool.engine_polars._axis_enums import (
|
|
52
|
+
rename_to_axis,
|
|
53
|
+
schema_dtype,
|
|
54
|
+
)
|
|
55
|
+
from flextool.engine_polars._solve_state import FlexToolConfigError
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Substrate handle for the cascade-wide axis enum vocabulary.
|
|
59
|
+
# Bare ``None`` here; ``cast_dim`` / ``schema_dtype`` in
|
|
60
|
+
# ``_axis_enums`` fall back to ``_LIVE_AXIS_ENUMS_CTX`` (the live
|
|
61
|
+
# ContextVar) when this is ``None``, so substrate sites pick up
|
|
62
|
+
# activation set by ``load_flextool`` automatically.
|
|
63
|
+
_enums: "dict | None" = None
|
|
64
|
+
|
|
65
|
+
if TYPE_CHECKING: # pragma: no cover — import cycle guard only
|
|
66
|
+
from flextool.engine_polars._solve_config import SolveConfig
|
|
67
|
+
from flextool.engine_polars._timeline import TimelineConfig
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Public constants & schema
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
DEFAULT_BLOCK = "default"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Eight per-solve block CSV stems written by
|
|
79
|
+
# ``flextool.engine_polars._blocks.write_block_data`` and consumed by
|
|
80
|
+
# :meth:`BlockLayout.load_from_solve_data`. Defined as a module
|
|
81
|
+
# constant so the off-cascade workdir bridge can seed only these files
|
|
82
|
+
# into the ephemeral Provider (without picking up unrelated workdir
|
|
83
|
+
# artefacts like ``solve_progress.csv`` whose ragged shape would fail
|
|
84
|
+
# the strict CSV reader).
|
|
85
|
+
_BLOCK_CSV_STEMS: tuple[str, ...] = (
|
|
86
|
+
"entity_block",
|
|
87
|
+
"process_side_block",
|
|
88
|
+
"process_block",
|
|
89
|
+
"block_step_duration",
|
|
90
|
+
"overlap_set",
|
|
91
|
+
"block_step_previous",
|
|
92
|
+
"block_period_time_first",
|
|
93
|
+
"block_period_time_last",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Direct (1var) and indirect (nvar) ct_method classification — matches
|
|
98
|
+
# ``set method_direct`` / ``set method_indirect`` in flextool_base.dat.
|
|
99
|
+
# Classification is at the ct_method level: unknown values default to
|
|
100
|
+
# direct (the more conservative choice for block assignment).
|
|
101
|
+
_CT_METHOD_DIRECT: frozenset[str] = frozenset({
|
|
102
|
+
"constant_efficiency",
|
|
103
|
+
"min_load_efficiency",
|
|
104
|
+
"no_losses_no_variable_cost",
|
|
105
|
+
"variable_cost_only",
|
|
106
|
+
"unidirectional",
|
|
107
|
+
})
|
|
108
|
+
_CT_METHOD_INDIRECT: frozenset[str] = frozenset({
|
|
109
|
+
"regular",
|
|
110
|
+
"exact",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_direct(ct_method: str) -> bool:
|
|
115
|
+
"""Return ``True`` for 1var-per-process (direct) methods."""
|
|
116
|
+
return (
|
|
117
|
+
ct_method in _CT_METHOD_DIRECT
|
|
118
|
+
or ct_method not in _CT_METHOD_INDIRECT
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Validation
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def validate_group_membership(
|
|
128
|
+
group_unit: Iterable[tuple[str, str]],
|
|
129
|
+
group_connection: Iterable[tuple[str, str]],
|
|
130
|
+
group_node: Iterable[tuple[str, str]],
|
|
131
|
+
resolution_groups: dict[str, float],
|
|
132
|
+
decomposition_groups: dict[str, str],
|
|
133
|
+
reserve_upDown_group: Iterable[tuple[str, str, str]] | None = None,
|
|
134
|
+
process_reserve_upDown_node: Iterable[tuple[str, str, str, str]] | None = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Reject configurations with ambiguous block / region membership.
|
|
137
|
+
|
|
138
|
+
Mirrors ``flextool/engine_polars/_blocks.validate_group_membership``
|
|
139
|
+
1:1. Each entity may belong to at most one resolution-group and
|
|
140
|
+
at most one decomposition-group; reserve participants must NOT
|
|
141
|
+
sit in any resolution-group (V1 reserve-block compatibility rule).
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
FlexToolConfigError: on any of the rule violations.
|
|
145
|
+
"""
|
|
146
|
+
res_set = set(resolution_groups.keys())
|
|
147
|
+
decomp_set = {g for g, m in decomposition_groups.items() if m and m != "none"}
|
|
148
|
+
|
|
149
|
+
entity_res: dict[str, list[str]] = defaultdict(list)
|
|
150
|
+
entity_decomp: dict[str, list[str]] = defaultdict(list)
|
|
151
|
+
|
|
152
|
+
for members in (group_unit, group_connection, group_node):
|
|
153
|
+
for g, e in members:
|
|
154
|
+
if g in res_set:
|
|
155
|
+
entity_res[e].append(g)
|
|
156
|
+
if g in decomp_set:
|
|
157
|
+
entity_decomp[e].append(g)
|
|
158
|
+
|
|
159
|
+
errors: list[str] = []
|
|
160
|
+
for e, gs in entity_res.items():
|
|
161
|
+
if len(gs) > 1:
|
|
162
|
+
errors.append(
|
|
163
|
+
f"entity '{e}' is a member of multiple resolution-groups "
|
|
164
|
+
f"({sorted(set(gs))}); each entity may belong to at most "
|
|
165
|
+
f"one group with new_stepduration set."
|
|
166
|
+
)
|
|
167
|
+
for e, gs in entity_decomp.items():
|
|
168
|
+
if len(gs) > 1:
|
|
169
|
+
errors.append(
|
|
170
|
+
f"entity '{e}' is a member of multiple decomposition-groups "
|
|
171
|
+
f"({sorted(set(gs))}); each entity may belong to at most "
|
|
172
|
+
f"one group with decomposition_method != 'none'."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
reserve_groups: set[str] = set()
|
|
176
|
+
if reserve_upDown_group is not None:
|
|
177
|
+
for row in reserve_upDown_group:
|
|
178
|
+
if len(row) >= 3:
|
|
179
|
+
reserve_groups.add(row[2])
|
|
180
|
+
reserve_entities: set[str] = set()
|
|
181
|
+
for g, n in group_node:
|
|
182
|
+
if g in reserve_groups:
|
|
183
|
+
reserve_entities.add(n)
|
|
184
|
+
if process_reserve_upDown_node is not None:
|
|
185
|
+
for row in process_reserve_upDown_node:
|
|
186
|
+
if len(row) >= 4:
|
|
187
|
+
reserve_entities.add(row[0]) # process
|
|
188
|
+
reserve_entities.add(row[3]) # node
|
|
189
|
+
|
|
190
|
+
for e in sorted(reserve_entities):
|
|
191
|
+
gs = entity_res.get(e, [])
|
|
192
|
+
if gs:
|
|
193
|
+
errors.append(
|
|
194
|
+
f"entity '{e}' participates in reserves but is a member of "
|
|
195
|
+
f"resolution-group(s) {sorted(set(gs))}; V1 restricts "
|
|
196
|
+
f"reserves to the default (finest) block — move '{e}' out "
|
|
197
|
+
f"of the resolution-group or drop the reserve participation."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if errors:
|
|
201
|
+
raise FlexToolConfigError("; ".join(errors))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Block derivation primitives
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _solve_step_duration(
|
|
210
|
+
solve: str,
|
|
211
|
+
solve_config: "SolveConfig | None",
|
|
212
|
+
timeline_config: "TimelineConfig | None",
|
|
213
|
+
) -> float:
|
|
214
|
+
"""Return the default-block step duration for *solve* in hours.
|
|
215
|
+
|
|
216
|
+
Priority (matches ``blocks._solve_step_duration``):
|
|
217
|
+
|
|
218
|
+
1. ``timeline.new_step_durations[solve]`` (v50 parameter).
|
|
219
|
+
2. The smallest step duration that actually appears in any of the
|
|
220
|
+
solve's timelines.
|
|
221
|
+
|
|
222
|
+
Falls back to ``1.0`` when neither source is available (unit-test
|
|
223
|
+
fixture path).
|
|
224
|
+
"""
|
|
225
|
+
if timeline_config is not None:
|
|
226
|
+
raw = timeline_config.new_step_durations.get(solve)
|
|
227
|
+
if raw is not None:
|
|
228
|
+
try:
|
|
229
|
+
return float(raw)
|
|
230
|
+
except (TypeError, ValueError):
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
if timeline_config is not None and solve_config is not None:
|
|
234
|
+
timesets_used = solve_config.timesets_used_by_solves.get(solve, [])
|
|
235
|
+
durations: list[float] = []
|
|
236
|
+
for _period, ts in timesets_used:
|
|
237
|
+
tl = timeline_config.timesets__timeline.get(ts)
|
|
238
|
+
if not tl:
|
|
239
|
+
continue
|
|
240
|
+
for _step, dur in timeline_config.timelines.get(tl, []):
|
|
241
|
+
try:
|
|
242
|
+
durations.append(float(dur))
|
|
243
|
+
except (TypeError, ValueError):
|
|
244
|
+
continue
|
|
245
|
+
if durations:
|
|
246
|
+
return min(durations)
|
|
247
|
+
return 1.0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _assign_entity_to_group(
|
|
251
|
+
entity: str,
|
|
252
|
+
memberships: Iterable[tuple[str, str]],
|
|
253
|
+
resolution_groups: dict[str, float],
|
|
254
|
+
) -> str | None:
|
|
255
|
+
"""Return the resolution-group *entity* belongs to, or ``None``."""
|
|
256
|
+
for g, e in memberships:
|
|
257
|
+
if e == entity and g in resolution_groups:
|
|
258
|
+
return g
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _aggregate_timeline(
|
|
263
|
+
rows: list[tuple[str, float]], coarse_duration: float,
|
|
264
|
+
) -> list[tuple[str, float]]:
|
|
265
|
+
"""Aggregate a fine-resolution timeline to *coarse_duration* hours.
|
|
266
|
+
|
|
267
|
+
Mirrors ``blocks._aggregate_timeline`` 1:1. Aligned-subsets only:
|
|
268
|
+
raises ``NotImplementedError`` when fine rows can't pack cleanly
|
|
269
|
+
into coarse rows.
|
|
270
|
+
"""
|
|
271
|
+
out: list[tuple[str, float]] = []
|
|
272
|
+
i = 0
|
|
273
|
+
n = len(rows)
|
|
274
|
+
eps = 1e-9
|
|
275
|
+
while i < n:
|
|
276
|
+
acc = 0.0
|
|
277
|
+
first_label = rows[i][0]
|
|
278
|
+
j = i
|
|
279
|
+
while j < n and acc + float(rows[j][1]) <= coarse_duration + eps:
|
|
280
|
+
acc += float(rows[j][1])
|
|
281
|
+
j += 1
|
|
282
|
+
if abs(acc - coarse_duration) <= eps:
|
|
283
|
+
break
|
|
284
|
+
if abs(acc - coarse_duration) > eps:
|
|
285
|
+
raise NotImplementedError(
|
|
286
|
+
f"Non-aligned subset: fine rows starting at "
|
|
287
|
+
f"'{first_label}' accumulate to {acc}h but coarse "
|
|
288
|
+
f"block stepduration is {coarse_duration}h. "
|
|
289
|
+
f"Non-integer multiples of the default step are not "
|
|
290
|
+
f"supported yet (aligned-subsets only)."
|
|
291
|
+
)
|
|
292
|
+
out.append((first_label, coarse_duration))
|
|
293
|
+
i = j
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _cyclic_block_predecessors(
|
|
298
|
+
block: str,
|
|
299
|
+
per_period_rows: dict[str, list[tuple[str, float]]],
|
|
300
|
+
) -> list[tuple[str, str, str, str, str, str, str]]:
|
|
301
|
+
"""Cyclic predecessor pattern over a block's aggregated timeline.
|
|
302
|
+
|
|
303
|
+
Mirrors ``blocks._cyclic_block_predecessors``. Wraps cyclically
|
|
304
|
+
on the first step of the first period; first step of subsequent
|
|
305
|
+
periods crosses to the previous period's block-last step.
|
|
306
|
+
"""
|
|
307
|
+
periods = list(per_period_rows.keys())
|
|
308
|
+
if not periods:
|
|
309
|
+
return []
|
|
310
|
+
first_period = periods[0]
|
|
311
|
+
last_period = periods[-1]
|
|
312
|
+
|
|
313
|
+
out: list[tuple[str, str, str, str, str, str, str]] = []
|
|
314
|
+
for pi, period in enumerate(periods):
|
|
315
|
+
rows = per_period_rows[period]
|
|
316
|
+
if not rows:
|
|
317
|
+
continue
|
|
318
|
+
block_last_step = rows[-1][0]
|
|
319
|
+
for si, (step, _dur) in enumerate(rows):
|
|
320
|
+
if si > 0:
|
|
321
|
+
prev_step = rows[si - 1][0]
|
|
322
|
+
prev_within_ts = prev_step
|
|
323
|
+
prev_period = period
|
|
324
|
+
prev_within_solve = prev_step
|
|
325
|
+
else:
|
|
326
|
+
if period == first_period:
|
|
327
|
+
prev_period_rows = per_period_rows[last_period]
|
|
328
|
+
prev_step = (
|
|
329
|
+
prev_period_rows[-1][0]
|
|
330
|
+
if prev_period_rows else step
|
|
331
|
+
)
|
|
332
|
+
prev_period = last_period
|
|
333
|
+
prev_within_solve = prev_step
|
|
334
|
+
prev_within_ts = block_last_step
|
|
335
|
+
else:
|
|
336
|
+
prev_period = periods[pi - 1]
|
|
337
|
+
prev_rows = per_period_rows[prev_period]
|
|
338
|
+
prev_step = (
|
|
339
|
+
prev_rows[-1][0]
|
|
340
|
+
if prev_rows else step
|
|
341
|
+
)
|
|
342
|
+
prev_within_solve = prev_step
|
|
343
|
+
prev_within_ts = block_last_step
|
|
344
|
+
out.append(
|
|
345
|
+
(
|
|
346
|
+
block,
|
|
347
|
+
period,
|
|
348
|
+
step,
|
|
349
|
+
prev_step,
|
|
350
|
+
prev_within_ts,
|
|
351
|
+
prev_period,
|
|
352
|
+
prev_within_solve,
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
return out
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# BlockLayout — public surface
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass
|
|
364
|
+
class BlockLayout:
|
|
365
|
+
"""Per-solve block layout — multi-resolution storage.
|
|
366
|
+
|
|
367
|
+
Internal bookkeeping (``node_block``, ``process_block_in/_out/_block``,
|
|
368
|
+
``block_step_duration``) lives as Python dicts because the input
|
|
369
|
+
data is small (O(entities + arcs)) and the algorithm is naturally
|
|
370
|
+
expressed in dict/tuple form. The output surface (``entity_block``,
|
|
371
|
+
``process_side_block``, ``block_step_duration_frame``, etc.) exposes
|
|
372
|
+
every frame the LP-build layer needs as a polars DataFrame in the
|
|
373
|
+
same column-naming convention the reference CSVs use.
|
|
374
|
+
|
|
375
|
+
Attributes
|
|
376
|
+
----------
|
|
377
|
+
Internal (matching ``BlockAssignments``):
|
|
378
|
+
|
|
379
|
+
node_block : dict[str, str]
|
|
380
|
+
``node_name → block_name``.
|
|
381
|
+
process_block_in / process_block_out / process_block : dict[str, str]
|
|
382
|
+
Per-process block on each side; ``process_block`` is the
|
|
383
|
+
unified block used for UC / startup / shutdown indices.
|
|
384
|
+
block_step_duration : dict[str, float]
|
|
385
|
+
``block_name → hours per step``. ``DEFAULT_BLOCK`` always
|
|
386
|
+
present.
|
|
387
|
+
|
|
388
|
+
Frames (every CSV the reference emits):
|
|
389
|
+
|
|
390
|
+
entity_block_frame : pl.DataFrame
|
|
391
|
+
Columns ``(entity, block)``.
|
|
392
|
+
process_side_block_frame : pl.DataFrame
|
|
393
|
+
Columns ``(process, side, block)`` — two rows per process.
|
|
394
|
+
process_block_frame : pl.DataFrame
|
|
395
|
+
Columns ``(process, block)`` — one row per process.
|
|
396
|
+
block_step_duration_frame : pl.DataFrame
|
|
397
|
+
Columns ``(block, period, step, step_duration)``.
|
|
398
|
+
overlap_set_frame : pl.DataFrame
|
|
399
|
+
Columns ``(period, block_coarse, step_coarse, block_fine,
|
|
400
|
+
step_fine, fraction)``.
|
|
401
|
+
block_step_previous_frame : pl.DataFrame
|
|
402
|
+
Columns ``(block, period, step, step_previous,
|
|
403
|
+
step_previous_within_timeset, period_previous,
|
|
404
|
+
step_previous_within_solve)``.
|
|
405
|
+
block_period_time_first_frame / _last_frame : pl.DataFrame
|
|
406
|
+
Columns ``(block, period, step)``.
|
|
407
|
+
"""
|
|
408
|
+
|
|
409
|
+
# --- Internal bookkeeping --------------------------------------------
|
|
410
|
+
node_block: dict[str, str] = field(default_factory=dict)
|
|
411
|
+
process_block_in: dict[str, str] = field(default_factory=dict)
|
|
412
|
+
process_block_out: dict[str, str] = field(default_factory=dict)
|
|
413
|
+
process_block: dict[str, str] = field(default_factory=dict)
|
|
414
|
+
block_step_duration: dict[str, float] = field(default_factory=dict)
|
|
415
|
+
# Per-block (period → [(step, duration)]) timeline.
|
|
416
|
+
per_block_timeline: dict[str, dict[str, list[tuple[str, float]]]] = field(
|
|
417
|
+
default_factory=dict,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# --- Output frames ----------------------------------------------------
|
|
421
|
+
entity_block_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
|
|
422
|
+
process_side_block_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
|
|
423
|
+
process_block_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
|
|
424
|
+
block_step_duration_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
|
|
425
|
+
overlap_set_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
|
|
426
|
+
block_step_previous_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
|
|
427
|
+
block_period_time_first_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
|
|
428
|
+
block_period_time_last_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
|
|
429
|
+
|
|
430
|
+
# ------------------------------------------------------------------
|
|
431
|
+
# Builder
|
|
432
|
+
# ------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
@classmethod
|
|
435
|
+
def build(
|
|
436
|
+
cls,
|
|
437
|
+
*,
|
|
438
|
+
solve: str,
|
|
439
|
+
solve_config: "SolveConfig | None",
|
|
440
|
+
timeline_config: "TimelineConfig | None",
|
|
441
|
+
nodes: Iterable[str],
|
|
442
|
+
units: Iterable[str],
|
|
443
|
+
connections: Iterable[str],
|
|
444
|
+
resolution_groups: dict[str, float],
|
|
445
|
+
group_unit: Iterable[tuple[str, str]],
|
|
446
|
+
group_connection: Iterable[tuple[str, str]],
|
|
447
|
+
group_node: Iterable[tuple[str, str]],
|
|
448
|
+
process_source_sink: Iterable[tuple[str, str, str]],
|
|
449
|
+
process_ct_method: dict[str, str],
|
|
450
|
+
decomposition_groups: dict[str, str] | None = None,
|
|
451
|
+
reserve_upDown_group: Iterable[tuple[str, str, str]] | None = None,
|
|
452
|
+
process_reserve_upDown_node: (
|
|
453
|
+
Iterable[tuple[str, str, str, str]] | None
|
|
454
|
+
) = None,
|
|
455
|
+
active_time_list: dict[str, list] | None = None,
|
|
456
|
+
default_jump_list: Iterable[tuple] | None = None,
|
|
457
|
+
validate: bool = True,
|
|
458
|
+
) -> "BlockLayout":
|
|
459
|
+
"""Compute every per-solve block frame in one pass.
|
|
460
|
+
|
|
461
|
+
Parameters mirror flextool's ``write_block_data_for_solve`` /
|
|
462
|
+
``derive_blocks`` / ``derive_overlap_set`` signatures so the
|
|
463
|
+
port is a drop-in algorithmic equivalent.
|
|
464
|
+
|
|
465
|
+
``validate=False`` skips ``validate_group_membership`` —
|
|
466
|
+
useful when the caller already validated upstream.
|
|
467
|
+
|
|
468
|
+
Returns
|
|
469
|
+
-------
|
|
470
|
+
BlockLayout
|
|
471
|
+
Fully populated dataclass. Frames carry the canonical
|
|
472
|
+
CSV column names so downstream helpers can join directly.
|
|
473
|
+
|
|
474
|
+
Raises
|
|
475
|
+
------
|
|
476
|
+
FlexToolConfigError
|
|
477
|
+
On ambiguous group membership (validation failure).
|
|
478
|
+
NotImplementedError
|
|
479
|
+
On non-aligned-subset coarse blocks (reference behaviour).
|
|
480
|
+
"""
|
|
481
|
+
gu = list(group_unit)
|
|
482
|
+
gc = list(group_connection)
|
|
483
|
+
gn = list(group_node)
|
|
484
|
+
decomp = decomposition_groups or {}
|
|
485
|
+
|
|
486
|
+
if validate:
|
|
487
|
+
validate_group_membership(
|
|
488
|
+
gu, gc, gn,
|
|
489
|
+
resolution_groups,
|
|
490
|
+
decomp,
|
|
491
|
+
reserve_upDown_group=reserve_upDown_group,
|
|
492
|
+
process_reserve_upDown_node=process_reserve_upDown_node,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
layout = cls()
|
|
496
|
+
layout._derive_blocks(
|
|
497
|
+
solve=solve,
|
|
498
|
+
solve_config=solve_config,
|
|
499
|
+
timeline_config=timeline_config,
|
|
500
|
+
nodes=nodes,
|
|
501
|
+
units=units,
|
|
502
|
+
connections=connections,
|
|
503
|
+
resolution_groups=resolution_groups,
|
|
504
|
+
group_unit=gu,
|
|
505
|
+
group_connection=gc,
|
|
506
|
+
group_node=gn,
|
|
507
|
+
process_source_sink=process_source_sink,
|
|
508
|
+
process_ct_method=process_ct_method,
|
|
509
|
+
)
|
|
510
|
+
layout._build_block_timelines(
|
|
511
|
+
solve=solve,
|
|
512
|
+
solve_config=solve_config,
|
|
513
|
+
timeline_config=timeline_config,
|
|
514
|
+
active_time_list=active_time_list,
|
|
515
|
+
)
|
|
516
|
+
layout._build_overlap_set()
|
|
517
|
+
layout._build_block_predecessors(default_jump_list=default_jump_list)
|
|
518
|
+
layout._build_block_boundaries()
|
|
519
|
+
layout._materialize_frames()
|
|
520
|
+
return layout
|
|
521
|
+
|
|
522
|
+
# ------------------------------------------------------------------
|
|
523
|
+
# Source-only constructor (Phase 1 of the fast-path multi-block plan)
|
|
524
|
+
# ------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
@classmethod
|
|
527
|
+
def from_source(
|
|
528
|
+
cls,
|
|
529
|
+
source,
|
|
530
|
+
solve_config: "SolveConfig",
|
|
531
|
+
timeline_config: "TimelineConfig",
|
|
532
|
+
*,
|
|
533
|
+
active_solve: str | None = None,
|
|
534
|
+
validate: bool = True,
|
|
535
|
+
) -> "BlockLayout":
|
|
536
|
+
"""Construct a :class:`BlockLayout` directly from a Spine
|
|
537
|
+
:class:`~flextool.engine_polars._input_source.InputSource`.
|
|
538
|
+
|
|
539
|
+
Source-only counterpart to :meth:`load_from_solve_data`: pulls
|
|
540
|
+
every input that :meth:`build` needs from the source / configs
|
|
541
|
+
(no workdir CSV reads), then delegates. Intended for the fast
|
|
542
|
+
single-solve path where ``solve_data/`` is empty.
|
|
543
|
+
|
|
544
|
+
Parameters
|
|
545
|
+
----------
|
|
546
|
+
source : InputSource
|
|
547
|
+
Scenario-resolved per-(entity_class, parameter_name)
|
|
548
|
+
reader (e.g. :class:`SpineDbReader`,
|
|
549
|
+
:class:`InMemoryReader`).
|
|
550
|
+
solve_config, timeline_config :
|
|
551
|
+
Already-loaded configs. ``timeline_config`` must have had
|
|
552
|
+
:meth:`TimelineConfig.create_assumptive_parts` /
|
|
553
|
+
:meth:`TimelineConfig.create_timeline_from_timestep_duration`
|
|
554
|
+
applied so :func:`_timeline.get_active_time` succeeds for
|
|
555
|
+
``active_solve``.
|
|
556
|
+
active_solve : str | None
|
|
557
|
+
Active solve name. When ``None`` we pick the first solve
|
|
558
|
+
entity in the scenario (single-solve fixtures).
|
|
559
|
+
validate : bool
|
|
560
|
+
Forwarded to :meth:`build` — when ``True`` runs
|
|
561
|
+
:func:`validate_group_membership` on the assembled inputs.
|
|
562
|
+
|
|
563
|
+
Returns
|
|
564
|
+
-------
|
|
565
|
+
BlockLayout
|
|
566
|
+
Fully populated, identical surface to what
|
|
567
|
+
:meth:`load_from_solve_data` produces from the slow-path's
|
|
568
|
+
``solve_data/`` block CSVs (on the same scenario).
|
|
569
|
+
|
|
570
|
+
"""
|
|
571
|
+
# ── 1. Resolve the active solve name ───────────────────────────
|
|
572
|
+
from flextool.engine_polars._projection_params import (
|
|
573
|
+
_try_entities,
|
|
574
|
+
_try_param,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
if active_solve is None:
|
|
578
|
+
solves_param = _try_param(source, "model", "solves")
|
|
579
|
+
if solves_param is not None and solves_param.height > 0:
|
|
580
|
+
val_col = (
|
|
581
|
+
"value" if "value" in solves_param.columns
|
|
582
|
+
else solves_param.columns[-1]
|
|
583
|
+
)
|
|
584
|
+
active_solve = str(solves_param[val_col][0])
|
|
585
|
+
else:
|
|
586
|
+
solve_ents = _try_entities(source, "solve")
|
|
587
|
+
if solve_ents is None or solve_ents.height == 0:
|
|
588
|
+
raise FlexToolConfigError(
|
|
589
|
+
"BlockLayout.from_source: no active solve could be "
|
|
590
|
+
"resolved — pass ``active_solve`` explicitly or "
|
|
591
|
+
"populate ``model.solves`` / the ``solve`` entity "
|
|
592
|
+
"class in the source."
|
|
593
|
+
)
|
|
594
|
+
name_col = next(
|
|
595
|
+
c for c in solve_ents.columns if c != "id"
|
|
596
|
+
)
|
|
597
|
+
active_solve = str(solve_ents[name_col][0])
|
|
598
|
+
|
|
599
|
+
# ── 2. Entity universes ────────────────────────────────────────
|
|
600
|
+
units_df = _try_entities(source, "unit")
|
|
601
|
+
units = units_df["name"].to_list() if units_df is not None else []
|
|
602
|
+
conns_df = _try_entities(source, "connection")
|
|
603
|
+
connections = (
|
|
604
|
+
conns_df["name"].to_list() if conns_df is not None else []
|
|
605
|
+
)
|
|
606
|
+
nodes_df = _try_entities(source, "node")
|
|
607
|
+
nodes = nodes_df["name"].to_list() if nodes_df is not None else []
|
|
608
|
+
|
|
609
|
+
# ── 3. Group resolution (new_stepduration / decomposition_method)
|
|
610
|
+
resolution_groups: dict[str, float] = {}
|
|
611
|
+
rg_param = _try_param(source, "group", "new_stepduration")
|
|
612
|
+
if rg_param is not None:
|
|
613
|
+
name_col = (
|
|
614
|
+
"name" if "name" in rg_param.columns
|
|
615
|
+
else rg_param.columns[0]
|
|
616
|
+
)
|
|
617
|
+
for row in rg_param.iter_rows(named=True):
|
|
618
|
+
v = row["value"]
|
|
619
|
+
if v is None:
|
|
620
|
+
continue
|
|
621
|
+
try:
|
|
622
|
+
resolution_groups[row[name_col]] = float(v)
|
|
623
|
+
except (TypeError, ValueError):
|
|
624
|
+
continue
|
|
625
|
+
|
|
626
|
+
decomposition_groups: dict[str, str] = {}
|
|
627
|
+
dg_param = _try_param(source, "group", "decomposition_method")
|
|
628
|
+
if dg_param is not None:
|
|
629
|
+
name_col = (
|
|
630
|
+
"name" if "name" in dg_param.columns
|
|
631
|
+
else dg_param.columns[0]
|
|
632
|
+
)
|
|
633
|
+
for row in dg_param.iter_rows(named=True):
|
|
634
|
+
v = row["value"]
|
|
635
|
+
if v is None:
|
|
636
|
+
continue
|
|
637
|
+
decomposition_groups[row[name_col]] = str(v)
|
|
638
|
+
|
|
639
|
+
# ── 4. Group memberships ───────────────────────────────────────
|
|
640
|
+
def _pairs(cls_name: str, dim: str) -> list[tuple[str, str]]:
|
|
641
|
+
df = _try_entities(source, cls_name)
|
|
642
|
+
if df is None or df.height == 0:
|
|
643
|
+
return []
|
|
644
|
+
return list(zip(df["group"].to_list(), df[dim].to_list()))
|
|
645
|
+
|
|
646
|
+
group_node = _pairs("group__node", "node")
|
|
647
|
+
group_unit = _pairs("group__unit", "unit")
|
|
648
|
+
group_connection = _pairs("group__connection", "connection")
|
|
649
|
+
|
|
650
|
+
# ── 5. Reserve membership (optional; absent on most fixtures) ──
|
|
651
|
+
reserve_upDown_group: list[tuple[str, str, str]] = []
|
|
652
|
+
rug_df = _try_entities(source, "reserve__upDown__group")
|
|
653
|
+
if rug_df is not None and rug_df.height > 0:
|
|
654
|
+
# Filter rows whose ``method`` (if defined) is not ``no_reserve``.
|
|
655
|
+
method_param = _try_param(
|
|
656
|
+
source, "reserve__upDown__group", "method",
|
|
657
|
+
)
|
|
658
|
+
excluded: set[tuple[str, str, str]] = set()
|
|
659
|
+
if method_param is not None:
|
|
660
|
+
for row in method_param.iter_rows(named=True):
|
|
661
|
+
if str(row.get("value", "")) == "no_reserve":
|
|
662
|
+
excluded.add(
|
|
663
|
+
(row["reserve"], row["upDown"], row["group"])
|
|
664
|
+
)
|
|
665
|
+
for r, ud, g in zip(
|
|
666
|
+
rug_df["reserve"].to_list(),
|
|
667
|
+
rug_df["upDown"].to_list(),
|
|
668
|
+
rug_df["group"].to_list(),
|
|
669
|
+
):
|
|
670
|
+
if (r, ud, g) in excluded:
|
|
671
|
+
continue
|
|
672
|
+
reserve_upDown_group.append((r, ud, g))
|
|
673
|
+
|
|
674
|
+
process_reserve_upDown_node: list[tuple[str, str, str, str]] = []
|
|
675
|
+
for cls_name, p_col in (
|
|
676
|
+
("reserve__upDown__unit__node", "unit"),
|
|
677
|
+
("reserve__upDown__connection__node", "connection"),
|
|
678
|
+
):
|
|
679
|
+
df = _try_entities(source, cls_name)
|
|
680
|
+
if df is None or df.height == 0:
|
|
681
|
+
continue
|
|
682
|
+
for p, r, ud, n in zip(
|
|
683
|
+
df[p_col].to_list(),
|
|
684
|
+
df["reserve"].to_list(),
|
|
685
|
+
df["upDown"].to_list(),
|
|
686
|
+
df["node"].to_list(),
|
|
687
|
+
):
|
|
688
|
+
process_reserve_upDown_node.append((p, r, ud, n))
|
|
689
|
+
|
|
690
|
+
# ── 6. process_source_sink (first source / first sink per process)
|
|
691
|
+
# Built directly from ``unit__inputNode`` / ``unit__outputNode`` /
|
|
692
|
+
# ``connection__node__node`` — matching the slow path's
|
|
693
|
+
# ``process__source.csv`` / ``process__sink.csv`` derivation
|
|
694
|
+
# (input_writer.py emits one row per (process, input-or-output
|
|
695
|
+
# node) pair; first one wins per `derive_blocks`'s usage).
|
|
696
|
+
first_source: dict[str, str] = {}
|
|
697
|
+
first_sink: dict[str, str] = {}
|
|
698
|
+
uin = _try_entities(source, "unit__inputNode")
|
|
699
|
+
if uin is not None:
|
|
700
|
+
for u, n in zip(uin["unit"].to_list(), uin["node"].to_list()):
|
|
701
|
+
first_source.setdefault(u, n)
|
|
702
|
+
uout = _try_entities(source, "unit__outputNode")
|
|
703
|
+
if uout is not None:
|
|
704
|
+
for u, n in zip(uout["unit"].to_list(), uout["node"].to_list()):
|
|
705
|
+
first_sink.setdefault(u, n)
|
|
706
|
+
cnn = _try_entities(source, "connection__node__node")
|
|
707
|
+
if cnn is not None:
|
|
708
|
+
for c, a, b in zip(
|
|
709
|
+
cnn["connection"].to_list(),
|
|
710
|
+
cnn["node_1"].to_list(),
|
|
711
|
+
cnn["node_2"].to_list(),
|
|
712
|
+
):
|
|
713
|
+
first_source.setdefault(c, a)
|
|
714
|
+
first_sink.setdefault(c, b)
|
|
715
|
+
process_source_sink: list[tuple[str, str, str]] = []
|
|
716
|
+
all_procs = set(first_source) | set(first_sink)
|
|
717
|
+
for p in all_procs:
|
|
718
|
+
process_source_sink.append(
|
|
719
|
+
(p, first_source.get(p, ""), first_sink.get(p, ""))
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# ── 7. process_ct_method ──────────────────────────────────────
|
|
723
|
+
# Slow-path ``process__ct_method.csv`` = union of
|
|
724
|
+
# ``unit.conversion_method`` and ``connection.transfer_method``
|
|
725
|
+
# (input_writer.py:394).
|
|
726
|
+
process_ct_method: dict[str, str] = {}
|
|
727
|
+
cm_unit = _try_param(source, "unit", "conversion_method")
|
|
728
|
+
if cm_unit is not None:
|
|
729
|
+
for row in cm_unit.iter_rows(named=True):
|
|
730
|
+
process_ct_method[row["name"]] = str(row["value"])
|
|
731
|
+
cm_conn = _try_param(source, "connection", "transfer_method")
|
|
732
|
+
if cm_conn is not None:
|
|
733
|
+
for row in cm_conn.iter_rows(named=True):
|
|
734
|
+
process_ct_method[row["name"]] = str(row["value"])
|
|
735
|
+
|
|
736
|
+
# ── 8. active_time_list — derived from timeline + solve configs.
|
|
737
|
+
from flextool.engine_polars._timeline import get_active_time
|
|
738
|
+
|
|
739
|
+
active_time_list = get_active_time(
|
|
740
|
+
current_solve=active_solve,
|
|
741
|
+
timesets_used_by_solves=(
|
|
742
|
+
solve_config.timesets_used_by_solves
|
|
743
|
+
),
|
|
744
|
+
timesets=timeline_config.timeset_durations,
|
|
745
|
+
timelines=timeline_config.timelines,
|
|
746
|
+
timesets__timelines=timeline_config.timesets__timeline,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
# ── 9. default_jump_list:
|
|
750
|
+
# Phase-1 scope deliberately defers building this from
|
|
751
|
+
# ``make_step_jump`` (needs ``period__branch`` /
|
|
752
|
+
# ``solve_branch__time_branch_list``, which require additional
|
|
753
|
+
# source plumbing). Passing ``None`` lets ``build`` fall back
|
|
754
|
+
# to ``_cyclic_block_predecessors`` — on simple single-period
|
|
755
|
+
# non-stochastic fixtures the slow-path's ``step_previous.csv``
|
|
756
|
+
# produces the same rows (verified by the parity test below
|
|
757
|
+
# and by the existing ``test_load_from_solve_data_bridge_*``).
|
|
758
|
+
return cls.build(
|
|
759
|
+
solve=active_solve,
|
|
760
|
+
solve_config=solve_config,
|
|
761
|
+
timeline_config=timeline_config,
|
|
762
|
+
nodes=nodes,
|
|
763
|
+
units=units,
|
|
764
|
+
connections=connections,
|
|
765
|
+
resolution_groups=resolution_groups,
|
|
766
|
+
group_unit=group_unit,
|
|
767
|
+
group_connection=group_connection,
|
|
768
|
+
group_node=group_node,
|
|
769
|
+
process_source_sink=process_source_sink,
|
|
770
|
+
process_ct_method=process_ct_method,
|
|
771
|
+
decomposition_groups=decomposition_groups,
|
|
772
|
+
reserve_upDown_group=reserve_upDown_group or None,
|
|
773
|
+
process_reserve_upDown_node=(
|
|
774
|
+
process_reserve_upDown_node or None
|
|
775
|
+
),
|
|
776
|
+
active_time_list=active_time_list,
|
|
777
|
+
default_jump_list=None,
|
|
778
|
+
validate=validate,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
# ------------------------------------------------------------------
|
|
782
|
+
# Phase 1: block tagging (entity + process side)
|
|
783
|
+
# ------------------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
def _derive_blocks(
|
|
786
|
+
self,
|
|
787
|
+
*,
|
|
788
|
+
solve: str,
|
|
789
|
+
solve_config,
|
|
790
|
+
timeline_config,
|
|
791
|
+
nodes: Iterable[str],
|
|
792
|
+
units: Iterable[str],
|
|
793
|
+
connections: Iterable[str],
|
|
794
|
+
resolution_groups: dict[str, float],
|
|
795
|
+
group_unit: list[tuple[str, str]],
|
|
796
|
+
group_connection: list[tuple[str, str]],
|
|
797
|
+
group_node: list[tuple[str, str]],
|
|
798
|
+
process_source_sink: Iterable[tuple[str, str, str]],
|
|
799
|
+
process_ct_method: dict[str, str],
|
|
800
|
+
) -> None:
|
|
801
|
+
"""Mirror ``blocks.derive_blocks``."""
|
|
802
|
+
gu = group_unit
|
|
803
|
+
gc = group_connection
|
|
804
|
+
gn = group_node
|
|
805
|
+
|
|
806
|
+
default_step_duration = _solve_step_duration(
|
|
807
|
+
solve, solve_config, timeline_config
|
|
808
|
+
)
|
|
809
|
+
block_step_duration: dict[str, float] = {
|
|
810
|
+
DEFAULT_BLOCK: default_step_duration,
|
|
811
|
+
}
|
|
812
|
+
for g, dur in resolution_groups.items():
|
|
813
|
+
try:
|
|
814
|
+
block_step_duration[g] = float(dur)
|
|
815
|
+
except (TypeError, ValueError):
|
|
816
|
+
continue
|
|
817
|
+
|
|
818
|
+
# Node block.
|
|
819
|
+
node_block: dict[str, str] = {}
|
|
820
|
+
for n in nodes:
|
|
821
|
+
g = _assign_entity_to_group(n, gn, resolution_groups)
|
|
822
|
+
node_block[n] = g if g is not None else DEFAULT_BLOCK
|
|
823
|
+
|
|
824
|
+
# Process source/sink — first source/sink per process.
|
|
825
|
+
first_source: dict[str, str] = {}
|
|
826
|
+
first_sink: dict[str, str] = {}
|
|
827
|
+
for p, s, k in process_source_sink:
|
|
828
|
+
first_source.setdefault(p, s)
|
|
829
|
+
first_sink.setdefault(p, k)
|
|
830
|
+
|
|
831
|
+
process_block_in: dict[str, str] = {}
|
|
832
|
+
process_block_out: dict[str, str] = {}
|
|
833
|
+
process_block: dict[str, str] = {}
|
|
834
|
+
|
|
835
|
+
def _finer(a: str, b: str) -> str:
|
|
836
|
+
da = block_step_duration.get(a, default_step_duration)
|
|
837
|
+
db = block_step_duration.get(b, default_step_duration)
|
|
838
|
+
return a if da <= db else b
|
|
839
|
+
|
|
840
|
+
explicit_membership = list(gu) + list(gc)
|
|
841
|
+
for p in list(units) + list(connections):
|
|
842
|
+
explicit_block = _assign_entity_to_group(
|
|
843
|
+
p, explicit_membership, resolution_groups
|
|
844
|
+
)
|
|
845
|
+
if explicit_block is not None:
|
|
846
|
+
process_block_in[p] = explicit_block
|
|
847
|
+
process_block_out[p] = explicit_block
|
|
848
|
+
process_block[p] = explicit_block
|
|
849
|
+
continue
|
|
850
|
+
|
|
851
|
+
src = first_source.get(p)
|
|
852
|
+
snk = first_sink.get(p)
|
|
853
|
+
src_block = (
|
|
854
|
+
node_block.get(src, DEFAULT_BLOCK) if src else DEFAULT_BLOCK
|
|
855
|
+
)
|
|
856
|
+
snk_block = (
|
|
857
|
+
node_block.get(snk, DEFAULT_BLOCK) if snk else DEFAULT_BLOCK
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
ct = process_ct_method.get(p, "")
|
|
861
|
+
if _is_direct(ct):
|
|
862
|
+
finer = _finer(src_block, snk_block)
|
|
863
|
+
process_block_in[p] = finer
|
|
864
|
+
process_block_out[p] = finer
|
|
865
|
+
process_block[p] = finer
|
|
866
|
+
else:
|
|
867
|
+
process_block_in[p] = src_block
|
|
868
|
+
process_block_out[p] = snk_block
|
|
869
|
+
process_block[p] = _finer(src_block, snk_block)
|
|
870
|
+
|
|
871
|
+
self.node_block = node_block
|
|
872
|
+
self.process_block_in = process_block_in
|
|
873
|
+
self.process_block_out = process_block_out
|
|
874
|
+
self.process_block = process_block
|
|
875
|
+
self.block_step_duration = block_step_duration
|
|
876
|
+
|
|
877
|
+
# ------------------------------------------------------------------
|
|
878
|
+
# Phase 2: per-block timelines
|
|
879
|
+
# ------------------------------------------------------------------
|
|
880
|
+
|
|
881
|
+
def _build_block_timelines(
|
|
882
|
+
self,
|
|
883
|
+
*,
|
|
884
|
+
solve: str,
|
|
885
|
+
solve_config,
|
|
886
|
+
timeline_config,
|
|
887
|
+
active_time_list: dict[str, list] | None,
|
|
888
|
+
) -> None:
|
|
889
|
+
"""Mirror ``blocks._build_block_timelines``."""
|
|
890
|
+
per_block: dict[str, dict[str, list[tuple[str, float]]]] = {}
|
|
891
|
+
|
|
892
|
+
default_periods: dict[str, list[tuple[str, float]]] = {}
|
|
893
|
+
|
|
894
|
+
if active_time_list is not None:
|
|
895
|
+
for period, rows in active_time_list.items():
|
|
896
|
+
default_periods[period] = [
|
|
897
|
+
(entry.timestep, float(entry.duration)) for entry in rows
|
|
898
|
+
]
|
|
899
|
+
elif solve_config is not None and timeline_config is not None:
|
|
900
|
+
for period, ts in solve_config.timesets_used_by_solves.get(solve, []):
|
|
901
|
+
tl = timeline_config.timesets__timeline.get(ts)
|
|
902
|
+
if not tl:
|
|
903
|
+
continue
|
|
904
|
+
tl_rows = timeline_config.timelines.get(tl, [])
|
|
905
|
+
default_periods.setdefault(period, [])
|
|
906
|
+
for step, dur in tl_rows:
|
|
907
|
+
default_periods[period].append((step, float(dur)))
|
|
908
|
+
|
|
909
|
+
per_block[DEFAULT_BLOCK] = default_periods
|
|
910
|
+
|
|
911
|
+
for block, dur in self.block_step_duration.items():
|
|
912
|
+
if block == DEFAULT_BLOCK:
|
|
913
|
+
continue
|
|
914
|
+
# Skip blocks no entity is assigned to.
|
|
915
|
+
if (
|
|
916
|
+
block not in self.node_block.values()
|
|
917
|
+
and block not in self.process_block_in.values()
|
|
918
|
+
and block not in self.process_block_out.values()
|
|
919
|
+
and block not in self.process_block.values()
|
|
920
|
+
):
|
|
921
|
+
continue
|
|
922
|
+
block_rows: dict[str, list[tuple[str, float]]] = {}
|
|
923
|
+
for period, rows in default_periods.items():
|
|
924
|
+
aggregated = _aggregate_timeline(
|
|
925
|
+
rows, coarse_duration=float(dur),
|
|
926
|
+
)
|
|
927
|
+
block_rows[period] = aggregated
|
|
928
|
+
per_block[block] = block_rows
|
|
929
|
+
|
|
930
|
+
self.per_block_timeline = per_block
|
|
931
|
+
|
|
932
|
+
# ------------------------------------------------------------------
|
|
933
|
+
# Phase 3: overlap set (cross-resolution)
|
|
934
|
+
# ------------------------------------------------------------------
|
|
935
|
+
|
|
936
|
+
def _build_overlap_set(self) -> None:
|
|
937
|
+
"""Mirror ``blocks.derive_overlap_set``.
|
|
938
|
+
|
|
939
|
+
Caches per-(b_coarse, b_fine) coverage maps so repeated arc
|
|
940
|
+
lookups don't re-walk the fine timeline (schematic
|
|
941
|
+
recommendation — the materialised join can balloon for
|
|
942
|
+
multi-block scenarios with ~30K rows).
|
|
943
|
+
"""
|
|
944
|
+
per_block = self.per_block_timeline
|
|
945
|
+
default_rows = per_block.get(DEFAULT_BLOCK, {})
|
|
946
|
+
|
|
947
|
+
rows: list[tuple[str, str, str, str, str, float]] = []
|
|
948
|
+
non_default = [b for b in per_block if b != DEFAULT_BLOCK]
|
|
949
|
+
|
|
950
|
+
# Degenerate case: only the default block exists.
|
|
951
|
+
if not non_default:
|
|
952
|
+
for period, pr in default_rows.items():
|
|
953
|
+
for step, _dur in pr:
|
|
954
|
+
rows.append(
|
|
955
|
+
(period, DEFAULT_BLOCK, step,
|
|
956
|
+
DEFAULT_BLOCK, step, 1.0)
|
|
957
|
+
)
|
|
958
|
+
self._overlap_rows = rows
|
|
959
|
+
return
|
|
960
|
+
|
|
961
|
+
# Default-against-default identity rows.
|
|
962
|
+
for period, pr in default_rows.items():
|
|
963
|
+
for step, _dur in pr:
|
|
964
|
+
rows.append(
|
|
965
|
+
(period, DEFAULT_BLOCK, step,
|
|
966
|
+
DEFAULT_BLOCK, step, 1.0)
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
# Cache: coverage[block][period] = {fine_step → coarse_step}.
|
|
970
|
+
coverage: dict[str, dict[str, dict[str, str]]] = {}
|
|
971
|
+
|
|
972
|
+
# For every resolution-group block, emit:
|
|
973
|
+
# * self-identity rows (b, t, b, t, 1.0)
|
|
974
|
+
# * coarse↔default fine rows
|
|
975
|
+
# * default↔coarse symmetric rows
|
|
976
|
+
# Build coverage along the way.
|
|
977
|
+
for coarse in non_default:
|
|
978
|
+
coarse_rows = per_block.get(coarse, {})
|
|
979
|
+
coverage[coarse] = {}
|
|
980
|
+
# Self-identity.
|
|
981
|
+
for period, c_rows in coarse_rows.items():
|
|
982
|
+
for coarse_step, _dur in c_rows:
|
|
983
|
+
rows.append(
|
|
984
|
+
(period, coarse, coarse_step,
|
|
985
|
+
coarse, coarse_step, 1.0)
|
|
986
|
+
)
|
|
987
|
+
for period, c_rows in coarse_rows.items():
|
|
988
|
+
fine = default_rows.get(period, [])
|
|
989
|
+
f_idx = 0
|
|
990
|
+
coverage[coarse].setdefault(period, {})
|
|
991
|
+
for coarse_step, coarse_dur in c_rows:
|
|
992
|
+
remaining = float(coarse_dur)
|
|
993
|
+
eps = 1e-9
|
|
994
|
+
while remaining > eps and f_idx < len(fine):
|
|
995
|
+
fine_step, fine_dur = fine[f_idx]
|
|
996
|
+
rows.append(
|
|
997
|
+
(period, coarse, coarse_step,
|
|
998
|
+
DEFAULT_BLOCK, fine_step, 1.0)
|
|
999
|
+
)
|
|
1000
|
+
rows.append(
|
|
1001
|
+
(period, DEFAULT_BLOCK, fine_step,
|
|
1002
|
+
coarse, coarse_step, 1.0)
|
|
1003
|
+
)
|
|
1004
|
+
coverage[coarse][period][fine_step] = coarse_step
|
|
1005
|
+
remaining -= float(fine_dur)
|
|
1006
|
+
f_idx += 1
|
|
1007
|
+
if remaining > eps:
|
|
1008
|
+
raise NotImplementedError(
|
|
1009
|
+
f"overlap_set: coarse step '{coarse_step}' on "
|
|
1010
|
+
f"block '{coarse}' left {remaining}h unmatched "
|
|
1011
|
+
f"at end of fine timeline — non-aligned subsets "
|
|
1012
|
+
f"are not supported yet."
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
# Pairs of resolution-group blocks: emit coarse→fine rows
|
|
1016
|
+
# (canonical direction) for blocks of different durations.
|
|
1017
|
+
if len(non_default) >= 2:
|
|
1018
|
+
for a in non_default:
|
|
1019
|
+
for b in non_default:
|
|
1020
|
+
if a == b:
|
|
1021
|
+
continue
|
|
1022
|
+
a_dur = self.block_step_duration.get(a, 0.0)
|
|
1023
|
+
b_dur = self.block_step_duration.get(b, 0.0)
|
|
1024
|
+
# Only emit coarse→fine pairs to avoid duplication.
|
|
1025
|
+
if a_dur > b_dur:
|
|
1026
|
+
emit = True
|
|
1027
|
+
elif a_dur == b_dur and a < b:
|
|
1028
|
+
emit = True
|
|
1029
|
+
else:
|
|
1030
|
+
emit = False
|
|
1031
|
+
if not emit:
|
|
1032
|
+
continue
|
|
1033
|
+
for period, a_cov in coverage[a].items():
|
|
1034
|
+
b_cov = coverage[b].get(period, {})
|
|
1035
|
+
for fine_step, a_step in a_cov.items():
|
|
1036
|
+
b_step = b_cov.get(fine_step)
|
|
1037
|
+
if b_step is None:
|
|
1038
|
+
continue
|
|
1039
|
+
rows.append(
|
|
1040
|
+
(period, a, a_step, b, b_step, 1.0)
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
# Deduplicate while preserving order.
|
|
1044
|
+
seen: set[tuple] = set()
|
|
1045
|
+
deduped: list[tuple[str, str, str, str, str, float]] = []
|
|
1046
|
+
for row in rows:
|
|
1047
|
+
if row in seen:
|
|
1048
|
+
continue
|
|
1049
|
+
seen.add(row)
|
|
1050
|
+
deduped.append(row)
|
|
1051
|
+
self._overlap_rows = deduped
|
|
1052
|
+
# Cache for downstream consumers.
|
|
1053
|
+
self._coverage_cache = coverage
|
|
1054
|
+
|
|
1055
|
+
# ------------------------------------------------------------------
|
|
1056
|
+
# Phase 4: per-block predecessors + boundaries
|
|
1057
|
+
# ------------------------------------------------------------------
|
|
1058
|
+
|
|
1059
|
+
def _build_block_predecessors(
|
|
1060
|
+
self, *, default_jump_list: Iterable[tuple] | None,
|
|
1061
|
+
) -> None:
|
|
1062
|
+
"""Mirror ``blocks.derive_block_predecessors``."""
|
|
1063
|
+
rows: list[tuple[str, str, str, str, str, str, str]] = []
|
|
1064
|
+
|
|
1065
|
+
if default_jump_list is not None:
|
|
1066
|
+
for entry in default_jump_list:
|
|
1067
|
+
period = entry[0]
|
|
1068
|
+
step = entry[1]
|
|
1069
|
+
previous = entry[2]
|
|
1070
|
+
previous_within_timeset = entry[3]
|
|
1071
|
+
previous_period = entry[4]
|
|
1072
|
+
previous_within_solve = entry[5]
|
|
1073
|
+
rows.append(
|
|
1074
|
+
(
|
|
1075
|
+
DEFAULT_BLOCK,
|
|
1076
|
+
period,
|
|
1077
|
+
step,
|
|
1078
|
+
previous,
|
|
1079
|
+
previous_within_timeset,
|
|
1080
|
+
previous_period,
|
|
1081
|
+
previous_within_solve,
|
|
1082
|
+
)
|
|
1083
|
+
)
|
|
1084
|
+
else:
|
|
1085
|
+
default_rows = self.per_block_timeline.get(DEFAULT_BLOCK, {})
|
|
1086
|
+
rows.extend(_cyclic_block_predecessors(DEFAULT_BLOCK, default_rows))
|
|
1087
|
+
|
|
1088
|
+
for block in self.block_step_duration:
|
|
1089
|
+
if block == DEFAULT_BLOCK:
|
|
1090
|
+
continue
|
|
1091
|
+
block_rows_per_period = self.per_block_timeline.get(block, {})
|
|
1092
|
+
if not block_rows_per_period:
|
|
1093
|
+
continue
|
|
1094
|
+
rows.extend(_cyclic_block_predecessors(block, block_rows_per_period))
|
|
1095
|
+
|
|
1096
|
+
self._predecessor_rows = rows
|
|
1097
|
+
|
|
1098
|
+
def _build_block_boundaries(self) -> None:
|
|
1099
|
+
"""Mirror ``blocks.derive_block_boundaries``."""
|
|
1100
|
+
first: list[tuple[str, str, str]] = []
|
|
1101
|
+
last: list[tuple[str, str, str]] = []
|
|
1102
|
+
for block in self.block_step_duration:
|
|
1103
|
+
rows_per_period = self.per_block_timeline.get(block, {})
|
|
1104
|
+
for period, rows in rows_per_period.items():
|
|
1105
|
+
if not rows:
|
|
1106
|
+
continue
|
|
1107
|
+
first.append((block, period, rows[0][0]))
|
|
1108
|
+
last.append((block, period, rows[-1][0]))
|
|
1109
|
+
self._boundary_first_rows = first
|
|
1110
|
+
self._boundary_last_rows = last
|
|
1111
|
+
|
|
1112
|
+
# ------------------------------------------------------------------
|
|
1113
|
+
# Phase 5: materialise polars frames
|
|
1114
|
+
# ------------------------------------------------------------------
|
|
1115
|
+
|
|
1116
|
+
def _materialize_frames(self) -> None:
|
|
1117
|
+
"""Build the polars DataFrame surface from the dict bookkeeping.
|
|
1118
|
+
|
|
1119
|
+
Column names match the canonical CSV writers' headers so
|
|
1120
|
+
downstream callers can join directly without renaming.
|
|
1121
|
+
"""
|
|
1122
|
+
# entity_block.csv — one row per node.
|
|
1123
|
+
eb_rows = list(self.node_block.items())
|
|
1124
|
+
self.entity_block_frame = pl.DataFrame(
|
|
1125
|
+
{
|
|
1126
|
+
"entity": [r[0] for r in eb_rows],
|
|
1127
|
+
"block": [r[1] for r in eb_rows],
|
|
1128
|
+
},
|
|
1129
|
+
schema={"entity": schema_dtype(_enums, "entity"),
|
|
1130
|
+
"block": pl.Utf8},
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
# process_side_block.csv — two rows per process (source + sink).
|
|
1134
|
+
psb_rows: list[tuple[str, str, str]] = []
|
|
1135
|
+
for process, block in self.process_block_in.items():
|
|
1136
|
+
psb_rows.append((process, "source", block))
|
|
1137
|
+
for process, block in self.process_block_out.items():
|
|
1138
|
+
psb_rows.append((process, "sink", block))
|
|
1139
|
+
self.process_side_block_frame = pl.DataFrame(
|
|
1140
|
+
{
|
|
1141
|
+
"process": [r[0] for r in psb_rows],
|
|
1142
|
+
"side": [r[1] for r in psb_rows],
|
|
1143
|
+
"block": [r[2] for r in psb_rows],
|
|
1144
|
+
},
|
|
1145
|
+
schema={"process": schema_dtype(_enums, "process"),
|
|
1146
|
+
"side": pl.Utf8, "block": pl.Utf8},
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
# process_block.csv — process-unified block.
|
|
1150
|
+
pb_rows = list(self.process_block.items())
|
|
1151
|
+
self.process_block_frame = pl.DataFrame(
|
|
1152
|
+
{
|
|
1153
|
+
"process": [r[0] for r in pb_rows],
|
|
1154
|
+
"block": [r[1] for r in pb_rows],
|
|
1155
|
+
},
|
|
1156
|
+
schema={"process": schema_dtype(_enums, "process"),
|
|
1157
|
+
"block": pl.Utf8},
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
# block_step_duration.csv — full per-block timeline.
|
|
1161
|
+
bsd_rows: list[tuple[str, str, str, float]] = []
|
|
1162
|
+
for block, period_rows in self.per_block_timeline.items():
|
|
1163
|
+
for period, rows in period_rows.items():
|
|
1164
|
+
for step, dur in rows:
|
|
1165
|
+
bsd_rows.append((block, period, step, dur))
|
|
1166
|
+
self.block_step_duration_frame = pl.DataFrame(
|
|
1167
|
+
{
|
|
1168
|
+
"block": [r[0] for r in bsd_rows],
|
|
1169
|
+
"period": [r[1] for r in bsd_rows],
|
|
1170
|
+
"step": [r[2] for r in bsd_rows],
|
|
1171
|
+
"step_duration": [r[3] for r in bsd_rows],
|
|
1172
|
+
},
|
|
1173
|
+
schema={
|
|
1174
|
+
"block": pl.Utf8, "period": pl.Utf8,
|
|
1175
|
+
"step": pl.Utf8, "step_duration": pl.Float64,
|
|
1176
|
+
},
|
|
1177
|
+
)
|
|
1178
|
+
|
|
1179
|
+
# overlap_set.csv — cross-resolution rows.
|
|
1180
|
+
ov_rows = self._overlap_rows
|
|
1181
|
+
self.overlap_set_frame = pl.DataFrame(
|
|
1182
|
+
{
|
|
1183
|
+
"period": [r[0] for r in ov_rows],
|
|
1184
|
+
"block_coarse": [r[1] for r in ov_rows],
|
|
1185
|
+
"step_coarse": [r[2] for r in ov_rows],
|
|
1186
|
+
"block_fine": [r[3] for r in ov_rows],
|
|
1187
|
+
"step_fine": [r[4] for r in ov_rows],
|
|
1188
|
+
"fraction": [r[5] for r in ov_rows],
|
|
1189
|
+
},
|
|
1190
|
+
schema={
|
|
1191
|
+
"period": pl.Utf8, "block_coarse": pl.Utf8,
|
|
1192
|
+
"step_coarse": pl.Utf8, "block_fine": pl.Utf8,
|
|
1193
|
+
"step_fine": pl.Utf8, "fraction": pl.Float64,
|
|
1194
|
+
},
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1197
|
+
# block_step_previous.csv — per-block predecessor rows.
|
|
1198
|
+
pred_rows = self._predecessor_rows
|
|
1199
|
+
self.block_step_previous_frame = pl.DataFrame(
|
|
1200
|
+
{
|
|
1201
|
+
"block": [r[0] for r in pred_rows],
|
|
1202
|
+
"period": [r[1] for r in pred_rows],
|
|
1203
|
+
"step": [r[2] for r in pred_rows],
|
|
1204
|
+
"step_previous": [r[3] for r in pred_rows],
|
|
1205
|
+
"step_previous_within_timeset": [r[4] for r in pred_rows],
|
|
1206
|
+
"period_previous": [r[5] for r in pred_rows],
|
|
1207
|
+
"step_previous_within_solve": [r[6] for r in pred_rows],
|
|
1208
|
+
},
|
|
1209
|
+
schema={
|
|
1210
|
+
"block": pl.Utf8, "period": pl.Utf8, "step": pl.Utf8,
|
|
1211
|
+
"step_previous": pl.Utf8,
|
|
1212
|
+
"step_previous_within_timeset": pl.Utf8,
|
|
1213
|
+
"period_previous": pl.Utf8,
|
|
1214
|
+
"step_previous_within_solve": pl.Utf8,
|
|
1215
|
+
},
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
# block_period_time_first.csv / _last.csv.
|
|
1219
|
+
first_rows = self._boundary_first_rows
|
|
1220
|
+
last_rows = self._boundary_last_rows
|
|
1221
|
+
self.block_period_time_first_frame = pl.DataFrame(
|
|
1222
|
+
{
|
|
1223
|
+
"block": [r[0] for r in first_rows],
|
|
1224
|
+
"period": [r[1] for r in first_rows],
|
|
1225
|
+
"step": [r[2] for r in first_rows],
|
|
1226
|
+
},
|
|
1227
|
+
schema={"block": pl.Utf8,
|
|
1228
|
+
"period": schema_dtype(_enums, "period"),
|
|
1229
|
+
"step": schema_dtype(_enums, "step")},
|
|
1230
|
+
)
|
|
1231
|
+
self.block_period_time_last_frame = pl.DataFrame(
|
|
1232
|
+
{
|
|
1233
|
+
"block": [r[0] for r in last_rows],
|
|
1234
|
+
"period": [r[1] for r in last_rows],
|
|
1235
|
+
"step": [r[2] for r in last_rows],
|
|
1236
|
+
},
|
|
1237
|
+
schema={"block": pl.Utf8,
|
|
1238
|
+
"period": schema_dtype(_enums, "period"),
|
|
1239
|
+
"step": schema_dtype(_enums, "step")},
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
# ------------------------------------------------------------------
|
|
1244
|
+
# CSV bridge — load from flextool's solve_data/ CSVs
|
|
1245
|
+
# ------------------------------------------------------------------
|
|
1246
|
+
|
|
1247
|
+
@classmethod
|
|
1248
|
+
def load_from_solve_data(
|
|
1249
|
+
cls,
|
|
1250
|
+
solve_data_dir,
|
|
1251
|
+
*,
|
|
1252
|
+
missing_ok: bool = True,
|
|
1253
|
+
provider: "object | None" = None,
|
|
1254
|
+
) -> "BlockLayout":
|
|
1255
|
+
"""Load a ``BlockLayout`` from a :class:`FlexDataProvider`.
|
|
1256
|
+
|
|
1257
|
+
Step 2.5 Phase A: ``blocks.write_block_data`` is now patched
|
|
1258
|
+
into ``_PATCH_MODULES`` so the eight per-solve block frames
|
|
1259
|
+
live in the Provider after the cascade's preprocessing pass.
|
|
1260
|
+
Callers MUST pass *provider*. When only a workdir is available
|
|
1261
|
+
(off-cascade tests, transitional bridges), use
|
|
1262
|
+
:func:`flextool.engine_polars._input_source.seed_provider_from_dir`
|
|
1263
|
+
on ``workdir/"solve_data"`` to construct an ephemeral Provider
|
|
1264
|
+
first.
|
|
1265
|
+
|
|
1266
|
+
Parameters
|
|
1267
|
+
----------
|
|
1268
|
+
solve_data_dir : Path
|
|
1269
|
+
Retained for symmetry with the legacy signature; unused
|
|
1270
|
+
when *provider* carries every requested key. The
|
|
1271
|
+
classmethod will refuse to read disk directly — the
|
|
1272
|
+
workdir-bridge contract is now Provider-only.
|
|
1273
|
+
missing_ok : bool
|
|
1274
|
+
When True (default), missing Provider keys produce empty
|
|
1275
|
+
frames. When False, raise ``KeyError`` on the first
|
|
1276
|
+
missing key.
|
|
1277
|
+
provider :
|
|
1278
|
+
:class:`FlexDataProvider` to serve frames from. Required.
|
|
1279
|
+
|
|
1280
|
+
Returns
|
|
1281
|
+
-------
|
|
1282
|
+
BlockLayout
|
|
1283
|
+
Populated layout. ``per_block_timeline`` is reconstructed
|
|
1284
|
+
from ``block_step_duration_frame`` rows.
|
|
1285
|
+
"""
|
|
1286
|
+
if provider is None:
|
|
1287
|
+
# Off-cascade bridge path: seed an ephemeral Provider from
|
|
1288
|
+
# the workdir CSV directory and serve from that. Cascade
|
|
1289
|
+
# callers always pass *provider* explicitly; this branch is
|
|
1290
|
+
# reserved for tests and the legacy ``load_block_bundle``
|
|
1291
|
+
# contract. Centralised here so the Rule 1 audit can stay
|
|
1292
|
+
# strict — no bare ``pl.read_csv`` / ``_read_csv_file`` in
|
|
1293
|
+
# this module.
|
|
1294
|
+
from pathlib import Path as _Path
|
|
1295
|
+
from flextool.engine_polars._flex_data_provider import (
|
|
1296
|
+
FlexDataProvider,
|
|
1297
|
+
)
|
|
1298
|
+
from flextool.engine_polars._input_source import (
|
|
1299
|
+
seed_provider_from_dir,
|
|
1300
|
+
)
|
|
1301
|
+
sd_dir = _Path(solve_data_dir)
|
|
1302
|
+
provider = FlexDataProvider()
|
|
1303
|
+
seed_provider_from_dir(
|
|
1304
|
+
provider, sd_dir, "solve_data",
|
|
1305
|
+
names=_BLOCK_CSV_STEMS,
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
def _read(name: str, schema: dict) -> pl.DataFrame:
|
|
1309
|
+
stem = name.removesuffix(".csv")
|
|
1310
|
+
k = f"solve_data/{stem}"
|
|
1311
|
+
if provider.has(k):
|
|
1312
|
+
df = provider.get(k)
|
|
1313
|
+
# Coerce dtypes — Provider may carry Utf8 for
|
|
1314
|
+
# numeric columns when the writer's _empty_frame
|
|
1315
|
+
# shortcut hits.
|
|
1316
|
+
for col, dt in schema.items():
|
|
1317
|
+
if col in df.columns and df.schema[col] != dt:
|
|
1318
|
+
df = df.with_columns(
|
|
1319
|
+
pl.col(col).cast(dt, strict=False),
|
|
1320
|
+
)
|
|
1321
|
+
return df
|
|
1322
|
+
if missing_ok:
|
|
1323
|
+
return pl.DataFrame(schema=schema)
|
|
1324
|
+
raise KeyError(
|
|
1325
|
+
f"BlockLayout.load_from_solve_data: provider has no "
|
|
1326
|
+
f"key for '{name}' (tried 'solve_data/{stem}' and "
|
|
1327
|
+
f"'{stem}')."
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
layout = cls()
|
|
1331
|
+
layout.entity_block_frame = _read(
|
|
1332
|
+
"entity_block.csv",
|
|
1333
|
+
{"entity": pl.Utf8, "block": pl.Utf8},
|
|
1334
|
+
)
|
|
1335
|
+
layout.process_side_block_frame = _read(
|
|
1336
|
+
"process_side_block.csv",
|
|
1337
|
+
{"process": pl.Utf8, "side": pl.Utf8, "block": pl.Utf8},
|
|
1338
|
+
)
|
|
1339
|
+
layout.process_block_frame = _read(
|
|
1340
|
+
"process_block.csv",
|
|
1341
|
+
{"process": pl.Utf8, "block": pl.Utf8},
|
|
1342
|
+
)
|
|
1343
|
+
layout.block_step_duration_frame = _read(
|
|
1344
|
+
"block_step_duration.csv",
|
|
1345
|
+
{
|
|
1346
|
+
"block": pl.Utf8, "period": pl.Utf8,
|
|
1347
|
+
"step": pl.Utf8, "step_duration": pl.Float64,
|
|
1348
|
+
},
|
|
1349
|
+
)
|
|
1350
|
+
layout.overlap_set_frame = _read(
|
|
1351
|
+
"overlap_set.csv",
|
|
1352
|
+
{
|
|
1353
|
+
"period": pl.Utf8, "block_coarse": pl.Utf8,
|
|
1354
|
+
"step_coarse": pl.Utf8, "block_fine": pl.Utf8,
|
|
1355
|
+
"step_fine": pl.Utf8, "fraction": pl.Float64,
|
|
1356
|
+
},
|
|
1357
|
+
)
|
|
1358
|
+
layout.block_step_previous_frame = _read(
|
|
1359
|
+
"block_step_previous.csv",
|
|
1360
|
+
{
|
|
1361
|
+
"block": pl.Utf8, "period": pl.Utf8, "step": pl.Utf8,
|
|
1362
|
+
"step_previous": pl.Utf8,
|
|
1363
|
+
"step_previous_within_timeset": pl.Utf8,
|
|
1364
|
+
"period_previous": pl.Utf8,
|
|
1365
|
+
"step_previous_within_solve": pl.Utf8,
|
|
1366
|
+
},
|
|
1367
|
+
)
|
|
1368
|
+
layout.block_period_time_first_frame = _read(
|
|
1369
|
+
"block_period_time_first.csv",
|
|
1370
|
+
{"block": pl.Utf8, "period": pl.Utf8, "step": pl.Utf8},
|
|
1371
|
+
)
|
|
1372
|
+
layout.block_period_time_last_frame = _read(
|
|
1373
|
+
"block_period_time_last.csv",
|
|
1374
|
+
{"block": pl.Utf8, "period": pl.Utf8, "step": pl.Utf8},
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
# Reconstruct internal bookkeeping dicts from frames so callers
|
|
1378
|
+
# can index by entity name.
|
|
1379
|
+
if layout.entity_block_frame.height > 0:
|
|
1380
|
+
layout.node_block = dict(zip(
|
|
1381
|
+
layout.entity_block_frame["entity"].to_list(),
|
|
1382
|
+
layout.entity_block_frame["block"].to_list(),
|
|
1383
|
+
))
|
|
1384
|
+
if layout.process_side_block_frame.height > 0:
|
|
1385
|
+
for row in layout.process_side_block_frame.iter_rows(named=True):
|
|
1386
|
+
if row["side"] == "source":
|
|
1387
|
+
layout.process_block_in[row["process"]] = row["block"]
|
|
1388
|
+
elif row["side"] == "sink":
|
|
1389
|
+
layout.process_block_out[row["process"]] = row["block"]
|
|
1390
|
+
if layout.process_block_frame.height > 0:
|
|
1391
|
+
layout.process_block = dict(zip(
|
|
1392
|
+
layout.process_block_frame["process"].to_list(),
|
|
1393
|
+
layout.process_block_frame["block"].to_list(),
|
|
1394
|
+
))
|
|
1395
|
+
|
|
1396
|
+
# block_step_duration: dict[block → step_duration] uses the
|
|
1397
|
+
# first step's duration for each block (every step in a block
|
|
1398
|
+
# shares the same duration by construction).
|
|
1399
|
+
bsd = layout.block_step_duration_frame
|
|
1400
|
+
if bsd.height > 0:
|
|
1401
|
+
layout.block_step_duration = dict(
|
|
1402
|
+
bsd.group_by("block")
|
|
1403
|
+
.agg(pl.col("step_duration").first())
|
|
1404
|
+
.iter_rows()
|
|
1405
|
+
)
|
|
1406
|
+
# per_block_timeline reconstruction.
|
|
1407
|
+
for row in bsd.iter_rows(named=True):
|
|
1408
|
+
blk = row["block"]
|
|
1409
|
+
period = row["period"]
|
|
1410
|
+
layout.per_block_timeline.setdefault(blk, {}).setdefault(
|
|
1411
|
+
period, [],
|
|
1412
|
+
).append((row["step"], row["step_duration"]))
|
|
1413
|
+
return layout
|
|
1414
|
+
|
|
1415
|
+
# ------------------------------------------------------------------
|
|
1416
|
+
# Convenience accessors for downstream consumers
|
|
1417
|
+
# ------------------------------------------------------------------
|
|
1418
|
+
|
|
1419
|
+
def block_compat(self) -> pl.DataFrame:
|
|
1420
|
+
"""Return the (bk, b_f) → 1 compatibility set used by the
|
|
1421
|
+
block-aware filtering of flow_to_n / flow_from_n.
|
|
1422
|
+
|
|
1423
|
+
The block axis column is named ``bk`` (not ``b``) to disambiguate
|
|
1424
|
+
from the branch axis — see the b_collision review note in
|
|
1425
|
+
``schemas/flextool_axis_contract.json``. ``b_f`` is the
|
|
1426
|
+
block-fine companion column (also block vocabulary, but distinct
|
|
1427
|
+
column name so it doesn't collide).
|
|
1428
|
+
|
|
1429
|
+
Equivalent to::
|
|
1430
|
+
|
|
1431
|
+
overlap_set_frame
|
|
1432
|
+
.rename({"block_coarse": "bk", "block_fine": "b_f"})
|
|
1433
|
+
.select("bk", "b_f").unique()
|
|
1434
|
+
|
|
1435
|
+
Cached attribute would help on hot paths; the materialised set
|
|
1436
|
+
is small (≤ |blocks|² ≈ tens of rows).
|
|
1437
|
+
"""
|
|
1438
|
+
if self.overlap_set_frame.height == 0:
|
|
1439
|
+
return pl.DataFrame(schema={
|
|
1440
|
+
"bk": schema_dtype(_enums, "bk"),
|
|
1441
|
+
"b_f": schema_dtype(_enums, "b_f"),
|
|
1442
|
+
})
|
|
1443
|
+
return (
|
|
1444
|
+
self.overlap_set_frame
|
|
1445
|
+
.pipe(rename_to_axis, {"block_coarse": "bk", "block_fine": "b_f"})
|
|
1446
|
+
.select("bk", "b_f").unique()
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
def coarse_blocks(self, threshold: float = 1.0) -> list[str]:
|
|
1450
|
+
"""Return blocks with at least one row of ``step_duration >
|
|
1451
|
+
threshold``.
|
|
1452
|
+
|
|
1453
|
+
Mirrors the ``coarse_blocks`` selection in
|
|
1454
|
+
``input.py``'s nodeStateBlock synthesis (lines 2013-2040).
|
|
1455
|
+
"""
|
|
1456
|
+
if self.block_step_duration_frame.height == 0:
|
|
1457
|
+
return []
|
|
1458
|
+
return (
|
|
1459
|
+
self.block_step_duration_frame
|
|
1460
|
+
.filter(pl.col("step_duration") > threshold)
|
|
1461
|
+
["block"].unique().to_list()
|
|
1462
|
+
)
|
|
1463
|
+
|
|
1464
|
+
def is_empty(self) -> bool:
|
|
1465
|
+
"""Return True if the layout carries no block data at all
|
|
1466
|
+
(every frame is empty). Useful for the ``missing_ok`` branch
|
|
1467
|
+
of the CSV-loading bridge."""
|
|
1468
|
+
return (
|
|
1469
|
+
self.entity_block_frame.height == 0
|
|
1470
|
+
and self.block_step_duration_frame.height == 0
|
|
1471
|
+
and self.overlap_set_frame.height == 0
|
|
1472
|
+
)
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
__all__ = [
|
|
1476
|
+
"DEFAULT_BLOCK",
|
|
1477
|
+
"BlockLayout",
|
|
1478
|
+
"validate_group_membership",
|
|
1479
|
+
]
|