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,244 @@
|
|
|
1
|
+
"""Comparison-mode helpers built on the merged ``default_plots.yaml``.
|
|
2
|
+
|
|
3
|
+
Each leaf config in ``default_plots.yaml`` carries optional comparison-mode
|
|
4
|
+
add-ons:
|
|
5
|
+
|
|
6
|
+
* ``scenario_rule`` — single character that says how the ``scenario`` dim is
|
|
7
|
+
folded into the comparison view (``g``=grouped/coloured bars, ``l``=lines,
|
|
8
|
+
``u``=subplots, ``f``=files, ``s``=stacked, ``e``=expand-axis).
|
|
9
|
+
* ``comparison_overrides`` — optional dict of plot-setting overrides applied
|
|
10
|
+
on top of the single config in comparison mode. When it includes
|
|
11
|
+
``map_dimensions_for_plots``, that explicit value wins over the value
|
|
12
|
+
auto-derived from ``single + scenario_rule``.
|
|
13
|
+
|
|
14
|
+
Two helpers live here:
|
|
15
|
+
|
|
16
|
+
* :func:`derive_comparison_config` — builds a comparison-mode ``PlotConfig``
|
|
17
|
+
from a single-mode one by inserting ``s`` + ``scenario_rule`` into the
|
|
18
|
+
column part of ``index_types``/``rules`` and merging in
|
|
19
|
+
``comparison_overrides``.
|
|
20
|
+
* :func:`union_raw_data` — reads each viewer scenario's raw result parquet
|
|
21
|
+
and unions them with ``scenario`` as the outermost column-MultiIndex
|
|
22
|
+
level, so the derived config can be applied via the standard
|
|
23
|
+
``compute_live_plan`` pipeline.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import dataclasses
|
|
28
|
+
import logging
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import pandas as pd
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from flextool.lean_parquet import read_lean_parquet
|
|
36
|
+
except ImportError: # pragma: no cover — defensive fallback
|
|
37
|
+
read_lean_parquet = None # type: ignore[assignment]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_parquet(path: Path) -> pd.DataFrame:
|
|
44
|
+
"""Read a parquet file, preferring the lean reader when available."""
|
|
45
|
+
if read_lean_parquet is not None:
|
|
46
|
+
return read_lean_parquet(path)
|
|
47
|
+
return pd.read_parquet(path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _cfg_get(cfg: Any, name: str) -> Any:
|
|
51
|
+
"""Read *name* from a config that may be a PlotConfig dataclass or dict."""
|
|
52
|
+
if hasattr(cfg, name):
|
|
53
|
+
return getattr(cfg, name)
|
|
54
|
+
if isinstance(cfg, dict):
|
|
55
|
+
return cfg.get(name)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def has_comparison_view(cfg: Any) -> bool:
|
|
60
|
+
"""Return ``True`` if *cfg* is renderable in comparison mode.
|
|
61
|
+
|
|
62
|
+
A config has a comparison view when it sets ``scenario_rule`` (the
|
|
63
|
+
``scenario`` dim is auto-folded into the rules) **or** when its
|
|
64
|
+
``comparison_overrides`` supply ``map_dimensions_for_plots`` (the
|
|
65
|
+
comparison layout is given explicitly, so ``scenario_rule`` is
|
|
66
|
+
irrelevant). Mirrors the gate in :func:`derive_comparison_config`.
|
|
67
|
+
"""
|
|
68
|
+
if _cfg_get(cfg, "scenario_rule") is not None:
|
|
69
|
+
return True
|
|
70
|
+
overrides = _cfg_get(cfg, "comparison_overrides")
|
|
71
|
+
if isinstance(overrides, dict):
|
|
72
|
+
md = overrides.get("map_dimensions_for_plots")
|
|
73
|
+
if isinstance(md, (list, tuple)) and len(md) >= 2:
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def derive_comparison_config(single_cfg: Any) -> Any:
|
|
79
|
+
"""Build a comparison-mode PlotConfig from a single-mode one.
|
|
80
|
+
|
|
81
|
+
The unioned **raw** comparison frame has shape::
|
|
82
|
+
|
|
83
|
+
row = (single's row dims)
|
|
84
|
+
col = ('scenario', *single's col dims)
|
|
85
|
+
|
|
86
|
+
so we prepend ``s`` to ``index_types``'s column part and prepend
|
|
87
|
+
``scenario_rule`` to the rules string's column part. The single-mode
|
|
88
|
+
rules string is otherwise unchanged, which means
|
|
89
|
+
``_apply_dimension_rules`` will reshape the data exactly as it does in
|
|
90
|
+
single mode, with one extra column dim representing scenario.
|
|
91
|
+
|
|
92
|
+
``comparison_overrides`` (also on the single config) provides per-mode
|
|
93
|
+
overrides for layout knobs. Overrides win — including
|
|
94
|
+
``map_dimensions_for_plots`` if explicitly given (used by the rare
|
|
95
|
+
configs whose comparison view wants a different visual treatment than
|
|
96
|
+
"single + scenario_rule prepended").
|
|
97
|
+
|
|
98
|
+
``scenario_rule`` is normally required to mark a config as
|
|
99
|
+
comparison-renderable. It may be omitted, however, when
|
|
100
|
+
``comparison_overrides`` supplies ``map_dimensions_for_plots`` — that
|
|
101
|
+
fully specifies the comparison layout (including the scenario dim), so
|
|
102
|
+
no auto-derivation is needed and ``scenario_rule`` is irrelevant.
|
|
103
|
+
|
|
104
|
+
Raises ``ValueError`` when neither ``scenario_rule`` nor an override
|
|
105
|
+
``map_dimensions_for_plots`` is set, or when ``map_dimensions_for_plots``
|
|
106
|
+
is malformed.
|
|
107
|
+
"""
|
|
108
|
+
# Read overrides first: a comparison config may give
|
|
109
|
+
# ``map_dimensions_for_plots`` directly here, which fully defines the
|
|
110
|
+
# comparison layout and makes ``scenario_rule`` unnecessary.
|
|
111
|
+
overrides = getattr(single_cfg, "comparison_overrides", None)
|
|
112
|
+
if overrides is None and isinstance(single_cfg, dict):
|
|
113
|
+
overrides = single_cfg.get("comparison_overrides")
|
|
114
|
+
overrides = dict(overrides or {})
|
|
115
|
+
override_md = overrides.get("map_dimensions_for_plots")
|
|
116
|
+
has_override_md = isinstance(override_md, (list, tuple)) and len(override_md) >= 2
|
|
117
|
+
|
|
118
|
+
scenario_rule = getattr(single_cfg, "scenario_rule", None)
|
|
119
|
+
if scenario_rule is None and isinstance(single_cfg, dict):
|
|
120
|
+
scenario_rule = single_cfg.get("scenario_rule")
|
|
121
|
+
if scenario_rule is None and not has_override_md:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"derive_comparison_config: the single config has no "
|
|
124
|
+
"scenario_rule — define one (e.g. 'g' for grouped bars, "
|
|
125
|
+
"'l' for lines, 'u' for subplots), or supply "
|
|
126
|
+
"map_dimensions_for_plots in comparison_overrides, to enable "
|
|
127
|
+
"comparison view."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if scenario_rule is None:
|
|
131
|
+
# ``comparison_overrides`` fully specifies the comparison layout via
|
|
132
|
+
# ``map_dimensions_for_plots``; skip auto-derivation and apply the
|
|
133
|
+
# override as-is below.
|
|
134
|
+
new_md = None
|
|
135
|
+
else:
|
|
136
|
+
if hasattr(single_cfg, "map_dimensions_for_plots"):
|
|
137
|
+
md = single_cfg.map_dimensions_for_plots
|
|
138
|
+
else:
|
|
139
|
+
md = single_cfg.get("map_dimensions_for_plots")
|
|
140
|
+
if not isinstance(md, (list, tuple)) or len(md) < 2:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"derive_comparison_config: map_dimensions_for_plots must be a "
|
|
143
|
+
f"2-element [index_types, rules] list, got {md!r}"
|
|
144
|
+
)
|
|
145
|
+
idx, rules = md[0], md[1]
|
|
146
|
+
if not isinstance(idx, str) or "_" not in idx:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"derive_comparison_config: index_types must contain '_' "
|
|
149
|
+
f"separating row and column parts, got {idx!r}"
|
|
150
|
+
)
|
|
151
|
+
if not isinstance(rules, str) or "_" not in rules:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"derive_comparison_config: rules must contain '_' separating "
|
|
154
|
+
f"row and column parts, got {rules!r}"
|
|
155
|
+
)
|
|
156
|
+
row_idx, col_idx = idx.split("_", 1)
|
|
157
|
+
row_rules, col_rules = rules.split("_", 1)
|
|
158
|
+
|
|
159
|
+
# If the single rules already name 'scenario' in the col part (e.g.
|
|
160
|
+
# ``[d_s, s_b]`` for a costs-by-scenario chart that's the same shape
|
|
161
|
+
# in both modes), don't auto-prepend another ``s`` — the existing rule
|
|
162
|
+
# for scenario stands. ``scenario_rule`` is required to mark the
|
|
163
|
+
# config as comparison-renderable, but its value is unused on this
|
|
164
|
+
# branch (and may be overridden via ``comparison_overrides``).
|
|
165
|
+
if "s" in col_idx:
|
|
166
|
+
new_idx, new_rules = idx, rules
|
|
167
|
+
else:
|
|
168
|
+
new_idx = f"{row_idx}_s{col_idx}"
|
|
169
|
+
new_rules = f"{row_rules}_{scenario_rule}{col_rules}"
|
|
170
|
+
new_md = [new_idx, new_rules]
|
|
171
|
+
|
|
172
|
+
if hasattr(single_cfg, "__dataclass_fields__"):
|
|
173
|
+
valid_fields = set(single_cfg.__dataclass_fields__.keys())
|
|
174
|
+
replace_kwargs = {k: v for k, v in overrides.items() if k in valid_fields}
|
|
175
|
+
# Two-step replace: derive first, then apply overrides — so a config
|
|
176
|
+
# whose comparison rules differ beyond just adding scenario can carry
|
|
177
|
+
# ``map_dimensions_for_plots`` in ``comparison_overrides`` and have it
|
|
178
|
+
# take precedence over the derived value.
|
|
179
|
+
derived = (
|
|
180
|
+
single_cfg if new_md is None
|
|
181
|
+
else dataclasses.replace(single_cfg, map_dimensions_for_plots=new_md)
|
|
182
|
+
)
|
|
183
|
+
return dataclasses.replace(derived, **replace_kwargs)
|
|
184
|
+
|
|
185
|
+
if isinstance(single_cfg, dict):
|
|
186
|
+
new_cfg = dict(single_cfg)
|
|
187
|
+
if new_md is not None:
|
|
188
|
+
new_cfg["map_dimensions_for_plots"] = new_md
|
|
189
|
+
new_cfg.update(overrides) # overrides win, including map_dimensions_for_plots
|
|
190
|
+
return new_cfg
|
|
191
|
+
|
|
192
|
+
raise TypeError(
|
|
193
|
+
f"derive_comparison_config: unsupported config type {type(single_cfg)!r}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def union_raw_data(
|
|
198
|
+
project_path: Path,
|
|
199
|
+
scenarios: list[str],
|
|
200
|
+
result_key: str,
|
|
201
|
+
) -> pd.DataFrame | None:
|
|
202
|
+
"""Read each scenario's raw result parquet and union them.
|
|
203
|
+
|
|
204
|
+
Each per-scenario raw parquet at ``output_parquet/<scenario>/<rk>.parquet``
|
|
205
|
+
already carries a ``scenario`` column-MultiIndex level (added at write
|
|
206
|
+
time); we drop that local level and re-concat across scenarios with the
|
|
207
|
+
**viewer-supplied** scenario name as the new outermost ``scenario``
|
|
208
|
+
level. This handles the case where a scenario folder name differs from
|
|
209
|
+
the embedded scenario name (e.g. sensitivity replicas where the folder
|
|
210
|
+
is ``scenario_test_6h_2`` but the embedded name is ``scenario_test_6h``).
|
|
211
|
+
|
|
212
|
+
Returns ``None`` when no raw parquet exists for any of *scenarios*.
|
|
213
|
+
Missing files for a subset are silently skipped — the union proceeds
|
|
214
|
+
with whatever is present.
|
|
215
|
+
"""
|
|
216
|
+
pieces: list[pd.DataFrame] = []
|
|
217
|
+
found: list[str] = []
|
|
218
|
+
for s in scenarios:
|
|
219
|
+
path = Path(project_path) / "output_parquet" / s / f"{result_key}.parquet"
|
|
220
|
+
if not path.is_file():
|
|
221
|
+
continue
|
|
222
|
+
try:
|
|
223
|
+
df = _read_parquet(path)
|
|
224
|
+
except Exception as exc: # noqa: BLE001
|
|
225
|
+
logger.warning("union_raw_data: failed to read %s: %s", path, exc)
|
|
226
|
+
continue
|
|
227
|
+
# Strip the embedded scenario level so the keys=... we set below is
|
|
228
|
+
# the only authoritative scenario label. Two cases:
|
|
229
|
+
# * MultiIndex columns with a 'scenario' level → droplevel.
|
|
230
|
+
# * Single-level Index named 'scenario' (e.g. costs_discounted_p_,
|
|
231
|
+
# a category × scenario table) → squeeze to a Series so concat
|
|
232
|
+
# below adds 'scenario' as the only column level (otherwise the
|
|
233
|
+
# inner unnamed level lingers and breaks rules like ``s_b``).
|
|
234
|
+
if isinstance(df.columns, pd.MultiIndex) and "scenario" in df.columns.names:
|
|
235
|
+
df = df.droplevel("scenario", axis=1)
|
|
236
|
+
elif (not isinstance(df.columns, pd.MultiIndex)
|
|
237
|
+
and df.columns.name == "scenario"
|
|
238
|
+
and len(df.columns) == 1):
|
|
239
|
+
df = df.iloc[:, 0] # Series with the row index preserved
|
|
240
|
+
pieces.append(df)
|
|
241
|
+
found.append(s)
|
|
242
|
+
if not pieces:
|
|
243
|
+
return None
|
|
244
|
+
return pd.concat(pieces, axis=1, keys=found, names=["scenario"])
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Structured seeding of discovered colors into ``plot_settings.yaml``.
|
|
2
|
+
|
|
3
|
+
The GUI color/order picker (Stage 6) is now the canonical editor for the
|
|
4
|
+
project ``plot_settings.yaml``, so the file is treated as a plain STRUCTURED
|
|
5
|
+
data file: load with :mod:`pyyaml`, modify the dict, dump with
|
|
6
|
+
``sort_keys=False`` to preserve key order (= stacking order). Comments are no
|
|
7
|
+
longer preserved in the PROJECT file — they live only in the bundled template
|
|
8
|
+
(schema reference) and as future UI tooltips/labels.
|
|
9
|
+
|
|
10
|
+
This module provides the single read/modify/write helper used by both seeds:
|
|
11
|
+
|
|
12
|
+
* the upfront **input-DB** seed
|
|
13
|
+
(:mod:`flextool.scenario_comparison.input_entity_colors`), which knows the
|
|
14
|
+
FULL per-class entity set from the input DB and therefore ADDS new names and
|
|
15
|
+
PRUNES stale ones; and
|
|
16
|
+
* the **dispatch** seed
|
|
17
|
+
(:func:`flextool.scenario_comparison.orchestrator._seed_dispatch_colors_into_plot_settings`),
|
|
18
|
+
which discovers only a SUBSET of entities and therefore ADDS only (never
|
|
19
|
+
prunes).
|
|
20
|
+
|
|
21
|
+
Add semantics (always): for each entity class / scenario, names not already
|
|
22
|
+
present (case-insensitive) are appended AFTER the existing entries in that
|
|
23
|
+
subsection. Existing entry ORDER and existing VALUES are never disturbed (a
|
|
24
|
+
user's hand-picked color or order is preserved).
|
|
25
|
+
|
|
26
|
+
Prune semantics (opt-in, ``prune_entities``): names whose lowercased form is
|
|
27
|
+
NOT in the supplied full set for that class are removed from ``entities.<class>``.
|
|
28
|
+
Only ``entities.*`` is ever pruned; ``categories.*`` (schema-fixed) and
|
|
29
|
+
``scenarios`` are never pruned.
|
|
30
|
+
|
|
31
|
+
A pass that adds nothing and prunes nothing makes no write (returns ``False``),
|
|
32
|
+
so a freshly-copied, still-commented project file keeps its comments until the
|
|
33
|
+
first real change.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
import yaml
|
|
41
|
+
|
|
42
|
+
# Entity classes managed in the ``entities`` section (the group class is split
|
|
43
|
+
# into nodeGroup/flowGroup by membership upstream). Legacy ``group`` is NOT
|
|
44
|
+
# listed — old projects keep an inert ``entities.group`` section, never
|
|
45
|
+
# written or pruned here.
|
|
46
|
+
_ENTITY_CLASSES = ("nodeGroup", "flowGroup", "unit", "connection", "node")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dump_plot_settings(data: dict) -> str:
|
|
50
|
+
"""Serialize a ``plot_settings.yaml`` mapping to text.
|
|
51
|
+
|
|
52
|
+
Single source of truth for the dump parameters shared by the seed
|
|
53
|
+
writer here and the GUI color/order picker
|
|
54
|
+
(:mod:`flextool.gui.dialogs.plot_settings_picker`). ``sort_keys=False``
|
|
55
|
+
preserves KEY ORDER (= stacking order); ``default_flow_style=False``
|
|
56
|
+
keeps the block style; ``allow_unicode=True`` round-trips unicode
|
|
57
|
+
entity names verbatim.
|
|
58
|
+
"""
|
|
59
|
+
return yaml.safe_dump(
|
|
60
|
+
data,
|
|
61
|
+
sort_keys=False,
|
|
62
|
+
default_flow_style=False,
|
|
63
|
+
allow_unicode=True,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_mapping(path: Path) -> dict:
|
|
68
|
+
"""Load *path* as a YAML mapping (``None`` / empty -> ``{}``).
|
|
69
|
+
|
|
70
|
+
Raises :class:`ValueError` on a parse error or a non-mapping top level.
|
|
71
|
+
"""
|
|
72
|
+
text = path.read_text(encoding="utf-8")
|
|
73
|
+
try:
|
|
74
|
+
parsed = yaml.safe_load(text)
|
|
75
|
+
except yaml.YAMLError as exc: # pragma: no cover - corrupt file
|
|
76
|
+
raise ValueError(f"Cannot parse {path}: {exc}") from exc
|
|
77
|
+
if parsed is None:
|
|
78
|
+
return {}
|
|
79
|
+
if not isinstance(parsed, dict):
|
|
80
|
+
raise ValueError(f"{path} is not a YAML mapping")
|
|
81
|
+
return parsed
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _existing_lc(section: dict) -> set[str]:
|
|
85
|
+
"""Lowercased key set of *section* (for case-insensitive de-dup)."""
|
|
86
|
+
return {str(k).lower() for k in section}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _add_entries(
|
|
90
|
+
section: dict,
|
|
91
|
+
wanted: dict[str, str],
|
|
92
|
+
) -> bool:
|
|
93
|
+
"""Append names from *wanted* not already in *section* (case-insensitive).
|
|
94
|
+
|
|
95
|
+
Mutates *section* in place; existing entries (order + value) are left
|
|
96
|
+
untouched. Returns ``True`` if anything was added.
|
|
97
|
+
"""
|
|
98
|
+
existing = _existing_lc(section)
|
|
99
|
+
added = False
|
|
100
|
+
for name, color in wanted.items():
|
|
101
|
+
if str(name).lower() in existing:
|
|
102
|
+
continue
|
|
103
|
+
section[name] = color
|
|
104
|
+
existing.add(str(name).lower())
|
|
105
|
+
added = True
|
|
106
|
+
return added
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _prune_entries(section: dict, keep_lc: set[str]) -> bool:
|
|
110
|
+
"""Remove keys of *section* whose lowercase is NOT in *keep_lc*.
|
|
111
|
+
|
|
112
|
+
Mutates *section* in place. Returns ``True`` if anything was removed.
|
|
113
|
+
"""
|
|
114
|
+
stale = [k for k in section if str(k).lower() not in keep_lc]
|
|
115
|
+
for k in stale:
|
|
116
|
+
del section[k]
|
|
117
|
+
return bool(stale)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def seed_colors_into_plot_settings(
|
|
121
|
+
path: Path,
|
|
122
|
+
entity_colors: dict[str, dict[str, str]],
|
|
123
|
+
scenario_colors: dict[str, str],
|
|
124
|
+
prune_entities: dict[str, set[str]] | None = None,
|
|
125
|
+
) -> bool:
|
|
126
|
+
"""Structurally add (and optionally prune) colors in ``plot_settings.yaml``.
|
|
127
|
+
|
|
128
|
+
*entity_colors* maps entity class (``nodeGroup`` / ``flowGroup`` /
|
|
129
|
+
``unit`` / ``connection`` / ``node``) to an ordered ``{name -> color}``
|
|
130
|
+
mapping (unknown classes are ignored); *scenario_colors* is
|
|
131
|
+
the ``{name -> color}`` mapping for the ``scenarios`` section. Color
|
|
132
|
+
values are written verbatim (``"#RRGGBB"`` strings, matplotlib color
|
|
133
|
+
names, or ``{color, neg_color}`` dicts — whatever the caller supplies).
|
|
134
|
+
|
|
135
|
+
ADD: names not already present (active key, case-insensitively) in their
|
|
136
|
+
target (sub)section are appended AFTER the existing entries. Existing
|
|
137
|
+
entries — their order and their values — are never overwritten.
|
|
138
|
+
|
|
139
|
+
PRUNE (only when *prune_entities* is given): for each class present in
|
|
140
|
+
*prune_entities*, entity names in ``entities.<class>`` whose lowercased
|
|
141
|
+
name is NOT in ``prune_entities[class]`` are removed. This is how the
|
|
142
|
+
input-DB seed drops entities that no longer exist in the DB. Only
|
|
143
|
+
``entities.*`` is pruned — never ``categories.*`` (schema-fixed) nor
|
|
144
|
+
``scenarios``. When *prune_entities* is ``None`` the call is add-only.
|
|
145
|
+
|
|
146
|
+
The file is loaded with :func:`yaml.safe_load`, modified, and re-dumped
|
|
147
|
+
with ``sort_keys=False`` so KEY ORDER (= stacking order) is preserved and
|
|
148
|
+
``"#RRGGBB"`` values round-trip as quoted strings. ``categories.*``,
|
|
149
|
+
``scenarios``, and any unmanaged keys are preserved.
|
|
150
|
+
|
|
151
|
+
Returns ``True`` when the file was modified, ``False`` when nothing was
|
|
152
|
+
added or pruned (the no-op / idempotent case — the file is left untouched
|
|
153
|
+
on disk so a still-commented fresh copy keeps its comments).
|
|
154
|
+
"""
|
|
155
|
+
path = Path(path)
|
|
156
|
+
data = _load_mapping(path)
|
|
157
|
+
|
|
158
|
+
modified = False
|
|
159
|
+
|
|
160
|
+
# --- Entities: add + (optional) prune -----------------------------------
|
|
161
|
+
entities = data.get("entities")
|
|
162
|
+
entities_existed = isinstance(entities, dict)
|
|
163
|
+
if not entities_existed:
|
|
164
|
+
entities = {}
|
|
165
|
+
|
|
166
|
+
for cls in _ENTITY_CLASSES:
|
|
167
|
+
wanted = entity_colors.get(cls) or {}
|
|
168
|
+
do_prune = prune_entities is not None and cls in prune_entities
|
|
169
|
+
if not wanted and not do_prune:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
class_map = entities.get(cls)
|
|
173
|
+
if not isinstance(class_map, dict):
|
|
174
|
+
class_map = {}
|
|
175
|
+
|
|
176
|
+
changed_here = False
|
|
177
|
+
if wanted and _add_entries(class_map, wanted):
|
|
178
|
+
changed_here = True
|
|
179
|
+
if do_prune and _prune_entries(class_map, prune_entities[cls]):
|
|
180
|
+
changed_here = True
|
|
181
|
+
|
|
182
|
+
if changed_here:
|
|
183
|
+
# Attach the (possibly newly created) class map to the section.
|
|
184
|
+
entities[cls] = class_map
|
|
185
|
+
modified = True
|
|
186
|
+
|
|
187
|
+
# Only attach the entities section if we actually changed something and
|
|
188
|
+
# there is content to write — never inject an empty section on a no-op.
|
|
189
|
+
if modified and entities and not entities_existed:
|
|
190
|
+
data["entities"] = entities
|
|
191
|
+
|
|
192
|
+
# --- Scenarios: add only -------------------------------------------------
|
|
193
|
+
if scenario_colors:
|
|
194
|
+
scenarios = data.get("scenarios")
|
|
195
|
+
if not isinstance(scenarios, dict):
|
|
196
|
+
scenarios = {}
|
|
197
|
+
if _add_entries(scenarios, scenario_colors):
|
|
198
|
+
data["scenarios"] = scenarios
|
|
199
|
+
modified = True
|
|
200
|
+
|
|
201
|
+
if not modified:
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
path.write_text(dump_plot_settings(data), encoding="utf-8")
|
|
205
|
+
return True
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# FlexTool axis contract
|
|
2
|
+
|
|
3
|
+
This directory holds two coupled JSON files that together define the
|
|
4
|
+
canonical FlexTool axis vocabularies for the `pl.Enum` dtype activation
|
|
5
|
+
(see `specs/enum_dtype_refactor_plan.md`):
|
|
6
|
+
|
|
7
|
+
| File | Purpose |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `flextool_axis_contract.json` | Authoritative declaration of every axis the cascade casts to `pl.Enum`. Each axis row names the entity class / parameter map / synthetic-token list that supplies its vocabulary. |
|
|
10
|
+
| `flextool_axis_contract.schema.json` | JSON Schema (draft 2020-12) validating the structure of `flextool_axis_contract.json` itself. |
|
|
11
|
+
|
|
12
|
+
The contract is consumed by `flextool/spinedb_backend/_axis_enums.py`
|
|
13
|
+
(landing in Phase 1) at runtime to build the per-axis `pl.Enum` dtypes
|
|
14
|
+
that the Backend and the cascade cast against.
|
|
15
|
+
|
|
16
|
+
## Coupling with `spinedb_schema.json`
|
|
17
|
+
|
|
18
|
+
The contract migrates **hand-in-hand** with `spinedb_schema.json`.
|
|
19
|
+
Any schema change that adds, renames, or removes an entity class or a
|
|
20
|
+
parameter definition referenced by an axis row REQUIRES updating both
|
|
21
|
+
files in the same commit:
|
|
22
|
+
|
|
23
|
+
* If you add a new entity class that the cascade joins on, decide
|
|
24
|
+
whether it deserves its own axis and add a row to `axes`.
|
|
25
|
+
* If you rename an entity class, update every `source` field that
|
|
26
|
+
references it.
|
|
27
|
+
* If you remove an entity class, remove or repoint the axis row.
|
|
28
|
+
* If you rename or remove a parameter referenced under
|
|
29
|
+
`source_type: parameter_keys` or `parameter_value_list`, update the
|
|
30
|
+
`source.entity_class` / `source.parameter` / `source.parameters` field.
|
|
31
|
+
|
|
32
|
+
The `_template_master_version_compat` block at the top of
|
|
33
|
+
`flextool_axis_contract.json` pins the schema commit hash the contract
|
|
34
|
+
was last verified against. Bump that block whenever a schema-affecting
|
|
35
|
+
edit lands.
|
|
36
|
+
|
|
37
|
+
## Self-validation
|
|
38
|
+
|
|
39
|
+
`tests/spinedb_backend/test_axis_contract.py` validates the contract
|
|
40
|
+
against its own schema and cross-checks that every entity class /
|
|
41
|
+
parameter referenced by an axis row exists in `spinedb_schema.json`.
|
|
42
|
+
Run it after every contract edit:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m pytest tests/spinedb_backend/test_axis_contract.py -v
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Adding or changing an axis
|
|
49
|
+
|
|
50
|
+
1. Decide the `source_type`:
|
|
51
|
+
- `entity_class` — one entity class, single member list.
|
|
52
|
+
- `entity_class_union` — multiple entity classes unioned (e.g. process = unit ∪ connection).
|
|
53
|
+
- `parameter_keys` — vocabulary lives in the keys of a parameter Map (or the values of an Array).
|
|
54
|
+
- `parameter_value_list` — vocabulary is the values of a Spine `parameter_value_list`.
|
|
55
|
+
- `synthetic` — fixed tokens introduced by cascade derivations (no DB source).
|
|
56
|
+
- `union` — composite axis combining other axes' vocabularies.
|
|
57
|
+
2. Fill in the `source` field — string / list / object per `source_type`.
|
|
58
|
+
3. Add `column_synonyms` if the cascade uses friendly column names that should map to the same enum (mirrors `_AXIS_SYNONYMS` in `flextool/engine_polars/_axis_enums.py`).
|
|
59
|
+
4. Add `filter` if the vocabulary is intersected with another set (e.g. scenario filter, realized periods).
|
|
60
|
+
5. If introducing synthetic tokens that aren't from the DB, register them in `synthetic_token_allowlist` AND in the axis's `tokens` field (for `source_type: synthetic`) or `tokens_default_extension` (for hybrid axes).
|
|
61
|
+
6. Update the schema (`flextool_axis_contract.schema.json`) only if you need a new `source_type` enum value or a new optional field shape.
|
|
62
|
+
7. Run the self-check tests.
|
|
63
|
+
|
|
64
|
+
## Outstanding review notes
|
|
65
|
+
|
|
66
|
+
The `_review_notes` array at the bottom of `flextool_axis_contract.json`
|
|
67
|
+
tracks Phase 0 audit findings that need Phase 1 resolution decisions
|
|
68
|
+
(e.g. column-letter collisions between axes like `c` for both commodity
|
|
69
|
+
and constraint, or `b` for both block and branch). These notes are
|
|
70
|
+
informational; Phase 1's `build_axis_enums` is where the disambiguation
|
|
71
|
+
strategy is actually implemented.
|