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,1416 @@
|
|
|
1
|
+
"""Timeline-level configuration loaded from a SpineDB scenario.
|
|
2
|
+
|
|
3
|
+
Architecture notes
|
|
4
|
+
------------------
|
|
5
|
+
|
|
6
|
+
* Every downstream module (recursive solve builder, stochastic,
|
|
7
|
+
orchestration loop) reads ``state.timeline.<dict>`` keys produced
|
|
8
|
+
here. Quirks:
|
|
9
|
+
|
|
10
|
+
- ``timeset_weights`` and ``representative_period_weights`` decoders
|
|
11
|
+
accept BOTH :class:`spinedb_api.Map` and ``list[tuple]`` shapes
|
|
12
|
+
(Spine API quirk; see ``audit/solve_orchestration_plan.md §3.2``).
|
|
13
|
+
- ``new_step_durations`` is solve-scoped as of DB v50 (was
|
|
14
|
+
timeset-scoped pre-v50; ``update_flextool/db_migration.py`` upgrades
|
|
15
|
+
older DBs before this loader runs).
|
|
16
|
+
- The six-rule fallback chain in :meth:`TimelineConfig.create_assumptive_parts`
|
|
17
|
+
mutates *both* ``self`` AND the supplied ``solve_config`` in three
|
|
18
|
+
of its rules. Order matters; idempotency is achieved through the
|
|
19
|
+
membership checks each rule applies before mutating.
|
|
20
|
+
|
|
21
|
+
* :func:`get_active_time` is the most-called free function — every
|
|
22
|
+
recursive solve invocation walks it. It reads :attr:`timesets`
|
|
23
|
+
(``list[str]``) but the parameter is sometimes a *dict* of timeset
|
|
24
|
+
durations passed by the orchestration layer; the function tolerates
|
|
25
|
+
either by taking timeset durations as the second argument.
|
|
26
|
+
|
|
27
|
+
* ``create_averaged_timeseries`` aggregates per-(``solve``) timeline
|
|
28
|
+
data by averaging or summing matched timesteps. The
|
|
29
|
+
``pt_node_inflow.csv`` / ``pbt_node_inflow.csv`` files are summed;
|
|
30
|
+
every other ``pt_*.csv`` / ``pbt_*.csv`` is averaged. The
|
|
31
|
+
``storage_state_reference_value`` row in any of those files bypasses
|
|
32
|
+
aggregation entirely.
|
|
33
|
+
|
|
34
|
+
* ``_derived_params.py::dt_and_step_duration_from_source`` is a
|
|
35
|
+
polars-lazy alternative that consumes the per-(entity_class,
|
|
36
|
+
parameter_name) ``InputSource`` Protocol; this module uses
|
|
37
|
+
``params_to_dict`` directly. The two paths produce identical
|
|
38
|
+
timeline / timeset state and coexist because they serve different
|
|
39
|
+
consumers — the InputSource one feeds Param-frame helpers, this one
|
|
40
|
+
feeds the orchestration loop.
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import logging
|
|
45
|
+
import math
|
|
46
|
+
from collections import defaultdict
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import TYPE_CHECKING, Any
|
|
49
|
+
|
|
50
|
+
import polars as pl
|
|
51
|
+
import spinedb_api as api
|
|
52
|
+
|
|
53
|
+
from flextool.engine_polars._solve_config import (
|
|
54
|
+
DictMode,
|
|
55
|
+
get_single_entities,
|
|
56
|
+
params_to_dict,
|
|
57
|
+
)
|
|
58
|
+
from flextool.engine_polars._solve_state import (
|
|
59
|
+
ActiveTimeEntry,
|
|
60
|
+
FlexToolConfigError,
|
|
61
|
+
)
|
|
62
|
+
from flextool.engine_polars._emit_provider_io import (
|
|
63
|
+
_provider_key,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if TYPE_CHECKING:
|
|
67
|
+
from spinedb_api import DatabaseMapping
|
|
68
|
+
|
|
69
|
+
from flextool.engine_polars._flex_data_provider import FlexDataProvider
|
|
70
|
+
from flextool.engine_polars._solve_config import SolveConfig
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# TimelineConfig — main container
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TimelineConfig:
|
|
79
|
+
"""All timeline definitions and timeset mappings for a native run.
|
|
80
|
+
|
|
81
|
+
Attributes
|
|
82
|
+
----------
|
|
83
|
+
timelines : defaultdict[str, list[tuple]]
|
|
84
|
+
timeline_name -> [(timestep, duration), ...]
|
|
85
|
+
timesets : list[str]
|
|
86
|
+
timeset entity name list.
|
|
87
|
+
timesets__timeline : defaultdict[str, str]
|
|
88
|
+
timeset_name -> timeline_name.
|
|
89
|
+
timeset_durations : defaultdict[str, list[tuple]]
|
|
90
|
+
timeset_name -> [(start, count), ...].
|
|
91
|
+
new_step_durations : dict[str, str]
|
|
92
|
+
solve_name -> new_duration (hours). Solve-scoped as of DB v50
|
|
93
|
+
(prior to v50 this was keyed by timeset_name; the runtime
|
|
94
|
+
already required all timesets within a solve to carry the same
|
|
95
|
+
value, so the effective scope is unchanged).
|
|
96
|
+
rp_weights : dict[str, dict]
|
|
97
|
+
timeset_name -> {base_start: {rep_start: weight}}.
|
|
98
|
+
timeset_weights : dict[str, dict[str, float]]
|
|
99
|
+
timeset_name -> {timestep: weight}. Raw user input; runner
|
|
100
|
+
normalizes during ``write_timeset_cost_weight``.
|
|
101
|
+
stochastic_timesteps : defaultdict[str, list[tuple]]
|
|
102
|
+
Mutable — populated during the solve loop (Γ.8.C).
|
|
103
|
+
original_timeline : defaultdict[str, str]
|
|
104
|
+
new_timeline_name -> original_timeline_name.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
timelines: defaultdict,
|
|
110
|
+
timesets: list,
|
|
111
|
+
timesets__timeline: defaultdict,
|
|
112
|
+
timeset_durations: defaultdict,
|
|
113
|
+
new_step_durations: dict,
|
|
114
|
+
rp_weights: dict | None = None,
|
|
115
|
+
timeset_weights: dict | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
self.timelines = timelines
|
|
118
|
+
self.timesets = timesets
|
|
119
|
+
self.timesets__timeline = timesets__timeline
|
|
120
|
+
self.timeset_durations = timeset_durations
|
|
121
|
+
self.new_step_durations = new_step_durations
|
|
122
|
+
self.rp_weights: dict = rp_weights or {}
|
|
123
|
+
self.timeset_weights: dict[str, dict[str, float]] = (
|
|
124
|
+
timeset_weights or {}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Mutable state — populated later by orchestration / stochastic.
|
|
128
|
+
self.stochastic_timesteps: defaultdict[str, list] = defaultdict(list)
|
|
129
|
+
self.original_timeline: defaultdict[str, str] = defaultdict()
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Factories
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def load_from_db(
|
|
137
|
+
cls, db: "DatabaseMapping", logger: logging.Logger
|
|
138
|
+
) -> "TimelineConfig":
|
|
139
|
+
"""Read all timeline-level parameters from *db* into a TimelineConfig.
|
|
140
|
+
|
|
141
|
+
Reads:
|
|
142
|
+
|
|
143
|
+
* ``timeline.timestep_duration`` — base timeline definitions.
|
|
144
|
+
* ``timeset`` entities — flat list.
|
|
145
|
+
* ``timeset.timeset_duration`` — (start, count) blocks per timeset.
|
|
146
|
+
* ``timeset.timeline`` — timeset → timeline mapping.
|
|
147
|
+
* ``solve.new_stepduration`` — per-solve aggregation override
|
|
148
|
+
(DB v50+; older DBs are upgraded by db_migration first).
|
|
149
|
+
* ``timeset.representative_period_weights`` — nested Map decoder
|
|
150
|
+
(handles BOTH ``api.Map`` and ``list[tuple]`` shapes).
|
|
151
|
+
* ``timeset.timeset_weights`` — flat Map decoder (same dual-
|
|
152
|
+
shape handling).
|
|
153
|
+
"""
|
|
154
|
+
timelines: defaultdict = params_to_dict(
|
|
155
|
+
db=db,
|
|
156
|
+
cl="timeline",
|
|
157
|
+
par="timestep_duration",
|
|
158
|
+
mode=DictMode.DEFAULTDICT,
|
|
159
|
+
)
|
|
160
|
+
timesets: list = get_single_entities(
|
|
161
|
+
db=db, entity_class_name="timeset"
|
|
162
|
+
)
|
|
163
|
+
timeset_durations: defaultdict = params_to_dict(
|
|
164
|
+
db=db,
|
|
165
|
+
cl="timeset",
|
|
166
|
+
par="timeset_duration",
|
|
167
|
+
mode=DictMode.DEFAULTDICT,
|
|
168
|
+
)
|
|
169
|
+
timesets__timeline: defaultdict = params_to_dict(
|
|
170
|
+
db=db, cl="timeset", par="timeline", mode=DictMode.DEFAULTDICT
|
|
171
|
+
)
|
|
172
|
+
new_step_durations: dict = params_to_dict(
|
|
173
|
+
db=db,
|
|
174
|
+
cl="solve",
|
|
175
|
+
par="new_stepduration",
|
|
176
|
+
mode=DictMode.DICT,
|
|
177
|
+
)
|
|
178
|
+
# Representative period weights: nested Map
|
|
179
|
+
# ``base_start -> {rep_start -> weight}``. ``params_to_dict``
|
|
180
|
+
# returns the raw spinedb value because the inner is a Map
|
|
181
|
+
# (the DICT mode doesn't auto-flatten nested Map-of-Map).
|
|
182
|
+
rp_weights_raw = params_to_dict(
|
|
183
|
+
db=db,
|
|
184
|
+
cl="timeset",
|
|
185
|
+
par="representative_period_weights",
|
|
186
|
+
mode=DictMode.DICT,
|
|
187
|
+
)
|
|
188
|
+
rp_weights: dict = _decode_rp_weights(rp_weights_raw)
|
|
189
|
+
|
|
190
|
+
# Flat Map: ``timestep -> weight``. Used for non-RP per-step
|
|
191
|
+
# cost/slack weighting. Decodes BOTH ``api.Map`` and
|
|
192
|
+
# ``list[tuple]`` shapes (Spine API quirk).
|
|
193
|
+
timeset_weights_raw = params_to_dict(
|
|
194
|
+
db=db,
|
|
195
|
+
cl="timeset",
|
|
196
|
+
par="timeset_weights",
|
|
197
|
+
mode=DictMode.DICT,
|
|
198
|
+
)
|
|
199
|
+
timeset_weights: dict[str, dict[str, float]] = (
|
|
200
|
+
_decode_timeset_weights(timeset_weights_raw)
|
|
201
|
+
)
|
|
202
|
+
return cls(
|
|
203
|
+
timelines=timelines,
|
|
204
|
+
timesets=timesets,
|
|
205
|
+
timesets__timeline=timesets__timeline,
|
|
206
|
+
timeset_durations=timeset_durations,
|
|
207
|
+
new_step_durations=new_step_durations,
|
|
208
|
+
rp_weights=rp_weights,
|
|
209
|
+
timeset_weights=timeset_weights,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def load_from_db_url(
|
|
214
|
+
cls,
|
|
215
|
+
db_url: str,
|
|
216
|
+
scenario: str,
|
|
217
|
+
logger: logging.Logger | None = None,
|
|
218
|
+
) -> "TimelineConfig":
|
|
219
|
+
"""Convenience factory: open *db_url*, apply *scenario*, load.
|
|
220
|
+
|
|
221
|
+
Mirrors :meth:`flextool.engine_polars._solve_config.SolveConfig.load_from_db_url`
|
|
222
|
+
— same pattern.
|
|
223
|
+
"""
|
|
224
|
+
from spinedb_api import DatabaseMapping
|
|
225
|
+
from spinedb_api.filters.scenario_filter import (
|
|
226
|
+
apply_scenario_filter_to_subqueries,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if logger is None:
|
|
230
|
+
logger = logging.getLogger(f"flextool.engine_polars.timeline[{scenario}]")
|
|
231
|
+
url = str(db_url)
|
|
232
|
+
if "://" not in url:
|
|
233
|
+
url = f"sqlite:///{url}"
|
|
234
|
+
with DatabaseMapping(url) as db:
|
|
235
|
+
apply_scenario_filter_to_subqueries(db, scenario)
|
|
236
|
+
# Pre-warm caches — see SolveConfig.load_from_db_url for
|
|
237
|
+
# the rationale. Measured 1.76× speedup on large customer
|
|
238
|
+
# DBs (H2_trade), insignificant noise on small test DBs.
|
|
239
|
+
db.fetch_all("entity")
|
|
240
|
+
db.fetch_all("parameter_value")
|
|
241
|
+
return cls.load_from_db(db, logger)
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def load_from_source(
|
|
245
|
+
cls,
|
|
246
|
+
source: object,
|
|
247
|
+
logger: logging.Logger | None = None,
|
|
248
|
+
) -> "TimelineConfig":
|
|
249
|
+
"""Load via the :class:`InputSource` Protocol (Γ.8.D wiring).
|
|
250
|
+
|
|
251
|
+
Currently only :class:`flextool.engine_polars._spinedb_reader.SpineDbReader`
|
|
252
|
+
sources are supported — they expose ``db_url`` and ``scenario`` so
|
|
253
|
+
the canonical :meth:`load_from_db_url` path can be reused.
|
|
254
|
+
In-memory and CSV-backed sources are deferred to Γ.8.D.
|
|
255
|
+
"""
|
|
256
|
+
from flextool.engine_polars._spinedb_reader import SpineDbReader
|
|
257
|
+
|
|
258
|
+
if isinstance(source, SpineDbReader):
|
|
259
|
+
return cls.load_from_db_url(
|
|
260
|
+
source.db_url, source.scenario, logger=logger
|
|
261
|
+
)
|
|
262
|
+
raise NotImplementedError(
|
|
263
|
+
f"TimelineConfig.load_from_source does not yet support "
|
|
264
|
+
f"{type(source).__name__!r} sources. Use load_from_db / "
|
|
265
|
+
f"load_from_db_url with a Spine DB for now; the in-memory and "
|
|
266
|
+
f"CSV adapters land in Gamma.8.D when chain.run_chain is rewired."
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
# Methods
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def create_timeline_from_timestep_duration(
|
|
274
|
+
self, solve_config: "SolveConfig"
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Synthesise per-solve aggregated timelines.
|
|
277
|
+
|
|
278
|
+
For each solve that sets ``new_stepduration``, build a freshly
|
|
279
|
+
aggregated timeline ``"{timeline}_{solve}"`` and re-point every
|
|
280
|
+
timeset the solve uses at that new timeline. Preserves the
|
|
281
|
+
``original_timeline`` map so :meth:`create_averaged_timeseries`
|
|
282
|
+
can find the unaggregated source.
|
|
283
|
+
|
|
284
|
+
Mirrors lines 161-236 of the flextool source.
|
|
285
|
+
|
|
286
|
+
A defensive guard (``"fail loudly"`` per
|
|
287
|
+
``audit/handoff_post_split_todo.md``): if the same timeset is
|
|
288
|
+
shared between two solves with *different* ``new_stepduration``
|
|
289
|
+
values, raise rather than silently overwrite. Flextool's source
|
|
290
|
+
comment claims this is "rejected at migration time" but doesn't
|
|
291
|
+
check; the native engine enforces the invariant.
|
|
292
|
+
"""
|
|
293
|
+
logger = logging.getLogger(__name__)
|
|
294
|
+
|
|
295
|
+
# Defensive check for "shared timeset, conflicting new_stepduration".
|
|
296
|
+
# Walk all (solve, timeset) pairs and assert at most one
|
|
297
|
+
# ``new_stepduration`` value per timeset.
|
|
298
|
+
timeset_to_step_duration: dict[str, tuple[str, float]] = {}
|
|
299
|
+
for solve_name, step_duration_raw in self.new_step_durations.items():
|
|
300
|
+
if step_duration_raw is None:
|
|
301
|
+
continue
|
|
302
|
+
sd = float(step_duration_raw)
|
|
303
|
+
for _period, ts in solve_config.timesets_used_by_solves.get(
|
|
304
|
+
solve_name, []
|
|
305
|
+
):
|
|
306
|
+
prev = timeset_to_step_duration.get(ts)
|
|
307
|
+
if prev is None:
|
|
308
|
+
timeset_to_step_duration[ts] = (solve_name, sd)
|
|
309
|
+
elif prev[1] != sd:
|
|
310
|
+
message = (
|
|
311
|
+
f"Timeset '{ts}' is shared between solves "
|
|
312
|
+
f"'{prev[0]}' (new_stepduration={prev[1]}) and "
|
|
313
|
+
f"'{solve_name}' (new_stepduration={sd}); "
|
|
314
|
+
f"flextool needs one timeline per timeset."
|
|
315
|
+
)
|
|
316
|
+
logger.error(message)
|
|
317
|
+
raise FlexToolConfigError(message)
|
|
318
|
+
|
|
319
|
+
for solve_name, step_duration_raw in self.new_step_durations.items():
|
|
320
|
+
if step_duration_raw is None:
|
|
321
|
+
continue
|
|
322
|
+
step_duration = float(step_duration_raw)
|
|
323
|
+
period_timesets = solve_config.timesets_used_by_solves.get(
|
|
324
|
+
solve_name, []
|
|
325
|
+
)
|
|
326
|
+
# De-duplicate timeset names while keeping their order.
|
|
327
|
+
seen_timesets: set[str] = set()
|
|
328
|
+
timesets_in_solve: list[str] = []
|
|
329
|
+
for _period, timeset_name in period_timesets:
|
|
330
|
+
if timeset_name in seen_timesets:
|
|
331
|
+
continue
|
|
332
|
+
seen_timesets.add(timeset_name)
|
|
333
|
+
timesets_in_solve.append(timeset_name)
|
|
334
|
+
|
|
335
|
+
for timeset_name in timesets_in_solve:
|
|
336
|
+
timeset = self.timeset_durations.get(timeset_name)
|
|
337
|
+
if not timeset:
|
|
338
|
+
continue
|
|
339
|
+
timeline_name = self.timesets__timeline[timeset_name]
|
|
340
|
+
old_steps = self.timelines[timeline_name]
|
|
341
|
+
new_steps: list[tuple[str, str]] = []
|
|
342
|
+
new_timesets: list[tuple[str, int]] = []
|
|
343
|
+
for ts in timeset:
|
|
344
|
+
first_step = ts[0]
|
|
345
|
+
first_index = [step[0] for step in old_steps].index(ts[0])
|
|
346
|
+
step_counter = 0.0
|
|
347
|
+
last_index = first_index + int(float(ts[1]))
|
|
348
|
+
added_steps = 0
|
|
349
|
+
for step in old_steps[first_index:last_index]:
|
|
350
|
+
if step_counter >= step_duration:
|
|
351
|
+
new_steps.append((first_step, str(step_counter)))
|
|
352
|
+
first_step = step[0]
|
|
353
|
+
step_counter = 0.0
|
|
354
|
+
added_steps += 1
|
|
355
|
+
if step_counter > step_duration:
|
|
356
|
+
logger.warning(
|
|
357
|
+
"Warning: All new steps are not the "
|
|
358
|
+
"size of the given step duration. The "
|
|
359
|
+
"new step duration has to be multiple "
|
|
360
|
+
"of old step durations for this to "
|
|
361
|
+
"happen."
|
|
362
|
+
)
|
|
363
|
+
step_counter += float(step[1])
|
|
364
|
+
new_steps.append((first_step, str(step_counter)))
|
|
365
|
+
added_steps += 1
|
|
366
|
+
new_timesets.append((ts[0], added_steps))
|
|
367
|
+
self.timeset_durations[timeset_name] = new_timesets
|
|
368
|
+
new_timeline_name = timeline_name + "_" + solve_name
|
|
369
|
+
self.timelines[new_timeline_name] = new_steps
|
|
370
|
+
self.timesets__timeline[timeset_name] = new_timeline_name
|
|
371
|
+
self.original_timeline[new_timeline_name] = timeline_name
|
|
372
|
+
|
|
373
|
+
def create_assumptive_parts(self, solve_config: "SolveConfig") -> None:
|
|
374
|
+
"""Fill in missing time-structure parts from assumptions when
|
|
375
|
+
the input data is incomplete.
|
|
376
|
+
|
|
377
|
+
Six fallback rules (lines 238-382 of the flextool source):
|
|
378
|
+
|
|
379
|
+
1. No timeset → synthesise from a single timeline.
|
|
380
|
+
2. No ``timeset_durations`` → full-timeline timeset.
|
|
381
|
+
3. No ``timesets__timeline`` mapping → use the sole timeline.
|
|
382
|
+
4. No ``model:solves`` → use the sole solve (mutates
|
|
383
|
+
``solve_config.model_solve``).
|
|
384
|
+
5. No ``period_timeset`` → use the sole timeset (mutates
|
|
385
|
+
``solve_config.timesets_used_by_solves`` and
|
|
386
|
+
``solve_config.realized_periods``).
|
|
387
|
+
6. No ``solve_period_years_represented`` → 1 year per period
|
|
388
|
+
(mutates ``solve_config.solve_period_years_represented``).
|
|
389
|
+
|
|
390
|
+
The rules mutate ``self`` AND ``solve_config`` in-place. Each
|
|
391
|
+
rule's guard checks whether its target is already populated;
|
|
392
|
+
running :meth:`create_assumptive_parts` twice on the same pair
|
|
393
|
+
is a no-op (idempotent).
|
|
394
|
+
"""
|
|
395
|
+
logger = logging.getLogger(__name__)
|
|
396
|
+
|
|
397
|
+
# Rule 1: No timeset → synthesise "full_timeline" from sole timeline.
|
|
398
|
+
if not self.timesets and len(self.timelines) == 1:
|
|
399
|
+
timeset_name = "full_timeline"
|
|
400
|
+
self.timesets = list(timeset_name)
|
|
401
|
+
|
|
402
|
+
# Rule 2: No timeset_durations + sole timeline → block the whole
|
|
403
|
+
# timeline as one timeset_duration entry.
|
|
404
|
+
for timeset_name in self.timesets:
|
|
405
|
+
if not self.timeset_durations and len(self.timelines) == 1:
|
|
406
|
+
self.timesets__timeline[timeset_name] = list(
|
|
407
|
+
self.timelines.keys()
|
|
408
|
+
)[0]
|
|
409
|
+
self.timeset_durations[timeset_name] = [
|
|
410
|
+
(
|
|
411
|
+
list(self.timelines.values())[0][0][0],
|
|
412
|
+
len(list(self.timelines.values())[0]),
|
|
413
|
+
)
|
|
414
|
+
]
|
|
415
|
+
|
|
416
|
+
# Rule 3: timeset:timeline missing + sole timeline → use it.
|
|
417
|
+
# If multiple timelines exist, the user must declare which one
|
|
418
|
+
# this timeset uses — fail loudly.
|
|
419
|
+
for timeset_name, _block in self.timeset_durations.items():
|
|
420
|
+
if timeset_name not in self.timesets__timeline:
|
|
421
|
+
if len(self.timelines) == 1:
|
|
422
|
+
self.timesets__timeline[timeset_name] = list(
|
|
423
|
+
self.timelines.keys()
|
|
424
|
+
)[0]
|
|
425
|
+
elif len(self.timelines) > 1:
|
|
426
|
+
message = (
|
|
427
|
+
"More than one timeline available and FlexTool "
|
|
428
|
+
"does not know which ones to use. Please use "
|
|
429
|
+
"'timeline' parameter of 'timeset' class to "
|
|
430
|
+
"define which timelines are part of the "
|
|
431
|
+
"timeset(s) in the model instance"
|
|
432
|
+
)
|
|
433
|
+
logger.error(message)
|
|
434
|
+
raise FlexToolConfigError(message)
|
|
435
|
+
|
|
436
|
+
# Rule 4: No model:solves → pick the sole solve (or fail).
|
|
437
|
+
# MUTATES solve_config.model_solve.
|
|
438
|
+
if not solve_config.model_solve:
|
|
439
|
+
if len(solve_config.model) == 1:
|
|
440
|
+
solve = None
|
|
441
|
+
if len(solve_config.timesets_used_by_solves) == 1:
|
|
442
|
+
solve = list(
|
|
443
|
+
solve_config.timesets_used_by_solves.keys()
|
|
444
|
+
)[0]
|
|
445
|
+
elif len(solve_config.timesets_used_by_solves) > 1:
|
|
446
|
+
message = (
|
|
447
|
+
"Data contains multiple solve entities and "
|
|
448
|
+
"FlexTool does not know which to use. Please use "
|
|
449
|
+
"'solves' parameter of 'model' class to inform "
|
|
450
|
+
"which solves are to be included in the model "
|
|
451
|
+
"instance."
|
|
452
|
+
)
|
|
453
|
+
logger.error(message)
|
|
454
|
+
raise FlexToolConfigError(message)
|
|
455
|
+
all_solves_in_periods = (
|
|
456
|
+
set(solve_config.realized_periods.keys())
|
|
457
|
+
| set(solve_config.invest_periods.keys())
|
|
458
|
+
)
|
|
459
|
+
for solve_name in all_solves_in_periods:
|
|
460
|
+
if solve:
|
|
461
|
+
if solve_name != solve:
|
|
462
|
+
message = (
|
|
463
|
+
"Data contains multiple solve entities "
|
|
464
|
+
"and FlexTool does not know which to "
|
|
465
|
+
"use. Please use 'solves' parameter of "
|
|
466
|
+
"'model' class to inform which solves "
|
|
467
|
+
"are to be included in the model "
|
|
468
|
+
"instance."
|
|
469
|
+
)
|
|
470
|
+
logger.error(message)
|
|
471
|
+
raise FlexToolConfigError(message)
|
|
472
|
+
else:
|
|
473
|
+
solve = solve_name
|
|
474
|
+
solve_config.model_solve[solve_config.model[0]] = [solve]
|
|
475
|
+
else:
|
|
476
|
+
message = (
|
|
477
|
+
"More than one model entity found in the database "
|
|
478
|
+
"and FlexTool does not know which to use."
|
|
479
|
+
)
|
|
480
|
+
logger.error(message)
|
|
481
|
+
raise FlexToolConfigError(message)
|
|
482
|
+
|
|
483
|
+
# Rule 5a: Solve listed but no realized_periods/invest_periods/
|
|
484
|
+
# timesets_used_by_solves → fall back to model.periods_available.
|
|
485
|
+
# MUTATES solve_config.realized_periods.
|
|
486
|
+
for model, solves in solve_config.model_solve.items():
|
|
487
|
+
for solve in solves:
|
|
488
|
+
if (
|
|
489
|
+
solve not in solve_config.realized_periods
|
|
490
|
+
and solve not in solve_config.invest_periods
|
|
491
|
+
and solve not in solve_config.timesets_used_by_solves
|
|
492
|
+
):
|
|
493
|
+
if model in solve_config.periods_available:
|
|
494
|
+
for period in solve_config.periods_available[model]:
|
|
495
|
+
solve_config.realized_periods[solve].append(
|
|
496
|
+
(period, period)
|
|
497
|
+
)
|
|
498
|
+
else:
|
|
499
|
+
message = (
|
|
500
|
+
f"The solve {solve} in the model: solves "
|
|
501
|
+
f"array does not have any periods defined: "
|
|
502
|
+
f"(period_timeset, realized_periods, "
|
|
503
|
+
f"invest_periods)\nAlternatively add "
|
|
504
|
+
f"periods_available to the model to create "
|
|
505
|
+
f"simple full timelines for those periods"
|
|
506
|
+
)
|
|
507
|
+
logger.error(message)
|
|
508
|
+
raise FlexToolConfigError(message)
|
|
509
|
+
|
|
510
|
+
# Rule 5b: solve has no period_timeset but a single timeset
|
|
511
|
+
# exists → use it. MUTATES timesets_used_by_solves.
|
|
512
|
+
for solve in list(solve_config.model_solve.values())[0]:
|
|
513
|
+
if solve not in list(
|
|
514
|
+
solve_config.timesets_used_by_solves.keys()
|
|
515
|
+
):
|
|
516
|
+
if len(self.timeset_durations) == 1:
|
|
517
|
+
period__timeset_list: list[tuple[str, str]] = []
|
|
518
|
+
timeset_name = list(self.timeset_durations.keys())[0]
|
|
519
|
+
for period_tuple in (
|
|
520
|
+
solve_config.invest_periods.get(solve, [])
|
|
521
|
+
+ solve_config.realized_periods.get(solve, [])
|
|
522
|
+
):
|
|
523
|
+
period__timeset_list.append(
|
|
524
|
+
(period_tuple[1], timeset_name)
|
|
525
|
+
)
|
|
526
|
+
solve_config.timesets_used_by_solves[solve] = (
|
|
527
|
+
period__timeset_list
|
|
528
|
+
)
|
|
529
|
+
else:
|
|
530
|
+
message = (
|
|
531
|
+
"More than one timeset available and FlexTool "
|
|
532
|
+
"does not know which ones to use. Please use "
|
|
533
|
+
"'period_timeset' parameter of 'solve' class to "
|
|
534
|
+
"define which periods and which timesets are "
|
|
535
|
+
"part of the solve(s) in the model instance"
|
|
536
|
+
)
|
|
537
|
+
logger.error(message)
|
|
538
|
+
raise FlexToolConfigError(message)
|
|
539
|
+
|
|
540
|
+
# Rule 5c: solve has period__timeset but no realized/invest/
|
|
541
|
+
# contains_solves → assume all in period_timeset are realized.
|
|
542
|
+
# MUTATES solve_config.realized_periods.
|
|
543
|
+
for solve in list(solve_config.model_solve.values())[0]:
|
|
544
|
+
if (
|
|
545
|
+
solve not in solve_config.realized_periods
|
|
546
|
+
and solve not in solve_config.invest_periods
|
|
547
|
+
and not solve_config.contains_solves[solve]
|
|
548
|
+
and solve in solve_config.timesets_used_by_solves
|
|
549
|
+
):
|
|
550
|
+
for period_timeset in solve_config.timesets_used_by_solves[
|
|
551
|
+
solve
|
|
552
|
+
]:
|
|
553
|
+
solve_config.realized_periods[solve].append(
|
|
554
|
+
(period_timeset[0], period_timeset[0])
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Rule 6: solve_period_years_represented missing → 1 year/period.
|
|
558
|
+
# MUTATES solve_config.solve_period_years_represented.
|
|
559
|
+
for solve in list(solve_config.model_solve.values())[0]:
|
|
560
|
+
all_periods_tuples = (
|
|
561
|
+
solve_config.realized_periods[solve]
|
|
562
|
+
+ solve_config.invest_periods[solve]
|
|
563
|
+
)
|
|
564
|
+
all_periods = {item for tup in all_periods_tuples for item in tup}
|
|
565
|
+
for period in all_periods:
|
|
566
|
+
if solve not in solve_config.solve_period_years_represented:
|
|
567
|
+
solve_config.solve_period_years_represented[solve].append(
|
|
568
|
+
[period, 1.0]
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
def create_averaged_timeseries(
|
|
572
|
+
self,
|
|
573
|
+
solve: str,
|
|
574
|
+
solve_config: "SolveConfig",
|
|
575
|
+
logger: logging.Logger,
|
|
576
|
+
*,
|
|
577
|
+
provider: "FlexDataProvider",
|
|
578
|
+
work_folder: "Path | None" = None,
|
|
579
|
+
) -> None:
|
|
580
|
+
"""Average or sum input timeseries to match a coarsened timeline.
|
|
581
|
+
|
|
582
|
+
If ``solve`` does not set ``new_stepduration`` (solve-scoped as
|
|
583
|
+
of DB v50), pass ``input/pt_*`` frames through verbatim under
|
|
584
|
+
the ``solve_data/pt_*`` Provider key. Otherwise re-aggregate
|
|
585
|
+
each timeseries row-by-row to match the new step size and
|
|
586
|
+
``provider.put`` the aggregated frame under the canonical
|
|
587
|
+
``solve_data/<basename>`` key.
|
|
588
|
+
|
|
589
|
+
Aggregation rules (lines 405-422 of the flextool source):
|
|
590
|
+
|
|
591
|
+
* ``pt_node_inflow.csv`` and ``pbt_node_inflow.csv`` →
|
|
592
|
+
``"sum"`` (inflow energies sum across coarsened steps).
|
|
593
|
+
* Every other ``pt_*.csv`` / ``pbt_*.csv`` → ``"average"``.
|
|
594
|
+
* Rows with parameter ``"storage_state_reference_value"`` are
|
|
595
|
+
ALWAYS bypassed (line 474) — they're per-step state targets
|
|
596
|
+
rather than time-integrated quantities.
|
|
597
|
+
|
|
598
|
+
After file aggregation, ``input/p_node``'s ``inflow`` rows
|
|
599
|
+
are appended to ``solve_data/pt_node_inflow`` as one entry
|
|
600
|
+
per new timestep, scaled by the new step's duration.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
solve: Active solve name.
|
|
604
|
+
solve_config: SolveConfig containing the
|
|
605
|
+
``timesets_used_by_solves`` map.
|
|
606
|
+
logger: Logger for error reporting.
|
|
607
|
+
provider: Active cascade-input :class:`FlexDataProvider`.
|
|
608
|
+
Required — supplies the ``input/pt_*`` frames and
|
|
609
|
+
receives the derived ``solve_data/pt_*`` frames.
|
|
610
|
+
work_folder: Working directory. When provided,
|
|
611
|
+
``solve_data/`` is resolved under it (used only to
|
|
612
|
+
build canonical Provider keys); defaults to the
|
|
613
|
+
current working directory.
|
|
614
|
+
"""
|
|
615
|
+
wf = Path(work_folder) if work_folder is not None else Path.cwd()
|
|
616
|
+
timeseries_map: dict[str, str] = {
|
|
617
|
+
"pt_node_inflow.csv": "sum",
|
|
618
|
+
"pt_commodity.csv": "average",
|
|
619
|
+
"pt_group.csv": "average",
|
|
620
|
+
"pt_node.csv": "average",
|
|
621
|
+
"pt_process.csv": "average",
|
|
622
|
+
"pt_profile.csv": "average",
|
|
623
|
+
"pt_process_source.csv": "average",
|
|
624
|
+
"pt_process_sink.csv": "average",
|
|
625
|
+
"pt_reserve__upDown__group.csv": "average",
|
|
626
|
+
"pbt_node_inflow.csv": "sum",
|
|
627
|
+
"pbt_node.csv": "average",
|
|
628
|
+
"pbt_process.csv": "average",
|
|
629
|
+
"pbt_profile.csv": "average",
|
|
630
|
+
"pbt_process_source.csv": "average",
|
|
631
|
+
"pbt_process_sink.csv": "average",
|
|
632
|
+
"pbt_reserve__upDown__group.csv": "average",
|
|
633
|
+
}
|
|
634
|
+
# As of DB v50 new_stepduration is keyed by solve. ``None`` is
|
|
635
|
+
# the parameter_definition default in the master template; treat
|
|
636
|
+
# it as "not set" and skip aggregation.
|
|
637
|
+
create = (
|
|
638
|
+
solve in self.new_step_durations
|
|
639
|
+
and self.new_step_durations[solve] is not None
|
|
640
|
+
)
|
|
641
|
+
if not create:
|
|
642
|
+
for timeseries in timeseries_map:
|
|
643
|
+
src_path = wf / "input" / timeseries
|
|
644
|
+
dst_path = wf / "solve_data" / timeseries
|
|
645
|
+
src_key = _provider_key(src_path)
|
|
646
|
+
dst_key = _provider_key(dst_path)
|
|
647
|
+
frame = _provider_get_or_read(provider, src_key, src_path)
|
|
648
|
+
if frame is None:
|
|
649
|
+
continue
|
|
650
|
+
provider.put(dst_key, frame)
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
# All timesets used by *solve* must resolve to the same
|
|
654
|
+
# timeline — new_stepduration rewrites that timeline once per
|
|
655
|
+
# solve and we re-aggregate every timeseries row by it.
|
|
656
|
+
timelines_list: list[str] = []
|
|
657
|
+
for _period, timeset in solve_config.timesets_used_by_solves[solve]:
|
|
658
|
+
timeline = self.timesets__timeline[timeset]
|
|
659
|
+
if timeline not in timelines_list:
|
|
660
|
+
if len(timelines_list) != 0:
|
|
661
|
+
message = (
|
|
662
|
+
f"solve '{solve}' sets new_stepduration but its "
|
|
663
|
+
f"timesets resolve to more than one timeline; "
|
|
664
|
+
f"new_stepduration requires a single shared "
|
|
665
|
+
f"timeline per solve."
|
|
666
|
+
)
|
|
667
|
+
logger.error(message)
|
|
668
|
+
raise FlexToolConfigError(message)
|
|
669
|
+
timelines_list.append(timeline)
|
|
670
|
+
# Pre-build timeline duration lookup for O(1) access per row.
|
|
671
|
+
timeline_duration_lookup: dict[str, int] = {}
|
|
672
|
+
for timeline in timelines_list:
|
|
673
|
+
new_timeline = self.timelines[timeline]
|
|
674
|
+
for timeline_row in new_timeline:
|
|
675
|
+
timeline_duration_lookup[timeline_row[0]] = int(
|
|
676
|
+
float(timeline_row[1])
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
# node-name → constant-inflow value (used to append per-step
|
|
680
|
+
# contributions to pt_node_inflow below).
|
|
681
|
+
node__inflow: list[list[str]] = []
|
|
682
|
+
p_node_path = wf / "input" / "p_node.csv"
|
|
683
|
+
p_node_key = _provider_key(p_node_path)
|
|
684
|
+
p_node_frame = _provider_get_or_read(
|
|
685
|
+
provider, p_node_key, p_node_path,
|
|
686
|
+
)
|
|
687
|
+
if p_node_frame is not None and p_node_frame.height > 0:
|
|
688
|
+
# Expected (node, nodeParam, p_node); the 'inflow' rows
|
|
689
|
+
# have value in column index 2.
|
|
690
|
+
for row in p_node_frame.iter_rows():
|
|
691
|
+
if len(row) >= 3 and row[1] == "inflow":
|
|
692
|
+
node__inflow.append([row[0], row[2]])
|
|
693
|
+
|
|
694
|
+
for timeseries in timeseries_map:
|
|
695
|
+
src_path = wf / "input" / timeseries
|
|
696
|
+
dst_path = wf / "solve_data" / timeseries
|
|
697
|
+
src_key = _provider_key(src_path)
|
|
698
|
+
dst_key = _provider_key(dst_path)
|
|
699
|
+
src_frame = _provider_get_or_read(provider, src_key, src_path)
|
|
700
|
+
if src_frame is None:
|
|
701
|
+
continue
|
|
702
|
+
headers = src_frame.columns
|
|
703
|
+
try:
|
|
704
|
+
time_index = headers.index("time")
|
|
705
|
+
except ValueError:
|
|
706
|
+
# No time column — pass through verbatim.
|
|
707
|
+
provider.put(dst_key, src_frame)
|
|
708
|
+
continue
|
|
709
|
+
# Row-by-row state machine port of the legacy CSV loop.
|
|
710
|
+
rows_iter = iter(src_frame.iter_rows())
|
|
711
|
+
out_rows: list[list[Any]] = []
|
|
712
|
+
timeline_for_bypass = (
|
|
713
|
+
timelines_list[0] if timelines_list else None
|
|
714
|
+
)
|
|
715
|
+
try:
|
|
716
|
+
while True:
|
|
717
|
+
datain = next(rows_iter)
|
|
718
|
+
timeline_step_duration = (
|
|
719
|
+
timeline_duration_lookup.get(datain[time_index])
|
|
720
|
+
)
|
|
721
|
+
if timeline_step_duration is not None:
|
|
722
|
+
values: list[float] = []
|
|
723
|
+
params = list(datain[0:time_index])
|
|
724
|
+
row = list(datain[0 : time_index + 1])
|
|
725
|
+
values.append(float(datain[time_index + 1]))
|
|
726
|
+
if (
|
|
727
|
+
time_index >= 2
|
|
728
|
+
and datain[1] != "storage_state_reference_value"
|
|
729
|
+
) or time_index < 2:
|
|
730
|
+
for _i in range(timeline_step_duration - 1):
|
|
731
|
+
try:
|
|
732
|
+
datain = next(rows_iter)
|
|
733
|
+
except StopIteration:
|
|
734
|
+
break
|
|
735
|
+
if list(datain[0:time_index]) != params:
|
|
736
|
+
message = (
|
|
737
|
+
"Cannot find the same "
|
|
738
|
+
"timesteps in input data as "
|
|
739
|
+
"in timeline for file "
|
|
740
|
+
+ timeseries
|
|
741
|
+
+ " after "
|
|
742
|
+
+ str(row[-1])
|
|
743
|
+
)
|
|
744
|
+
logger.error(message)
|
|
745
|
+
raise FlexToolConfigError(message)
|
|
746
|
+
values.append(
|
|
747
|
+
float(datain[time_index + 1])
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
if timeseries_map[timeseries] == "average":
|
|
751
|
+
out_value = round(
|
|
752
|
+
sum(values) / len(values), 6
|
|
753
|
+
)
|
|
754
|
+
else:
|
|
755
|
+
out_value = sum(values)
|
|
756
|
+
row.append(out_value)
|
|
757
|
+
out_rows.append(row)
|
|
758
|
+
else:
|
|
759
|
+
# Bypass aggregation for
|
|
760
|
+
# storage_state_reference_value rows — pin to
|
|
761
|
+
# the new timeline's nearest step at-or-before
|
|
762
|
+
# the original target step.
|
|
763
|
+
if (
|
|
764
|
+
time_index >= 2
|
|
765
|
+
and datain[1] == "storage_state_reference_value"
|
|
766
|
+
and timeline_for_bypass is not None
|
|
767
|
+
):
|
|
768
|
+
original_tl = self.original_timeline[
|
|
769
|
+
timeline_for_bypass
|
|
770
|
+
]
|
|
771
|
+
new_timeline = self.timelines[
|
|
772
|
+
timeline_for_bypass
|
|
773
|
+
]
|
|
774
|
+
counter = 0
|
|
775
|
+
current_index = 0
|
|
776
|
+
for timestep in self.timelines[original_tl]:
|
|
777
|
+
if datain[2] == timestep[0]:
|
|
778
|
+
current_index = counter
|
|
779
|
+
counter += 1
|
|
780
|
+
found = False
|
|
781
|
+
new_index = None
|
|
782
|
+
for timestep in reversed(
|
|
783
|
+
self.timelines[original_tl][
|
|
784
|
+
0 : current_index + 1
|
|
785
|
+
]
|
|
786
|
+
):
|
|
787
|
+
for timeline_row in new_timeline:
|
|
788
|
+
if timeline_row[0] == timestep[0]:
|
|
789
|
+
new_index = timeline_row[0]
|
|
790
|
+
found = True
|
|
791
|
+
break
|
|
792
|
+
if found:
|
|
793
|
+
break
|
|
794
|
+
if found:
|
|
795
|
+
row = list(datain[0:time_index])
|
|
796
|
+
row.append(new_index)
|
|
797
|
+
row.append(datain[time_index + 1])
|
|
798
|
+
out_rows.append(row)
|
|
799
|
+
except StopIteration:
|
|
800
|
+
pass
|
|
801
|
+
|
|
802
|
+
# Append per-step inflow contributions from p_node to
|
|
803
|
+
# pt_node_inflow only; scaled by each new step's duration.
|
|
804
|
+
if timeseries == "pt_node_inflow.csv" and node__inflow:
|
|
805
|
+
for timeline in timelines_list:
|
|
806
|
+
new_timeline = self.timelines[timeline]
|
|
807
|
+
for node__value in node__inflow:
|
|
808
|
+
for timeline_row in new_timeline:
|
|
809
|
+
tl_step_duration = int(float(timeline_row[1]))
|
|
810
|
+
value = (
|
|
811
|
+
float(node__value[1]) * tl_step_duration
|
|
812
|
+
)
|
|
813
|
+
out_rows.append(
|
|
814
|
+
[node__value[0], timeline_row[0], value]
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
provider.put(
|
|
818
|
+
dst_key,
|
|
819
|
+
_build_frame_with_headers(headers, out_rows),
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
# ---------------------------------------------------------------------------
|
|
824
|
+
# Helpers (private)
|
|
825
|
+
# ---------------------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _provider_get_or_read(
|
|
829
|
+
provider: "FlexDataProvider | None",
|
|
830
|
+
key: str,
|
|
831
|
+
path: "Path", # noqa: ARG001 — kept for symmetry with call sites
|
|
832
|
+
) -> "pl.DataFrame | None":
|
|
833
|
+
"""Return the frame for *key* from the Provider.
|
|
834
|
+
|
|
835
|
+
Provider-only — the disk fallback was deleted in Step 2.5.
|
|
836
|
+
Returns ``None`` when the Provider is missing or doesn't carry
|
|
837
|
+
*key*. Callers that previously seeded inputs to disk without
|
|
838
|
+
populating the Provider must now seed the Provider directly.
|
|
839
|
+
"""
|
|
840
|
+
if provider is None:
|
|
841
|
+
return None
|
|
842
|
+
if not provider.has(key):
|
|
843
|
+
return None
|
|
844
|
+
return provider.get(key)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _build_frame_with_headers(
|
|
848
|
+
headers: list[str], rows: list[list],
|
|
849
|
+
) -> "pl.DataFrame":
|
|
850
|
+
"""Build an all-Utf8 polars frame matching *headers* with *rows*.
|
|
851
|
+
|
|
852
|
+
Each row's values are stringified verbatim (``str(v)``) so the
|
|
853
|
+
frame round-trips through :meth:`polars.DataFrame.write_csv`
|
|
854
|
+
byte-identical to the legacy ``csv.writer.writerow`` output.
|
|
855
|
+
Rows shorter than ``headers`` are padded with empty strings;
|
|
856
|
+
rows longer are truncated.
|
|
857
|
+
"""
|
|
858
|
+
n = len(headers)
|
|
859
|
+
cols: list[list[str]] = [[] for _ in range(n)]
|
|
860
|
+
for row in rows:
|
|
861
|
+
for i in range(n):
|
|
862
|
+
if i < len(row):
|
|
863
|
+
v = row[i]
|
|
864
|
+
cols[i].append("" if v is None else str(v))
|
|
865
|
+
else:
|
|
866
|
+
cols[i].append("")
|
|
867
|
+
schema = {h: pl.Utf8 for h in headers}
|
|
868
|
+
if not rows:
|
|
869
|
+
return pl.DataFrame(schema=schema)
|
|
870
|
+
return pl.DataFrame(
|
|
871
|
+
{headers[i]: cols[i] for i in range(n)},
|
|
872
|
+
schema=schema,
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _decode_rp_weights(rp_weights_raw: dict) -> dict:
|
|
877
|
+
"""Decode :attr:`TimelineConfig.rp_weights`.
|
|
878
|
+
|
|
879
|
+
Spine API quirk: a ``Map`` of ``Map`` may come back from
|
|
880
|
+
``params_to_dict`` either as :class:`spinedb_api.Map` (when the
|
|
881
|
+
outer Map is read directly) or as ``list[tuple]`` (when the outer
|
|
882
|
+
is flattened via ``convert_map_to_table``). Both shapes must
|
|
883
|
+
decode to the same nested ``{base: {rep: weight}}`` dict.
|
|
884
|
+
|
|
885
|
+
Mirrors lines 100-130 of the flextool source.
|
|
886
|
+
"""
|
|
887
|
+
rp_weights: dict = {}
|
|
888
|
+
for timeset_name, nested_map in rp_weights_raw.items():
|
|
889
|
+
if isinstance(nested_map, api.Map):
|
|
890
|
+
weight_dict: dict[str, dict[str, float]] = {}
|
|
891
|
+
for i, base_key in enumerate(nested_map.indexes):
|
|
892
|
+
inner = nested_map.values[i]
|
|
893
|
+
if isinstance(inner, api.Map):
|
|
894
|
+
weight_dict[str(base_key)] = {
|
|
895
|
+
str(k): float(v)
|
|
896
|
+
for k, v in zip(inner.indexes, inner.values)
|
|
897
|
+
}
|
|
898
|
+
rp_weights[timeset_name] = weight_dict
|
|
899
|
+
elif isinstance(nested_map, list):
|
|
900
|
+
# Two list-of-entry shapes coming from ``params_to_dict``:
|
|
901
|
+
# (a) Flat triples [base, rep, weight] — what
|
|
902
|
+
# ``convert_map_to_table`` produces when a 2-level Map
|
|
903
|
+
# is flattened.
|
|
904
|
+
# (b) Nested [(base, inner_map_or_list), ...] — emitted when
|
|
905
|
+
# only the outer Map is flattened.
|
|
906
|
+
# Both decode to {base: {rep: weight}}.
|
|
907
|
+
weight_dict: dict[str, dict[str, float]] = {}
|
|
908
|
+
for entry in nested_map:
|
|
909
|
+
if len(entry) >= 3 and not isinstance(entry[1], (list, api.Map)):
|
|
910
|
+
# (a) flat triple
|
|
911
|
+
base_key = str(entry[0])
|
|
912
|
+
rep_key = str(entry[1])
|
|
913
|
+
weight = float(entry[2])
|
|
914
|
+
weight_dict.setdefault(base_key, {})[rep_key] = weight
|
|
915
|
+
elif len(entry) >= 2:
|
|
916
|
+
# (b) [base, inner]
|
|
917
|
+
base_key = str(entry[0])
|
|
918
|
+
inner_data = entry[1]
|
|
919
|
+
if isinstance(inner_data, list):
|
|
920
|
+
weight_dict[base_key] = {
|
|
921
|
+
str(k): float(v) for k, v in inner_data
|
|
922
|
+
}
|
|
923
|
+
elif isinstance(inner_data, api.Map):
|
|
924
|
+
weight_dict[base_key] = {
|
|
925
|
+
str(k): float(v)
|
|
926
|
+
for k, v in zip(
|
|
927
|
+
inner_data.indexes, inner_data.values
|
|
928
|
+
)
|
|
929
|
+
}
|
|
930
|
+
if weight_dict:
|
|
931
|
+
rp_weights[timeset_name] = weight_dict
|
|
932
|
+
return rp_weights
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _decode_timeset_weights(
|
|
936
|
+
timeset_weights_raw: dict,
|
|
937
|
+
) -> dict[str, dict[str, float]]:
|
|
938
|
+
"""Decode :attr:`TimelineConfig.timeset_weights`.
|
|
939
|
+
|
|
940
|
+
Like :func:`_decode_rp_weights` but flat (one level of Map).
|
|
941
|
+
Handles both ``api.Map`` and ``list[tuple]`` shapes.
|
|
942
|
+
|
|
943
|
+
Mirrors lines 132-146 of the flextool source.
|
|
944
|
+
"""
|
|
945
|
+
timeset_weights: dict[str, dict[str, float]] = {}
|
|
946
|
+
for timeset_name, flat_map in timeset_weights_raw.items():
|
|
947
|
+
if isinstance(flat_map, api.Map):
|
|
948
|
+
timeset_weights[timeset_name] = {
|
|
949
|
+
str(k): float(v)
|
|
950
|
+
for k, v in zip(flat_map.indexes, flat_map.values)
|
|
951
|
+
}
|
|
952
|
+
elif isinstance(flat_map, list):
|
|
953
|
+
timeset_weights[timeset_name] = {
|
|
954
|
+
str(entry[0]): float(entry[1])
|
|
955
|
+
for entry in flat_map
|
|
956
|
+
if len(entry) >= 2
|
|
957
|
+
}
|
|
958
|
+
return timeset_weights
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
# ---------------------------------------------------------------------------
|
|
962
|
+
# Free functions (pure — no side effects beyond return values / file I/O)
|
|
963
|
+
# ---------------------------------------------------------------------------
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def get_active_time(
|
|
967
|
+
current_solve: str,
|
|
968
|
+
timesets_used_by_solves: dict,
|
|
969
|
+
timesets: dict,
|
|
970
|
+
timelines: dict,
|
|
971
|
+
timesets__timelines: dict,
|
|
972
|
+
) -> defaultdict:
|
|
973
|
+
"""Map periods to their corresponding timeline entries for a solve.
|
|
974
|
+
|
|
975
|
+
Returns a defaultdict mapping period IDs to lists of
|
|
976
|
+
:class:`ActiveTimeEntry` records. The recursive solve builder
|
|
977
|
+
(Γ.8.C) calls this at every node of its tree walk; the shape is
|
|
978
|
+
load-bearing for downstream :func:`make_step_jump` and
|
|
979
|
+
:func:`make_period_block` callers.
|
|
980
|
+
|
|
981
|
+
Args:
|
|
982
|
+
current_solve: Active solve name.
|
|
983
|
+
timesets_used_by_solves: ``solve -> [(period, timeset), ...]``.
|
|
984
|
+
timesets: ``timeset -> [(start, count), ...]`` (this is the
|
|
985
|
+
``timeset_durations`` field of :class:`TimelineConfig`,
|
|
986
|
+
despite the parameter name).
|
|
987
|
+
timelines: ``timeline -> [(timestep, duration), ...]``.
|
|
988
|
+
timesets__timelines: ``timeset -> timeline``.
|
|
989
|
+
"""
|
|
990
|
+
active_time: defaultdict = defaultdict(list)
|
|
991
|
+
|
|
992
|
+
if current_solve not in timesets_used_by_solves:
|
|
993
|
+
raise ValueError(
|
|
994
|
+
f"{current_solve}: this solve does not have period_timeset "
|
|
995
|
+
"defined. Check that it has period_timeset parameter "
|
|
996
|
+
"defined and the names of period and timeset are spelled "
|
|
997
|
+
"correctly (case sensitive). Check that the alternative is "
|
|
998
|
+
"included in the scenario."
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
# Pre-build index for O(1) timestep-to-position lookup per timeline.
|
|
1002
|
+
timeline_index_cache: dict[str, dict[str, int]] = {}
|
|
1003
|
+
|
|
1004
|
+
# Track which (timeset, block-start) first claimed each (period,
|
|
1005
|
+
# timestep) so an overlap can name the two colliding blocks.
|
|
1006
|
+
seen_by_period: dict[str, dict[str, tuple[str, str]]] = defaultdict(dict)
|
|
1007
|
+
# (period, timestep, incumbent (timeset, block_start), colliding (…)).
|
|
1008
|
+
overlaps: list[tuple[str, str, tuple[str, str], tuple[str, str]]] = []
|
|
1009
|
+
|
|
1010
|
+
for period, timeset_id in timesets_used_by_solves[current_solve]:
|
|
1011
|
+
timeline_id = timesets__timelines.get(timeset_id)
|
|
1012
|
+
if not timeline_id:
|
|
1013
|
+
continue
|
|
1014
|
+
timeline_data = timelines.get(timeline_id, [])
|
|
1015
|
+
if not timeline_data:
|
|
1016
|
+
continue
|
|
1017
|
+
if timeline_id not in timeline_index_cache:
|
|
1018
|
+
timeline_index_cache[timeline_id] = {
|
|
1019
|
+
time_val: idx
|
|
1020
|
+
for idx, (time_val, _) in enumerate(timeline_data)
|
|
1021
|
+
}
|
|
1022
|
+
time_val_to_idx = timeline_index_cache[timeline_id]
|
|
1023
|
+
seen = seen_by_period[period]
|
|
1024
|
+
for start_time, duration in timesets[timeset_id]:
|
|
1025
|
+
idx = time_val_to_idx.get(start_time)
|
|
1026
|
+
if idx is not None:
|
|
1027
|
+
for step in range(int(float(duration))):
|
|
1028
|
+
if idx + step < len(timeline_data):
|
|
1029
|
+
entry = timeline_data[idx + step]
|
|
1030
|
+
# An overlap means a single calendar timestep would
|
|
1031
|
+
# belong to two representative-period blocks at once.
|
|
1032
|
+
# We cannot silently collapse it: the timeline
|
|
1033
|
+
# (``steps_in_use``) needs each timestep exactly once,
|
|
1034
|
+
# but its representation weight is emitted per block
|
|
1035
|
+
# (:func:`_compute_rp_frames` → ``timestep_weight``),
|
|
1036
|
+
# so a collapse would keep one block's weight and drop
|
|
1037
|
+
# the other — a silently wrong annualisation rather
|
|
1038
|
+
# than the sum. Refuse and point at the source data.
|
|
1039
|
+
if entry[0] in seen:
|
|
1040
|
+
overlaps.append(
|
|
1041
|
+
(period, entry[0], seen[entry[0]],
|
|
1042
|
+
(timeset_id, start_time)))
|
|
1043
|
+
continue
|
|
1044
|
+
seen[entry[0]] = (timeset_id, start_time)
|
|
1045
|
+
active_time[period].append(
|
|
1046
|
+
ActiveTimeEntry(
|
|
1047
|
+
timestep=entry[0],
|
|
1048
|
+
index=idx + step,
|
|
1049
|
+
duration=entry[1],
|
|
1050
|
+
)
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
if overlaps:
|
|
1054
|
+
n = len(overlaps)
|
|
1055
|
+
|
|
1056
|
+
def _block(b: tuple[str, str]) -> str:
|
|
1057
|
+
ts, start = b
|
|
1058
|
+
return f"block starting {start!r}" + (
|
|
1059
|
+
f" (timeset {ts!r})" if ts != overlaps[0][3][0] else "")
|
|
1060
|
+
|
|
1061
|
+
sample = "; ".join(
|
|
1062
|
+
f"period {p!r} timestep {t!r} is in both {_block(a)} and "
|
|
1063
|
+
f"{_block(c)}"
|
|
1064
|
+
for p, t, a, c in overlaps[:5]
|
|
1065
|
+
)
|
|
1066
|
+
more = "" if n <= 5 else f" (+{n - 5} more)"
|
|
1067
|
+
bad_timesets = sorted(
|
|
1068
|
+
{o[2][0] for o in overlaps} | {o[3][0] for o in overlaps})
|
|
1069
|
+
raise ValueError(
|
|
1070
|
+
f"{current_solve}: overlapping timeset_duration blocks put the "
|
|
1071
|
+
f"same timestep into more than one representative period. "
|
|
1072
|
+
f"{n} overlapping timestep(s) in timeset(s) "
|
|
1073
|
+
f"{', '.join(repr(ts) for ts in bad_timesets)}: {sample}{more}. "
|
|
1074
|
+
"Each timestep may belong to exactly one representative period — "
|
|
1075
|
+
"adjust the timeset_duration start/length values so the blocks "
|
|
1076
|
+
"do not overlap (a shorter block wholly inside a longer one, or "
|
|
1077
|
+
"two blocks whose windows intersect, both trigger this)."
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
if not active_time:
|
|
1081
|
+
raise ValueError(
|
|
1082
|
+
f"{current_solve}: Failed to map timeset to timeline. "
|
|
1083
|
+
"Check that all timeset entities have timeline parameter "
|
|
1084
|
+
"defined and the name of the timeline is spelled correctly "
|
|
1085
|
+
"(case sensitive). Check that the alternative of the "
|
|
1086
|
+
"timeline parameter is included in the modelled scenario."
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
return active_time
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def make_step_jump(
|
|
1093
|
+
active_time_list: dict,
|
|
1094
|
+
period__branch: list[tuple],
|
|
1095
|
+
solve_branch__time_branch_list: list[tuple],
|
|
1096
|
+
) -> list[tuple]:
|
|
1097
|
+
"""Build a list of step-jump entries for the solver.
|
|
1098
|
+
|
|
1099
|
+
Each entry describes the jump from one simulation step to the
|
|
1100
|
+
next, including cross-period jumps. Stochastic branches use the
|
|
1101
|
+
last-realized-step on a sibling branch as the predecessor.
|
|
1102
|
+
"""
|
|
1103
|
+
step_lengths: list[tuple] = []
|
|
1104
|
+
period_start_pos = 0
|
|
1105
|
+
period_counter = -1
|
|
1106
|
+
first_period_name = list(active_time_list)[0]
|
|
1107
|
+
last_period_name = list(active_time_list)[-1]
|
|
1108
|
+
for period, active_time in reversed(active_time_list.items()):
|
|
1109
|
+
period_counter -= 1
|
|
1110
|
+
period_last = len(active_time)
|
|
1111
|
+
block_last = len(active_time) - 1
|
|
1112
|
+
if period == first_period_name:
|
|
1113
|
+
previous_period_name = last_period_name
|
|
1114
|
+
else:
|
|
1115
|
+
previous_period_name = list(active_time_list)[period_counter]
|
|
1116
|
+
for i, step in enumerate(reversed(active_time)):
|
|
1117
|
+
j = period_last - i - 1
|
|
1118
|
+
if j > 0:
|
|
1119
|
+
jump = active_time[j].index - active_time[j - 1].index
|
|
1120
|
+
if jump > 1:
|
|
1121
|
+
step_lengths.insert(
|
|
1122
|
+
period_start_pos,
|
|
1123
|
+
(
|
|
1124
|
+
period,
|
|
1125
|
+
step.timestep,
|
|
1126
|
+
active_time[j - 1].timestep,
|
|
1127
|
+
active_time[block_last].timestep,
|
|
1128
|
+
period,
|
|
1129
|
+
active_time[j - 1].timestep,
|
|
1130
|
+
jump,
|
|
1131
|
+
),
|
|
1132
|
+
)
|
|
1133
|
+
block_last = j - 1
|
|
1134
|
+
else:
|
|
1135
|
+
step_lengths.insert(
|
|
1136
|
+
period_start_pos,
|
|
1137
|
+
(
|
|
1138
|
+
period,
|
|
1139
|
+
step.timestep,
|
|
1140
|
+
active_time[j - 1].timestep,
|
|
1141
|
+
active_time[j - 1].timestep,
|
|
1142
|
+
period,
|
|
1143
|
+
active_time[j - 1].timestep,
|
|
1144
|
+
jump,
|
|
1145
|
+
),
|
|
1146
|
+
)
|
|
1147
|
+
else:
|
|
1148
|
+
if (period, period) not in period__branch:
|
|
1149
|
+
original_period = None
|
|
1150
|
+
for pb in period__branch:
|
|
1151
|
+
if pb[1] == period:
|
|
1152
|
+
original_period = pb[0]
|
|
1153
|
+
if (
|
|
1154
|
+
original_period is not None
|
|
1155
|
+
and (original_period, original_period)
|
|
1156
|
+
in period__branch
|
|
1157
|
+
and original_period in active_time_list
|
|
1158
|
+
):
|
|
1159
|
+
jump = (
|
|
1160
|
+
active_time[j].index - active_time[-1].index
|
|
1161
|
+
)
|
|
1162
|
+
step_lengths.insert(
|
|
1163
|
+
period_start_pos,
|
|
1164
|
+
(
|
|
1165
|
+
period,
|
|
1166
|
+
step.timestep,
|
|
1167
|
+
active_time[j - 1].timestep,
|
|
1168
|
+
active_time[block_last].timestep,
|
|
1169
|
+
period,
|
|
1170
|
+
active_time_list[period][-1].timestep,
|
|
1171
|
+
jump,
|
|
1172
|
+
),
|
|
1173
|
+
)
|
|
1174
|
+
elif (
|
|
1175
|
+
original_period is not None
|
|
1176
|
+
and (original_period, original_period)
|
|
1177
|
+
in period__branch
|
|
1178
|
+
):
|
|
1179
|
+
jump = (
|
|
1180
|
+
active_time[j].index - active_time[-1].index
|
|
1181
|
+
)
|
|
1182
|
+
step_lengths.insert(
|
|
1183
|
+
period_start_pos,
|
|
1184
|
+
(
|
|
1185
|
+
period,
|
|
1186
|
+
step.timestep,
|
|
1187
|
+
active_time[j - 1].timestep,
|
|
1188
|
+
active_time[block_last].timestep,
|
|
1189
|
+
period,
|
|
1190
|
+
active_time_list[period][-1].timestep,
|
|
1191
|
+
jump,
|
|
1192
|
+
),
|
|
1193
|
+
)
|
|
1194
|
+
else:
|
|
1195
|
+
time_branch = None
|
|
1196
|
+
for sb_tb in solve_branch__time_branch_list:
|
|
1197
|
+
if sb_tb[0] == period:
|
|
1198
|
+
time_branch = sb_tb[1]
|
|
1199
|
+
past = False
|
|
1200
|
+
found = False
|
|
1201
|
+
previous_period_with_branch = None
|
|
1202
|
+
for solve_period, _a_t in reversed(
|
|
1203
|
+
active_time_list.items()
|
|
1204
|
+
):
|
|
1205
|
+
if past:
|
|
1206
|
+
for sb_tb in solve_branch__time_branch_list:
|
|
1207
|
+
if (
|
|
1208
|
+
sb_tb[0] == solve_period
|
|
1209
|
+
and sb_tb[1] == time_branch
|
|
1210
|
+
):
|
|
1211
|
+
previous_period_with_branch = (
|
|
1212
|
+
solve_period
|
|
1213
|
+
)
|
|
1214
|
+
found = True
|
|
1215
|
+
if found:
|
|
1216
|
+
break
|
|
1217
|
+
else:
|
|
1218
|
+
if solve_period == period:
|
|
1219
|
+
past = True
|
|
1220
|
+
jump = (
|
|
1221
|
+
active_time[j].index
|
|
1222
|
+
- active_time_list[
|
|
1223
|
+
previous_period_with_branch
|
|
1224
|
+
][-1].index
|
|
1225
|
+
)
|
|
1226
|
+
step_lengths.insert(
|
|
1227
|
+
period_start_pos,
|
|
1228
|
+
(
|
|
1229
|
+
period,
|
|
1230
|
+
step.timestep,
|
|
1231
|
+
active_time[j - 1].timestep,
|
|
1232
|
+
active_time[block_last].timestep,
|
|
1233
|
+
previous_period_with_branch,
|
|
1234
|
+
active_time_list[
|
|
1235
|
+
previous_period_with_branch
|
|
1236
|
+
][-1].timestep,
|
|
1237
|
+
jump,
|
|
1238
|
+
),
|
|
1239
|
+
)
|
|
1240
|
+
else:
|
|
1241
|
+
jump = (
|
|
1242
|
+
active_time[j].index
|
|
1243
|
+
- active_time_list[previous_period_name][-1].index
|
|
1244
|
+
)
|
|
1245
|
+
step_lengths.insert(
|
|
1246
|
+
period_start_pos,
|
|
1247
|
+
(
|
|
1248
|
+
period,
|
|
1249
|
+
step.timestep,
|
|
1250
|
+
active_time[j - 1].timestep,
|
|
1251
|
+
active_time[block_last].timestep,
|
|
1252
|
+
previous_period_name,
|
|
1253
|
+
active_time_list[previous_period_name][
|
|
1254
|
+
-1
|
|
1255
|
+
].timestep,
|
|
1256
|
+
jump,
|
|
1257
|
+
),
|
|
1258
|
+
)
|
|
1259
|
+
return step_lengths
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def make_period_block(
|
|
1263
|
+
active_time_list: dict,
|
|
1264
|
+
) -> tuple[list[tuple], list[tuple]]:
|
|
1265
|
+
"""Build period_block_time and period_block_succ data.
|
|
1266
|
+
|
|
1267
|
+
Blocks are maximal contiguous runs of active steps in the timeline
|
|
1268
|
+
— same detection rule as :func:`make_step_jump` (jump > 1 starts a
|
|
1269
|
+
new block). Used by the ``bind_intraperiod_blocks`` storage
|
|
1270
|
+
binding method.
|
|
1271
|
+
|
|
1272
|
+
Returns:
|
|
1273
|
+
period_block_time: ``[(period, block_first, step), ...]``,
|
|
1274
|
+
one row per active step.
|
|
1275
|
+
period_block_succ: ``[(period, block_first, block_first_next),
|
|
1276
|
+
...]``, cyclic within period.
|
|
1277
|
+
"""
|
|
1278
|
+
period_block_time: list[tuple] = []
|
|
1279
|
+
period_block_succ: list[tuple] = []
|
|
1280
|
+
|
|
1281
|
+
for period, active_time in active_time_list.items():
|
|
1282
|
+
if not active_time:
|
|
1283
|
+
continue
|
|
1284
|
+
block_firsts_in_order: list[str] = [active_time[0].timestep]
|
|
1285
|
+
cur_block_first = active_time[0].timestep
|
|
1286
|
+
for j, step in enumerate(active_time):
|
|
1287
|
+
if (
|
|
1288
|
+
j > 0
|
|
1289
|
+
and active_time[j].index - active_time[j - 1].index > 1
|
|
1290
|
+
):
|
|
1291
|
+
cur_block_first = step.timestep
|
|
1292
|
+
block_firsts_in_order.append(cur_block_first)
|
|
1293
|
+
period_block_time.append(
|
|
1294
|
+
(period, cur_block_first, step.timestep)
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
n_blocks = len(block_firsts_in_order)
|
|
1298
|
+
for i, b_first in enumerate(block_firsts_in_order):
|
|
1299
|
+
b_next = block_firsts_in_order[(i + 1) % n_blocks]
|
|
1300
|
+
period_block_succ.append((period, b_first, b_next))
|
|
1301
|
+
|
|
1302
|
+
return period_block_time, period_block_succ
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def make_steps(steplist: list, start: int, stop: int) -> list:
|
|
1306
|
+
"""Return a slice of *steplist* from *start* to *stop* (inclusive)."""
|
|
1307
|
+
active_step = start
|
|
1308
|
+
steps: list = []
|
|
1309
|
+
while active_step <= stop:
|
|
1310
|
+
steps.append(steplist[active_step])
|
|
1311
|
+
active_step += 1
|
|
1312
|
+
return steps
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def make_timeset_timeline(
|
|
1316
|
+
steplist: list, start: str, length: float
|
|
1317
|
+
) -> list:
|
|
1318
|
+
"""Build a timeset timeline from a steplist.
|
|
1319
|
+
|
|
1320
|
+
Returns a slice of *steplist* starting at *start* and continuing
|
|
1321
|
+
for ``ceil(length)`` steps.
|
|
1322
|
+
"""
|
|
1323
|
+
result: list = []
|
|
1324
|
+
startnum = steplist.index(start)
|
|
1325
|
+
for i in range(startnum, math.ceil(startnum + float(length))):
|
|
1326
|
+
result.append(steplist[i])
|
|
1327
|
+
return result
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def separate_period_and_timeseries_data(
|
|
1331
|
+
timelines: dict,
|
|
1332
|
+
solve__period__timeset: dict,
|
|
1333
|
+
*,
|
|
1334
|
+
provider: "FlexDataProvider",
|
|
1335
|
+
work_folder: "Path | None" = None,
|
|
1336
|
+
) -> None:
|
|
1337
|
+
"""Split ``input/pdt_*.csv`` into per-period and per-timeseries shards.
|
|
1338
|
+
|
|
1339
|
+
For each ``pdt_<X>.csv`` (currently ``pdt_commodity.csv`` and
|
|
1340
|
+
``pdt_group.csv``), put two derived frames back onto the Provider:
|
|
1341
|
+
|
|
1342
|
+
* ``input/pd_<X>`` — rows whose third column is a period identifier.
|
|
1343
|
+
* ``input/pt_<X>`` — rows whose third column is a timestep identifier.
|
|
1344
|
+
|
|
1345
|
+
The discriminator is column-2 membership in the union of all
|
|
1346
|
+
timesteps (across all timelines) vs. all periods (from the
|
|
1347
|
+
``solve__period__timeset`` map).
|
|
1348
|
+
|
|
1349
|
+
*provider* is required: the cascade's Provider supplies the
|
|
1350
|
+
``input/pdt_<X>`` frames and receives the two derived shards
|
|
1351
|
+
keyed under the same canonical ``<parent>/<stem>`` convention
|
|
1352
|
+
other writer-port modules use. The Provider is consulted first
|
|
1353
|
+
for the source frame; ``_provider_open`` falls back to disk for
|
|
1354
|
+
the off-cascade test harness only.
|
|
1355
|
+
"""
|
|
1356
|
+
wf = Path(work_folder) if work_folder is not None else Path.cwd()
|
|
1357
|
+
|
|
1358
|
+
inputfiles = ["pdt_commodity.csv", "pdt_group.csv"]
|
|
1359
|
+
timesteps: set[str] = set()
|
|
1360
|
+
for timeline in timelines.values():
|
|
1361
|
+
for step in timeline:
|
|
1362
|
+
timesteps.add(step[0])
|
|
1363
|
+
periods: set[str] = set()
|
|
1364
|
+
for period__timesets in solve__period__timeset.values():
|
|
1365
|
+
for period__timeset in period__timesets:
|
|
1366
|
+
periods.add(period__timeset[0])
|
|
1367
|
+
|
|
1368
|
+
for inputfile in inputfiles:
|
|
1369
|
+
suffix = inputfile[4:-4] # e.g. "commodity"
|
|
1370
|
+
in_path = wf / "input" / inputfile
|
|
1371
|
+
in_key = _provider_key(in_path)
|
|
1372
|
+
pd_key = _provider_key(wf / "input" / f"pd_{suffix}.csv")
|
|
1373
|
+
pt_key = _provider_key(wf / "input" / f"pt_{suffix}.csv")
|
|
1374
|
+
|
|
1375
|
+
if not provider.has(in_key):
|
|
1376
|
+
# No source — skip the split. Downstream consumers tolerate
|
|
1377
|
+
# missing keys. Step 2.5: Provider-only — no disk fallback.
|
|
1378
|
+
continue
|
|
1379
|
+
df = provider.get(in_key)
|
|
1380
|
+
key_col = df.columns[2]
|
|
1381
|
+
pt_frame = df.filter(pl.col(key_col).is_in(timesteps))
|
|
1382
|
+
pd_frame = _rename_pdt_to_pd(
|
|
1383
|
+
df.filter(pl.col(key_col).is_in(periods))
|
|
1384
|
+
)
|
|
1385
|
+
provider.put(pt_key, pt_frame)
|
|
1386
|
+
provider.put(pd_key, pd_frame)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _rename_pdt_to_pd(df: "pl.DataFrame") -> "pl.DataFrame":
|
|
1390
|
+
"""Rename a ``pdt_<X>`` frame's last-two columns to the ``pd_<X>``
|
|
1391
|
+
convention: ``[..., 'period', 'pd_<X>_value']``.
|
|
1392
|
+
|
|
1393
|
+
Legacy ``write_csv`` emitted ``headers[:-2] + ['period',
|
|
1394
|
+
f'pd_{headers[-1][3:]}']``; reproduce the same column-name
|
|
1395
|
+
transform on the frame so downstream readers (which key by
|
|
1396
|
+
position) see byte-identical column orderings.
|
|
1397
|
+
"""
|
|
1398
|
+
cols = df.columns
|
|
1399
|
+
if len(cols) < 2:
|
|
1400
|
+
return df
|
|
1401
|
+
new_names = list(cols)
|
|
1402
|
+
new_names[-2] = "period"
|
|
1403
|
+
last = cols[-1]
|
|
1404
|
+
new_names[-1] = f"pd_{last[3:]}" if len(last) > 3 else last
|
|
1405
|
+
return df.rename(dict(zip(cols, new_names)))
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
__all__ = [
|
|
1409
|
+
"TimelineConfig",
|
|
1410
|
+
"get_active_time",
|
|
1411
|
+
"make_step_jump",
|
|
1412
|
+
"make_period_block",
|
|
1413
|
+
"make_steps",
|
|
1414
|
+
"make_timeset_timeline",
|
|
1415
|
+
"separate_period_and_timeseries_data",
|
|
1416
|
+
]
|