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,478 @@
|
|
|
1
|
+
""":class:`FlexDataProvider` — single carrier for preprocessing artefacts.
|
|
2
|
+
|
|
3
|
+
The Provider is the single abstraction by which loaders, writers,
|
|
4
|
+
post-processing readers, and orchestration exchange preprocessing
|
|
5
|
+
artefacts. Dict-backed: ``put(name, frame)`` stores a frame;
|
|
6
|
+
``get(name)`` / ``has(name)`` look it up by exact-match key.
|
|
7
|
+
|
|
8
|
+
Name conventions
|
|
9
|
+
----------------
|
|
10
|
+
|
|
11
|
+
* The Provider keys frames by their **parent-qualified key**
|
|
12
|
+
(``"<parent>/<stem>"``). Producers emit qualified keys
|
|
13
|
+
(``"solve_data/p_flow_max"``, ``"input/timeline"``, ``"derived/..."``,
|
|
14
|
+
``"handoff/..."``) and consumers query the same form — typically via
|
|
15
|
+
the typed constants in ``_provider_keys.py`` (``K.SOLVE_DATA_...``)
|
|
16
|
+
or via ``_emit_provider_io._provider_key(path)``.
|
|
17
|
+
* The same basename can appear under multiple parents (the canonical
|
|
18
|
+
example is ``timeline.csv`` which exists in both ``input/`` and
|
|
19
|
+
``solve_data/``); each is stored under its qualified key and looked
|
|
20
|
+
up by the same.
|
|
21
|
+
* ``get`` is an exact-match lookup against the stored key (after
|
|
22
|
+
``.csv``-suffix stripping). A typo or unqualified key returns
|
|
23
|
+
``None`` — the Phase 0a-era bare↔qualified fallback was dropped in
|
|
24
|
+
Phase 4.2-2 to give one canonical key form and one lookup path.
|
|
25
|
+
* ``has(name)`` returns ``True`` iff ``get(name)`` would return a
|
|
26
|
+
non-``None`` frame.
|
|
27
|
+
|
|
28
|
+
Suffix handling
|
|
29
|
+
---------------
|
|
30
|
+
|
|
31
|
+
The Provider stores keys *without* the ``.csv`` suffix. ``get`` /
|
|
32
|
+
``has`` / ``put`` accept either form — a trailing ``.csv`` is stripped
|
|
33
|
+
before keying.
|
|
34
|
+
|
|
35
|
+
Source tagging
|
|
36
|
+
--------------
|
|
37
|
+
|
|
38
|
+
``put(key, frame, *, source=None)`` accepts an optional free-form
|
|
39
|
+
``source`` string that is retained alongside the frame and surfaced by
|
|
40
|
+
:meth:`FlexDataProvider.get_source`. The natural cascade leaves
|
|
41
|
+
``source`` unset; external-override writes (see
|
|
42
|
+
:func:`flextool.engine_polars._provider_translators.translate_overrides_to_provider`)
|
|
43
|
+
tag their entries with ``"external_override"`` so downstream audit
|
|
44
|
+
tooling can distinguish overridden keys from naturally-produced ones.
|
|
45
|
+
Eviction by :meth:`release_unused` drops the source entry alongside the
|
|
46
|
+
frame.
|
|
47
|
+
"""
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import os
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from typing import Any, Iterable, Iterator
|
|
53
|
+
|
|
54
|
+
import polars as pl
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EvictedFrameError(KeyError):
|
|
58
|
+
"""Raised by :meth:`FlexDataProvider.get` when *name* has been
|
|
59
|
+
evicted by :meth:`FlexDataProvider.release_unused`.
|
|
60
|
+
|
|
61
|
+
Catching this *silently* (e.g. ``except (KeyError, EvictedFrameError)``)
|
|
62
|
+
is almost certainly a bug — eviction is sound by construction
|
|
63
|
+
(driven by the static lifetime map computed from declared ``READS``),
|
|
64
|
+
so a hit here means either the ``READS`` declaration is incomplete
|
|
65
|
+
for the active handler or a handler is reading a frame outside its
|
|
66
|
+
declared scope. Either way, surface the failure.
|
|
67
|
+
|
|
68
|
+
The exception carries the offending frame name and the
|
|
69
|
+
``last_needed`` item-group token so the caller can pinpoint the
|
|
70
|
+
drift.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, name: str, last_needed: Any) -> None:
|
|
74
|
+
super().__init__(
|
|
75
|
+
f"FlexDataProvider: frame {name!r} has been evicted "
|
|
76
|
+
f"(last_needed={last_needed!r}). This typically means "
|
|
77
|
+
f"the active handler's READS declaration is incomplete "
|
|
78
|
+
f"or a handler is reading a frame outside its declared "
|
|
79
|
+
f"scope. See specs/step_3_handoff.md Track B for details."
|
|
80
|
+
)
|
|
81
|
+
self.frame_name = name
|
|
82
|
+
self.last_needed = last_needed
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _strip_csv(name: str) -> str:
|
|
86
|
+
"""Drop a trailing ``.csv`` suffix if present."""
|
|
87
|
+
if name.endswith(".csv"):
|
|
88
|
+
return name[: -len(".csv")]
|
|
89
|
+
return name
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class FlexDataProvider:
|
|
93
|
+
"""Dict-backed carrier of preprocessing artefacts.
|
|
94
|
+
|
|
95
|
+
Interface contract — see the handoff (Step 1 section). This is the
|
|
96
|
+
scaffolding class for the migration; subsequent steps will replace
|
|
97
|
+
its consumers and eventually evolve it into the single data pathway
|
|
98
|
+
of the cascade.
|
|
99
|
+
|
|
100
|
+
The class is intentionally minimal and "dumb" — no caching beyond
|
|
101
|
+
the dict, no lazy loading, no eviction, no indexing. Optimisations
|
|
102
|
+
come later (Step 3) once measurements show where the peaks are.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
rss_budget_mb: float | None = None,
|
|
109
|
+
retain_all: bool = False,
|
|
110
|
+
) -> None:
|
|
111
|
+
self._frames: dict[str, pl.DataFrame] = {}
|
|
112
|
+
# Phase 6a — opt-in source-tagging. ``put(key, frame, source=...)``
|
|
113
|
+
# records the tag here; ``get_source(key)`` looks it up. Entries
|
|
114
|
+
# with ``source=None`` (the default) are *not* added to this map
|
|
115
|
+
# so the natural cascade pays zero memory overhead. Eviction
|
|
116
|
+
# clears the matching entry alongside the frame.
|
|
117
|
+
self._sources: dict[str, str] = {}
|
|
118
|
+
# Phase 4 — axis enum vocabulary + contract. Populated by
|
|
119
|
+
# ``input_derivation.run`` against the active SpineDBBackend, or
|
|
120
|
+
# lazy-built by ``load_flextool`` against the workdir sqlite when
|
|
121
|
+
# the cascade entry point bypasses input_derivation. ``None``
|
|
122
|
+
# means activation is off (legacy behaviour).
|
|
123
|
+
self.axis_enums: "dict[str, pl.Enum] | None" = None
|
|
124
|
+
self.contract: "object | None" = None
|
|
125
|
+
|
|
126
|
+
# Track B — READS-driven lifetime + threshold-gated eviction.
|
|
127
|
+
#
|
|
128
|
+
# ``_reads[handler_id]`` is the list of frame names the handler
|
|
129
|
+
# declares it consumes. ``precompute_lifetimes`` walks the
|
|
130
|
+
# handler-to-item-group map and the READS declarations to compute
|
|
131
|
+
# ``_last_needed[name] = max item-group that still needs it``.
|
|
132
|
+
# ``release_unused(after=group)`` drops every frame whose
|
|
133
|
+
# ``_last_needed`` is past ``group`` in iteration order. After
|
|
134
|
+
# eviction a subsequent ``get(name)`` raises
|
|
135
|
+
# :class:`EvictedFrameError` instead of silently re-fetching —
|
|
136
|
+
# we deliberately reject re-derivation (Step 3 design call).
|
|
137
|
+
#
|
|
138
|
+
# Eviction is threshold-gated by ``rss_budget_mb``: when the
|
|
139
|
+
# currently-cached frame footprint stays below the budget, the
|
|
140
|
+
# gate is closed and ``release_unused`` is a no-op. Small
|
|
141
|
+
# problems therefore pay zero eviction overhead; large problems
|
|
142
|
+
# hit the memory bound. ``retain_all`` (set by ``--csv-dump``)
|
|
143
|
+
# disables eviction unconditionally so debug-snapshot runs see
|
|
144
|
+
# every frame intact.
|
|
145
|
+
#
|
|
146
|
+
# ``rss_budget_mb`` resolution order:
|
|
147
|
+
# 1. constructor arg, if non-None.
|
|
148
|
+
# 2. ``FLEXTOOL_RSS_BUDGET_MB`` env var, if set.
|
|
149
|
+
# 3. ``None`` (eviction never fires; threshold gate always closed).
|
|
150
|
+
env_budget = os.environ.get("FLEXTOOL_RSS_BUDGET_MB")
|
|
151
|
+
self.rss_budget_mb: float | None = (
|
|
152
|
+
float(rss_budget_mb) if rss_budget_mb is not None
|
|
153
|
+
else (float(env_budget) if env_budget else None)
|
|
154
|
+
)
|
|
155
|
+
self.retain_all: bool = bool(retain_all)
|
|
156
|
+
|
|
157
|
+
self._reads: dict[str, list[str]] = {}
|
|
158
|
+
# The handler-to-item-group mapping; populated by
|
|
159
|
+
# ``register_handler``. Each handler is invoked at one or more
|
|
160
|
+
# item-groups (often exactly one).
|
|
161
|
+
self._handler_groups: dict[str, list[Any]] = {}
|
|
162
|
+
# The item-group iteration order; populated by
|
|
163
|
+
# ``precompute_lifetimes``. Maps each group token to its
|
|
164
|
+
# position index (0-based).
|
|
165
|
+
self._group_order: dict[Any, int] | None = None
|
|
166
|
+
# The lifetime map: frame name → (item_group_token, index).
|
|
167
|
+
# ``_last_needed`` is only populated after
|
|
168
|
+
# ``precompute_lifetimes`` runs; before that, all frames are
|
|
169
|
+
# treated as live indefinitely.
|
|
170
|
+
self._last_needed: dict[str, tuple[Any, int]] = {}
|
|
171
|
+
# Names that have been evicted by ``release_unused``. Reads on
|
|
172
|
+
# these raise :class:`EvictedFrameError`.
|
|
173
|
+
self._evicted: dict[str, Any] = {}
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Mutation
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def put(
|
|
180
|
+
self,
|
|
181
|
+
name: str,
|
|
182
|
+
frame: pl.DataFrame,
|
|
183
|
+
*,
|
|
184
|
+
source: str | None = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Store *frame* under *name*.
|
|
187
|
+
|
|
188
|
+
*name* may be bare (``"p_flow_max"``), qualified
|
|
189
|
+
(``"solve_data/p_flow_max"``), and may include the ``.csv``
|
|
190
|
+
suffix in either form — the suffix is stripped before keying.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
source:
|
|
195
|
+
Optional free-form tag retained alongside the frame and
|
|
196
|
+
surfaced via :meth:`get_source`. ``None`` (the default)
|
|
197
|
+
leaves no source entry — the natural-cascade case. Phase 6a
|
|
198
|
+
of ``specs/provider_consolidation.md`` adds this so the
|
|
199
|
+
external-override layer can mark its writes with a stable
|
|
200
|
+
``"external_override"`` tag for downstream audit.
|
|
201
|
+
"""
|
|
202
|
+
key = _strip_csv(name)
|
|
203
|
+
self._frames[key] = frame
|
|
204
|
+
if source is None:
|
|
205
|
+
# Overwriting a previously-tagged entry with an untagged put
|
|
206
|
+
# must clear the stale tag — the new frame is the new truth.
|
|
207
|
+
self._sources.pop(key, None)
|
|
208
|
+
else:
|
|
209
|
+
self._sources[key] = source
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
# Lookup
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def get(self, name: str) -> pl.DataFrame | None:
|
|
216
|
+
"""Return the frame for *name*, or ``None`` if unavailable.
|
|
217
|
+
|
|
218
|
+
Lookup logic:
|
|
219
|
+
|
|
220
|
+
1. If *name* is in the evicted set (Track B), raise
|
|
221
|
+
:class:`EvictedFrameError`. We deliberately do NOT silently
|
|
222
|
+
re-fetch — eviction is sound by static analysis when
|
|
223
|
+
``READS`` declarations are complete; a hit here points at a
|
|
224
|
+
READS-declaration drift or a handler reading outside its
|
|
225
|
+
declared scope.
|
|
226
|
+
2. Exact match on the supplied (suffix-stripped) key.
|
|
227
|
+
|
|
228
|
+
Returns ``None`` if no candidate matches. The Phase 0a-era
|
|
229
|
+
bare↔qualified fallback was dropped in Phase 4.2-2 — callers
|
|
230
|
+
must pass the canonical parent-qualified key (typically a
|
|
231
|
+
``K.*`` constant from ``_provider_keys.py`` or the result of
|
|
232
|
+
``_emit_provider_io._provider_key(path)``).
|
|
233
|
+
"""
|
|
234
|
+
key = _strip_csv(name)
|
|
235
|
+
if key in self._evicted:
|
|
236
|
+
raise EvictedFrameError(key, self._evicted[key])
|
|
237
|
+
return self._frames.get(key)
|
|
238
|
+
|
|
239
|
+
def has(self, name: str) -> bool:
|
|
240
|
+
"""Return ``True`` iff :meth:`get` would return a non-``None``
|
|
241
|
+
frame for *name*."""
|
|
242
|
+
return self.get(name) is not None
|
|
243
|
+
|
|
244
|
+
def get_source(self, name: str) -> str | None:
|
|
245
|
+
"""Return the source tag recorded for *name*, or ``None``.
|
|
246
|
+
|
|
247
|
+
Phase 6a — opt-in source-tagging. ``None`` is returned both for
|
|
248
|
+
keys that were ``put`` without a ``source`` argument and for
|
|
249
|
+
keys that have never been ``put`` (or have been evicted). This
|
|
250
|
+
accessor never raises.
|
|
251
|
+
"""
|
|
252
|
+
return self._sources.get(_strip_csv(name))
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
# Iteration helpers (handy for tests + future snapshot impls).
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def keys(self) -> list[str]:
|
|
259
|
+
return list(self._frames.keys())
|
|
260
|
+
|
|
261
|
+
def __contains__(self, name: str) -> bool: # pragma: no cover - trivial
|
|
262
|
+
return self.has(name)
|
|
263
|
+
|
|
264
|
+
def __iter__(self) -> Iterator[str]: # pragma: no cover - trivial
|
|
265
|
+
return iter(self._frames)
|
|
266
|
+
|
|
267
|
+
def items(self) -> Iterable[tuple[str, pl.DataFrame]]: # pragma: no cover
|
|
268
|
+
return self._frames.items()
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
# Track B — READS registry + lifetime + eviction.
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def register_handler(
|
|
275
|
+
self,
|
|
276
|
+
handler_id: str,
|
|
277
|
+
*,
|
|
278
|
+
reads: Iterable[str],
|
|
279
|
+
groups: Iterable[Any] | None = None,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Declare a handler's frame reads and (optionally) the
|
|
282
|
+
item-groups it fires in.
|
|
283
|
+
|
|
284
|
+
*handler_id* is a stable identifier — typically the fully-
|
|
285
|
+
qualified function name (e.g.
|
|
286
|
+
``"flextool.engine_polars._emit_arc_unions.write_process_arc_unions"``).
|
|
287
|
+
*reads* is the list of (suffix-stripped) frame names the handler
|
|
288
|
+
consumes via ``get``/``has``. *groups* is the item-group tokens
|
|
289
|
+
at which the handler fires; ``None`` means "fires once at the
|
|
290
|
+
sentinel single-pass group token".
|
|
291
|
+
|
|
292
|
+
Calling twice with the same *handler_id* replaces the previous
|
|
293
|
+
declaration. The Provider does not enforce that registered
|
|
294
|
+
handlers are the only ones reading frames — that's the job of
|
|
295
|
+
the CI tracing mode (Phase B.3).
|
|
296
|
+
"""
|
|
297
|
+
key = str(handler_id)
|
|
298
|
+
self._reads[key] = [_strip_csv(r) for r in reads]
|
|
299
|
+
self._handler_groups[key] = list(groups) if groups is not None else []
|
|
300
|
+
|
|
301
|
+
def precompute_lifetimes(self, item_groups: Iterable[Any]) -> None:
|
|
302
|
+
"""Compute ``_last_needed[name] = (group, group_idx)`` from the
|
|
303
|
+
currently-registered handler ``READS`` and the iteration order
|
|
304
|
+
of *item_groups*.
|
|
305
|
+
|
|
306
|
+
*item_groups* defines the order in which item-groups will be
|
|
307
|
+
visited by the build loop. For each registered handler, its
|
|
308
|
+
``groups`` are intersected with this order; the maximum group
|
|
309
|
+
index across all handlers reading *name* becomes the frame's
|
|
310
|
+
``last_needed``.
|
|
311
|
+
|
|
312
|
+
Handlers that registered with ``groups=None`` (single-pass) are
|
|
313
|
+
assumed to read at every group, so they pin their READS to the
|
|
314
|
+
*last* group in iteration order — effectively "retain for the
|
|
315
|
+
whole build." This is the safe default; refine the registration
|
|
316
|
+
once a handler's actual group span is known.
|
|
317
|
+
|
|
318
|
+
Must be called exactly once per build loop. After this, the
|
|
319
|
+
Provider knows which frames can be evicted *if* the memory
|
|
320
|
+
threshold triggers. Eviction itself is the job of
|
|
321
|
+
:meth:`release_unused`.
|
|
322
|
+
|
|
323
|
+
Re-running ``precompute_lifetimes`` (e.g. between sub-solves)
|
|
324
|
+
re-derives the map from scratch and clears the evicted set —
|
|
325
|
+
eviction state is per-build-loop, not persistent.
|
|
326
|
+
"""
|
|
327
|
+
order = list(item_groups)
|
|
328
|
+
if not order:
|
|
329
|
+
# No groups → nothing to evict.
|
|
330
|
+
self._group_order = {}
|
|
331
|
+
self._last_needed = {}
|
|
332
|
+
self._evicted = {}
|
|
333
|
+
return
|
|
334
|
+
group_idx: dict[Any, int] = {g: i for i, g in enumerate(order)}
|
|
335
|
+
last_group_idx = len(order) - 1
|
|
336
|
+
last_group_token = order[-1]
|
|
337
|
+
|
|
338
|
+
last_needed: dict[str, tuple[Any, int]] = {}
|
|
339
|
+
for handler_id, reads in self._reads.items():
|
|
340
|
+
handler_groups = self._handler_groups.get(handler_id, [])
|
|
341
|
+
if not handler_groups:
|
|
342
|
+
# Single-pass / unknown: pin to last group.
|
|
343
|
+
handler_last_idx = last_group_idx
|
|
344
|
+
handler_last_token = last_group_token
|
|
345
|
+
else:
|
|
346
|
+
# Find the latest of the handler's groups in iteration
|
|
347
|
+
# order. Unknown groups are treated as 'past the end',
|
|
348
|
+
# i.e. retain to last (defensive).
|
|
349
|
+
idxs = [
|
|
350
|
+
group_idx.get(g, last_group_idx) for g in handler_groups
|
|
351
|
+
]
|
|
352
|
+
handler_last_idx = max(idxs)
|
|
353
|
+
handler_last_token = order[handler_last_idx]
|
|
354
|
+
for name in reads:
|
|
355
|
+
prev = last_needed.get(name)
|
|
356
|
+
if prev is None or prev[1] < handler_last_idx:
|
|
357
|
+
last_needed[name] = (handler_last_token, handler_last_idx)
|
|
358
|
+
self._group_order = group_idx
|
|
359
|
+
self._last_needed = last_needed
|
|
360
|
+
self._evicted = {}
|
|
361
|
+
|
|
362
|
+
def release_unused(self, *, after: Any) -> list[str]:
|
|
363
|
+
"""Drop every frame whose ``_last_needed`` group index is ``≤``
|
|
364
|
+
the index of *after* in the precomputed iteration order.
|
|
365
|
+
|
|
366
|
+
Returns the list of evicted frame names (callers can log /
|
|
367
|
+
assert on it). Frames that are still needed downstream are
|
|
368
|
+
untouched.
|
|
369
|
+
|
|
370
|
+
No-op when:
|
|
371
|
+
|
|
372
|
+
* ``retain_all`` is True (e.g. ``--csv-dump`` mode).
|
|
373
|
+
* ``precompute_lifetimes`` hasn't been called.
|
|
374
|
+
* The current footprint (``rss_estimate_mb``) is below the
|
|
375
|
+
``rss_budget_mb`` threshold — small problems pay no eviction
|
|
376
|
+
overhead.
|
|
377
|
+
|
|
378
|
+
Tests can force eviction by setting ``rss_budget_mb=0`` or by
|
|
379
|
+
constructing the Provider with ``rss_budget_mb=0``.
|
|
380
|
+
"""
|
|
381
|
+
if self.retain_all or self._group_order is None:
|
|
382
|
+
return []
|
|
383
|
+
if self.rss_budget_mb is not None:
|
|
384
|
+
current_mb = self.rss_estimate_mb()
|
|
385
|
+
if current_mb <= self.rss_budget_mb:
|
|
386
|
+
return []
|
|
387
|
+
try:
|
|
388
|
+
after_idx = self._group_order[after]
|
|
389
|
+
except KeyError:
|
|
390
|
+
# Unknown group token → conservative: don't evict.
|
|
391
|
+
return []
|
|
392
|
+
evicted: list[str] = []
|
|
393
|
+
for name in list(self._frames.keys()):
|
|
394
|
+
ln = self._last_needed.get(name)
|
|
395
|
+
if ln is None:
|
|
396
|
+
continue # frame not declared in any READS — keep
|
|
397
|
+
_, idx = ln
|
|
398
|
+
if idx <= after_idx:
|
|
399
|
+
del self._frames[name]
|
|
400
|
+
# Phase 6a — the source tag is per-frame metadata, so it
|
|
401
|
+
# must vacate when the frame does.
|
|
402
|
+
self._sources.pop(name, None)
|
|
403
|
+
self._evicted[name] = ln[0]
|
|
404
|
+
evicted.append(name)
|
|
405
|
+
return evicted
|
|
406
|
+
|
|
407
|
+
def rss_estimate_mb(self) -> float:
|
|
408
|
+
"""Sum of ``frame.estimated_size()`` across the live cache, in
|
|
409
|
+
megabytes. Used as the threshold gate for
|
|
410
|
+
:meth:`release_unused`.
|
|
411
|
+
|
|
412
|
+
``polars`` tracks frame sizes cheaply (no full walk required).
|
|
413
|
+
Empty / not-yet-materialised frames contribute 0.
|
|
414
|
+
"""
|
|
415
|
+
total = 0.0
|
|
416
|
+
for frame in self._frames.values():
|
|
417
|
+
try:
|
|
418
|
+
total += float(frame.estimated_size())
|
|
419
|
+
except Exception: # pragma: no cover — defensive
|
|
420
|
+
# If polars ever changes the API, fall back to 0 rather
|
|
421
|
+
# than crash the budget check.
|
|
422
|
+
pass
|
|
423
|
+
return total / (1024.0 * 1024.0)
|
|
424
|
+
|
|
425
|
+
def is_evicted(self, name: str) -> bool:
|
|
426
|
+
"""Return True iff *name* has been evicted by
|
|
427
|
+
:meth:`release_unused`.
|
|
428
|
+
|
|
429
|
+
Test helper; production callers use :meth:`get` and let
|
|
430
|
+
:class:`EvictedFrameError` surface naturally.
|
|
431
|
+
"""
|
|
432
|
+
return _strip_csv(name) in self._evicted
|
|
433
|
+
|
|
434
|
+
def reset_lifetimes(self) -> None:
|
|
435
|
+
"""Clear the registered READS, item-group order, lifetime map,
|
|
436
|
+
and evicted-frame markers.
|
|
437
|
+
|
|
438
|
+
Use this when transitioning between LP-build phases (e.g.
|
|
439
|
+
between sub-solves in a cascade) so each phase computes its own
|
|
440
|
+
lifetime map. Does NOT touch the actual frame cache.
|
|
441
|
+
"""
|
|
442
|
+
self._reads = {}
|
|
443
|
+
self._handler_groups = {}
|
|
444
|
+
self._group_order = None
|
|
445
|
+
self._last_needed = {}
|
|
446
|
+
self._evicted = {}
|
|
447
|
+
|
|
448
|
+
# ------------------------------------------------------------------
|
|
449
|
+
# Snapshot — one-way disk dumps for ``--csv-dump`` debug runs.
|
|
450
|
+
# ------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
def snapshot_raw_inputs(self, work_folder: Path) -> None:
|
|
453
|
+
"""Write raw input frames to *work_folder*.
|
|
454
|
+
|
|
455
|
+
Currently a no-op — the Provider populates with *derived* /
|
|
456
|
+
processed frames during the cascade; "raw inputs" don't have a
|
|
457
|
+
well-defined separate carrier in the current pipeline. Left as
|
|
458
|
+
a deliberate stub so the contract surface stays stable; revisit
|
|
459
|
+
if a real raw-input snapshot becomes useful.
|
|
460
|
+
"""
|
|
461
|
+
pass
|
|
462
|
+
|
|
463
|
+
def snapshot_processed_inputs(self, work_folder: Path) -> None:
|
|
464
|
+
"""Write every stored frame to ``work_folder/{name}.csv``.
|
|
465
|
+
|
|
466
|
+
Bare-keyed frames go directly under *work_folder*; parent-qualified
|
|
467
|
+
keys (``"solve_data/foo"``) materialise into subdirectories
|
|
468
|
+
(``work_folder/solve_data/foo.csv``).
|
|
469
|
+
"""
|
|
470
|
+
work_folder = Path(work_folder)
|
|
471
|
+
work_folder.mkdir(parents=True, exist_ok=True)
|
|
472
|
+
for name, frame in self._frames.items():
|
|
473
|
+
target = work_folder / f"{name}.csv"
|
|
474
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
475
|
+
frame.write_csv(target)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
__all__ = ["FlexDataProvider"]
|