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,3559 @@
|
|
|
1
|
+
"""Write FlexTool data to Excel sheets based on SheetSpec configurations.
|
|
2
|
+
|
|
3
|
+
Each ``write_*`` function populates one openpyxl worksheet according to
|
|
4
|
+
its layout type (constant, periodic, timeseries, link, scenario, version,
|
|
5
|
+
navigate).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from openpyxl.styles import Font, PatternFill
|
|
14
|
+
from openpyxl.styles.colors import Color
|
|
15
|
+
from openpyxl.worksheet.worksheet import Worksheet
|
|
16
|
+
|
|
17
|
+
from spinedb_api import Map, Array
|
|
18
|
+
|
|
19
|
+
from flextool.export_to_tabular.db_reader import DatabaseContents
|
|
20
|
+
|
|
21
|
+
from flextool.export_to_tabular.formatting import (
|
|
22
|
+
add_navigate_link,
|
|
23
|
+
auto_column_width,
|
|
24
|
+
format_constant_sheet,
|
|
25
|
+
format_constant_sheet_v2,
|
|
26
|
+
format_link_sheet,
|
|
27
|
+
format_link_sheet_v2,
|
|
28
|
+
format_periodic_sheet,
|
|
29
|
+
format_periodic_sheet_v2,
|
|
30
|
+
format_timeseries_sheet,
|
|
31
|
+
format_timeseries_sheet_v2,
|
|
32
|
+
FILL_DEF_COL,
|
|
33
|
+
FILL_ENTITY_HEADER,
|
|
34
|
+
FILL_INDEX_DATA,
|
|
35
|
+
FILL_INDEX_HEADER,
|
|
36
|
+
FILL_DESC_ROW,
|
|
37
|
+
FILL_DESC_DATA,
|
|
38
|
+
FILL_PARAM_HEADER,
|
|
39
|
+
FONT_DESC_ROW,
|
|
40
|
+
FONT_DESC_DATA,
|
|
41
|
+
FONT_NAVIGATE_LINK,
|
|
42
|
+
)
|
|
43
|
+
from flextool.export_to_tabular.sheet_config import SheetSpec
|
|
44
|
+
|
|
45
|
+
# Index-cell marker written for a zero-length Array/Map parameter value so
|
|
46
|
+
# the round-trip can rebuild an *empty* collection rather than dropping it
|
|
47
|
+
# (a positional layout emits no rows for a collection with no elements).
|
|
48
|
+
# The reader side mirrors this literal in
|
|
49
|
+
# write_self_describing_to_db._group_map_records — keep the two in sync.
|
|
50
|
+
EMPTY_COLLECTION_SENTINEL = "(empty)"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _to_native(value: Any) -> Any:
|
|
54
|
+
"""Convert numpy scalar types to native Python types for openpyxl."""
|
|
55
|
+
if isinstance(value, np.floating):
|
|
56
|
+
return float(value)
|
|
57
|
+
if isinstance(value, np.integer):
|
|
58
|
+
return int(value)
|
|
59
|
+
if isinstance(value, (np.str_, np.bytes_)):
|
|
60
|
+
return str(value)
|
|
61
|
+
if isinstance(value, np.bool_):
|
|
62
|
+
return bool(value)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _array_to_str(arr: Array) -> str:
|
|
67
|
+
"""Convert an Array value to a comma-separated string."""
|
|
68
|
+
return ", ".join(str(_to_native(v)) for v in arr.values)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_map(value: Any) -> bool:
|
|
72
|
+
"""Check if a value is a Map."""
|
|
73
|
+
return isinstance(value, Map)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_array(value: Any) -> bool:
|
|
77
|
+
"""Check if a value is an Array."""
|
|
78
|
+
return isinstance(value, Array)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_scalar(value: Any) -> bool:
|
|
82
|
+
"""Check if a value is a scalar (float, int, str) — not Map or Array."""
|
|
83
|
+
return isinstance(value, (int, float, str, np.floating, np.integer, np.str_))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class IndexClassifier:
|
|
87
|
+
"""Classifies Map index values as time-indexed or period-indexed.
|
|
88
|
+
|
|
89
|
+
Built once from DatabaseContents by collecting known timestep names
|
|
90
|
+
(from timeline entities) and period names (from solve entities).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, db_contents: DatabaseContents) -> None:
|
|
94
|
+
self.time_indexes: set[str] = set()
|
|
95
|
+
self.period_indexes: set[str] = set()
|
|
96
|
+
|
|
97
|
+
for (cls, _byname, pname, _alt), val in db_contents.parameter_values.items():
|
|
98
|
+
if not isinstance(val, Map):
|
|
99
|
+
continue
|
|
100
|
+
# Timeline timestep_duration maps contain all timestep names as indexes
|
|
101
|
+
if cls == "timeline" and pname == "timestep_duration":
|
|
102
|
+
self.time_indexes.update(str(_to_native(idx)) for idx in val.indexes)
|
|
103
|
+
# Solve parameters with Map values use period names as indexes
|
|
104
|
+
if cls == "solve" and pname != "solver_arguments":
|
|
105
|
+
self.period_indexes.update(str(_to_native(idx)) for idx in val.indexes)
|
|
106
|
+
|
|
107
|
+
def is_time_indexed(self, value: Any) -> bool:
|
|
108
|
+
"""Check if a Map value contains time-series data (should go on _t sheet).
|
|
109
|
+
|
|
110
|
+
Classification order:
|
|
111
|
+
1. Explicit index_name == "time" → True
|
|
112
|
+
2. Explicit index_name == "period" → False
|
|
113
|
+
3. Cross-reference indexes with known timesteps/periods from the DB
|
|
114
|
+
4. Fallback: large index count (>100) suggests time-series
|
|
115
|
+
"""
|
|
116
|
+
if not isinstance(value, Map) or len(value.indexes) == 0:
|
|
117
|
+
return False
|
|
118
|
+
iname = getattr(value, "index_name", "")
|
|
119
|
+
if iname == "time":
|
|
120
|
+
return True
|
|
121
|
+
if iname == "period":
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
# Cross-reference with known indexes
|
|
125
|
+
map_idxs = {str(_to_native(idx)) for idx in value.indexes}
|
|
126
|
+
has_time_overlap = bool(map_idxs & self.time_indexes)
|
|
127
|
+
has_period_overlap = bool(map_idxs & self.period_indexes)
|
|
128
|
+
|
|
129
|
+
if has_time_overlap and not has_period_overlap:
|
|
130
|
+
return True
|
|
131
|
+
if has_period_overlap and not has_time_overlap:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
# Both or neither overlap — use count as last resort
|
|
135
|
+
return len(map_idxs) > 100
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# Module-level classifier instance, set by export_to_excel before writing sheets
|
|
139
|
+
_index_classifier: IndexClassifier | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_time_indexed_map(value: Any) -> bool:
|
|
143
|
+
"""Check if a Map value contains time-series data (should go on _t sheet, not _p)."""
|
|
144
|
+
if _index_classifier is not None:
|
|
145
|
+
return _index_classifier.is_time_indexed(value)
|
|
146
|
+
# Fallback if classifier not initialized
|
|
147
|
+
if not isinstance(value, Map) or len(value.indexes) == 0:
|
|
148
|
+
return False
|
|
149
|
+
return getattr(value, "index_name", "") == "time"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _get_extra_columns_for_entity(
|
|
153
|
+
entity_byname: tuple,
|
|
154
|
+
entity_class_name: str,
|
|
155
|
+
spec: SheetSpec,
|
|
156
|
+
db_contents: DatabaseContents,
|
|
157
|
+
) -> list[str | None]:
|
|
158
|
+
"""Look up extra entity column values for a given entity.
|
|
159
|
+
|
|
160
|
+
For example, for connection_c with extra_entity_class='connection__node__node',
|
|
161
|
+
finds the matching multi-dim entity and extracts the extra dimension values.
|
|
162
|
+
|
|
163
|
+
Returns a list of values for each extra_entity_column, or Nones if not found.
|
|
164
|
+
"""
|
|
165
|
+
if not spec.extra_entity_columns or not spec.extra_entity_class:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
extra_entities = db_contents.entities.get(spec.extra_entity_class, [])
|
|
169
|
+
# The main entity name is the first element (e.g. 'ConnBat' for connection)
|
|
170
|
+
main_entity_name = entity_byname[0] if entity_byname else None
|
|
171
|
+
|
|
172
|
+
# Find the extra entity that has this main entity as its first element
|
|
173
|
+
for extra_ent in extra_entities:
|
|
174
|
+
extra_byname = extra_ent["entity_byname"]
|
|
175
|
+
if extra_byname[0] == main_entity_name:
|
|
176
|
+
# Return the extra dimensions (skip the first which is the main entity)
|
|
177
|
+
extra_vals = [str(_to_native(v)) for v in extra_byname[1:]]
|
|
178
|
+
# Pad or truncate to match extra_entity_columns length
|
|
179
|
+
while len(extra_vals) < len(spec.extra_entity_columns):
|
|
180
|
+
extra_vals.append(None)
|
|
181
|
+
return extra_vals[: len(spec.extra_entity_columns)]
|
|
182
|
+
|
|
183
|
+
return [None] * len(spec.extra_entity_columns)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Pre-built index for fast alternative lookups, set by export_to_excel.
|
|
187
|
+
_entity_alts_index: dict[tuple[str, tuple], set[str]] = {}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _build_entity_alts_index(db_contents: DatabaseContents) -> None:
|
|
191
|
+
"""Build a lookup from (class, byname) → set of alternatives."""
|
|
192
|
+
global _entity_alts_index
|
|
193
|
+
_entity_alts_index = {}
|
|
194
|
+
for (cls, byname, _param, alt) in db_contents.parameter_values:
|
|
195
|
+
key = (cls, byname)
|
|
196
|
+
if key not in _entity_alts_index:
|
|
197
|
+
_entity_alts_index[key] = set()
|
|
198
|
+
_entity_alts_index[key].add(alt)
|
|
199
|
+
for (cls, byname, alt) in db_contents.entity_alternatives:
|
|
200
|
+
key = (cls, byname)
|
|
201
|
+
if key not in _entity_alts_index:
|
|
202
|
+
_entity_alts_index[key] = set()
|
|
203
|
+
_entity_alts_index[key].add(alt)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _find_alternatives_for_entity(
|
|
207
|
+
entity_class: str,
|
|
208
|
+
entity_byname: tuple,
|
|
209
|
+
spec: SheetSpec,
|
|
210
|
+
db_contents: DatabaseContents,
|
|
211
|
+
) -> list[str]:
|
|
212
|
+
"""Find all alternatives that have parameter values or entity_alternatives for an entity.
|
|
213
|
+
|
|
214
|
+
If the entity has no data at all, returns ``[""]`` so that it still
|
|
215
|
+
gets written as an empty row (preserving the entity in the export).
|
|
216
|
+
"""
|
|
217
|
+
key = (entity_class, entity_byname)
|
|
218
|
+
alts = _entity_alts_index.get(key, set())
|
|
219
|
+
|
|
220
|
+
if not alts:
|
|
221
|
+
return [""] # empty alt → entity row with no data
|
|
222
|
+
|
|
223
|
+
return sorted(alts)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _get_direction(entity_class: str, spec: SheetSpec) -> str | None:
|
|
227
|
+
"""Get the direction value for an entity class from the spec's direction_map."""
|
|
228
|
+
if spec.direction_column and spec.direction_map:
|
|
229
|
+
return spec.direction_map.get(entity_class)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Constant sheet
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def write_constant_sheet(
|
|
239
|
+
ws: Worksheet,
|
|
240
|
+
spec: SheetSpec,
|
|
241
|
+
db_contents: DatabaseContents,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Write a constant-layout sheet."""
|
|
244
|
+
# Build header columns
|
|
245
|
+
headers: list[str] = ["alternative"]
|
|
246
|
+
headers.extend(spec.entity_columns)
|
|
247
|
+
headers.extend(spec.extra_entity_columns)
|
|
248
|
+
# Pre-EA params (e.g. node_type for node_c) go before Entity Alternative
|
|
249
|
+
if spec.pre_ea_params:
|
|
250
|
+
headers.extend(spec.pre_ea_params)
|
|
251
|
+
if spec.has_entity_alternative:
|
|
252
|
+
headers.append("Entity Alternative")
|
|
253
|
+
if spec.direction_column:
|
|
254
|
+
headers.append(spec.direction_column)
|
|
255
|
+
if spec.unpack_index_column:
|
|
256
|
+
headers.append(spec.unpack_index_column)
|
|
257
|
+
|
|
258
|
+
# Parameter columns (pre_ea_params already in headers, not repeated here)
|
|
259
|
+
param_headers: list[str] = list(spec.parameter_names)
|
|
260
|
+
|
|
261
|
+
all_headers = headers + param_headers
|
|
262
|
+
|
|
263
|
+
# --- Row 1: descriptions ---
|
|
264
|
+
ws.cell(row=1, column=1, value="navigate")
|
|
265
|
+
len(headers)
|
|
266
|
+
for col_idx, hdr in enumerate(all_headers, start=1):
|
|
267
|
+
if hdr == "Entity Alternative":
|
|
268
|
+
ws.cell(row=1, column=col_idx, value="Whether the entity is active in this alternative.")
|
|
269
|
+
elif hdr in spec.descriptions:
|
|
270
|
+
ws.cell(row=1, column=col_idx, value=spec.descriptions[hdr])
|
|
271
|
+
|
|
272
|
+
# --- Row 2: headers ---
|
|
273
|
+
for col_idx, hdr in enumerate(all_headers, start=1):
|
|
274
|
+
ws.cell(row=2, column=col_idx, value=hdr)
|
|
275
|
+
|
|
276
|
+
# --- Collect data rows ---
|
|
277
|
+
data_rows: list[list[Any]] = []
|
|
278
|
+
|
|
279
|
+
if spec.unpack_index_column:
|
|
280
|
+
_collect_unpack_rows(data_rows, spec, db_contents, headers, param_headers)
|
|
281
|
+
else:
|
|
282
|
+
_collect_constant_rows(data_rows, spec, db_contents, headers, param_headers)
|
|
283
|
+
|
|
284
|
+
# --- Sort rows ---
|
|
285
|
+
data_rows.sort(key=lambda r: tuple(str(v) if v is not None else "" for v in r))
|
|
286
|
+
|
|
287
|
+
# --- Write data rows ---
|
|
288
|
+
for row_idx, row_data in enumerate(data_rows, start=3):
|
|
289
|
+
for col_idx, value in enumerate(row_data, start=1):
|
|
290
|
+
if value is not None:
|
|
291
|
+
ws.cell(row=row_idx, column=col_idx, value=value)
|
|
292
|
+
|
|
293
|
+
# --- Formatting ---
|
|
294
|
+
n_entity_cols = len(spec.entity_columns)
|
|
295
|
+
n_extra = len(spec.extra_entity_columns) + len(spec.pre_ea_params)
|
|
296
|
+
if spec.has_entity_alternative:
|
|
297
|
+
n_extra += 1
|
|
298
|
+
if spec.direction_column:
|
|
299
|
+
n_extra += 1
|
|
300
|
+
if spec.unpack_index_column:
|
|
301
|
+
n_extra += 1
|
|
302
|
+
|
|
303
|
+
format_constant_sheet(ws, n_entity_cols, n_extra, has_entity_alt=False)
|
|
304
|
+
add_navigate_link(ws)
|
|
305
|
+
auto_column_width(ws)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _collect_constant_rows(
|
|
309
|
+
data_rows: list[list[Any]],
|
|
310
|
+
spec: SheetSpec,
|
|
311
|
+
db_contents: DatabaseContents,
|
|
312
|
+
headers: list[str],
|
|
313
|
+
param_headers: list[str],
|
|
314
|
+
) -> None:
|
|
315
|
+
"""Collect rows for a regular (non-unpack) constant sheet."""
|
|
316
|
+
for entity_class in spec.entity_classes:
|
|
317
|
+
entities = db_contents.entities.get(entity_class, [])
|
|
318
|
+
direction = _get_direction(entity_class, spec)
|
|
319
|
+
|
|
320
|
+
for entity in entities:
|
|
321
|
+
entity_byname = entity["entity_byname"]
|
|
322
|
+
alts = _find_alternatives_for_entity(
|
|
323
|
+
entity_class, entity_byname, spec, db_contents
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
for alt in alts:
|
|
327
|
+
row: list[Any] = [alt]
|
|
328
|
+
|
|
329
|
+
# Entity elements
|
|
330
|
+
for elem in entity_byname:
|
|
331
|
+
row.append(str(_to_native(elem)))
|
|
332
|
+
|
|
333
|
+
# Extra entity columns
|
|
334
|
+
if spec.extra_entity_columns:
|
|
335
|
+
extras = _get_extra_columns_for_entity(
|
|
336
|
+
entity_byname, entity_class, spec, db_contents
|
|
337
|
+
)
|
|
338
|
+
row.extend(extras)
|
|
339
|
+
|
|
340
|
+
# Pre-EA params (e.g. node_type for node_c)
|
|
341
|
+
for pname in spec.pre_ea_params:
|
|
342
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
343
|
+
value = db_contents.parameter_values.get(key)
|
|
344
|
+
if value is not None and _is_scalar(value):
|
|
345
|
+
row.append(_to_native(value))
|
|
346
|
+
else:
|
|
347
|
+
row.append(None)
|
|
348
|
+
|
|
349
|
+
# Entity Alternative (after entity cols + pre-EA, before direction)
|
|
350
|
+
if spec.has_entity_alternative:
|
|
351
|
+
ea_key = (entity_class, entity_byname, alt)
|
|
352
|
+
ea_val = db_contents.entity_alternatives.get(ea_key)
|
|
353
|
+
row.append(ea_val if ea_val is not None else None)
|
|
354
|
+
|
|
355
|
+
# Direction
|
|
356
|
+
if spec.direction_column:
|
|
357
|
+
row.append(direction)
|
|
358
|
+
|
|
359
|
+
# Parameter values — check for Arrays that need expansion
|
|
360
|
+
param_values: list[Any] = []
|
|
361
|
+
has_array = False
|
|
362
|
+
array_param_idx: int | None = None
|
|
363
|
+
array_elements: list[Any] = []
|
|
364
|
+
|
|
365
|
+
for pi, pname in enumerate(spec.parameter_names):
|
|
366
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
367
|
+
value = db_contents.parameter_values.get(key)
|
|
368
|
+
|
|
369
|
+
if value is None:
|
|
370
|
+
param_values.append(None)
|
|
371
|
+
elif _is_map(value):
|
|
372
|
+
# Maps go on _p or _t sheets, not constant
|
|
373
|
+
param_values.append(None)
|
|
374
|
+
elif _is_array(value):
|
|
375
|
+
has_array = True
|
|
376
|
+
array_param_idx = pi
|
|
377
|
+
array_elements = [_to_native(v) for v in value.values]
|
|
378
|
+
param_values.append(None) # placeholder
|
|
379
|
+
elif _is_scalar(value):
|
|
380
|
+
param_values.append(_to_native(value))
|
|
381
|
+
else:
|
|
382
|
+
param_values.append(None)
|
|
383
|
+
|
|
384
|
+
if has_array and array_elements:
|
|
385
|
+
# Expand: one row per array element
|
|
386
|
+
for elem in array_elements:
|
|
387
|
+
expanded_row = list(row)
|
|
388
|
+
expanded_params = list(param_values)
|
|
389
|
+
expanded_params[array_param_idx] = elem
|
|
390
|
+
expanded_row.extend(expanded_params)
|
|
391
|
+
data_rows.append(expanded_row)
|
|
392
|
+
else:
|
|
393
|
+
row.extend(param_values)
|
|
394
|
+
data_rows.append(row)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _collect_unpack_rows(
|
|
398
|
+
data_rows: list[list[Any]],
|
|
399
|
+
spec: SheetSpec,
|
|
400
|
+
db_contents: DatabaseContents,
|
|
401
|
+
headers: list[str],
|
|
402
|
+
param_headers: list[str],
|
|
403
|
+
) -> None:
|
|
404
|
+
"""Collect rows for an unpacked-Map constant sheet (e.g. constraint coefficients)."""
|
|
405
|
+
for entity_class in spec.entity_classes:
|
|
406
|
+
entities = db_contents.entities.get(entity_class, [])
|
|
407
|
+
direction = _get_direction(entity_class, spec)
|
|
408
|
+
|
|
409
|
+
for entity in entities:
|
|
410
|
+
entity_byname = entity["entity_byname"]
|
|
411
|
+
alts = _find_alternatives_for_entity(
|
|
412
|
+
entity_class, entity_byname, spec, db_contents
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
for alt in alts:
|
|
416
|
+
# Collect all map indexes across all unpack params
|
|
417
|
+
all_indexes: set[str] = set()
|
|
418
|
+
param_maps: dict[str, Map] = {}
|
|
419
|
+
|
|
420
|
+
for pname in spec.parameter_names:
|
|
421
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
422
|
+
value = db_contents.parameter_values.get(key)
|
|
423
|
+
if value is not None and _is_map(value):
|
|
424
|
+
param_maps[pname] = value
|
|
425
|
+
for idx in value.indexes:
|
|
426
|
+
all_indexes.add(str(_to_native(idx)))
|
|
427
|
+
|
|
428
|
+
if not all_indexes:
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
for idx_val in sorted(all_indexes):
|
|
432
|
+
row: list[Any] = [alt]
|
|
433
|
+
|
|
434
|
+
# Entity elements
|
|
435
|
+
for elem in entity_byname:
|
|
436
|
+
row.append(str(_to_native(elem)))
|
|
437
|
+
|
|
438
|
+
# Extra entity columns
|
|
439
|
+
if spec.extra_entity_columns:
|
|
440
|
+
extras = _get_extra_columns_for_entity(
|
|
441
|
+
entity_byname, entity_class, spec, db_contents
|
|
442
|
+
)
|
|
443
|
+
row.extend(extras)
|
|
444
|
+
|
|
445
|
+
# Pre-EA params
|
|
446
|
+
for pre_pname in spec.pre_ea_params:
|
|
447
|
+
pre_key = (entity_class, entity_byname, pre_pname, alt)
|
|
448
|
+
pre_val = db_contents.parameter_values.get(pre_key)
|
|
449
|
+
if pre_val is not None and _is_scalar(pre_val):
|
|
450
|
+
row.append(_to_native(pre_val))
|
|
451
|
+
else:
|
|
452
|
+
row.append(None)
|
|
453
|
+
|
|
454
|
+
# Entity Alternative (after entity cols + pre-EA, before direction)
|
|
455
|
+
if spec.has_entity_alternative:
|
|
456
|
+
ea_key = (entity_class, entity_byname, alt)
|
|
457
|
+
ea_val = db_contents.entity_alternatives.get(ea_key)
|
|
458
|
+
row.append(ea_val if ea_val is not None else None)
|
|
459
|
+
|
|
460
|
+
# Direction
|
|
461
|
+
if spec.direction_column:
|
|
462
|
+
row.append(direction)
|
|
463
|
+
|
|
464
|
+
# Unpack index column
|
|
465
|
+
row.append(idx_val)
|
|
466
|
+
|
|
467
|
+
# Parameter values
|
|
468
|
+
for pname in spec.parameter_names:
|
|
469
|
+
m = param_maps.get(pname)
|
|
470
|
+
if m is not None:
|
|
471
|
+
# Find the value at this index
|
|
472
|
+
found = False
|
|
473
|
+
for mi, mv in zip(m.indexes, m.values):
|
|
474
|
+
if str(_to_native(mi)) == idx_val:
|
|
475
|
+
row.append(_to_native(mv))
|
|
476
|
+
found = True
|
|
477
|
+
break
|
|
478
|
+
if not found:
|
|
479
|
+
row.append(None)
|
|
480
|
+
else:
|
|
481
|
+
row.append(None)
|
|
482
|
+
|
|
483
|
+
data_rows.append(row)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ---------------------------------------------------------------------------
|
|
487
|
+
# Periodic sheet
|
|
488
|
+
# ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def write_periodic_sheet(
|
|
492
|
+
ws: Worksheet,
|
|
493
|
+
spec: SheetSpec,
|
|
494
|
+
db_contents: DatabaseContents,
|
|
495
|
+
) -> None:
|
|
496
|
+
"""Write a periodic-layout sheet."""
|
|
497
|
+
# Determine index column name from Map values
|
|
498
|
+
index_col_name = _find_index_column_name(spec, db_contents, default="period")
|
|
499
|
+
|
|
500
|
+
# Build header columns
|
|
501
|
+
headers: list[str] = ["alternative"]
|
|
502
|
+
headers.extend(spec.entity_columns)
|
|
503
|
+
if spec.direction_column:
|
|
504
|
+
headers.append(spec.direction_column)
|
|
505
|
+
headers.append(index_col_name)
|
|
506
|
+
|
|
507
|
+
# Parameter columns (no Entity Alternative on periodic sheets)
|
|
508
|
+
all_headers = headers + list(spec.parameter_names)
|
|
509
|
+
|
|
510
|
+
# --- Row 1: descriptions ---
|
|
511
|
+
ws.cell(row=1, column=1, value="navigate")
|
|
512
|
+
n_fixed = len(headers)
|
|
513
|
+
for i, pname in enumerate(spec.parameter_names):
|
|
514
|
+
col_idx = n_fixed + 1 + i
|
|
515
|
+
if pname in spec.descriptions:
|
|
516
|
+
ws.cell(row=1, column=col_idx, value=spec.descriptions[pname])
|
|
517
|
+
|
|
518
|
+
# --- Row 2: headers ---
|
|
519
|
+
for col_idx, hdr in enumerate(all_headers, start=1):
|
|
520
|
+
ws.cell(row=2, column=col_idx, value=hdr)
|
|
521
|
+
|
|
522
|
+
# --- Collect data rows ---
|
|
523
|
+
data_rows: list[list[Any]] = []
|
|
524
|
+
len(headers) - 1 # 0-based position of index column
|
|
525
|
+
|
|
526
|
+
for entity_class in spec.entity_classes:
|
|
527
|
+
entities = db_contents.entities.get(entity_class, [])
|
|
528
|
+
direction = _get_direction(entity_class, spec)
|
|
529
|
+
|
|
530
|
+
for entity in entities:
|
|
531
|
+
entity_byname = entity["entity_byname"]
|
|
532
|
+
alts = _find_alternatives_for_entity(
|
|
533
|
+
entity_class, entity_byname, spec, db_contents
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
for alt in alts:
|
|
537
|
+
# Collect all index values and map/array data across params
|
|
538
|
+
all_indexes: set[str] = set()
|
|
539
|
+
param_maps: dict[str, Map] = {}
|
|
540
|
+
param_arrays: dict[str, list[str]] = {}
|
|
541
|
+
|
|
542
|
+
for pname in spec.parameter_names:
|
|
543
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
544
|
+
value = db_contents.parameter_values.get(key)
|
|
545
|
+
if value is not None and _is_map(value):
|
|
546
|
+
# Check for nested Map (Map whose values are Maps)
|
|
547
|
+
is_nested = any(_is_map(mv) for mv in value.values)
|
|
548
|
+
if is_nested:
|
|
549
|
+
# Skip nested Maps — they go to solve_period_period
|
|
550
|
+
continue
|
|
551
|
+
# Skip time-indexed Maps — they belong on _t sheets
|
|
552
|
+
if _is_time_indexed_map(value):
|
|
553
|
+
continue
|
|
554
|
+
param_maps[pname] = value
|
|
555
|
+
for idx in value.indexes:
|
|
556
|
+
all_indexes.add(str(_to_native(idx)))
|
|
557
|
+
elif value is not None and _is_array(value):
|
|
558
|
+
# Array = boolean indicator (period names)
|
|
559
|
+
period_names = [str(_to_native(v)) for v in value.values]
|
|
560
|
+
param_arrays[pname] = period_names
|
|
561
|
+
|
|
562
|
+
if not all_indexes:
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
for idx_val in sorted(all_indexes):
|
|
566
|
+
row: list[Any] = [alt]
|
|
567
|
+
|
|
568
|
+
# Entity elements
|
|
569
|
+
for elem in entity_byname:
|
|
570
|
+
row.append(str(_to_native(elem)))
|
|
571
|
+
|
|
572
|
+
# Direction
|
|
573
|
+
if spec.direction_column:
|
|
574
|
+
row.append(direction)
|
|
575
|
+
|
|
576
|
+
# Index column value
|
|
577
|
+
row.append(idx_val)
|
|
578
|
+
|
|
579
|
+
# Parameter values
|
|
580
|
+
for pname in spec.parameter_names:
|
|
581
|
+
m = param_maps.get(pname)
|
|
582
|
+
if m is not None:
|
|
583
|
+
found = False
|
|
584
|
+
for mi, mv in zip(m.indexes, m.values):
|
|
585
|
+
if str(_to_native(mi)) == idx_val:
|
|
586
|
+
row.append(_to_native(mv))
|
|
587
|
+
found = True
|
|
588
|
+
break
|
|
589
|
+
if not found:
|
|
590
|
+
row.append(None)
|
|
591
|
+
elif pname in param_arrays:
|
|
592
|
+
# Boolean: check if current period is in the array
|
|
593
|
+
if idx_val in param_arrays[pname]:
|
|
594
|
+
row.append(True)
|
|
595
|
+
else:
|
|
596
|
+
row.append(None)
|
|
597
|
+
else:
|
|
598
|
+
row.append(None)
|
|
599
|
+
|
|
600
|
+
data_rows.append(row)
|
|
601
|
+
|
|
602
|
+
# --- Sort rows ---
|
|
603
|
+
data_rows.sort(key=lambda r: tuple(str(v) if v is not None else "" for v in r))
|
|
604
|
+
|
|
605
|
+
# --- Write data rows ---
|
|
606
|
+
for row_idx, row_data in enumerate(data_rows, start=3):
|
|
607
|
+
for col_idx, value in enumerate(row_data, start=1):
|
|
608
|
+
if value is not None:
|
|
609
|
+
ws.cell(row=row_idx, column=col_idx, value=value)
|
|
610
|
+
|
|
611
|
+
# --- Formatting ---
|
|
612
|
+
n_entity_cols = len(spec.entity_columns)
|
|
613
|
+
n_extra = 0
|
|
614
|
+
if spec.direction_column:
|
|
615
|
+
n_extra += 1
|
|
616
|
+
n_extra += 1 # index column counts as extra
|
|
617
|
+
|
|
618
|
+
format_periodic_sheet(ws, n_entity_cols, n_extra)
|
|
619
|
+
add_navigate_link(ws)
|
|
620
|
+
auto_column_width(ws)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _find_index_column_name(
|
|
624
|
+
spec: SheetSpec,
|
|
625
|
+
db_contents: DatabaseContents,
|
|
626
|
+
default: str = "period",
|
|
627
|
+
) -> str:
|
|
628
|
+
"""Determine the index column name by scanning Map values for index_name.
|
|
629
|
+
|
|
630
|
+
Ignores the placeholder 'x' from the DB and falls back to the spec's
|
|
631
|
+
``index_name_default`` (from export_settings.yaml) or *default*.
|
|
632
|
+
|
|
633
|
+
Only maps that are actually written on THIS (periodic) sheet are
|
|
634
|
+
counted: nested Maps go to the nested-periodic sheet and time-indexed
|
|
635
|
+
Maps go to the ``_t`` sheet (except for time-structure classes), so
|
|
636
|
+
including them would let a param's stray time-series values out-vote
|
|
637
|
+
the genuine period index and mislabel the column ``index: time`` —
|
|
638
|
+
making every period-indexed value on the sheet round-trip with the
|
|
639
|
+
wrong axis name.
|
|
640
|
+
"""
|
|
641
|
+
fallback = spec.index_name_default or default
|
|
642
|
+
name_counts: dict[str, int] = {}
|
|
643
|
+
|
|
644
|
+
for entity_class in spec.entity_classes:
|
|
645
|
+
for (cls, _byname, pname, _alt), value in db_contents.parameter_values.items():
|
|
646
|
+
if cls == entity_class and pname in spec.parameter_names and _is_map(value):
|
|
647
|
+
if any(_is_map(mv) for mv in value.values):
|
|
648
|
+
continue # nested Map → nested-periodic sheet
|
|
649
|
+
if _is_time_indexed_map(value) and entity_class not in _time_structure_classes:
|
|
650
|
+
continue # time-indexed → _t sheet
|
|
651
|
+
iname = value.index_name
|
|
652
|
+
if iname and iname != "x":
|
|
653
|
+
name_counts[iname] = name_counts.get(iname, 0) + 1
|
|
654
|
+
|
|
655
|
+
if name_counts:
|
|
656
|
+
return max(name_counts, key=name_counts.get) # type: ignore[arg-type]
|
|
657
|
+
return fallback
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# ---------------------------------------------------------------------------
|
|
661
|
+
# Nested periodic sheet (solve_period_period)
|
|
662
|
+
# ---------------------------------------------------------------------------
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def write_nested_periodic_sheet(
|
|
666
|
+
ws: Worksheet,
|
|
667
|
+
spec: SheetSpec,
|
|
668
|
+
db_contents: DatabaseContents,
|
|
669
|
+
) -> None:
|
|
670
|
+
"""Write a nested-periodic-layout sheet for nested Map parameters.
|
|
671
|
+
|
|
672
|
+
Handles parameters whose values are Map-of-Map (outer index =
|
|
673
|
+
current_solve_period, inner Map = periods_included -> 'yes').
|
|
674
|
+
"""
|
|
675
|
+
# Build header columns
|
|
676
|
+
headers: list[str] = ["alternative"]
|
|
677
|
+
headers.extend(spec.entity_columns)
|
|
678
|
+
headers.append("current_solve_period")
|
|
679
|
+
headers.append("periods_included")
|
|
680
|
+
|
|
681
|
+
# Only include params that actually have nested Map values
|
|
682
|
+
nested_params: list[str] = []
|
|
683
|
+
for pname in spec.parameter_names:
|
|
684
|
+
for (cls, _byname, p, _alt), value in db_contents.parameter_values.items():
|
|
685
|
+
if cls in spec.entity_classes and p == pname and _is_map(value):
|
|
686
|
+
if any(_is_map(mv) for mv in value.values):
|
|
687
|
+
nested_params.append(pname)
|
|
688
|
+
break
|
|
689
|
+
|
|
690
|
+
if not nested_params:
|
|
691
|
+
# No nested data — write minimal headers
|
|
692
|
+
ws.cell(row=1, column=1, value="navigate")
|
|
693
|
+
all_headers = headers + list(spec.parameter_names)
|
|
694
|
+
for col_idx, hdr in enumerate(all_headers, start=1):
|
|
695
|
+
ws.cell(row=2, column=col_idx, value=hdr)
|
|
696
|
+
add_navigate_link(ws)
|
|
697
|
+
auto_column_width(ws)
|
|
698
|
+
return
|
|
699
|
+
|
|
700
|
+
all_headers = headers + nested_params
|
|
701
|
+
|
|
702
|
+
# --- Row 1: descriptions ---
|
|
703
|
+
ws.cell(row=1, column=1, value="navigate")
|
|
704
|
+
n_fixed = len(headers)
|
|
705
|
+
for i, pname in enumerate(nested_params):
|
|
706
|
+
col_idx = n_fixed + 1 + i
|
|
707
|
+
if pname in spec.descriptions:
|
|
708
|
+
ws.cell(row=1, column=col_idx, value=spec.descriptions[pname])
|
|
709
|
+
|
|
710
|
+
# --- Row 2: headers ---
|
|
711
|
+
for col_idx, hdr in enumerate(all_headers, start=1):
|
|
712
|
+
ws.cell(row=2, column=col_idx, value=hdr)
|
|
713
|
+
|
|
714
|
+
# --- Collect data rows ---
|
|
715
|
+
data_rows: list[list[Any]] = []
|
|
716
|
+
|
|
717
|
+
for entity_class in spec.entity_classes:
|
|
718
|
+
entities = db_contents.entities.get(entity_class, [])
|
|
719
|
+
|
|
720
|
+
for entity in entities:
|
|
721
|
+
entity_byname = entity["entity_byname"]
|
|
722
|
+
alts = _find_alternatives_for_entity(
|
|
723
|
+
entity_class, entity_byname, spec, db_contents
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
for alt in alts:
|
|
727
|
+
# Collect nested Map data for this entity+alt
|
|
728
|
+
nested_data: dict[str, Map] = {}
|
|
729
|
+
all_outer_indexes: set[str] = set()
|
|
730
|
+
|
|
731
|
+
for pname in nested_params:
|
|
732
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
733
|
+
value = db_contents.parameter_values.get(key)
|
|
734
|
+
if value is not None and _is_map(value):
|
|
735
|
+
if any(_is_map(mv) for mv in value.values):
|
|
736
|
+
nested_data[pname] = value
|
|
737
|
+
for oi in value.indexes:
|
|
738
|
+
outer_str = str(_to_native(oi))
|
|
739
|
+
all_outer_indexes.add(outer_str)
|
|
740
|
+
|
|
741
|
+
if not nested_data:
|
|
742
|
+
continue
|
|
743
|
+
|
|
744
|
+
# Collect all (outer, inner) combos across all params
|
|
745
|
+
row_combos: set[tuple[str, str]] = set()
|
|
746
|
+
for pname, outer_map in nested_data.items():
|
|
747
|
+
for oi, inner_val in zip(outer_map.indexes, outer_map.values):
|
|
748
|
+
outer_str = str(_to_native(oi))
|
|
749
|
+
if _is_map(inner_val):
|
|
750
|
+
for ii in inner_val.indexes:
|
|
751
|
+
inner_str = str(_to_native(ii))
|
|
752
|
+
row_combos.add((outer_str, inner_str))
|
|
753
|
+
|
|
754
|
+
for outer_idx, inner_idx in sorted(row_combos):
|
|
755
|
+
row: list[Any] = [alt]
|
|
756
|
+
|
|
757
|
+
# Entity elements
|
|
758
|
+
for elem in entity_byname:
|
|
759
|
+
row.append(str(_to_native(elem)))
|
|
760
|
+
|
|
761
|
+
# current_solve_period and periods_included
|
|
762
|
+
row.append(outer_idx)
|
|
763
|
+
row.append(inner_idx)
|
|
764
|
+
|
|
765
|
+
# Parameter values
|
|
766
|
+
for pname in nested_params:
|
|
767
|
+
outer_map = nested_data.get(pname)
|
|
768
|
+
if outer_map is not None:
|
|
769
|
+
# Find the inner map at this outer index
|
|
770
|
+
found = False
|
|
771
|
+
for oi, inner_val in zip(outer_map.indexes, outer_map.values):
|
|
772
|
+
if str(_to_native(oi)) == outer_idx and _is_map(inner_val):
|
|
773
|
+
# Find value at inner index
|
|
774
|
+
for ii, iv in zip(inner_val.indexes, inner_val.values):
|
|
775
|
+
if str(_to_native(ii)) == inner_idx:
|
|
776
|
+
row.append(_to_native(iv))
|
|
777
|
+
found = True
|
|
778
|
+
break
|
|
779
|
+
break
|
|
780
|
+
if not found:
|
|
781
|
+
row.append(None)
|
|
782
|
+
else:
|
|
783
|
+
row.append(None)
|
|
784
|
+
|
|
785
|
+
data_rows.append(row)
|
|
786
|
+
|
|
787
|
+
# --- Sort rows ---
|
|
788
|
+
data_rows.sort(key=lambda r: tuple(str(v) if v is not None else "" for v in r))
|
|
789
|
+
|
|
790
|
+
# --- Write data rows ---
|
|
791
|
+
for row_idx, row_data in enumerate(data_rows, start=3):
|
|
792
|
+
for col_idx, value in enumerate(row_data, start=1):
|
|
793
|
+
if value is not None:
|
|
794
|
+
ws.cell(row=row_idx, column=col_idx, value=value)
|
|
795
|
+
|
|
796
|
+
# --- Formatting ---
|
|
797
|
+
n_entity_cols = len(spec.entity_columns)
|
|
798
|
+
n_extra = 2 # current_solve_period + periods_included
|
|
799
|
+
|
|
800
|
+
format_periodic_sheet(ws, n_entity_cols, n_extra)
|
|
801
|
+
add_navigate_link(ws)
|
|
802
|
+
auto_column_width(ws)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def write_nested_periodic_sheet_v2(
|
|
806
|
+
ws: Worksheet,
|
|
807
|
+
spec: SheetSpec,
|
|
808
|
+
db_contents: DatabaseContents,
|
|
809
|
+
) -> None:
|
|
810
|
+
"""Write a nested-periodic-layout sheet in v2 self-describing format.
|
|
811
|
+
|
|
812
|
+
Layout (solve_period_period):
|
|
813
|
+
Row 1: navigate | | | | description | data type | ...
|
|
814
|
+
Row 2: | | | | data type | string/float (2d-map) | ...
|
|
815
|
+
Row 3: alternative | entity: solve | index: current_solve_period | index: periods_included | parameter | param1 | ...
|
|
816
|
+
Row 4+: data...
|
|
817
|
+
"""
|
|
818
|
+
# Only include params that actually have nested Map values
|
|
819
|
+
nested_params: list[str] = []
|
|
820
|
+
for pname in spec.parameter_names:
|
|
821
|
+
for (cls, _byname, p, _alt), value in db_contents.parameter_values.items():
|
|
822
|
+
if cls in spec.entity_classes and p == pname and _is_map(value):
|
|
823
|
+
if any(_is_map(mv) for mv in value.values):
|
|
824
|
+
nested_params.append(pname)
|
|
825
|
+
break
|
|
826
|
+
|
|
827
|
+
if not nested_params:
|
|
828
|
+
nested_params = list(spec.parameter_names)
|
|
829
|
+
|
|
830
|
+
# Build left-side columns
|
|
831
|
+
left_cols: list[str] = ["alternative"]
|
|
832
|
+
entity_label = _build_entity_def_label(spec)
|
|
833
|
+
left_cols.append(entity_label)
|
|
834
|
+
left_cols.append("index: current_solve_period")
|
|
835
|
+
left_cols.append("index: periods_included")
|
|
836
|
+
|
|
837
|
+
def_col = len(left_cols) + 1 # 1-based
|
|
838
|
+
|
|
839
|
+
# Right-side columns
|
|
840
|
+
right_cols: list[str] = ["parameter"]
|
|
841
|
+
right_cols.extend(nested_params)
|
|
842
|
+
|
|
843
|
+
# --- Row 1: descriptions ---
|
|
844
|
+
ws.cell(row=1, column=def_col, value="description")
|
|
845
|
+
ws.cell(row=1, column=def_col).fill = FILL_DESC_ROW
|
|
846
|
+
ws.cell(row=1, column=def_col).font = FONT_DESC_ROW
|
|
847
|
+
for i, pname in enumerate(nested_params):
|
|
848
|
+
col = def_col + 1 + i
|
|
849
|
+
desc = _get_param_description(pname, spec.entity_classes, db_contents)
|
|
850
|
+
if desc:
|
|
851
|
+
ws.cell(row=1, column=col, value=desc)
|
|
852
|
+
ws.cell(row=1, column=col).fill = FILL_DESC_DATA
|
|
853
|
+
ws.cell(row=1, column=col).font = FONT_DESC_DATA
|
|
854
|
+
|
|
855
|
+
# --- Row 2: data types ---
|
|
856
|
+
ws.cell(row=2, column=def_col, value="data type")
|
|
857
|
+
ws.cell(row=2, column=def_col).fill = FILL_DESC_ROW
|
|
858
|
+
ws.cell(row=2, column=def_col).font = FONT_DESC_ROW
|
|
859
|
+
for i, pname in enumerate(nested_params):
|
|
860
|
+
col = def_col + 1 + i
|
|
861
|
+
dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout="nested_periodic")
|
|
862
|
+
ws.cell(row=2, column=col, value=dtype)
|
|
863
|
+
ws.cell(row=2, column=col).fill = FILL_DESC_DATA
|
|
864
|
+
ws.cell(row=2, column=col).font = FONT_DESC_DATA
|
|
865
|
+
|
|
866
|
+
# --- Optional row 3: defaults (only when at least one param has one) ---
|
|
867
|
+
param_defaults = {
|
|
868
|
+
pname: _get_param_default_string(pname, spec.entity_classes, db_contents)
|
|
869
|
+
for pname in nested_params
|
|
870
|
+
}
|
|
871
|
+
has_default_row = any(v is not None for v in param_defaults.values())
|
|
872
|
+
if has_default_row:
|
|
873
|
+
default_row = 3
|
|
874
|
+
ws.cell(row=default_row, column=def_col, value="default")
|
|
875
|
+
ws.cell(row=default_row, column=def_col).fill = FILL_DESC_ROW
|
|
876
|
+
ws.cell(row=default_row, column=def_col).font = FONT_DESC_ROW
|
|
877
|
+
for i, pname in enumerate(nested_params):
|
|
878
|
+
col = def_col + 1 + i
|
|
879
|
+
dv = param_defaults[pname]
|
|
880
|
+
if dv is not None:
|
|
881
|
+
ws.cell(row=default_row, column=col, value=dv)
|
|
882
|
+
ws.cell(row=default_row, column=col).fill = FILL_DESC_DATA
|
|
883
|
+
ws.cell(row=default_row, column=col).font = FONT_DESC_DATA
|
|
884
|
+
def_row = 4
|
|
885
|
+
else:
|
|
886
|
+
def_row = 3
|
|
887
|
+
data_start_row = def_row + 1
|
|
888
|
+
|
|
889
|
+
# --- Definition row ---
|
|
890
|
+
for col_idx, label in enumerate(left_cols, start=1):
|
|
891
|
+
ws.cell(row=def_row, column=col_idx, value=label)
|
|
892
|
+
for col_idx, label in enumerate(right_cols, start=def_col):
|
|
893
|
+
ws.cell(row=def_row, column=col_idx, value=label)
|
|
894
|
+
|
|
895
|
+
# --- Collect data rows ---
|
|
896
|
+
data_rows: list[list[Any]] = []
|
|
897
|
+
|
|
898
|
+
for entity_class in spec.entity_classes:
|
|
899
|
+
entities = db_contents.entities.get(entity_class, [])
|
|
900
|
+
|
|
901
|
+
for entity in entities:
|
|
902
|
+
entity_byname = entity["entity_byname"]
|
|
903
|
+
alts = _find_alternatives_for_entity(
|
|
904
|
+
entity_class, entity_byname, spec, db_contents
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
for alt in alts:
|
|
908
|
+
nested_data: dict[str, Map] = {}
|
|
909
|
+
for pname in nested_params:
|
|
910
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
911
|
+
value = db_contents.parameter_values.get(key)
|
|
912
|
+
if value is not None and _is_map(value):
|
|
913
|
+
if any(_is_map(mv) for mv in value.values):
|
|
914
|
+
nested_data[pname] = value
|
|
915
|
+
|
|
916
|
+
if not nested_data:
|
|
917
|
+
continue
|
|
918
|
+
|
|
919
|
+
row_combos: set[tuple[str, str]] = set()
|
|
920
|
+
for pname, outer_map in nested_data.items():
|
|
921
|
+
for oi, inner_val in zip(outer_map.indexes, outer_map.values):
|
|
922
|
+
outer_str = str(_to_native(oi))
|
|
923
|
+
if _is_map(inner_val):
|
|
924
|
+
for ii in inner_val.indexes:
|
|
925
|
+
inner_str = str(_to_native(ii))
|
|
926
|
+
row_combos.add((outer_str, inner_str))
|
|
927
|
+
|
|
928
|
+
for outer_idx, inner_idx in sorted(row_combos):
|
|
929
|
+
row: list[Any] = [alt]
|
|
930
|
+
for elem in entity_byname:
|
|
931
|
+
row.append(str(_to_native(elem)))
|
|
932
|
+
row.append(outer_idx)
|
|
933
|
+
row.append(inner_idx)
|
|
934
|
+
row.append(None) # def column
|
|
935
|
+
|
|
936
|
+
for pname in nested_params:
|
|
937
|
+
outer_map = nested_data.get(pname)
|
|
938
|
+
if outer_map is not None:
|
|
939
|
+
found = False
|
|
940
|
+
for oi, inner_val in zip(outer_map.indexes, outer_map.values):
|
|
941
|
+
if str(_to_native(oi)) == outer_idx and _is_map(inner_val):
|
|
942
|
+
for ii, iv in zip(inner_val.indexes, inner_val.values):
|
|
943
|
+
if str(_to_native(ii)) == inner_idx:
|
|
944
|
+
row.append(_to_native(iv))
|
|
945
|
+
found = True
|
|
946
|
+
break
|
|
947
|
+
break
|
|
948
|
+
if not found:
|
|
949
|
+
row.append(None)
|
|
950
|
+
else:
|
|
951
|
+
row.append(None)
|
|
952
|
+
|
|
953
|
+
data_rows.append(row)
|
|
954
|
+
|
|
955
|
+
data_rows.sort(key=lambda r: tuple(str(v) if v is not None else "" for v in r))
|
|
956
|
+
|
|
957
|
+
# --- Write data rows ---
|
|
958
|
+
for row_idx, row_data in enumerate(data_rows, start=data_start_row):
|
|
959
|
+
for col_idx, value in enumerate(row_data, start=1):
|
|
960
|
+
if value is not None:
|
|
961
|
+
ws.cell(row=row_idx, column=col_idx, value=value)
|
|
962
|
+
|
|
963
|
+
# --- Reference section ---
|
|
964
|
+
last_data_col = def_col + len(right_cols) - 1
|
|
965
|
+
_write_param_reference(
|
|
966
|
+
ws, last_data_col + 1, spec, db_contents, header_row=def_row,
|
|
967
|
+
n_data_rows=len(data_rows), layout="nested_periodic",
|
|
968
|
+
shown_params=nested_params,
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
# --- Formatting ---
|
|
972
|
+
n_entity_cols = len(spec.entity_columns)
|
|
973
|
+
index_col_positions = {3, 4} # current_solve_period and periods_included
|
|
974
|
+
last_data_col_nested = def_col + len(right_cols) - 1
|
|
975
|
+
format_constant_sheet_v2(
|
|
976
|
+
ws, n_entity_cols, 2, def_col, index_col_positions,
|
|
977
|
+
last_data_col_nested, def_row=def_row,
|
|
978
|
+
)
|
|
979
|
+
add_navigate_link(ws)
|
|
980
|
+
auto_column_width(ws, min_param_width=_min_param_width,
|
|
981
|
+
non_param_width=_non_param_width,
|
|
982
|
+
def_col_width=_def_col_width,
|
|
983
|
+
index_col_width=_index_col_width,
|
|
984
|
+
header_row=def_row, def_col=def_col,
|
|
985
|
+
index_cols=index_col_positions)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
# ---------------------------------------------------------------------------
|
|
989
|
+
# Timeseries sheet
|
|
990
|
+
# ---------------------------------------------------------------------------
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def write_timeseries_sheet(
|
|
994
|
+
ws: Worksheet,
|
|
995
|
+
spec: SheetSpec,
|
|
996
|
+
db_contents: DatabaseContents,
|
|
997
|
+
) -> None:
|
|
998
|
+
"""Write a transposed timeseries-layout sheet."""
|
|
999
|
+
# Collect all data columns: each is (entity_class, entity_byname, param, alt, Map)
|
|
1000
|
+
columns: list[tuple[str, tuple, str, str, Map]] = []
|
|
1001
|
+
all_time_indexes: set[str] = set()
|
|
1002
|
+
|
|
1003
|
+
for entity_class in spec.entity_classes:
|
|
1004
|
+
entities = db_contents.entities.get(entity_class, [])
|
|
1005
|
+
|
|
1006
|
+
for entity in entities:
|
|
1007
|
+
entity_byname = entity["entity_byname"]
|
|
1008
|
+
|
|
1009
|
+
for pname in spec.parameter_names:
|
|
1010
|
+
for alt in db_contents.alternatives:
|
|
1011
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
1012
|
+
value = db_contents.parameter_values.get(key)
|
|
1013
|
+
if value is not None and _is_map(value):
|
|
1014
|
+
# Only include time-indexed Maps on timeseries sheets
|
|
1015
|
+
if not _is_time_indexed_map(value):
|
|
1016
|
+
continue
|
|
1017
|
+
columns.append(
|
|
1018
|
+
(entity_class, entity_byname, pname, alt, value)
|
|
1019
|
+
)
|
|
1020
|
+
for idx in value.indexes:
|
|
1021
|
+
all_time_indexes.add(str(_to_native(idx)))
|
|
1022
|
+
|
|
1023
|
+
if not columns:
|
|
1024
|
+
# Write minimal headers even if no data
|
|
1025
|
+
ws.cell(row=1, column=1, value="navigate")
|
|
1026
|
+
ws.cell(row=1, column=2, value="alternative")
|
|
1027
|
+
ws.cell(row=2, column=2, value="parameter")
|
|
1028
|
+
add_navigate_link(ws)
|
|
1029
|
+
auto_column_width(ws)
|
|
1030
|
+
return
|
|
1031
|
+
|
|
1032
|
+
# Sort columns by (alt, entity_byname, param)
|
|
1033
|
+
columns.sort(key=lambda c: (c[3], c[1], c[2]))
|
|
1034
|
+
|
|
1035
|
+
# Sort time indexes
|
|
1036
|
+
sorted_times = sorted(all_time_indexes)
|
|
1037
|
+
|
|
1038
|
+
# Determine header structure
|
|
1039
|
+
n_entity_dims = len(spec.entity_columns)
|
|
1040
|
+
has_direction = spec.direction_column is not None
|
|
1041
|
+
|
|
1042
|
+
# n_header_rows = 2 (alt, param) + n_entity_dims + (1 if direction)
|
|
1043
|
+
n_header_rows = 2 + n_entity_dims
|
|
1044
|
+
if has_direction:
|
|
1045
|
+
n_header_rows += 1
|
|
1046
|
+
|
|
1047
|
+
# --- Write header rows ---
|
|
1048
|
+
# Row 1: 'navigate' | 'alternative' | alt values...
|
|
1049
|
+
ws.cell(row=1, column=1, value="navigate")
|
|
1050
|
+
ws.cell(row=1, column=2, value="alternative")
|
|
1051
|
+
for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
|
|
1052
|
+
ws.cell(row=1, column=col_idx, value=alt)
|
|
1053
|
+
|
|
1054
|
+
# Row 2: '' | 'parameter' | param names...
|
|
1055
|
+
ws.cell(row=2, column=2, value="parameter")
|
|
1056
|
+
for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
|
|
1057
|
+
ws.cell(row=2, column=col_idx, value=pname)
|
|
1058
|
+
|
|
1059
|
+
# Entity dimension rows
|
|
1060
|
+
for dim_idx in range(n_entity_dims):
|
|
1061
|
+
row = 3 + dim_idx
|
|
1062
|
+
dim_label = spec.entity_columns[dim_idx]
|
|
1063
|
+
ws.cell(row=row, column=2, value=dim_label)
|
|
1064
|
+
for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
|
|
1065
|
+
if dim_idx < len(byname):
|
|
1066
|
+
ws.cell(row=row, column=col_idx, value=str(_to_native(byname[dim_idx])))
|
|
1067
|
+
|
|
1068
|
+
# Direction row (if applicable)
|
|
1069
|
+
if has_direction:
|
|
1070
|
+
dir_row = 2 + n_entity_dims + 1
|
|
1071
|
+
ws.cell(row=dir_row, column=2, value=spec.direction_column)
|
|
1072
|
+
for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
|
|
1073
|
+
direction = _get_direction(ec, spec)
|
|
1074
|
+
if direction:
|
|
1075
|
+
ws.cell(row=dir_row, column=col_idx, value=direction)
|
|
1076
|
+
|
|
1077
|
+
# 'time' label on the last header row, column A
|
|
1078
|
+
ws.cell(row=n_header_rows, column=1, value="time")
|
|
1079
|
+
|
|
1080
|
+
# --- Write time data rows ---
|
|
1081
|
+
# Build index lookup for each column's Map for fast access
|
|
1082
|
+
col_index_maps: list[dict[str, Any]] = []
|
|
1083
|
+
for ec, byname, pname, alt, m in columns:
|
|
1084
|
+
idx_map: dict[str, Any] = {}
|
|
1085
|
+
for mi, mv in zip(m.indexes, m.values):
|
|
1086
|
+
idx_map[str(_to_native(mi))] = _to_native(mv)
|
|
1087
|
+
col_index_maps.append(idx_map)
|
|
1088
|
+
|
|
1089
|
+
for time_row_idx, time_val in enumerate(sorted_times):
|
|
1090
|
+
row = n_header_rows + 1 + time_row_idx
|
|
1091
|
+
ws.cell(row=row, column=1, value=time_val)
|
|
1092
|
+
for col_idx, idx_map in enumerate(col_index_maps):
|
|
1093
|
+
value = idx_map.get(time_val)
|
|
1094
|
+
if value is not None:
|
|
1095
|
+
ws.cell(row=row, column=col_idx + 3, value=value)
|
|
1096
|
+
|
|
1097
|
+
# --- Formatting ---
|
|
1098
|
+
format_timeseries_sheet(ws, n_header_rows)
|
|
1099
|
+
add_navigate_link(ws)
|
|
1100
|
+
auto_column_width(ws)
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
# ---------------------------------------------------------------------------
|
|
1104
|
+
# Link sheet
|
|
1105
|
+
# ---------------------------------------------------------------------------
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def write_link_sheet(
|
|
1109
|
+
ws: Worksheet,
|
|
1110
|
+
spec: SheetSpec,
|
|
1111
|
+
db_contents: DatabaseContents,
|
|
1112
|
+
) -> None:
|
|
1113
|
+
"""Write a link-only (relationship) sheet."""
|
|
1114
|
+
# Row 1: headers = entity dimension names
|
|
1115
|
+
for col_idx, col_name in enumerate(spec.entity_columns, start=1):
|
|
1116
|
+
ws.cell(row=1, column=col_idx, value=col_name)
|
|
1117
|
+
|
|
1118
|
+
# Collect and sort entity rows
|
|
1119
|
+
entity_class = spec.entity_classes[0] if spec.entity_classes else None
|
|
1120
|
+
if not entity_class:
|
|
1121
|
+
format_link_sheet(ws)
|
|
1122
|
+
auto_column_width(ws)
|
|
1123
|
+
return
|
|
1124
|
+
|
|
1125
|
+
entities = db_contents.entities.get(entity_class, [])
|
|
1126
|
+
rows: list[tuple] = []
|
|
1127
|
+
for entity in entities:
|
|
1128
|
+
byname = entity["entity_byname"]
|
|
1129
|
+
rows.append(tuple(str(_to_native(v)) for v in byname))
|
|
1130
|
+
|
|
1131
|
+
rows.sort()
|
|
1132
|
+
|
|
1133
|
+
for row_idx, row_data in enumerate(rows, start=2):
|
|
1134
|
+
for col_idx, value in enumerate(row_data, start=1):
|
|
1135
|
+
ws.cell(row=row_idx, column=col_idx, value=value)
|
|
1136
|
+
|
|
1137
|
+
format_link_sheet(ws)
|
|
1138
|
+
auto_column_width(ws)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
# ---------------------------------------------------------------------------
|
|
1142
|
+
# Scenario sheet
|
|
1143
|
+
# ---------------------------------------------------------------------------
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def write_scenario_sheet(
|
|
1147
|
+
ws: Worksheet,
|
|
1148
|
+
db_contents: DatabaseContents,
|
|
1149
|
+
include_stochastics: bool = False,
|
|
1150
|
+
) -> None:
|
|
1151
|
+
"""Write the scenario sheet with formatting.
|
|
1152
|
+
|
|
1153
|
+
Layout:
|
|
1154
|
+
Row 1: navigate | Scenario names
|
|
1155
|
+
Row 2: index: | scenario_1 | scenario_2 | ...
|
|
1156
|
+
Row 3: base_alternative | alt | alt | ...
|
|
1157
|
+
Row 4-33: alternative_1..30 | ...
|
|
1158
|
+
"""
|
|
1159
|
+
scenarios = db_contents.scenarios
|
|
1160
|
+
font_dark = Font(color=Color(theme=1, tint=0.0))
|
|
1161
|
+
|
|
1162
|
+
# Row 1: 'navigate' | 'Scenario names'
|
|
1163
|
+
cell_b1 = ws.cell(row=1, column=2, value="Scenario names")
|
|
1164
|
+
cell_b1.font = font_dark
|
|
1165
|
+
|
|
1166
|
+
# Row 2: 'index:' | scenario names
|
|
1167
|
+
ws.cell(row=2, column=1, value="index:")
|
|
1168
|
+
for col_idx, sc in enumerate(scenarios, start=2):
|
|
1169
|
+
cell = ws.cell(row=2, column=col_idx, value=sc["name"])
|
|
1170
|
+
cell.fill = FILL_ENTITY_HEADER
|
|
1171
|
+
cell.font = font_dark
|
|
1172
|
+
|
|
1173
|
+
# Row 3: 'base_alternative' | alt names (rank 0)
|
|
1174
|
+
cell_a3 = ws.cell(row=3, column=1, value="base_alternative")
|
|
1175
|
+
cell_a3.fill = FILL_PARAM_HEADER
|
|
1176
|
+
cell_a3.font = font_dark
|
|
1177
|
+
for col_idx, sc in enumerate(scenarios, start=2):
|
|
1178
|
+
alts = sc.get("alternatives", [])
|
|
1179
|
+
if alts:
|
|
1180
|
+
cell = ws.cell(row=3, column=col_idx, value=alts[0][0])
|
|
1181
|
+
cell.font = font_dark
|
|
1182
|
+
|
|
1183
|
+
# Rows 4-33: alternative_1..30 — autofill to 30
|
|
1184
|
+
max_alts = max((len(sc.get("alternatives", [])) for sc in scenarios), default=0)
|
|
1185
|
+
total_alt_rows = max(30, max_alts)
|
|
1186
|
+
|
|
1187
|
+
for alt_idx in range(1, total_alt_rows + 1):
|
|
1188
|
+
row = 3 + alt_idx
|
|
1189
|
+
cell_label = ws.cell(row=row, column=1, value=f"alternative_{alt_idx}")
|
|
1190
|
+
cell_label.fill = FILL_PARAM_HEADER
|
|
1191
|
+
cell_label.font = font_dark
|
|
1192
|
+
for col_idx, sc in enumerate(scenarios, start=2):
|
|
1193
|
+
alts = sc.get("alternatives", [])
|
|
1194
|
+
if alt_idx < len(alts):
|
|
1195
|
+
cell = ws.cell(row=row, column=col_idx, value=alts[alt_idx][0])
|
|
1196
|
+
cell.font = font_dark
|
|
1197
|
+
|
|
1198
|
+
add_navigate_link(ws)
|
|
1199
|
+
auto_column_width(ws)
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
# ---------------------------------------------------------------------------
|
|
1203
|
+
# Version sheet
|
|
1204
|
+
# ---------------------------------------------------------------------------
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def write_version_sheet(
|
|
1208
|
+
ws: Worksheet,
|
|
1209
|
+
version: float | None,
|
|
1210
|
+
) -> None:
|
|
1211
|
+
"""Write the version sheet."""
|
|
1212
|
+
if version is not None:
|
|
1213
|
+
version_int = int(version) if version == int(version) else version
|
|
1214
|
+
ws.cell(row=1, column=1, value=f"Generated from FlexTool sqlite version: {version_int}")
|
|
1215
|
+
else:
|
|
1216
|
+
ws.cell(row=1, column=1, value="Generated from FlexTool sqlite")
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
# ---------------------------------------------------------------------------
|
|
1220
|
+
# Array-transposed sheet (model_solve_sequence, model_periods_available)
|
|
1221
|
+
# ---------------------------------------------------------------------------
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def write_array_transposed_sheet_v2(
|
|
1225
|
+
ws: Worksheet,
|
|
1226
|
+
spec: SheetSpec,
|
|
1227
|
+
db_contents: DatabaseContents,
|
|
1228
|
+
) -> None:
|
|
1229
|
+
"""Write an array-parameter sheet in transposed layout.
|
|
1230
|
+
|
|
1231
|
+
Like profile_t but for array parameters: columns are (entity, alt)
|
|
1232
|
+
combinations, rows are array index positions.
|
|
1233
|
+
|
|
1234
|
+
Layout:
|
|
1235
|
+
Row 1: navigate | parameter: solves | data type: boolean (array) | description: ...
|
|
1236
|
+
Row 2: INFO: ... (optional, from _info_rows setting)
|
|
1237
|
+
Row N: | entity: model | flexTool | flexTool | ...
|
|
1238
|
+
Row N+1: | alternative | init | 5weeks | ...
|
|
1239
|
+
Row N+2: index: | 0 | solve_a | solve_b | ...
|
|
1240
|
+
...autofill to 30
|
|
1241
|
+
"""
|
|
1242
|
+
if not spec.parameter_names:
|
|
1243
|
+
add_navigate_link(ws)
|
|
1244
|
+
return
|
|
1245
|
+
|
|
1246
|
+
pname = spec.parameter_names[0]
|
|
1247
|
+
entity_label = _build_entity_def_label(spec)
|
|
1248
|
+
dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout="constant")
|
|
1249
|
+
desc = _get_param_description(pname, spec.entity_classes, db_contents)
|
|
1250
|
+
|
|
1251
|
+
# Collect data: (entity_byname, alt) → array values
|
|
1252
|
+
columns: list[tuple[str, tuple, list[Any]]] = [] # (alt, entity_byname, values)
|
|
1253
|
+
max_array_len = 0
|
|
1254
|
+
|
|
1255
|
+
for entity_class in spec.entity_classes:
|
|
1256
|
+
for entity in db_contents.entities.get(entity_class, []):
|
|
1257
|
+
entity_byname = entity["entity_byname"]
|
|
1258
|
+
for alt in db_contents.alternatives:
|
|
1259
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
1260
|
+
value = db_contents.parameter_values.get(key)
|
|
1261
|
+
if value is not None and _is_array(value):
|
|
1262
|
+
arr = [_to_native(v) for v in value.values]
|
|
1263
|
+
columns.append((alt, entity_byname, arr))
|
|
1264
|
+
if len(arr) > max_array_len:
|
|
1265
|
+
max_array_len = len(arr)
|
|
1266
|
+
elif value is not None and _is_scalar(value):
|
|
1267
|
+
# String value = single-element array
|
|
1268
|
+
columns.append((alt, entity_byname, [_to_native(value)]))
|
|
1269
|
+
if 1 > max_array_len:
|
|
1270
|
+
max_array_len = 1
|
|
1271
|
+
|
|
1272
|
+
columns.sort(key=lambda c: (c[0], c[1]))
|
|
1273
|
+
|
|
1274
|
+
cur_row = 1
|
|
1275
|
+
|
|
1276
|
+
# INFO row (starts from definition column B, not A)
|
|
1277
|
+
info_text = _info_rows.get(spec.sheet_name)
|
|
1278
|
+
if not info_text:
|
|
1279
|
+
info_text = "INFO: Add new data columns as needed by right clicking on the column name and then selecting 'Insert columns before'."
|
|
1280
|
+
if info_text:
|
|
1281
|
+
ws.cell(row=cur_row, column=2, value=info_text)
|
|
1282
|
+
cur_row += 1
|
|
1283
|
+
|
|
1284
|
+
# Triplet row (parameter info)
|
|
1285
|
+
triplet = f"parameter: {pname} | data type: {dtype}"
|
|
1286
|
+
if desc:
|
|
1287
|
+
triplet += f" | description: {desc}"
|
|
1288
|
+
default_str = _get_param_default_string(pname, spec.entity_classes, db_contents)
|
|
1289
|
+
if default_str is not None:
|
|
1290
|
+
triplet += f" | default: {default_str}"
|
|
1291
|
+
ws.cell(row=cur_row, column=2, value=triplet)
|
|
1292
|
+
cur_row += 1
|
|
1293
|
+
|
|
1294
|
+
# Entity row
|
|
1295
|
+
ws.cell(row=cur_row, column=2, value=entity_label)
|
|
1296
|
+
for col_idx, (alt, byname, _arr) in enumerate(columns, start=3):
|
|
1297
|
+
ws.cell(row=cur_row, column=col_idx, value=str(_to_native(byname[0])))
|
|
1298
|
+
cur_row += 1
|
|
1299
|
+
|
|
1300
|
+
# Alternative row — this is the last header row; put index: label in A
|
|
1301
|
+
ws.cell(row=cur_row, column=1, value="index:")
|
|
1302
|
+
ws.cell(row=cur_row, column=2, value="alternative")
|
|
1303
|
+
for col_idx, (alt, byname, _arr) in enumerate(columns, start=3):
|
|
1304
|
+
ws.cell(row=cur_row, column=col_idx, value=alt)
|
|
1305
|
+
cur_row += 1
|
|
1306
|
+
|
|
1307
|
+
# Data rows — index numbers in column A, values in columns C+
|
|
1308
|
+
n_index_rows = max(30, max_array_len)
|
|
1309
|
+
index_start_row = cur_row
|
|
1310
|
+
|
|
1311
|
+
for idx in range(n_index_rows):
|
|
1312
|
+
row = index_start_row + idx
|
|
1313
|
+
ws.cell(row=row, column=1, value=idx)
|
|
1314
|
+
for col_idx, (alt, byname, arr) in enumerate(columns, start=3):
|
|
1315
|
+
if idx < len(arr):
|
|
1316
|
+
ws.cell(row=row, column=col_idx, value=arr[idx])
|
|
1317
|
+
|
|
1318
|
+
# Formatting — build row_types based on actual row positions
|
|
1319
|
+
n_header_rows = index_start_row - 1
|
|
1320
|
+
row_types: dict[int, str] = {}
|
|
1321
|
+
r = 1
|
|
1322
|
+
if info_text:
|
|
1323
|
+
# INFO row has no special formatting type (plain text)
|
|
1324
|
+
r += 1
|
|
1325
|
+
row_types[r] = "param_info" # triplet row
|
|
1326
|
+
r += 1
|
|
1327
|
+
row_types[r] = "entity"
|
|
1328
|
+
r += 1
|
|
1329
|
+
row_types[r] = "alternative"
|
|
1330
|
+
|
|
1331
|
+
arr_last_data_col = max(len(columns) + 2, 3)
|
|
1332
|
+
format_timeseries_sheet_v2(ws, n_header_rows, single_param=True,
|
|
1333
|
+
row_types=row_types, last_data_col=arr_last_data_col)
|
|
1334
|
+
|
|
1335
|
+
# Column A: green index fill (header + data + 100 extra rows)
|
|
1336
|
+
from openpyxl.formatting.rule import CellIsRule
|
|
1337
|
+
extend_end = index_start_row + n_index_rows + 100
|
|
1338
|
+
ws.cell(row=n_header_rows, column=1).fill = FILL_INDEX_HEADER
|
|
1339
|
+
ws.conditional_formatting.add(
|
|
1340
|
+
f"A{index_start_row}:A{extend_end}",
|
|
1341
|
+
CellIsRule(operator="notEqual", formula=['"§§§NEVER§§§"'],
|
|
1342
|
+
fill=FILL_INDEX_DATA),
|
|
1343
|
+
)
|
|
1344
|
+
# Column B: grey definition column fill for data rows
|
|
1345
|
+
ws.conditional_formatting.add(
|
|
1346
|
+
f"B{index_start_row}:B{extend_end}",
|
|
1347
|
+
CellIsRule(operator="notEqual", formula=['"§§§NEVER§§§"'],
|
|
1348
|
+
fill=FILL_DEF_COL),
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
add_navigate_link(ws) # always A1
|
|
1352
|
+
auto_column_width(ws)
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
# ---------------------------------------------------------------------------
|
|
1356
|
+
# Stochastic sheet (_s)
|
|
1357
|
+
# ---------------------------------------------------------------------------
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _flatten_nested_map(
|
|
1361
|
+
value: Any,
|
|
1362
|
+
depth: int,
|
|
1363
|
+
prefix: tuple = (),
|
|
1364
|
+
) -> list[tuple[tuple[str, ...], Any]]:
|
|
1365
|
+
"""Flatten a nested Map into (index_tuple, scalar_value) pairs.
|
|
1366
|
+
|
|
1367
|
+
For a 3d Map (Map of Map of Map), returns tuples of length 3.
|
|
1368
|
+
For a 4d Map, length 4, etc.
|
|
1369
|
+
"""
|
|
1370
|
+
if depth <= 1 or not _is_map(value):
|
|
1371
|
+
if _is_map(value):
|
|
1372
|
+
# Deepest level — expand to individual entries
|
|
1373
|
+
return [(prefix + (str(_to_native(idx)),), _to_native(val))
|
|
1374
|
+
for idx, val in zip(value.indexes, value.values)]
|
|
1375
|
+
else:
|
|
1376
|
+
return [(prefix, _to_native(value))]
|
|
1377
|
+
|
|
1378
|
+
result: list[tuple[tuple[str, ...], Any]] = []
|
|
1379
|
+
for idx, inner in zip(value.indexes, value.values):
|
|
1380
|
+
idx_str = str(_to_native(idx))
|
|
1381
|
+
result.extend(_flatten_nested_map(inner, depth - 1, prefix + (idx_str,)))
|
|
1382
|
+
return result
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def _get_nested_index_names(value: Any, depth: int) -> list[str]:
|
|
1386
|
+
"""Extract index names from each level of a nested Map.
|
|
1387
|
+
|
|
1388
|
+
Falls back to stochastic_index_names defaults from settings when
|
|
1389
|
+
the DB stores 'x' or empty index names.
|
|
1390
|
+
"""
|
|
1391
|
+
names: list[str] = []
|
|
1392
|
+
current = value
|
|
1393
|
+
for level in range(depth):
|
|
1394
|
+
if _is_map(current) and hasattr(current, "index_name"):
|
|
1395
|
+
iname = current.index_name
|
|
1396
|
+
if iname and iname != "x":
|
|
1397
|
+
names.append(iname)
|
|
1398
|
+
elif level < len(_stochastic_index_names):
|
|
1399
|
+
names.append(_stochastic_index_names[level])
|
|
1400
|
+
else:
|
|
1401
|
+
names.append(f"index_{level + 1}")
|
|
1402
|
+
if current.values:
|
|
1403
|
+
current = current.values[0]
|
|
1404
|
+
else:
|
|
1405
|
+
break
|
|
1406
|
+
else:
|
|
1407
|
+
break
|
|
1408
|
+
return names
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def write_stochastic_sheet_v2(
|
|
1412
|
+
ws: Worksheet,
|
|
1413
|
+
spec: SheetSpec,
|
|
1414
|
+
db_contents: DatabaseContents,
|
|
1415
|
+
) -> None:
|
|
1416
|
+
"""Write a stochastic (_s) sheet in the v2 self-describing format.
|
|
1417
|
+
|
|
1418
|
+
Layout (e.g. profile_s_profile with 3d map: branch × analysis_time × time):
|
|
1419
|
+
Row 1: navigate | | | parameter: profile | data type: ... | ...
|
|
1420
|
+
Row 2: | | | entity: profile | wind1 | wind1
|
|
1421
|
+
Row 3: index: branch | index: analysis_time | index: time | alternative | 1week_rolling | 2day
|
|
1422
|
+
Row 4+: realized | t0001 | t0001 | | 0.5 | 0.3
|
|
1423
|
+
"""
|
|
1424
|
+
if not spec.parameter_names:
|
|
1425
|
+
add_navigate_link(ws)
|
|
1426
|
+
return
|
|
1427
|
+
|
|
1428
|
+
pname = spec.parameter_names[0]
|
|
1429
|
+
entity_label = _build_entity_def_label(spec)
|
|
1430
|
+
dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout="timeseries")
|
|
1431
|
+
desc = _get_param_description(pname, spec.entity_classes, db_contents)
|
|
1432
|
+
|
|
1433
|
+
# Collect data: flatten nested maps and build columns
|
|
1434
|
+
columns: list[tuple[str, tuple, str, list[tuple[tuple[str, ...], Any]]]] = []
|
|
1435
|
+
# (alt, entity_byname, entity_class, flat_data)
|
|
1436
|
+
max_depth = 0
|
|
1437
|
+
index_names: list[str] = []
|
|
1438
|
+
|
|
1439
|
+
for entity_class in spec.entity_classes:
|
|
1440
|
+
for entity in db_contents.entities.get(entity_class, []):
|
|
1441
|
+
entity_byname = entity["entity_byname"]
|
|
1442
|
+
for alt in db_contents.alternatives:
|
|
1443
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
1444
|
+
value = db_contents.parameter_values.get(key)
|
|
1445
|
+
if value is None or not _is_map(value):
|
|
1446
|
+
continue
|
|
1447
|
+
# Determine nesting depth
|
|
1448
|
+
depth = 1
|
|
1449
|
+
current = value
|
|
1450
|
+
while _is_map(current) and current.values and _is_map(current.values[0]):
|
|
1451
|
+
depth += 1
|
|
1452
|
+
current = current.values[0]
|
|
1453
|
+
if depth < 2:
|
|
1454
|
+
continue # not stochastic (< 3d)
|
|
1455
|
+
|
|
1456
|
+
if not index_names:
|
|
1457
|
+
index_names = _get_nested_index_names(value, depth)
|
|
1458
|
+
max_depth = max(max_depth, depth)
|
|
1459
|
+
|
|
1460
|
+
flat = _flatten_nested_map(value, depth)
|
|
1461
|
+
columns.append((alt, entity_byname, entity_class, flat))
|
|
1462
|
+
|
|
1463
|
+
columns.sort(key=lambda c: (c[0], c[1]))
|
|
1464
|
+
|
|
1465
|
+
# Determine expected depth from parameter type_list even if no data
|
|
1466
|
+
if not max_depth:
|
|
1467
|
+
for entity_class in spec.entity_classes:
|
|
1468
|
+
for pdef in db_contents.parameter_definitions.get(entity_class, []):
|
|
1469
|
+
if pdef["name"] == pname:
|
|
1470
|
+
tl = pdef.get("parameter_type_list") or ()
|
|
1471
|
+
if "4d_map" in tl:
|
|
1472
|
+
max_depth = 4
|
|
1473
|
+
elif "3d_map" in tl:
|
|
1474
|
+
max_depth = 3
|
|
1475
|
+
break
|
|
1476
|
+
if not max_depth:
|
|
1477
|
+
max_depth = 3 # default for stochastic
|
|
1478
|
+
|
|
1479
|
+
n_index_cols = max_depth
|
|
1480
|
+
def_col = n_index_cols + 1 # 1-based
|
|
1481
|
+
|
|
1482
|
+
# Pad index_names to match depth using stochastic defaults
|
|
1483
|
+
while len(index_names) < n_index_cols:
|
|
1484
|
+
level = len(index_names)
|
|
1485
|
+
if level < len(_stochastic_index_names):
|
|
1486
|
+
index_names.append(_stochastic_index_names[level])
|
|
1487
|
+
else:
|
|
1488
|
+
index_names.append(f"index_{level + 1}")
|
|
1489
|
+
|
|
1490
|
+
# Collect unique index tuples preserving original Map order
|
|
1491
|
+
seen_tuples: set[tuple[str, ...]] = set()
|
|
1492
|
+
ordered_tuples: list[tuple[str, ...]] = []
|
|
1493
|
+
for _alt, _bn, _ec, flat in columns:
|
|
1494
|
+
for idx_tuple, _val in flat:
|
|
1495
|
+
if idx_tuple not in seen_tuples:
|
|
1496
|
+
seen_tuples.add(idx_tuple)
|
|
1497
|
+
ordered_tuples.append(idx_tuple)
|
|
1498
|
+
|
|
1499
|
+
# Build column data lookups (index_tuple → value)
|
|
1500
|
+
col_lookups: list[dict[tuple[str, ...], Any]] = []
|
|
1501
|
+
for _alt, _bn, _ec, flat in columns:
|
|
1502
|
+
lookup = {idx_tuple: val for idx_tuple, val in flat}
|
|
1503
|
+
col_lookups.append(lookup)
|
|
1504
|
+
|
|
1505
|
+
# ── Write header rows ────────────────────────────────────────
|
|
1506
|
+
cur_row = 1
|
|
1507
|
+
|
|
1508
|
+
# Row 1: navigate + triplet in def_col
|
|
1509
|
+
triplet = f"parameter: {pname} | data type: {dtype}"
|
|
1510
|
+
if desc:
|
|
1511
|
+
triplet += f" | description: {desc}"
|
|
1512
|
+
default_str = _get_param_default_string(pname, spec.entity_classes, db_contents)
|
|
1513
|
+
if default_str is not None:
|
|
1514
|
+
triplet += f" | default: {default_str}"
|
|
1515
|
+
ws.cell(row=cur_row, column=def_col, value=triplet)
|
|
1516
|
+
row_types: dict[int, str] = {cur_row: "param_info"}
|
|
1517
|
+
cur_row += 1
|
|
1518
|
+
|
|
1519
|
+
# Row 2: entity
|
|
1520
|
+
ws.cell(row=cur_row, column=def_col, value=entity_label)
|
|
1521
|
+
for col_idx, (alt, byname, ec, _flat) in enumerate(columns, start=def_col + 1):
|
|
1522
|
+
if byname:
|
|
1523
|
+
ws.cell(row=cur_row, column=col_idx, value=str(_to_native(byname[0])))
|
|
1524
|
+
row_types[cur_row] = "entity"
|
|
1525
|
+
cur_row += 1
|
|
1526
|
+
|
|
1527
|
+
# Row 3: index labels + alternative
|
|
1528
|
+
for i in range(n_index_cols):
|
|
1529
|
+
iname = index_names[i] if i < len(index_names) and index_names[i] else f"index_{i+1}"
|
|
1530
|
+
ws.cell(row=cur_row, column=i + 1, value=f"index: {iname}")
|
|
1531
|
+
ws.cell(row=cur_row, column=def_col, value="alternative")
|
|
1532
|
+
for col_idx, (alt, byname, ec, _flat) in enumerate(columns, start=def_col + 1):
|
|
1533
|
+
ws.cell(row=cur_row, column=col_idx, value=alt)
|
|
1534
|
+
row_types[cur_row] = "alternative"
|
|
1535
|
+
n_header_rows = cur_row
|
|
1536
|
+
cur_row += 1
|
|
1537
|
+
|
|
1538
|
+
# ── Write data rows ──────────────────────────────────────────
|
|
1539
|
+
for idx_tuple in ordered_tuples:
|
|
1540
|
+
for i, idx_val in enumerate(idx_tuple):
|
|
1541
|
+
ws.cell(row=cur_row, column=i + 1, value=idx_val)
|
|
1542
|
+
for col_idx, lookup in enumerate(col_lookups, start=def_col + 1):
|
|
1543
|
+
val = lookup.get(idx_tuple)
|
|
1544
|
+
if val is not None:
|
|
1545
|
+
ws.cell(row=cur_row, column=col_idx, value=val)
|
|
1546
|
+
cur_row += 1
|
|
1547
|
+
|
|
1548
|
+
# ── Convenience section (two-column gap) ────────────────────
|
|
1549
|
+
last_data_col = def_col + len(columns)
|
|
1550
|
+
color_end_col = last_data_col + 2 # extend coloring 2 cols beyond data
|
|
1551
|
+
_write_param_reference(
|
|
1552
|
+
ws, color_end_col, spec, db_contents, header_row=n_header_rows,
|
|
1553
|
+
n_data_rows=len(ordered_tuples), layout="stochastic",
|
|
1554
|
+
shown_params=list(spec.parameter_names),
|
|
1555
|
+
)
|
|
1556
|
+
|
|
1557
|
+
# ── Formatting (extend to color_end_col) ─────────────────────
|
|
1558
|
+
format_timeseries_sheet_v2(ws, n_header_rows, single_param=True,
|
|
1559
|
+
row_types=row_types, last_data_col=color_end_col)
|
|
1560
|
+
|
|
1561
|
+
# Index columns: green fill
|
|
1562
|
+
from openpyxl.formatting.rule import CellIsRule
|
|
1563
|
+
from openpyxl.utils import get_column_letter
|
|
1564
|
+
data_end = cur_row + 100
|
|
1565
|
+
for i in range(n_index_cols):
|
|
1566
|
+
cl = get_column_letter(i + 1)
|
|
1567
|
+
ws.cell(row=n_header_rows, column=i + 1).fill = FILL_INDEX_HEADER
|
|
1568
|
+
ws.conditional_formatting.add(
|
|
1569
|
+
f"{cl}{n_header_rows + 1}:{cl}{data_end}",
|
|
1570
|
+
CellIsRule(operator="notEqual", formula=['"§§§NEVER§§§"'],
|
|
1571
|
+
fill=FILL_INDEX_DATA),
|
|
1572
|
+
)
|
|
1573
|
+
|
|
1574
|
+
# Definition column: dark grey
|
|
1575
|
+
def_cl = get_column_letter(def_col)
|
|
1576
|
+
ws.conditional_formatting.add(
|
|
1577
|
+
f"{def_cl}{n_header_rows + 1}:{def_cl}{data_end}",
|
|
1578
|
+
CellIsRule(operator="notEqual", formula=['"§§§NEVER§§§"'],
|
|
1579
|
+
fill=FILL_DEF_COL),
|
|
1580
|
+
)
|
|
1581
|
+
|
|
1582
|
+
add_navigate_link(ws)
|
|
1583
|
+
auto_column_width(ws, min_param_width=_min_param_width,
|
|
1584
|
+
non_param_width=_non_param_width,
|
|
1585
|
+
def_col_width=_def_col_width,
|
|
1586
|
+
index_col_width=_index_col_width,
|
|
1587
|
+
header_row=n_header_rows, def_col=def_col,
|
|
1588
|
+
last_data_col=last_data_col)
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
# ---------------------------------------------------------------------------
|
|
1592
|
+
# v2 Ladder sheet (facet-leaf params — commodity.price_ladder_*)
|
|
1593
|
+
# ---------------------------------------------------------------------------
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def _xlsx_safe_float(value: Any) -> Any:
|
|
1597
|
+
"""Convert ``±inf`` / ``nan`` to string sentinels for openpyxl.
|
|
1598
|
+
|
|
1599
|
+
openpyxl silently writes a blank cell for IEEE non-finite floats, which
|
|
1600
|
+
breaks round-trip for ``price_ladder_*`` whose top-tier ``quantity``
|
|
1601
|
+
is canonically ``inf``. The reader recognises the string sentinels
|
|
1602
|
+
and converts them back to ``float`` via :func:`_convert_value`.
|
|
1603
|
+
"""
|
|
1604
|
+
if isinstance(value, float):
|
|
1605
|
+
if value != value: # NaN
|
|
1606
|
+
return "nan"
|
|
1607
|
+
if value == float("inf"):
|
|
1608
|
+
return "inf"
|
|
1609
|
+
if value == float("-inf"):
|
|
1610
|
+
return "-inf"
|
|
1611
|
+
return value
|
|
1612
|
+
if isinstance(value, (int, str)):
|
|
1613
|
+
return value
|
|
1614
|
+
nat = _to_native(value)
|
|
1615
|
+
if isinstance(nat, float):
|
|
1616
|
+
return _xlsx_safe_float(nat)
|
|
1617
|
+
return nat
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def _ladder_axis_name(axis: Any) -> str:
|
|
1621
|
+
"""Return the human-readable axis name for an :class:`AxisName` value.
|
|
1622
|
+
|
|
1623
|
+
Maps registry axis tokens to the index labels we emit in the sheet.
|
|
1624
|
+
"""
|
|
1625
|
+
# Use the axis's value (contract token) to derive a label. We keep
|
|
1626
|
+
# the mapping local to the writer so the registry stays UI-free.
|
|
1627
|
+
token = getattr(axis, "value", str(axis))
|
|
1628
|
+
return {"d": "period", "t": "time", "i": "tier"}.get(token, token)
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
def _walk_ladder_map(
|
|
1632
|
+
value: Any,
|
|
1633
|
+
depth: int,
|
|
1634
|
+
facet_keys_set: "frozenset[str]",
|
|
1635
|
+
) -> "list[tuple[tuple[str, ...], dict[str, Any]]]":
|
|
1636
|
+
"""Walk a depth-``depth`` Map down to the facet leaf.
|
|
1637
|
+
|
|
1638
|
+
Returns a list of ``(outer_index_tuple, {facet_key: value, ...})``.
|
|
1639
|
+
The leaf is itself a depth-1 Map with facet keys ``price`` / ``quantity``.
|
|
1640
|
+
``depth`` here counts the outer non-facet Map levels — depth=1 means
|
|
1641
|
+
``Map(tier -> Map(price/quantity))``, depth=2 means
|
|
1642
|
+
``Map(period -> Map(tier -> Map(price/quantity)))``.
|
|
1643
|
+
"""
|
|
1644
|
+
results: list[tuple[tuple[str, ...], dict[str, Any]]] = []
|
|
1645
|
+
if not _is_map(value):
|
|
1646
|
+
return results
|
|
1647
|
+
if depth == 1:
|
|
1648
|
+
# value itself is Map(tier -> facet_map); walk one level
|
|
1649
|
+
for idx, leaf in zip(value.indexes, value.values):
|
|
1650
|
+
if not _is_map(leaf):
|
|
1651
|
+
continue
|
|
1652
|
+
facets: dict[str, Any] = {}
|
|
1653
|
+
for fk, fv in zip(leaf.indexes, leaf.values):
|
|
1654
|
+
fk_s = str(_to_native(fk))
|
|
1655
|
+
if fk_s in facet_keys_set:
|
|
1656
|
+
facets[fk_s] = _to_native(fv)
|
|
1657
|
+
if facets:
|
|
1658
|
+
results.append((
|
|
1659
|
+
(str(_to_native(idx)),),
|
|
1660
|
+
facets,
|
|
1661
|
+
))
|
|
1662
|
+
return results
|
|
1663
|
+
# depth >= 2: peel one level and recurse
|
|
1664
|
+
for idx, sub in zip(value.indexes, value.values):
|
|
1665
|
+
for inner_tuple, facets in _walk_ladder_map(sub, depth - 1, facet_keys_set):
|
|
1666
|
+
results.append((
|
|
1667
|
+
(str(_to_native(idx)), *inner_tuple),
|
|
1668
|
+
facets,
|
|
1669
|
+
))
|
|
1670
|
+
return results
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def write_ladder_sheet_v2(
|
|
1674
|
+
ws: Worksheet,
|
|
1675
|
+
spec: SheetSpec,
|
|
1676
|
+
db_contents: DatabaseContents,
|
|
1677
|
+
) -> None:
|
|
1678
|
+
"""Write a ladder-layout sheet for facet-leaf params.
|
|
1679
|
+
|
|
1680
|
+
Layout (e.g. ``price_ladder_cumulative``, depth-2):
|
|
1681
|
+
Row 1: navigate | | | | | description | description
|
|
1682
|
+
Row 2: | data type: float | data type: float
|
|
1683
|
+
Row 3: alternative | entity: commodity | index: tier | parameter: price | parameter: quantity
|
|
1684
|
+
Row 4+: base | coal | 1 | 20.0 | 1.0
|
|
1685
|
+
|
|
1686
|
+
For ``price_ladder_annual`` with mixed depth-2/depth-3 data, the
|
|
1687
|
+
period column is added when ANY value has the depth-3 shape; depth-2
|
|
1688
|
+
rows simply leave the period cell empty.
|
|
1689
|
+
"""
|
|
1690
|
+
# Lazy registry import — kept off the hot path.
|
|
1691
|
+
from flextool.engine_polars._param_shapes import (
|
|
1692
|
+
LeafKind,
|
|
1693
|
+
PARAM_ALLOWED_SHAPES,
|
|
1694
|
+
facet_keys,
|
|
1695
|
+
shape_to_axes,
|
|
1696
|
+
)
|
|
1697
|
+
|
|
1698
|
+
if not spec.parameter_names:
|
|
1699
|
+
add_navigate_link(ws)
|
|
1700
|
+
return
|
|
1701
|
+
|
|
1702
|
+
pname = spec.parameter_names[0]
|
|
1703
|
+
entity_class = spec.entity_classes[0]
|
|
1704
|
+
allowed = PARAM_ALLOWED_SHAPES.get((entity_class, pname), set())
|
|
1705
|
+
facet_leaf_shapes = [
|
|
1706
|
+
s for s in allowed
|
|
1707
|
+
if shape_to_axes(s).leaf is LeafKind.FACET_PRICE_QUANTITY
|
|
1708
|
+
]
|
|
1709
|
+
if not facet_leaf_shapes:
|
|
1710
|
+
# Registry didn't tag this param as facet-leaf — bail safely.
|
|
1711
|
+
add_navigate_link(ws)
|
|
1712
|
+
return
|
|
1713
|
+
|
|
1714
|
+
# Determine whether the period axis MAY be present (any allowed shape
|
|
1715
|
+
# carries AxisName.D in its outer levels).
|
|
1716
|
+
has_period_variant = any(
|
|
1717
|
+
any(getattr(ax, "value", "") == "d" for ax in shape_to_axes(s).map_levels)
|
|
1718
|
+
for s in facet_leaf_shapes
|
|
1719
|
+
)
|
|
1720
|
+
# The shared facet keys (we trust the registry to keep them identical
|
|
1721
|
+
# across both variants for a given param).
|
|
1722
|
+
leaf_kind = shape_to_axes(facet_leaf_shapes[0]).leaf
|
|
1723
|
+
facet_key_set = facet_keys(leaf_kind)
|
|
1724
|
+
# Deterministic facet ordering: price → quantity for the canonical
|
|
1725
|
+
# FACET_PRICE_QUANTITY leaf.
|
|
1726
|
+
facet_columns = ["price", "quantity"] if facet_key_set == frozenset(
|
|
1727
|
+
("price", "quantity")
|
|
1728
|
+
) else sorted(facet_key_set)
|
|
1729
|
+
|
|
1730
|
+
entity_label = _build_entity_def_label(spec)
|
|
1731
|
+
|
|
1732
|
+
# ── Collect data ────────────────────────────────────────────────
|
|
1733
|
+
# rows: list of (alt, entity_byname, period_or_None, tier, {facet: value})
|
|
1734
|
+
rows: list[tuple[str, tuple, str | None, str, dict[str, Any]]] = []
|
|
1735
|
+
any_period_in_data = False
|
|
1736
|
+
|
|
1737
|
+
for ent_cls in spec.entity_classes:
|
|
1738
|
+
for entity in db_contents.entities.get(ent_cls, []):
|
|
1739
|
+
entity_byname = entity["entity_byname"]
|
|
1740
|
+
for alt in db_contents.alternatives:
|
|
1741
|
+
key = (ent_cls, entity_byname, pname, alt)
|
|
1742
|
+
val = db_contents.parameter_values.get(key)
|
|
1743
|
+
if val is None or not _is_map(val):
|
|
1744
|
+
continue
|
|
1745
|
+
# Detect depth by inspecting nesting. Outer Map levels
|
|
1746
|
+
# exclude the final facet level: depth 1 == tier only,
|
|
1747
|
+
# depth 2 == period + tier.
|
|
1748
|
+
depth = 1
|
|
1749
|
+
cursor = val
|
|
1750
|
+
while _is_map(cursor) and cursor.values and _is_map(cursor.values[0]):
|
|
1751
|
+
nested = cursor.values[0]
|
|
1752
|
+
# If the nested Map is the facet leaf (indexes are facet keys),
|
|
1753
|
+
# stop counting.
|
|
1754
|
+
nested_keys = {str(_to_native(k)) for k in nested.indexes}
|
|
1755
|
+
if nested_keys and nested_keys.issubset(facet_key_set):
|
|
1756
|
+
break
|
|
1757
|
+
depth += 1
|
|
1758
|
+
cursor = nested
|
|
1759
|
+
|
|
1760
|
+
# Walk to leaf, emit rows.
|
|
1761
|
+
walked = _walk_ladder_map(val, depth, facet_key_set)
|
|
1762
|
+
for idx_tuple, facets in walked:
|
|
1763
|
+
if depth == 1:
|
|
1764
|
+
rows.append((alt, entity_byname, None, idx_tuple[0], facets))
|
|
1765
|
+
elif depth == 2:
|
|
1766
|
+
any_period_in_data = True
|
|
1767
|
+
rows.append((
|
|
1768
|
+
alt, entity_byname,
|
|
1769
|
+
idx_tuple[0], idx_tuple[1], facets,
|
|
1770
|
+
))
|
|
1771
|
+
else:
|
|
1772
|
+
# Outer levels beyond 2 not currently registered;
|
|
1773
|
+
# skip rather than invent semantics.
|
|
1774
|
+
continue
|
|
1775
|
+
|
|
1776
|
+
# Sort for stable output.
|
|
1777
|
+
rows.sort(key=lambda r: (r[0], tuple(str(x) for x in r[1]),
|
|
1778
|
+
r[2] or "", r[3]))
|
|
1779
|
+
|
|
1780
|
+
# ── Build header columns ───────────────────────────────────────
|
|
1781
|
+
n_entity_cols = len(spec.entity_columns)
|
|
1782
|
+
n_dims = n_entity_cols
|
|
1783
|
+
|
|
1784
|
+
left_cols: list[str] = ["alternative", entity_label]
|
|
1785
|
+
if n_dims > 1:
|
|
1786
|
+
for dim_name in spec.entity_columns:
|
|
1787
|
+
left_cols.append(dim_name)
|
|
1788
|
+
|
|
1789
|
+
show_period_col = has_period_variant and any_period_in_data
|
|
1790
|
+
period_col_pos: int | None = None
|
|
1791
|
+
if show_period_col:
|
|
1792
|
+
left_cols.append("index: period")
|
|
1793
|
+
period_col_pos = len(left_cols) # 1-based
|
|
1794
|
+
|
|
1795
|
+
left_cols.append("index: tier")
|
|
1796
|
+
tier_col_pos = len(left_cols) # 1-based
|
|
1797
|
+
|
|
1798
|
+
def_col = len(left_cols) + 1 # 1-based
|
|
1799
|
+
|
|
1800
|
+
# ── Write metadata rows (description, data type) ────────────────
|
|
1801
|
+
# Layout follows the constant-sheet convention so the existing v2
|
|
1802
|
+
# reader recognises the right-of-crossing param columns:
|
|
1803
|
+
# Row 1, col 1 "navigate"
|
|
1804
|
+
# Row 1, def_col "description" (row label)
|
|
1805
|
+
# Row 1, def_col+1.. DB-level description per facet column
|
|
1806
|
+
# Row 2, def_col "data type" (row label)
|
|
1807
|
+
# Row 2, def_col+1.. "float" per facet column
|
|
1808
|
+
desc = _get_param_description(pname, spec.entity_classes, db_contents)
|
|
1809
|
+
ws.cell(row=1, column=1, value="navigate")
|
|
1810
|
+
ws.cell(row=1, column=def_col, value="description")
|
|
1811
|
+
for i, _facet in enumerate(facet_columns):
|
|
1812
|
+
if desc:
|
|
1813
|
+
ws.cell(row=1, column=def_col + 1 + i, value=desc)
|
|
1814
|
+
ws.cell(row=2, column=def_col, value="data type")
|
|
1815
|
+
for i, _facet in enumerate(facet_columns):
|
|
1816
|
+
ws.cell(row=2, column=def_col + 1 + i, value="float")
|
|
1817
|
+
|
|
1818
|
+
def_row = 3
|
|
1819
|
+
data_start_row = def_row + 1
|
|
1820
|
+
|
|
1821
|
+
# ── Definition row ─────────────────────────────────────────────
|
|
1822
|
+
for col_idx, label in enumerate(left_cols, start=1):
|
|
1823
|
+
ws.cell(row=def_row, column=col_idx, value=label)
|
|
1824
|
+
# def_col holds "parameter" (matches constant-sheet convention so
|
|
1825
|
+
# the standard v2 reader recognises right-of-crossing param cols).
|
|
1826
|
+
ws.cell(row=def_row, column=def_col, value="parameter")
|
|
1827
|
+
for i, facet in enumerate(facet_columns):
|
|
1828
|
+
ws.cell(row=def_row, column=def_col + 1 + i, value=facet)
|
|
1829
|
+
|
|
1830
|
+
# ── Column maps ────────────────────────────────────────────────
|
|
1831
|
+
col_alt = 1
|
|
1832
|
+
if n_dims <= 1:
|
|
1833
|
+
entity_data_cols = [2]
|
|
1834
|
+
else:
|
|
1835
|
+
entity_data_cols = list(range(3, 3 + n_dims))
|
|
1836
|
+
|
|
1837
|
+
facet_col_map: dict[str, int] = {
|
|
1838
|
+
f: def_col + 1 + i for i, f in enumerate(facet_columns)
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
# ── Write data rows ────────────────────────────────────────────
|
|
1842
|
+
for r_offset, (alt, entity_byname, period, tier, facets) in enumerate(rows):
|
|
1843
|
+
r = data_start_row + r_offset
|
|
1844
|
+
ws.cell(row=r, column=col_alt, value=alt)
|
|
1845
|
+
for i, col in enumerate(entity_data_cols):
|
|
1846
|
+
if i < len(entity_byname):
|
|
1847
|
+
ws.cell(row=r, column=col,
|
|
1848
|
+
value=str(_to_native(entity_byname[i])))
|
|
1849
|
+
if period_col_pos is not None and period is not None:
|
|
1850
|
+
ws.cell(row=r, column=period_col_pos, value=period)
|
|
1851
|
+
ws.cell(row=r, column=tier_col_pos, value=tier)
|
|
1852
|
+
for facet, fval in facets.items():
|
|
1853
|
+
col = facet_col_map.get(facet)
|
|
1854
|
+
if col is None:
|
|
1855
|
+
continue
|
|
1856
|
+
ws.cell(row=r, column=col, value=_xlsx_safe_float(fval))
|
|
1857
|
+
|
|
1858
|
+
# ── Reference section + formatting ─────────────────────────────
|
|
1859
|
+
last_data_col = def_col + len(facet_columns)
|
|
1860
|
+
index_col_positions: set[int] = {tier_col_pos}
|
|
1861
|
+
if period_col_pos is not None:
|
|
1862
|
+
index_col_positions.add(period_col_pos)
|
|
1863
|
+
n_extra_cols = 1 + (1 if show_period_col else 0)
|
|
1864
|
+
format_constant_sheet_v2(
|
|
1865
|
+
ws, n_entity_cols, n_extra_cols, def_col, index_col_positions,
|
|
1866
|
+
last_data_col, def_row=def_row,
|
|
1867
|
+
)
|
|
1868
|
+
add_navigate_link(ws)
|
|
1869
|
+
auto_column_width(
|
|
1870
|
+
ws,
|
|
1871
|
+
min_param_width=_min_param_width,
|
|
1872
|
+
non_param_width=_non_param_width,
|
|
1873
|
+
def_col_width=_def_col_width,
|
|
1874
|
+
index_col_width=_index_col_width,
|
|
1875
|
+
header_row=def_row, def_col=def_col,
|
|
1876
|
+
index_cols=index_col_positions,
|
|
1877
|
+
last_data_col=last_data_col,
|
|
1878
|
+
)
|
|
1879
|
+
|
|
1880
|
+
|
|
1881
|
+
# ---------------------------------------------------------------------------
|
|
1882
|
+
# Navigate sheet
|
|
1883
|
+
# ---------------------------------------------------------------------------
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
def write_navigate_sheet(
|
|
1887
|
+
ws: Worksheet,
|
|
1888
|
+
all_specs: list[SheetSpec],
|
|
1889
|
+
navigate_groups: list[dict[str, Any]] | None = None,
|
|
1890
|
+
version: float | None = None,
|
|
1891
|
+
) -> None:
|
|
1892
|
+
"""Write the navigate sheet with grouped, colour-coded hyperlinks.
|
|
1893
|
+
|
|
1894
|
+
Args:
|
|
1895
|
+
ws: Target worksheet.
|
|
1896
|
+
all_specs: All sheet specifications (used for fallback if no groups).
|
|
1897
|
+
navigate_groups: Navigate group configuration from export_settings.yaml.
|
|
1898
|
+
version: FlexTool DB version to display.
|
|
1899
|
+
"""
|
|
1900
|
+
# Build set of sheet names that actually exist in the workbook specs
|
|
1901
|
+
existing_sheets: set[str] = {spec.sheet_name for spec in all_specs if spec.layout != "navigate"}
|
|
1902
|
+
|
|
1903
|
+
if not navigate_groups:
|
|
1904
|
+
# Fallback: flat 3-column list (original behaviour)
|
|
1905
|
+
_write_navigate_flat(ws, all_specs, existing_sheets)
|
|
1906
|
+
return
|
|
1907
|
+
|
|
1908
|
+
# --- Row 1: column headers ---
|
|
1909
|
+
ws.cell(row=1, column=1, value="Constants")
|
|
1910
|
+
ws.cell(row=1, column=2, value="Periodic")
|
|
1911
|
+
ws.cell(row=1, column=3, value="Timeseries")
|
|
1912
|
+
ws.cell(row=1, column=4, value="Stochastic")
|
|
1913
|
+
|
|
1914
|
+
# --- Column F: help text ---
|
|
1915
|
+
help_lines = [
|
|
1916
|
+
"FlexTool can take parameter data in four ways:",
|
|
1917
|
+
"- constants (sheet ends with '_c') - Almost any value can be a constant or can be set as a time series.",
|
|
1918
|
+
"- period series (sheets ends with '_p') - These parameters have a period index (e.g. year).",
|
|
1919
|
+
"- time series (sheets ends with '_t') - These parameters can have values for each timestep.",
|
|
1920
|
+
"- stochastic (sheets ends with '_s') - These parameters have forecast branches with time series.",
|
|
1921
|
+
"",
|
|
1922
|
+
"Some sheets establish only relationships between entities without additional parameters.",
|
|
1923
|
+
"",
|
|
1924
|
+
"Parameters with valid types (drop-down lists) show allowed values when you select the cell.",
|
|
1925
|
+
]
|
|
1926
|
+
for i, text in enumerate(help_lines):
|
|
1927
|
+
if text:
|
|
1928
|
+
ws.cell(row=1 + i, column=6, value=text)
|
|
1929
|
+
|
|
1930
|
+
# --- Version info at E15 ---
|
|
1931
|
+
if version is not None:
|
|
1932
|
+
version_int = int(version) if version == int(version) else version
|
|
1933
|
+
ws.cell(row=15, column=6, value="FlexTool DB version:")
|
|
1934
|
+
ws.cell(row=15, column=7, value=version_int)
|
|
1935
|
+
|
|
1936
|
+
# --- Write grouped rows ---
|
|
1937
|
+
current_row = 2 # start after header row
|
|
1938
|
+
|
|
1939
|
+
for group in navigate_groups:
|
|
1940
|
+
group_color = group["color"]
|
|
1941
|
+
group_fill = PatternFill(patternType="solid", fgColor=group_color)
|
|
1942
|
+
rows_in_group = group.get("rows", [])
|
|
1943
|
+
|
|
1944
|
+
for sheet_row in rows_in_group:
|
|
1945
|
+
# Place each sheet name into columns A, B, C in order
|
|
1946
|
+
for col_offset, sheet_name in enumerate(sheet_row):
|
|
1947
|
+
if sheet_name in existing_sheets:
|
|
1948
|
+
col = col_offset + 1 # 1-based
|
|
1949
|
+
cell = ws.cell(row=current_row, column=col, value=sheet_name)
|
|
1950
|
+
cell.hyperlink = f"#{sheet_name}!A1"
|
|
1951
|
+
cell.font = FONT_NAVIGATE_LINK
|
|
1952
|
+
|
|
1953
|
+
# Apply group fill to columns A, B, C for this row
|
|
1954
|
+
for col in range(1, 5):
|
|
1955
|
+
ws.cell(row=current_row, column=col).fill = group_fill
|
|
1956
|
+
|
|
1957
|
+
current_row += 1
|
|
1958
|
+
|
|
1959
|
+
# Blank separator row after each group
|
|
1960
|
+
current_row += 1
|
|
1961
|
+
|
|
1962
|
+
# --- Sheet mappings section (documents multi-mapping entity classes) ---
|
|
1963
|
+
mapping_specs = [s for s in all_specs
|
|
1964
|
+
if s.direction_map and len(s.entity_classes) > 1
|
|
1965
|
+
and s.sheet_name in existing_sheets]
|
|
1966
|
+
if mapping_specs:
|
|
1967
|
+
current_row += 1
|
|
1968
|
+
header_fill = PatternFill(patternType="solid", fgColor="BDD7EE")
|
|
1969
|
+
ws.cell(row=current_row, column=1, value="Sheet mappings")
|
|
1970
|
+
ws.cell(row=current_row, column=1).fill = header_fill
|
|
1971
|
+
ws.cell(row=current_row, column=2, value="Entity classes")
|
|
1972
|
+
ws.cell(row=current_row, column=2).fill = header_fill
|
|
1973
|
+
ws.cell(row=current_row, column=3, value="Filter column")
|
|
1974
|
+
ws.cell(row=current_row, column=3).fill = header_fill
|
|
1975
|
+
ws.cell(row=current_row, column=4, value="Filter value")
|
|
1976
|
+
ws.cell(row=current_row, column=4).fill = header_fill
|
|
1977
|
+
ws.cell(row=current_row, column=5, value="Dimensions")
|
|
1978
|
+
ws.cell(row=current_row, column=5).fill = header_fill
|
|
1979
|
+
ws.cell(row=current_row, column=6, value="Orientation")
|
|
1980
|
+
ws.cell(row=current_row, column=6).fill = header_fill
|
|
1981
|
+
current_row += 1
|
|
1982
|
+
|
|
1983
|
+
seen_sheets: set[str] = set()
|
|
1984
|
+
for spec in mapping_specs:
|
|
1985
|
+
if spec.sheet_name in seen_sheets:
|
|
1986
|
+
continue
|
|
1987
|
+
seen_sheets.add(spec.sheet_name)
|
|
1988
|
+
|
|
1989
|
+
orientation = "rows" if spec.layout == "timeseries" else "columns"
|
|
1990
|
+
for cls_name, direction_val in spec.direction_map.items():
|
|
1991
|
+
ws.cell(row=current_row, column=1, value=spec.sheet_name)
|
|
1992
|
+
ws.cell(row=current_row, column=2, value=cls_name)
|
|
1993
|
+
ws.cell(row=current_row, column=3, value=spec.direction_column)
|
|
1994
|
+
ws.cell(row=current_row, column=4, value=direction_val)
|
|
1995
|
+
dims = ", ".join(spec.entity_columns)
|
|
1996
|
+
ws.cell(row=current_row, column=5, value=dims)
|
|
1997
|
+
ws.cell(row=current_row, column=6, value=orientation)
|
|
1998
|
+
current_row += 1
|
|
1999
|
+
|
|
2000
|
+
auto_column_width(ws)
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def _write_navigate_flat(
|
|
2004
|
+
ws: Worksheet,
|
|
2005
|
+
all_specs: list[SheetSpec],
|
|
2006
|
+
existing_sheets: set[str],
|
|
2007
|
+
) -> None:
|
|
2008
|
+
"""Fallback: write a flat 3-column navigate sheet (original behaviour)."""
|
|
2009
|
+
constants: list[str] = []
|
|
2010
|
+
periodic: list[str] = []
|
|
2011
|
+
timeseries: list[str] = []
|
|
2012
|
+
|
|
2013
|
+
for spec in all_specs:
|
|
2014
|
+
if spec.layout == "navigate":
|
|
2015
|
+
continue
|
|
2016
|
+
elif spec.layout in ("periodic", "nested_periodic"):
|
|
2017
|
+
periodic.append(spec.sheet_name)
|
|
2018
|
+
elif spec.layout == "timeseries":
|
|
2019
|
+
timeseries.append(spec.sheet_name)
|
|
2020
|
+
else:
|
|
2021
|
+
constants.append(spec.sheet_name)
|
|
2022
|
+
|
|
2023
|
+
ws.cell(row=1, column=1, value="Constants")
|
|
2024
|
+
ws.cell(row=1, column=2, value="Periodic")
|
|
2025
|
+
ws.cell(row=1, column=3, value="Timeseries")
|
|
2026
|
+
|
|
2027
|
+
for row_idx, name in enumerate(constants, start=2):
|
|
2028
|
+
cell = ws.cell(row=row_idx, column=1, value=name)
|
|
2029
|
+
cell.hyperlink = f"#{name}!A1"
|
|
2030
|
+
cell.font = FONT_NAVIGATE_LINK
|
|
2031
|
+
|
|
2032
|
+
for row_idx, name in enumerate(periodic, start=2):
|
|
2033
|
+
cell = ws.cell(row=row_idx, column=2, value=name)
|
|
2034
|
+
cell.hyperlink = f"#{name}!A1"
|
|
2035
|
+
cell.font = FONT_NAVIGATE_LINK
|
|
2036
|
+
|
|
2037
|
+
for row_idx, name in enumerate(timeseries, start=2):
|
|
2038
|
+
cell = ws.cell(row=row_idx, column=3, value=name)
|
|
2039
|
+
cell.hyperlink = f"#{name}!A1"
|
|
2040
|
+
cell.font = FONT_NAVIGATE_LINK
|
|
2041
|
+
|
|
2042
|
+
auto_column_width(ws)
|
|
2043
|
+
|
|
2044
|
+
|
|
2045
|
+
# ===========================================================================
|
|
2046
|
+
# v2 format writers — self-describing sheets with embedded metadata
|
|
2047
|
+
# ===========================================================================
|
|
2048
|
+
|
|
2049
|
+
|
|
2050
|
+
# Data type overrides from settings, set by export_to_excel before writing.
|
|
2051
|
+
# Maps (layout, param_name) -> override string.
|
|
2052
|
+
_data_type_overrides: dict[str, dict[str, str]] = {}
|
|
2053
|
+
_min_param_width: float = 10
|
|
2054
|
+
_non_param_width: float = 22
|
|
2055
|
+
_def_col_width: float = 11
|
|
2056
|
+
_index_col_width: float = 12
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
def _get_param_data_type(
|
|
2060
|
+
param_name: str,
|
|
2061
|
+
entity_classes: list[str],
|
|
2062
|
+
db_contents: DatabaseContents,
|
|
2063
|
+
layout: str = "constant",
|
|
2064
|
+
) -> str:
|
|
2065
|
+
"""Determine the data type label for a parameter on a specific sheet layout.
|
|
2066
|
+
|
|
2067
|
+
The label tells the user what to enter in each cell and helps the reader
|
|
2068
|
+
interpret values correctly.
|
|
2069
|
+
|
|
2070
|
+
Returns labels like:
|
|
2071
|
+
"float", "string", "boolean (array)",
|
|
2072
|
+
"float (1d-map)", "string (1d-map)", "string/float (1d-map)"
|
|
2073
|
+
"""
|
|
2074
|
+
# Check overrides first (e.g. nested_periodic convention for 2d-maps)
|
|
2075
|
+
overrides = _data_type_overrides.get(layout, {})
|
|
2076
|
+
if param_name in overrides:
|
|
2077
|
+
return overrides[param_name]
|
|
2078
|
+
|
|
2079
|
+
for entity_class in entity_classes:
|
|
2080
|
+
for pdef in db_contents.parameter_definitions.get(entity_class, []):
|
|
2081
|
+
if pdef["name"] == param_name:
|
|
2082
|
+
type_list = pdef.get("parameter_type_list")
|
|
2083
|
+
if not type_list:
|
|
2084
|
+
return "float"
|
|
2085
|
+
types = set(type_list)
|
|
2086
|
+
|
|
2087
|
+
# Pure array parameters — entered as boolean values
|
|
2088
|
+
if "array" in types and not (types & {"float", "1d_map", "2d_map", "3d_map", "4d_map"}):
|
|
2089
|
+
return "boolean (array)"
|
|
2090
|
+
|
|
2091
|
+
# On constant sheets: report the scalar type
|
|
2092
|
+
if layout == "constant":
|
|
2093
|
+
if "str" in types:
|
|
2094
|
+
return "string"
|
|
2095
|
+
return "float"
|
|
2096
|
+
|
|
2097
|
+
# On periodic sheets: array params are written one row per
|
|
2098
|
+
# array element with the period name as the cell value.
|
|
2099
|
+
# Labelling the column ``string (array)`` lets the v2
|
|
2100
|
+
# importer collapse the per-row records back into a
|
|
2101
|
+
# SpineDB Array(values=[period_names]), preserving the
|
|
2102
|
+
# original ``"array"`` type end-to-end. Downstream code
|
|
2103
|
+
# (``_solve_config.params_to_dict`` / ``periods_to_tuples``)
|
|
2104
|
+
# requires Array — Map<period, period> would silently
|
|
2105
|
+
# change the engine's solve identity to a (key, value)
|
|
2106
|
+
# tuple and break ``model.solves`` resolution.
|
|
2107
|
+
if layout == "periodic" and "array" in types:
|
|
2108
|
+
return "string (array)"
|
|
2109
|
+
|
|
2110
|
+
# On periodic/timeseries sheets: report the map dimension
|
|
2111
|
+
# relevant to THIS layout, not the highest available.
|
|
2112
|
+
# Periodic/timeseries use 1d_map; nested_periodic uses 2d_map;
|
|
2113
|
+
# 3d_map/4d_map are for stochastic sheets.
|
|
2114
|
+
if layout == "nested_periodic":
|
|
2115
|
+
map_suffix = "2d-map" if "2d_map" in types else "1d-map"
|
|
2116
|
+
elif layout in ("periodic", "timeseries"):
|
|
2117
|
+
map_suffix = "1d-map" if "1d_map" in types else ""
|
|
2118
|
+
elif "4d_map" in types:
|
|
2119
|
+
map_suffix = "4d-map"
|
|
2120
|
+
elif "3d_map" in types:
|
|
2121
|
+
map_suffix = "3d-map"
|
|
2122
|
+
elif "2d_map" in types:
|
|
2123
|
+
map_suffix = "2d-map"
|
|
2124
|
+
elif "1d_map" in types:
|
|
2125
|
+
map_suffix = "1d-map"
|
|
2126
|
+
else:
|
|
2127
|
+
map_suffix = ""
|
|
2128
|
+
|
|
2129
|
+
# Determine inner value type
|
|
2130
|
+
has_str = "str" in types
|
|
2131
|
+
has_float = "float" in types
|
|
2132
|
+
if has_str and not has_float:
|
|
2133
|
+
base = "string"
|
|
2134
|
+
elif has_float and not has_str:
|
|
2135
|
+
base = "float"
|
|
2136
|
+
elif has_str and has_float:
|
|
2137
|
+
base = "string/float"
|
|
2138
|
+
else:
|
|
2139
|
+
# No str or float info (pure map) — allow either
|
|
2140
|
+
base = "string/float"
|
|
2141
|
+
|
|
2142
|
+
if map_suffix:
|
|
2143
|
+
return f"{base} ({map_suffix})"
|
|
2144
|
+
return base
|
|
2145
|
+
|
|
2146
|
+
return "float"
|
|
2147
|
+
|
|
2148
|
+
|
|
2149
|
+
def _get_param_value_list(
|
|
2150
|
+
param_name: str,
|
|
2151
|
+
entity_classes: list[str],
|
|
2152
|
+
db_contents: DatabaseContents,
|
|
2153
|
+
) -> list[str] | None:
|
|
2154
|
+
"""Get the allowed value list for a parameter, if any."""
|
|
2155
|
+
for entity_class in entity_classes:
|
|
2156
|
+
for pdef in db_contents.parameter_definitions.get(entity_class, []):
|
|
2157
|
+
if pdef["name"] == param_name:
|
|
2158
|
+
vl_name = pdef.get("parameter_value_list_name")
|
|
2159
|
+
if vl_name and vl_name in db_contents.list_values:
|
|
2160
|
+
return [str(v) for v in db_contents.list_values[vl_name]]
|
|
2161
|
+
return None
|
|
2162
|
+
|
|
2163
|
+
|
|
2164
|
+
def _get_param_description(
|
|
2165
|
+
param_name: str,
|
|
2166
|
+
entity_classes: list[str],
|
|
2167
|
+
db_contents: DatabaseContents,
|
|
2168
|
+
) -> str | None:
|
|
2169
|
+
"""Get the description for a parameter from DB definitions."""
|
|
2170
|
+
for entity_class in entity_classes:
|
|
2171
|
+
for pdef in db_contents.parameter_definitions.get(entity_class, []):
|
|
2172
|
+
if pdef["name"] == param_name:
|
|
2173
|
+
return pdef.get("description") or None
|
|
2174
|
+
return None
|
|
2175
|
+
|
|
2176
|
+
|
|
2177
|
+
def _get_param_default_string(
|
|
2178
|
+
param_name: str,
|
|
2179
|
+
entity_classes: list[str],
|
|
2180
|
+
db_contents: DatabaseContents,
|
|
2181
|
+
) -> str | None:
|
|
2182
|
+
"""Return a stringified default value for a parameter, or None.
|
|
2183
|
+
|
|
2184
|
+
Scalars stringify directly. Maps/Arrays are rendered in a JSON-like
|
|
2185
|
+
form so they fit in a single Excel cell.
|
|
2186
|
+
"""
|
|
2187
|
+
for entity_class in entity_classes:
|
|
2188
|
+
for pdef in db_contents.parameter_definitions.get(entity_class, []):
|
|
2189
|
+
if pdef["name"] == param_name:
|
|
2190
|
+
dv = pdef.get("default_value")
|
|
2191
|
+
if dv is None:
|
|
2192
|
+
return None
|
|
2193
|
+
return _format_default_value(dv)
|
|
2194
|
+
return None
|
|
2195
|
+
|
|
2196
|
+
|
|
2197
|
+
def _format_default_value(value: Any) -> str:
|
|
2198
|
+
"""Render a parameter default value to a single-cell string."""
|
|
2199
|
+
if _is_map(value):
|
|
2200
|
+
parts = [
|
|
2201
|
+
f"{_to_native(idx)}: {_format_default_value(val)}"
|
|
2202
|
+
for idx, val in zip(value.indexes, value.values)
|
|
2203
|
+
]
|
|
2204
|
+
return "{" + ", ".join(parts) + "}"
|
|
2205
|
+
if _is_array(value):
|
|
2206
|
+
return "[" + ", ".join(_format_default_value(v) for v in value.values) + "]"
|
|
2207
|
+
return str(_to_native(value))
|
|
2208
|
+
|
|
2209
|
+
|
|
2210
|
+
def _build_entity_def_label(spec: SheetSpec) -> str:
|
|
2211
|
+
"""Build the 'entity: ...' definition label for the definition row.
|
|
2212
|
+
|
|
2213
|
+
When the spec merges multiple entity classes (e.g. unit__inputNode and
|
|
2214
|
+
unit__outputNode), uses the multi-class syntax so that the reader can
|
|
2215
|
+
reconstruct the mapping:
|
|
2216
|
+
entity: (unit__inputNode: (unit, node), unit__outputNode: (unit, node))
|
|
2217
|
+
|
|
2218
|
+
Otherwise uses the simple form:
|
|
2219
|
+
entity: unit, node
|
|
2220
|
+
"""
|
|
2221
|
+
dims = ', '.join(spec.entity_columns)
|
|
2222
|
+
if spec.direction_map and len(spec.entity_classes) > 1:
|
|
2223
|
+
parts = []
|
|
2224
|
+
for cls_name in spec.entity_classes:
|
|
2225
|
+
parts.append(f"{cls_name}: ({dims})")
|
|
2226
|
+
return f"entity: ({', '.join(parts)})"
|
|
2227
|
+
return f"entity: {dims}"
|
|
2228
|
+
|
|
2229
|
+
|
|
2230
|
+
def _build_filter_label(spec: SheetSpec) -> str | None:
|
|
2231
|
+
"""Build the filter column label for the definition row.
|
|
2232
|
+
|
|
2233
|
+
When the spec merges multiple entity classes, returns the full filter
|
|
2234
|
+
syntax so the reader can map filter values to entity classes:
|
|
2235
|
+
filter: {unit__inputNode: ^input$, unit__outputNode: ^output$}
|
|
2236
|
+
|
|
2237
|
+
Otherwise returns None.
|
|
2238
|
+
"""
|
|
2239
|
+
if not spec.direction_column or not spec.direction_map:
|
|
2240
|
+
return None
|
|
2241
|
+
pairs = ", ".join(
|
|
2242
|
+
f"{cls}: ^{val}$"
|
|
2243
|
+
for cls, val in spec.direction_map.items()
|
|
2244
|
+
)
|
|
2245
|
+
return f"filter: {{{pairs}}}"
|
|
2246
|
+
|
|
2247
|
+
|
|
2248
|
+
def _add_data_validation(
|
|
2249
|
+
ws: Worksheet,
|
|
2250
|
+
param_names: list[str],
|
|
2251
|
+
def_col: int,
|
|
2252
|
+
ea_offset: int,
|
|
2253
|
+
spec: SheetSpec,
|
|
2254
|
+
db_contents: DatabaseContents,
|
|
2255
|
+
max_data_rows: int = 500,
|
|
2256
|
+
data_start_row: int = 4,
|
|
2257
|
+
) -> None:
|
|
2258
|
+
"""Add dropdown validation lists for parameters that have value lists.
|
|
2259
|
+
|
|
2260
|
+
Applies validation from *data_start_row* down to a reasonable limit
|
|
2261
|
+
(not the entire column) to avoid bloating the file.
|
|
2262
|
+
"""
|
|
2263
|
+
from openpyxl.worksheet.datavalidation import DataValidation
|
|
2264
|
+
|
|
2265
|
+
last_row = min(data_start_row + max_data_rows, data_start_row + 500)
|
|
2266
|
+
|
|
2267
|
+
from openpyxl.utils import get_column_letter
|
|
2268
|
+
|
|
2269
|
+
# Entity existence column gets TRUE/FALSE dropdown
|
|
2270
|
+
if ea_offset > 0:
|
|
2271
|
+
ee_col = def_col + 1
|
|
2272
|
+
dv_ee = DataValidation(
|
|
2273
|
+
type="list",
|
|
2274
|
+
formula1='",TRUE,FALSE"',
|
|
2275
|
+
allow_blank=True,
|
|
2276
|
+
showDropDown=False,
|
|
2277
|
+
)
|
|
2278
|
+
dv_ee.error = "Select TRUE or FALSE"
|
|
2279
|
+
dv_ee.errorTitle = "Entity existence"
|
|
2280
|
+
ee_letter = get_column_letter(ee_col)
|
|
2281
|
+
dv_ee.sqref = f"{ee_letter}{data_start_row}:{ee_letter}{last_row}"
|
|
2282
|
+
ws.add_data_validation(dv_ee)
|
|
2283
|
+
|
|
2284
|
+
for i, pname in enumerate(param_names):
|
|
2285
|
+
col = def_col + 1 + ea_offset + i
|
|
2286
|
+
values = _get_param_value_list(pname, spec.entity_classes, db_contents)
|
|
2287
|
+
|
|
2288
|
+
# Boolean (array) params get TRUE/FALSE dropdown
|
|
2289
|
+
dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout="constant")
|
|
2290
|
+
if dtype == "boolean (array)" and not values:
|
|
2291
|
+
values = ["TRUE", "FALSE"]
|
|
2292
|
+
|
|
2293
|
+
if not values:
|
|
2294
|
+
continue
|
|
2295
|
+
# Add empty choice so user can clear a selection
|
|
2296
|
+
all_values = [""] + values
|
|
2297
|
+
formula = ",".join(all_values)
|
|
2298
|
+
dv = DataValidation(
|
|
2299
|
+
type="list",
|
|
2300
|
+
formula1=f'"{formula}"',
|
|
2301
|
+
allow_blank=True,
|
|
2302
|
+
showDropDown=False,
|
|
2303
|
+
)
|
|
2304
|
+
dv.error = f"Value must be one of: {', '.join(values)}"
|
|
2305
|
+
dv.errorTitle = f"Invalid {pname}"
|
|
2306
|
+
col_letter = get_column_letter(col)
|
|
2307
|
+
dv.sqref = f"{col_letter}{data_start_row}:{col_letter}{last_row}"
|
|
2308
|
+
ws.add_data_validation(dv)
|
|
2309
|
+
|
|
2310
|
+
|
|
2311
|
+
# Period-only params from settings, set by export_to_excel.
|
|
2312
|
+
_period_only_params: dict[str, list[str]] = {}
|
|
2313
|
+
_time_structure_classes: set[str] = set()
|
|
2314
|
+
_info_rows: dict[str, str] = {}
|
|
2315
|
+
_stochastic_index_names: list[str] = ["forecast", "branch_time", "time", "is_realized"]
|
|
2316
|
+
|
|
2317
|
+
|
|
2318
|
+
def _is_param_valid_for_layout(
|
|
2319
|
+
pdef: dict, layout: str, entity_class: str = "",
|
|
2320
|
+
) -> bool:
|
|
2321
|
+
"""Check if a parameter is valid for a given sheet layout."""
|
|
2322
|
+
type_list = pdef.get("parameter_type_list")
|
|
2323
|
+
if not type_list:
|
|
2324
|
+
return layout == "constant"
|
|
2325
|
+
types = set(type_list)
|
|
2326
|
+
pname = pdef["name"]
|
|
2327
|
+
|
|
2328
|
+
if layout == "constant":
|
|
2329
|
+
return bool(types & {"str", "float", "array", "2d_map"})
|
|
2330
|
+
elif layout == "periodic":
|
|
2331
|
+
return bool(types & {"1d_map", "2d_map", "array"})
|
|
2332
|
+
elif layout == "timeseries":
|
|
2333
|
+
if not (types & {"1d_map", "3d_map", "4d_map"}):
|
|
2334
|
+
return False
|
|
2335
|
+
# Filter out period-only params
|
|
2336
|
+
period_only = _period_only_params.get(entity_class, [])
|
|
2337
|
+
if pname in period_only:
|
|
2338
|
+
return False
|
|
2339
|
+
return True
|
|
2340
|
+
return True
|
|
2341
|
+
|
|
2342
|
+
|
|
2343
|
+
def _get_all_param_defs_for_class(
|
|
2344
|
+
entity_classes: list[str],
|
|
2345
|
+
db_contents: DatabaseContents,
|
|
2346
|
+
layout: str = "constant",
|
|
2347
|
+
) -> list[dict]:
|
|
2348
|
+
"""Get parameter definitions valid for the given layout, deduplicated by name."""
|
|
2349
|
+
seen: set[str] = set()
|
|
2350
|
+
result: list[dict] = []
|
|
2351
|
+
for entity_class in entity_classes:
|
|
2352
|
+
for pdef in db_contents.parameter_definitions.get(entity_class, []):
|
|
2353
|
+
if pdef["name"] not in seen and _is_param_valid_for_layout(pdef, layout, entity_class):
|
|
2354
|
+
seen.add(pdef["name"])
|
|
2355
|
+
result.append(pdef)
|
|
2356
|
+
result.sort(key=lambda p: p["name"])
|
|
2357
|
+
return result
|
|
2358
|
+
|
|
2359
|
+
|
|
2360
|
+
def _write_param_reference(
|
|
2361
|
+
ws: Worksheet,
|
|
2362
|
+
start_col: int,
|
|
2363
|
+
spec: SheetSpec,
|
|
2364
|
+
db_contents: DatabaseContents,
|
|
2365
|
+
header_row: int = 3,
|
|
2366
|
+
n_data_rows: int = 0,
|
|
2367
|
+
layout: str = "constant",
|
|
2368
|
+
shown_params: list[str] | None = None,
|
|
2369
|
+
row_types: dict[int, str] | None = None,
|
|
2370
|
+
) -> None:
|
|
2371
|
+
"""Write reference sections to the right of the data area.
|
|
2372
|
+
|
|
2373
|
+
For transposed sheets with row_types (multi-param timeseries):
|
|
2374
|
+
Writes a "quadruplet" section mirroring description/data_type/
|
|
2375
|
+
alternative(empty)/parameter rows, plus a convenience section below.
|
|
2376
|
+
|
|
2377
|
+
For standard sheets:
|
|
2378
|
+
Writes only the convenience section (parameter name | description).
|
|
2379
|
+
"""
|
|
2380
|
+
if not shown_params:
|
|
2381
|
+
return
|
|
2382
|
+
|
|
2383
|
+
# Get the full definitions for the shown params (preserving order)
|
|
2384
|
+
all_pdefs_map: dict[str, dict] = {}
|
|
2385
|
+
for entity_class in spec.entity_classes:
|
|
2386
|
+
for pdef in db_contents.parameter_definitions.get(entity_class, []):
|
|
2387
|
+
all_pdefs_map[pdef["name"]] = pdef
|
|
2388
|
+
|
|
2389
|
+
# Get ALL valid params for this layout (for the quadruplet — extra params not shown)
|
|
2390
|
+
all_valid = _get_all_param_defs_for_class(spec.entity_classes, db_contents, layout=layout)
|
|
2391
|
+
shown_set = set(shown_params)
|
|
2392
|
+
[p for p in all_valid if p["name"] not in shown_set]
|
|
2393
|
+
|
|
2394
|
+
pdefs = [all_pdefs_map[p] for p in shown_params if p in all_pdefs_map]
|
|
2395
|
+
if not pdefs:
|
|
2396
|
+
return
|
|
2397
|
+
|
|
2398
|
+
ref_col = start_col + 1 # after empty separator column
|
|
2399
|
+
|
|
2400
|
+
# ------------------------------------------------------------------
|
|
2401
|
+
# Quadruplet section for transposed sheets (description, data_type,
|
|
2402
|
+
# alternative[empty], parameter) — shows ALL valid params so user
|
|
2403
|
+
# can copy columns when adding new entities.
|
|
2404
|
+
# ------------------------------------------------------------------
|
|
2405
|
+
if row_types and all_valid:
|
|
2406
|
+
for i, pdef in enumerate(all_valid):
|
|
2407
|
+
col = ref_col + i
|
|
2408
|
+
pname = pdef["name"]
|
|
2409
|
+
for row_num, rtype in row_types.items():
|
|
2410
|
+
if rtype == "description":
|
|
2411
|
+
desc = pdef.get("description", "")
|
|
2412
|
+
if desc:
|
|
2413
|
+
ws.cell(row=row_num, column=col, value=desc)
|
|
2414
|
+
elif rtype == "data_type":
|
|
2415
|
+
dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout=layout)
|
|
2416
|
+
ws.cell(row=row_num, column=col, value=dtype)
|
|
2417
|
+
elif rtype == "alternative":
|
|
2418
|
+
pass # leave empty — user fills this when copying
|
|
2419
|
+
elif rtype == "parameter":
|
|
2420
|
+
ws.cell(row=row_num, column=col, value=pname)
|
|
2421
|
+
# entity row: leave empty
|
|
2422
|
+
|
|
2423
|
+
# ------------------------------------------------------------------
|
|
2424
|
+
# Convenience section (parameter | data type | description)
|
|
2425
|
+
# ------------------------------------------------------------------
|
|
2426
|
+
conv_row = header_row + 2 # one empty row after header
|
|
2427
|
+
|
|
2428
|
+
# Header
|
|
2429
|
+
ws.cell(row=conv_row, column=ref_col, value="Parameter")
|
|
2430
|
+
ws.cell(row=conv_row, column=ref_col).fill = FILL_PARAM_HEADER
|
|
2431
|
+
ws.cell(row=conv_row, column=ref_col + 1, value="Data type")
|
|
2432
|
+
ws.cell(row=conv_row, column=ref_col + 1).fill = FILL_PARAM_HEADER
|
|
2433
|
+
ws.cell(row=conv_row, column=ref_col + 2, value="Description")
|
|
2434
|
+
ws.cell(row=conv_row, column=ref_col + 2).fill = FILL_PARAM_HEADER
|
|
2435
|
+
|
|
2436
|
+
max_name_len = len("Parameter")
|
|
2437
|
+
for i, pdef in enumerate(pdefs):
|
|
2438
|
+
row = conv_row + 1 + i
|
|
2439
|
+
name = pdef["name"]
|
|
2440
|
+
ws.cell(row=row, column=ref_col, value=name)
|
|
2441
|
+
if len(name) > max_name_len:
|
|
2442
|
+
max_name_len = len(name)
|
|
2443
|
+
dtype = _get_param_data_type(name, spec.entity_classes, db_contents, layout=layout)
|
|
2444
|
+
ws.cell(row=row, column=ref_col + 1, value=dtype)
|
|
2445
|
+
desc = pdef.get("description")
|
|
2446
|
+
if desc:
|
|
2447
|
+
ws.cell(row=row, column=ref_col + 2, value=desc)
|
|
2448
|
+
|
|
2449
|
+
# Size the parameter name column and data type column
|
|
2450
|
+
from openpyxl.utils import get_column_letter
|
|
2451
|
+
col_letter = get_column_letter(ref_col)
|
|
2452
|
+
ws.column_dimensions[col_letter].width = max_name_len + 2
|
|
2453
|
+
# Data type column: 50% wider than param name column
|
|
2454
|
+
dt_col_letter = get_column_letter(ref_col + 1)
|
|
2455
|
+
ws.column_dimensions[dt_col_letter].width = int((max_name_len + 2) * 1.5)
|
|
2456
|
+
|
|
2457
|
+
|
|
2458
|
+
def _lock_metadata_cells(
|
|
2459
|
+
ws: Worksheet,
|
|
2460
|
+
meta_rows: int,
|
|
2461
|
+
meta_cols: int,
|
|
2462
|
+
data_start_row: int,
|
|
2463
|
+
data_start_col: int,
|
|
2464
|
+
max_data_rows: int = 500,
|
|
2465
|
+
) -> None:
|
|
2466
|
+
"""Apply sheet protection, locking metadata cells and unlocking data cells.
|
|
2467
|
+
|
|
2468
|
+
Uses column-level unlocking for efficiency: sets entire data columns as
|
|
2469
|
+
unlocked rather than individual cells. For the metadata columns in the
|
|
2470
|
+
data region, only the metadata rows stay locked (the default).
|
|
2471
|
+
|
|
2472
|
+
Skips protection entirely when the data region exceeds *max_data_rows*
|
|
2473
|
+
to avoid bloating the file with per-cell styles.
|
|
2474
|
+
|
|
2475
|
+
Args:
|
|
2476
|
+
ws: Target worksheet.
|
|
2477
|
+
meta_rows: Number of metadata rows (rows 1..meta_rows are locked).
|
|
2478
|
+
meta_cols: Number of metadata columns (columns 1..meta_cols are locked in data rows).
|
|
2479
|
+
data_start_row: First row of the data region.
|
|
2480
|
+
data_start_col: First column of the data region.
|
|
2481
|
+
max_data_rows: Skip protection if data exceeds this many rows.
|
|
2482
|
+
"""
|
|
2483
|
+
# TODO: Re-enable sheet protection once LibreOffice compatibility is verified.
|
|
2484
|
+
# The current openpyxl set_password("") creates a hash that can crash
|
|
2485
|
+
# LibreOffice on some systems. Disabled for now.
|
|
2486
|
+
return
|
|
2487
|
+
|
|
2488
|
+
|
|
2489
|
+
# ---------------------------------------------------------------------------
|
|
2490
|
+
# v2 Constant sheet
|
|
2491
|
+
# ---------------------------------------------------------------------------
|
|
2492
|
+
|
|
2493
|
+
|
|
2494
|
+
def write_constant_sheet_v2(
|
|
2495
|
+
ws: Worksheet,
|
|
2496
|
+
spec: SheetSpec,
|
|
2497
|
+
db_contents: DatabaseContents,
|
|
2498
|
+
) -> None:
|
|
2499
|
+
"""Write a constant-layout sheet in the v2 self-describing format.
|
|
2500
|
+
|
|
2501
|
+
Layout:
|
|
2502
|
+
Row 1: navigate | (empty) | description | desc/param descriptions...
|
|
2503
|
+
Row 2: (empty) | (empty) | data type | string/float...
|
|
2504
|
+
Row 3: alternative | entity: X | [filter:] [index:] parameter | [entity existence] | param names...
|
|
2505
|
+
Row 4+: data
|
|
2506
|
+
"""
|
|
2507
|
+
# Determine the definition column position
|
|
2508
|
+
n_entity_cols = len(spec.entity_columns)
|
|
2509
|
+
n_extra_entity = len(spec.extra_entity_columns)
|
|
2510
|
+
n_dims = n_entity_cols
|
|
2511
|
+
|
|
2512
|
+
# Build left-side column labels for row 3
|
|
2513
|
+
left_cols: list[str] = ["alternative"]
|
|
2514
|
+
entity_label = _build_entity_def_label(spec)
|
|
2515
|
+
left_cols.append(entity_label)
|
|
2516
|
+
|
|
2517
|
+
# For multi-dim entities, add ALL individual dim name columns after the
|
|
2518
|
+
# entity label (the entity label column has no data in data rows)
|
|
2519
|
+
if n_dims > 1:
|
|
2520
|
+
for dim_name in spec.entity_columns:
|
|
2521
|
+
left_cols.append(dim_name)
|
|
2522
|
+
|
|
2523
|
+
# Extra entity columns (e.g. left_node, right_node for connection)
|
|
2524
|
+
for extra_col in spec.extra_entity_columns:
|
|
2525
|
+
left_cols.append(extra_col)
|
|
2526
|
+
|
|
2527
|
+
# Filter column for direction
|
|
2528
|
+
filter_label = _build_filter_label(spec)
|
|
2529
|
+
filter_col_pos: int | None = None
|
|
2530
|
+
if filter_label:
|
|
2531
|
+
left_cols.append(filter_label)
|
|
2532
|
+
filter_col_pos = len(left_cols) # 1-based
|
|
2533
|
+
|
|
2534
|
+
# Unpack index column
|
|
2535
|
+
index_col_positions: set[int] = set()
|
|
2536
|
+
if spec.unpack_index_column:
|
|
2537
|
+
left_cols.append(f"index: {spec.unpack_index_column}")
|
|
2538
|
+
index_col_positions.add(len(left_cols)) # 1-based
|
|
2539
|
+
|
|
2540
|
+
def_col = len(left_cols) + 1 # 1-based position of definition column
|
|
2541
|
+
|
|
2542
|
+
# Build right-side columns (from def_col onward)
|
|
2543
|
+
right_cols: list[str] = ["parameter"]
|
|
2544
|
+
if spec.has_entity_alternative:
|
|
2545
|
+
right_cols.append("entity existence")
|
|
2546
|
+
# All parameters (pre_ea_params + parameter_names)
|
|
2547
|
+
all_params = list(spec.pre_ea_params) + list(spec.parameter_names)
|
|
2548
|
+
right_cols.extend(all_params)
|
|
2549
|
+
|
|
2550
|
+
# --- Row 1: navigate + descriptions (from def_col onward) ---
|
|
2551
|
+
ws.cell(row=1, column=1, value="navigate")
|
|
2552
|
+
ws.cell(row=1, column=def_col, value="description")
|
|
2553
|
+
ea_offset = 1 if spec.has_entity_alternative else 0
|
|
2554
|
+
if spec.has_entity_alternative:
|
|
2555
|
+
ws.cell(row=1, column=def_col + 1, value="Entity existence")
|
|
2556
|
+
for i, pname in enumerate(all_params):
|
|
2557
|
+
col = def_col + 1 + ea_offset + i
|
|
2558
|
+
desc = spec.descriptions.get(pname)
|
|
2559
|
+
if not desc:
|
|
2560
|
+
desc = _get_param_description(pname, spec.entity_classes, db_contents)
|
|
2561
|
+
if desc:
|
|
2562
|
+
ws.cell(row=1, column=col, value=desc)
|
|
2563
|
+
|
|
2564
|
+
# --- Row 2: data types (from def_col onward) ---
|
|
2565
|
+
ws.cell(row=2, column=def_col, value="data type")
|
|
2566
|
+
if spec.has_entity_alternative:
|
|
2567
|
+
ws.cell(row=2, column=def_col + 1, value="string")
|
|
2568
|
+
for i, pname in enumerate(all_params):
|
|
2569
|
+
col = def_col + 1 + ea_offset + i
|
|
2570
|
+
dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout="constant")
|
|
2571
|
+
ws.cell(row=2, column=col, value=dtype)
|
|
2572
|
+
|
|
2573
|
+
# --- Optional row 3: defaults (only when at least one param has one) ---
|
|
2574
|
+
param_defaults = {
|
|
2575
|
+
pname: _get_param_default_string(pname, spec.entity_classes, db_contents)
|
|
2576
|
+
for pname in all_params
|
|
2577
|
+
}
|
|
2578
|
+
has_default_row = any(v is not None for v in param_defaults.values())
|
|
2579
|
+
if has_default_row:
|
|
2580
|
+
default_row = 3
|
|
2581
|
+
ws.cell(row=default_row, column=def_col, value="default")
|
|
2582
|
+
for i, pname in enumerate(all_params):
|
|
2583
|
+
col = def_col + 1 + ea_offset + i
|
|
2584
|
+
dv = param_defaults[pname]
|
|
2585
|
+
if dv is not None:
|
|
2586
|
+
ws.cell(row=default_row, column=col, value=dv)
|
|
2587
|
+
def_row = 4
|
|
2588
|
+
else:
|
|
2589
|
+
def_row = 3
|
|
2590
|
+
data_start_row = def_row + 1
|
|
2591
|
+
|
|
2592
|
+
# --- Definition row ---
|
|
2593
|
+
for col_idx, label in enumerate(left_cols, start=1):
|
|
2594
|
+
ws.cell(row=def_row, column=col_idx, value=label)
|
|
2595
|
+
for col_idx, label in enumerate(right_cols, start=def_col):
|
|
2596
|
+
ws.cell(row=def_row, column=col_idx, value=label)
|
|
2597
|
+
|
|
2598
|
+
# --- Map column positions for data writing ---
|
|
2599
|
+
col_alt = 1
|
|
2600
|
+
|
|
2601
|
+
if n_dims <= 1:
|
|
2602
|
+
entity_data_cols = [2]
|
|
2603
|
+
next_col_after_entity = 3
|
|
2604
|
+
else:
|
|
2605
|
+
# Multi-dim: entity label in col 2 (no data in data rows),
|
|
2606
|
+
# dims in cols 3..2+n_dims
|
|
2607
|
+
entity_data_cols = list(range(3, 3 + n_dims))
|
|
2608
|
+
next_col_after_entity = 3 + n_dims
|
|
2609
|
+
|
|
2610
|
+
extra_entity_data_cols = list(
|
|
2611
|
+
range(next_col_after_entity, next_col_after_entity + n_extra_entity)
|
|
2612
|
+
)
|
|
2613
|
+
next_col = next_col_after_entity + n_extra_entity
|
|
2614
|
+
|
|
2615
|
+
filter_data_col: int | None = None
|
|
2616
|
+
if filter_label:
|
|
2617
|
+
filter_data_col = next_col
|
|
2618
|
+
next_col += 1
|
|
2619
|
+
|
|
2620
|
+
unpack_data_col: int | None = None
|
|
2621
|
+
if spec.unpack_index_column:
|
|
2622
|
+
unpack_data_col = next_col
|
|
2623
|
+
next_col += 1
|
|
2624
|
+
|
|
2625
|
+
# def_col should match
|
|
2626
|
+
assert next_col == def_col, f"Column mismatch: expected def_col={def_col}, got {next_col}"
|
|
2627
|
+
|
|
2628
|
+
ea_data_col: int | None = None
|
|
2629
|
+
param_data_start = def_col + 1
|
|
2630
|
+
if spec.has_entity_alternative:
|
|
2631
|
+
ea_data_col = def_col + 1
|
|
2632
|
+
param_data_start = def_col + 2
|
|
2633
|
+
|
|
2634
|
+
param_col_map: dict[str, int] = {}
|
|
2635
|
+
for i, pname in enumerate(all_params):
|
|
2636
|
+
param_col_map[pname] = param_data_start + i
|
|
2637
|
+
|
|
2638
|
+
# --- Collect data rows ---
|
|
2639
|
+
data_rows: list[list[tuple[int, Any]]] = []
|
|
2640
|
+
|
|
2641
|
+
if spec.unpack_index_column:
|
|
2642
|
+
_collect_unpack_rows_v2(
|
|
2643
|
+
data_rows, spec, db_contents,
|
|
2644
|
+
col_alt=col_alt,
|
|
2645
|
+
entity_data_cols=entity_data_cols,
|
|
2646
|
+
extra_entity_data_cols=extra_entity_data_cols,
|
|
2647
|
+
filter_data_col=filter_data_col,
|
|
2648
|
+
unpack_data_col=unpack_data_col,
|
|
2649
|
+
ea_data_col=ea_data_col,
|
|
2650
|
+
param_col_map=param_col_map,
|
|
2651
|
+
)
|
|
2652
|
+
else:
|
|
2653
|
+
_collect_constant_rows_v2(
|
|
2654
|
+
data_rows, spec, db_contents,
|
|
2655
|
+
col_alt=col_alt,
|
|
2656
|
+
entity_data_cols=entity_data_cols,
|
|
2657
|
+
extra_entity_data_cols=extra_entity_data_cols,
|
|
2658
|
+
filter_data_col=filter_data_col,
|
|
2659
|
+
ea_data_col=ea_data_col,
|
|
2660
|
+
param_col_map=param_col_map,
|
|
2661
|
+
)
|
|
2662
|
+
|
|
2663
|
+
# --- Sort rows ---
|
|
2664
|
+
data_rows.sort(key=lambda r: tuple(
|
|
2665
|
+
str(v) if v is not None else "" for _, v in r
|
|
2666
|
+
))
|
|
2667
|
+
|
|
2668
|
+
# --- Write data rows (starting at data_start_row) ---
|
|
2669
|
+
for row_idx, row_cells in enumerate(data_rows, start=data_start_row):
|
|
2670
|
+
for col, value in row_cells:
|
|
2671
|
+
if value is not None:
|
|
2672
|
+
ws.cell(row=row_idx, column=col, value=value)
|
|
2673
|
+
|
|
2674
|
+
# --- Write parameter reference section ---
|
|
2675
|
+
last_data_col = def_col + len(right_cols) - 1
|
|
2676
|
+
_write_param_reference(
|
|
2677
|
+
ws, last_data_col + 1, spec, db_contents, header_row=def_row,
|
|
2678
|
+
n_data_rows=len(data_rows), layout="constant",
|
|
2679
|
+
shown_params=all_params,
|
|
2680
|
+
)
|
|
2681
|
+
|
|
2682
|
+
# --- Formatting ---
|
|
2683
|
+
n_extra = (
|
|
2684
|
+
n_extra_entity
|
|
2685
|
+
+ (1 if filter_label else 0)
|
|
2686
|
+
+ (1 if spec.unpack_index_column else 0)
|
|
2687
|
+
)
|
|
2688
|
+
format_constant_sheet_v2(
|
|
2689
|
+
ws, n_entity_cols, n_extra, def_col, index_col_positions,
|
|
2690
|
+
last_data_col, def_row=def_row,
|
|
2691
|
+
)
|
|
2692
|
+
add_navigate_link(ws)
|
|
2693
|
+
|
|
2694
|
+
# --- Data validation (dropdown lists) for parameters with value lists ---
|
|
2695
|
+
_add_data_validation(
|
|
2696
|
+
ws, all_params, def_col, ea_offset, spec, db_contents,
|
|
2697
|
+
max_data_rows=max(len(data_rows), 1) + 3,
|
|
2698
|
+
data_start_row=data_start_row,
|
|
2699
|
+
)
|
|
2700
|
+
|
|
2701
|
+
# --- Filter dropdown for merged classes ---
|
|
2702
|
+
if filter_col_pos is not None and spec.direction_map:
|
|
2703
|
+
from openpyxl.worksheet.datavalidation import DataValidation
|
|
2704
|
+
from openpyxl.utils import get_column_letter
|
|
2705
|
+
filter_values = sorted(set(spec.direction_map.values()))
|
|
2706
|
+
filter_list = ",".join([""] + filter_values)
|
|
2707
|
+
dv_filter = DataValidation(
|
|
2708
|
+
type="list",
|
|
2709
|
+
formula1=f'"{filter_list}"',
|
|
2710
|
+
allow_blank=True,
|
|
2711
|
+
showDropDown=False,
|
|
2712
|
+
)
|
|
2713
|
+
dv_filter.error = f"Select: {', '.join(filter_values)}"
|
|
2714
|
+
dv_filter.errorTitle = spec.direction_column or "Filter"
|
|
2715
|
+
cl = get_column_letter(filter_col_pos)
|
|
2716
|
+
last_row = max(len(data_rows) + data_start_row - 1, data_start_row + 500)
|
|
2717
|
+
dv_filter.sqref = f"{cl}{data_start_row}:{cl}{last_row}"
|
|
2718
|
+
ws.add_data_validation(dv_filter)
|
|
2719
|
+
|
|
2720
|
+
auto_column_width(ws, min_param_width=_min_param_width,
|
|
2721
|
+
non_param_width=_non_param_width,
|
|
2722
|
+
def_col_width=_def_col_width,
|
|
2723
|
+
index_col_width=_index_col_width,
|
|
2724
|
+
header_row=def_row, def_col=def_col,
|
|
2725
|
+
index_cols=index_col_positions,
|
|
2726
|
+
last_data_col=last_data_col)
|
|
2727
|
+
|
|
2728
|
+
# --- Lock metadata ---
|
|
2729
|
+
_lock_metadata_cells(
|
|
2730
|
+
ws,
|
|
2731
|
+
meta_rows=def_row,
|
|
2732
|
+
meta_cols=def_col,
|
|
2733
|
+
data_start_row=data_start_row,
|
|
2734
|
+
data_start_col=1,
|
|
2735
|
+
)
|
|
2736
|
+
|
|
2737
|
+
|
|
2738
|
+
def _collect_constant_rows_v2(
|
|
2739
|
+
data_rows: list[list[tuple[int, Any]]],
|
|
2740
|
+
spec: SheetSpec,
|
|
2741
|
+
db_contents: DatabaseContents,
|
|
2742
|
+
*,
|
|
2743
|
+
col_alt: int,
|
|
2744
|
+
entity_data_cols: list[int],
|
|
2745
|
+
extra_entity_data_cols: list[int],
|
|
2746
|
+
filter_data_col: int | None,
|
|
2747
|
+
ea_data_col: int | None,
|
|
2748
|
+
param_col_map: dict[str, int],
|
|
2749
|
+
) -> None:
|
|
2750
|
+
"""Collect rows for a v2 constant sheet (non-unpack)."""
|
|
2751
|
+
all_params = list(spec.pre_ea_params) + list(spec.parameter_names)
|
|
2752
|
+
|
|
2753
|
+
for entity_class in spec.entity_classes:
|
|
2754
|
+
entities = db_contents.entities.get(entity_class, [])
|
|
2755
|
+
direction = _get_direction(entity_class, spec)
|
|
2756
|
+
|
|
2757
|
+
for entity in entities:
|
|
2758
|
+
entity_byname = entity["entity_byname"]
|
|
2759
|
+
alts = _find_alternatives_for_entity(
|
|
2760
|
+
entity_class, entity_byname, spec, db_contents
|
|
2761
|
+
)
|
|
2762
|
+
|
|
2763
|
+
for alt in alts:
|
|
2764
|
+
cells: list[tuple[int, Any]] = []
|
|
2765
|
+
|
|
2766
|
+
# Alternative
|
|
2767
|
+
cells.append((col_alt, alt))
|
|
2768
|
+
|
|
2769
|
+
# Entity dimension values
|
|
2770
|
+
for i, col in enumerate(entity_data_cols):
|
|
2771
|
+
if i < len(entity_byname):
|
|
2772
|
+
cells.append((col, str(_to_native(entity_byname[i]))))
|
|
2773
|
+
|
|
2774
|
+
# Extra entity columns
|
|
2775
|
+
if spec.extra_entity_columns:
|
|
2776
|
+
extras = _get_extra_columns_for_entity(
|
|
2777
|
+
entity_byname, entity_class, spec, db_contents
|
|
2778
|
+
)
|
|
2779
|
+
for i, col in enumerate(extra_entity_data_cols):
|
|
2780
|
+
if i < len(extras):
|
|
2781
|
+
cells.append((col, extras[i]))
|
|
2782
|
+
|
|
2783
|
+
# Direction/filter value
|
|
2784
|
+
if filter_data_col is not None and direction is not None:
|
|
2785
|
+
cells.append((filter_data_col, direction))
|
|
2786
|
+
|
|
2787
|
+
# Entity existence
|
|
2788
|
+
if ea_data_col is not None:
|
|
2789
|
+
ea_key = (entity_class, entity_byname, alt)
|
|
2790
|
+
ea_val = db_contents.entity_alternatives.get(ea_key)
|
|
2791
|
+
if ea_val is not None:
|
|
2792
|
+
cells.append((ea_data_col, "TRUE" if ea_val else "FALSE"))
|
|
2793
|
+
|
|
2794
|
+
# Parameter values — check for Arrays
|
|
2795
|
+
param_values: dict[str, Any] = {}
|
|
2796
|
+
has_array = False
|
|
2797
|
+
array_param: str | None = None
|
|
2798
|
+
array_elements: list[Any] = []
|
|
2799
|
+
|
|
2800
|
+
for pname in all_params:
|
|
2801
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
2802
|
+
value = db_contents.parameter_values.get(key)
|
|
2803
|
+
|
|
2804
|
+
if value is None:
|
|
2805
|
+
param_values[pname] = None
|
|
2806
|
+
elif _is_map(value):
|
|
2807
|
+
param_values[pname] = None
|
|
2808
|
+
elif _is_array(value):
|
|
2809
|
+
has_array = True
|
|
2810
|
+
array_param = pname
|
|
2811
|
+
array_elements = [_to_native(v) for v in value.values]
|
|
2812
|
+
param_values[pname] = None
|
|
2813
|
+
elif _is_scalar(value):
|
|
2814
|
+
param_values[pname] = _to_native(value)
|
|
2815
|
+
else:
|
|
2816
|
+
param_values[pname] = None
|
|
2817
|
+
|
|
2818
|
+
if has_array and array_elements and array_param is not None:
|
|
2819
|
+
for elem in array_elements:
|
|
2820
|
+
expanded_cells = list(cells)
|
|
2821
|
+
for pname in all_params:
|
|
2822
|
+
col = param_col_map[pname]
|
|
2823
|
+
if pname == array_param:
|
|
2824
|
+
expanded_cells.append((col, elem))
|
|
2825
|
+
elif param_values[pname] is not None:
|
|
2826
|
+
expanded_cells.append((col, param_values[pname]))
|
|
2827
|
+
data_rows.append(expanded_cells)
|
|
2828
|
+
else:
|
|
2829
|
+
for pname in all_params:
|
|
2830
|
+
if param_values[pname] is not None:
|
|
2831
|
+
cells.append((param_col_map[pname], param_values[pname]))
|
|
2832
|
+
data_rows.append(cells)
|
|
2833
|
+
|
|
2834
|
+
|
|
2835
|
+
def _collect_unpack_rows_v2(
|
|
2836
|
+
data_rows: list[list[tuple[int, Any]]],
|
|
2837
|
+
spec: SheetSpec,
|
|
2838
|
+
db_contents: DatabaseContents,
|
|
2839
|
+
*,
|
|
2840
|
+
col_alt: int,
|
|
2841
|
+
entity_data_cols: list[int],
|
|
2842
|
+
extra_entity_data_cols: list[int],
|
|
2843
|
+
filter_data_col: int | None,
|
|
2844
|
+
unpack_data_col: int | None,
|
|
2845
|
+
ea_data_col: int | None,
|
|
2846
|
+
param_col_map: dict[str, int],
|
|
2847
|
+
) -> None:
|
|
2848
|
+
"""Collect rows for a v2 unpacked-Map constant sheet."""
|
|
2849
|
+
for entity_class in spec.entity_classes:
|
|
2850
|
+
entities = db_contents.entities.get(entity_class, [])
|
|
2851
|
+
direction = _get_direction(entity_class, spec)
|
|
2852
|
+
|
|
2853
|
+
for entity in entities:
|
|
2854
|
+
entity_byname = entity["entity_byname"]
|
|
2855
|
+
alts = _find_alternatives_for_entity(
|
|
2856
|
+
entity_class, entity_byname, spec, db_contents
|
|
2857
|
+
)
|
|
2858
|
+
|
|
2859
|
+
for alt in alts:
|
|
2860
|
+
# Collect all map indexes across all unpack params
|
|
2861
|
+
all_indexes: set[str] = set()
|
|
2862
|
+
param_maps: dict[str, Map] = {}
|
|
2863
|
+
|
|
2864
|
+
for pname in spec.parameter_names:
|
|
2865
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
2866
|
+
value = db_contents.parameter_values.get(key)
|
|
2867
|
+
if value is not None and _is_map(value):
|
|
2868
|
+
param_maps[pname] = value
|
|
2869
|
+
for idx in value.indexes:
|
|
2870
|
+
all_indexes.add(str(_to_native(idx)))
|
|
2871
|
+
|
|
2872
|
+
if not all_indexes:
|
|
2873
|
+
continue
|
|
2874
|
+
|
|
2875
|
+
for idx_val in sorted(all_indexes):
|
|
2876
|
+
cells: list[tuple[int, Any]] = []
|
|
2877
|
+
|
|
2878
|
+
# Alternative
|
|
2879
|
+
cells.append((col_alt, alt))
|
|
2880
|
+
|
|
2881
|
+
# Entity dimension values
|
|
2882
|
+
for i, col in enumerate(entity_data_cols):
|
|
2883
|
+
if i < len(entity_byname):
|
|
2884
|
+
cells.append((col, str(_to_native(entity_byname[i]))))
|
|
2885
|
+
|
|
2886
|
+
# Extra entity columns
|
|
2887
|
+
if spec.extra_entity_columns:
|
|
2888
|
+
extras = _get_extra_columns_for_entity(
|
|
2889
|
+
entity_byname, entity_class, spec, db_contents
|
|
2890
|
+
)
|
|
2891
|
+
for i, col in enumerate(extra_entity_data_cols):
|
|
2892
|
+
if i < len(extras):
|
|
2893
|
+
cells.append((col, extras[i]))
|
|
2894
|
+
|
|
2895
|
+
# Direction/filter value
|
|
2896
|
+
if filter_data_col is not None and direction is not None:
|
|
2897
|
+
cells.append((filter_data_col, direction))
|
|
2898
|
+
|
|
2899
|
+
# Unpack index value
|
|
2900
|
+
if unpack_data_col is not None:
|
|
2901
|
+
cells.append((unpack_data_col, idx_val))
|
|
2902
|
+
|
|
2903
|
+
# Entity existence
|
|
2904
|
+
if ea_data_col is not None:
|
|
2905
|
+
ea_key = (entity_class, entity_byname, alt)
|
|
2906
|
+
ea_val = db_contents.entity_alternatives.get(ea_key)
|
|
2907
|
+
if ea_val is not None:
|
|
2908
|
+
cells.append((ea_data_col, ea_val))
|
|
2909
|
+
|
|
2910
|
+
# Parameter values from maps
|
|
2911
|
+
for pname in spec.parameter_names:
|
|
2912
|
+
m = param_maps.get(pname)
|
|
2913
|
+
if m is not None:
|
|
2914
|
+
for mi, mv in zip(m.indexes, m.values):
|
|
2915
|
+
if str(_to_native(mi)) == idx_val:
|
|
2916
|
+
cells.append((param_col_map[pname], _to_native(mv)))
|
|
2917
|
+
break
|
|
2918
|
+
|
|
2919
|
+
# Pre-EA scalar params (these are not maps)
|
|
2920
|
+
for pname in spec.pre_ea_params:
|
|
2921
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
2922
|
+
value = db_contents.parameter_values.get(key)
|
|
2923
|
+
if value is not None and _is_scalar(value):
|
|
2924
|
+
cells.append((param_col_map[pname], _to_native(value)))
|
|
2925
|
+
|
|
2926
|
+
data_rows.append(cells)
|
|
2927
|
+
|
|
2928
|
+
|
|
2929
|
+
# ---------------------------------------------------------------------------
|
|
2930
|
+
# v2 Periodic sheet
|
|
2931
|
+
# ---------------------------------------------------------------------------
|
|
2932
|
+
|
|
2933
|
+
|
|
2934
|
+
def write_periodic_sheet_v2(
|
|
2935
|
+
ws: Worksheet,
|
|
2936
|
+
spec: SheetSpec,
|
|
2937
|
+
db_contents: DatabaseContents,
|
|
2938
|
+
) -> None:
|
|
2939
|
+
"""Write a periodic-layout sheet in the v2 self-describing format.
|
|
2940
|
+
|
|
2941
|
+
Layout:
|
|
2942
|
+
Row 1: navigate | | | description | param descriptions...
|
|
2943
|
+
Row 2: | | | data type | float...
|
|
2944
|
+
Row 3: alternative | entity: X | [filter:] index: period | parameter | param names...
|
|
2945
|
+
Row 4+: data
|
|
2946
|
+
"""
|
|
2947
|
+
# Determine index column name
|
|
2948
|
+
index_col_name = _find_index_column_name(spec, db_contents, default="period")
|
|
2949
|
+
|
|
2950
|
+
n_entity_cols = len(spec.entity_columns)
|
|
2951
|
+
n_dims = n_entity_cols
|
|
2952
|
+
|
|
2953
|
+
# Build left-side columns for row 3
|
|
2954
|
+
left_cols: list[str] = ["alternative"]
|
|
2955
|
+
entity_label = _build_entity_def_label(spec)
|
|
2956
|
+
left_cols.append(entity_label)
|
|
2957
|
+
|
|
2958
|
+
# For multi-dim, add ALL individual dim name columns after entity label
|
|
2959
|
+
if n_dims > 1:
|
|
2960
|
+
for dim_name in spec.entity_columns:
|
|
2961
|
+
left_cols.append(dim_name)
|
|
2962
|
+
|
|
2963
|
+
# Direction/filter column
|
|
2964
|
+
filter_label = _build_filter_label(spec)
|
|
2965
|
+
if filter_label:
|
|
2966
|
+
left_cols.append(filter_label)
|
|
2967
|
+
|
|
2968
|
+
# Index column (period)
|
|
2969
|
+
left_cols.append(f"index: {index_col_name}")
|
|
2970
|
+
index_col_pos = len(left_cols) # 1-based
|
|
2971
|
+
|
|
2972
|
+
def_col = len(left_cols) + 1
|
|
2973
|
+
|
|
2974
|
+
# Right-side columns
|
|
2975
|
+
right_cols: list[str] = ["parameter"]
|
|
2976
|
+
right_cols.extend(spec.parameter_names)
|
|
2977
|
+
|
|
2978
|
+
# --- Row 1: navigate + descriptions ---
|
|
2979
|
+
ws.cell(row=1, column=1, value="navigate")
|
|
2980
|
+
ws.cell(row=1, column=def_col, value="description")
|
|
2981
|
+
for i, pname in enumerate(spec.parameter_names):
|
|
2982
|
+
col = def_col + 1 + i
|
|
2983
|
+
desc = spec.descriptions.get(pname)
|
|
2984
|
+
if not desc:
|
|
2985
|
+
desc = _get_param_description(pname, spec.entity_classes, db_contents)
|
|
2986
|
+
if desc:
|
|
2987
|
+
ws.cell(row=1, column=col, value=desc)
|
|
2988
|
+
|
|
2989
|
+
# --- Row 2: data types ---
|
|
2990
|
+
ws.cell(row=2, column=def_col, value="data type")
|
|
2991
|
+
for i, pname in enumerate(spec.parameter_names):
|
|
2992
|
+
col = def_col + 1 + i
|
|
2993
|
+
dtype = _get_param_data_type(pname, spec.entity_classes, db_contents, layout="periodic")
|
|
2994
|
+
ws.cell(row=2, column=col, value=dtype)
|
|
2995
|
+
|
|
2996
|
+
# --- Optional row 3: defaults (only when at least one param has one) ---
|
|
2997
|
+
param_defaults = {
|
|
2998
|
+
pname: _get_param_default_string(pname, spec.entity_classes, db_contents)
|
|
2999
|
+
for pname in spec.parameter_names
|
|
3000
|
+
}
|
|
3001
|
+
has_default_row = any(v is not None for v in param_defaults.values())
|
|
3002
|
+
if has_default_row:
|
|
3003
|
+
default_row = 3
|
|
3004
|
+
ws.cell(row=default_row, column=def_col, value="default")
|
|
3005
|
+
for i, pname in enumerate(spec.parameter_names):
|
|
3006
|
+
col = def_col + 1 + i
|
|
3007
|
+
dv = param_defaults[pname]
|
|
3008
|
+
if dv is not None:
|
|
3009
|
+
ws.cell(row=default_row, column=col, value=dv)
|
|
3010
|
+
def_row = 4
|
|
3011
|
+
else:
|
|
3012
|
+
def_row = 3
|
|
3013
|
+
data_start_row = def_row + 1
|
|
3014
|
+
|
|
3015
|
+
# --- Definition row ---
|
|
3016
|
+
for col_idx, label in enumerate(left_cols, start=1):
|
|
3017
|
+
ws.cell(row=def_row, column=col_idx, value=label)
|
|
3018
|
+
for col_idx, label in enumerate(right_cols, start=def_col):
|
|
3019
|
+
ws.cell(row=def_row, column=col_idx, value=label)
|
|
3020
|
+
|
|
3021
|
+
# --- Map column positions ---
|
|
3022
|
+
col_alt = 1
|
|
3023
|
+
|
|
3024
|
+
if n_dims <= 1:
|
|
3025
|
+
entity_data_cols = [2]
|
|
3026
|
+
next_col = 3
|
|
3027
|
+
else:
|
|
3028
|
+
entity_data_cols = list(range(3, 3 + n_dims))
|
|
3029
|
+
next_col = 3 + n_dims
|
|
3030
|
+
|
|
3031
|
+
filter_data_col: int | None = None
|
|
3032
|
+
if filter_label:
|
|
3033
|
+
filter_data_col = next_col
|
|
3034
|
+
next_col += 1
|
|
3035
|
+
|
|
3036
|
+
index_data_col = next_col
|
|
3037
|
+
next_col += 1
|
|
3038
|
+
|
|
3039
|
+
assert next_col == def_col, f"Column mismatch: expected def_col={def_col}, got {next_col}"
|
|
3040
|
+
|
|
3041
|
+
param_data_start = def_col + 1
|
|
3042
|
+
param_col_map: dict[str, int] = {}
|
|
3043
|
+
for i, pname in enumerate(spec.parameter_names):
|
|
3044
|
+
param_col_map[pname] = param_data_start + i
|
|
3045
|
+
|
|
3046
|
+
# --- Collect data rows ---
|
|
3047
|
+
data_rows: list[list[tuple[int, Any]]] = []
|
|
3048
|
+
|
|
3049
|
+
for entity_class in spec.entity_classes:
|
|
3050
|
+
entities = db_contents.entities.get(entity_class, [])
|
|
3051
|
+
direction = _get_direction(entity_class, spec)
|
|
3052
|
+
|
|
3053
|
+
for entity in entities:
|
|
3054
|
+
entity_byname = entity["entity_byname"]
|
|
3055
|
+
alts = _find_alternatives_for_entity(
|
|
3056
|
+
entity_class, entity_byname, spec, db_contents
|
|
3057
|
+
)
|
|
3058
|
+
|
|
3059
|
+
for alt in alts:
|
|
3060
|
+
all_indexes: set[str] = set()
|
|
3061
|
+
param_maps: dict[str, Map] = {}
|
|
3062
|
+
param_arrays: dict[str, list[str]] = {}
|
|
3063
|
+
param_scalars: dict[str, Any] = {}
|
|
3064
|
+
empty_collections: list[str] = []
|
|
3065
|
+
|
|
3066
|
+
for pname in spec.parameter_names:
|
|
3067
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
3068
|
+
value = db_contents.parameter_values.get(key)
|
|
3069
|
+
if value is None:
|
|
3070
|
+
continue
|
|
3071
|
+
if _is_map(value):
|
|
3072
|
+
is_nested = any(_is_map(mv) for mv in value.values)
|
|
3073
|
+
if is_nested:
|
|
3074
|
+
continue
|
|
3075
|
+
# Skip time-indexed maps on periodic sheets — unless
|
|
3076
|
+
# the entity class defines time structure itself
|
|
3077
|
+
if _is_time_indexed_map(value) and entity_class not in _time_structure_classes:
|
|
3078
|
+
continue
|
|
3079
|
+
if not value.indexes:
|
|
3080
|
+
# Empty Map carries no rows in the positional
|
|
3081
|
+
# layout — record it for the sentinel row below
|
|
3082
|
+
# so the empty value survives the round-trip
|
|
3083
|
+
# instead of vanishing into "no value".
|
|
3084
|
+
empty_collections.append(pname)
|
|
3085
|
+
continue
|
|
3086
|
+
param_maps[pname] = value
|
|
3087
|
+
for idx in value.indexes:
|
|
3088
|
+
all_indexes.add(str(_to_native(idx)))
|
|
3089
|
+
elif _is_array(value):
|
|
3090
|
+
period_names = [str(_to_native(v)) for v in value.values]
|
|
3091
|
+
if not period_names:
|
|
3092
|
+
# Empty Array — same problem as the empty Map: a
|
|
3093
|
+
# positional array of zero elements emits no rows
|
|
3094
|
+
# and is indistinguishable from an absent value
|
|
3095
|
+
# unless we mark it explicitly. An absent value
|
|
3096
|
+
# inherits the lower-ranked alternative; an empty
|
|
3097
|
+
# override (e.g. solve.contains_solves = []) must
|
|
3098
|
+
# NOT, so the distinction is load-bearing.
|
|
3099
|
+
empty_collections.append(pname)
|
|
3100
|
+
continue
|
|
3101
|
+
param_arrays[pname] = period_names
|
|
3102
|
+
elif _is_scalar(value):
|
|
3103
|
+
# Scalar values on a periodic-layout sheet — captured
|
|
3104
|
+
# so the entity-alt still produces an export row even
|
|
3105
|
+
# when no Map/Array index is present. Necessary for
|
|
3106
|
+
# split-params sheets like 'solve' that mix scalars
|
|
3107
|
+
# (solve_mode, solver, ...) with 1d-map params
|
|
3108
|
+
# (solver_arguments).
|
|
3109
|
+
#
|
|
3110
|
+
# BUT skip params that also live on a sibling constant
|
|
3111
|
+
# sheet (e.g. node.existing on node_c + node_p): the
|
|
3112
|
+
# constant writer already emits the scalar there, so a
|
|
3113
|
+
# scalar-only row here would duplicate the value and
|
|
3114
|
+
# collide on import round-trip.
|
|
3115
|
+
if pname in spec.scalar_params_on_constant_sibling:
|
|
3116
|
+
continue
|
|
3117
|
+
param_scalars[pname] = _to_native(value)
|
|
3118
|
+
|
|
3119
|
+
# If no Map indexes but we have Array values, use the array
|
|
3120
|
+
# values as indexes (they contain period names)
|
|
3121
|
+
if not all_indexes and param_arrays:
|
|
3122
|
+
for arr_vals in param_arrays.values():
|
|
3123
|
+
all_indexes.update(arr_vals)
|
|
3124
|
+
|
|
3125
|
+
# Build dict lookups from Maps for O(1) access (avoids O(n²))
|
|
3126
|
+
param_map_dicts: dict[str, dict[str, Any]] = {}
|
|
3127
|
+
for pname, m in param_maps.items():
|
|
3128
|
+
param_map_dicts[pname] = {
|
|
3129
|
+
str(_to_native(mi)): _to_native(mv)
|
|
3130
|
+
for mi, mv in zip(m.indexes, m.values)
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
def _build_left_cells(idx_val: str | None) -> list[tuple[int, Any]]:
|
|
3134
|
+
cells: list[tuple[int, Any]] = [(col_alt, alt)]
|
|
3135
|
+
for i, col in enumerate(entity_data_cols):
|
|
3136
|
+
if i < len(entity_byname):
|
|
3137
|
+
cells.append((col, str(_to_native(entity_byname[i]))))
|
|
3138
|
+
if filter_data_col is not None and direction is not None:
|
|
3139
|
+
cells.append((filter_data_col, direction))
|
|
3140
|
+
if idx_val is not None and idx_val != "":
|
|
3141
|
+
cells.append((index_data_col, idx_val))
|
|
3142
|
+
return cells
|
|
3143
|
+
|
|
3144
|
+
for idx_val in sorted(all_indexes):
|
|
3145
|
+
cells = _build_left_cells(idx_val)
|
|
3146
|
+
|
|
3147
|
+
for pname in spec.parameter_names:
|
|
3148
|
+
md = param_map_dicts.get(pname)
|
|
3149
|
+
if md is not None:
|
|
3150
|
+
val = md.get(idx_val)
|
|
3151
|
+
if val is not None:
|
|
3152
|
+
cells.append((param_col_map[pname], val))
|
|
3153
|
+
elif pname in param_arrays:
|
|
3154
|
+
if idx_val in param_arrays[pname]:
|
|
3155
|
+
# Round-trip preservation: emit the period
|
|
3156
|
+
# name as the cell value (not "TRUE") so the
|
|
3157
|
+
# importer reconstructs a Map<period,period>.
|
|
3158
|
+
# The engine reads Array values via
|
|
3159
|
+
# ``param_value.values`` and treats each
|
|
3160
|
+
# value as a period token (see
|
|
3161
|
+
# ``_solve_config.periods_to_tuples``); using
|
|
3162
|
+
# the period name keeps that contract intact
|
|
3163
|
+
# even though the type morphs Array→Map on
|
|
3164
|
+
# round-trip. Axis-enum vocab is built from
|
|
3165
|
+
# *both* map keys and array values
|
|
3166
|
+
# (``_build_period_vocab``), so the period
|
|
3167
|
+
# token survives in either representation.
|
|
3168
|
+
cells.append((param_col_map[pname], idx_val))
|
|
3169
|
+
|
|
3170
|
+
data_rows.append(cells)
|
|
3171
|
+
|
|
3172
|
+
# Emit a scalar-only row when the entity-alt has scalar
|
|
3173
|
+
# parameter values that no period-indexed row carries.
|
|
3174
|
+
# Without this, periodic sheets that bundle scalar params
|
|
3175
|
+
# (e.g. the split-params 'solve' sheet's solve_mode/solver/
|
|
3176
|
+
# contains_solves) silently drop those values on entities
|
|
3177
|
+
# that never set the periodic params.
|
|
3178
|
+
if param_scalars:
|
|
3179
|
+
cells = _build_left_cells(None)
|
|
3180
|
+
for pname, val in param_scalars.items():
|
|
3181
|
+
cells.append((param_col_map[pname], val))
|
|
3182
|
+
data_rows.append(cells)
|
|
3183
|
+
|
|
3184
|
+
# Emit a sentinel row for empty Array/Map parameter values.
|
|
3185
|
+
# The index cell carries EMPTY_COLLECTION_SENTINEL so the
|
|
3186
|
+
# importer can rebuild a zero-length collection (see
|
|
3187
|
+
# write_self_describing_to_db._group_map_records); the
|
|
3188
|
+
# data-type row already distinguishes Array from Map.
|
|
3189
|
+
if empty_collections:
|
|
3190
|
+
cells = _build_left_cells(EMPTY_COLLECTION_SENTINEL)
|
|
3191
|
+
for pname in empty_collections:
|
|
3192
|
+
cells.append((param_col_map[pname], EMPTY_COLLECTION_SENTINEL))
|
|
3193
|
+
data_rows.append(cells)
|
|
3194
|
+
|
|
3195
|
+
# --- Sort rows ---
|
|
3196
|
+
data_rows.sort(key=lambda r: tuple(
|
|
3197
|
+
str(v) if v is not None else "" for _, v in r
|
|
3198
|
+
))
|
|
3199
|
+
|
|
3200
|
+
# --- Write data rows ---
|
|
3201
|
+
for row_idx, row_cells in enumerate(data_rows, start=data_start_row):
|
|
3202
|
+
for col, value in row_cells:
|
|
3203
|
+
if value is not None:
|
|
3204
|
+
ws.cell(row=row_idx, column=col, value=value)
|
|
3205
|
+
|
|
3206
|
+
# --- Write parameter reference section ---
|
|
3207
|
+
last_data_col = def_col + len(right_cols) - 1
|
|
3208
|
+
_write_param_reference(
|
|
3209
|
+
ws, last_data_col + 1, spec, db_contents, header_row=def_row,
|
|
3210
|
+
n_data_rows=len(data_rows), layout="periodic",
|
|
3211
|
+
shown_params=list(spec.parameter_names),
|
|
3212
|
+
)
|
|
3213
|
+
|
|
3214
|
+
# --- Formatting ---
|
|
3215
|
+
n_extra = (
|
|
3216
|
+
(1 if filter_label else 0)
|
|
3217
|
+
+ 1 # index column
|
|
3218
|
+
)
|
|
3219
|
+
format_periodic_sheet_v2(
|
|
3220
|
+
ws, n_entity_cols, n_extra, def_col, {index_col_pos},
|
|
3221
|
+
last_data_col, def_row=def_row,
|
|
3222
|
+
)
|
|
3223
|
+
add_navigate_link(ws)
|
|
3224
|
+
auto_column_width(ws, min_param_width=_min_param_width,
|
|
3225
|
+
non_param_width=_non_param_width,
|
|
3226
|
+
def_col_width=_def_col_width,
|
|
3227
|
+
index_col_width=_index_col_width,
|
|
3228
|
+
header_row=def_row, def_col=def_col,
|
|
3229
|
+
index_cols={index_col_pos},
|
|
3230
|
+
last_data_col=last_data_col)
|
|
3231
|
+
|
|
3232
|
+
# --- Lock metadata ---
|
|
3233
|
+
_lock_metadata_cells(
|
|
3234
|
+
ws,
|
|
3235
|
+
meta_rows=def_row,
|
|
3236
|
+
meta_cols=def_col,
|
|
3237
|
+
data_start_row=data_start_row,
|
|
3238
|
+
data_start_col=1,
|
|
3239
|
+
)
|
|
3240
|
+
|
|
3241
|
+
|
|
3242
|
+
# ---------------------------------------------------------------------------
|
|
3243
|
+
# v2 Timeseries sheet
|
|
3244
|
+
# ---------------------------------------------------------------------------
|
|
3245
|
+
|
|
3246
|
+
|
|
3247
|
+
def write_timeseries_sheet_v2(
|
|
3248
|
+
ws: Worksheet,
|
|
3249
|
+
spec: SheetSpec,
|
|
3250
|
+
db_contents: DatabaseContents,
|
|
3251
|
+
) -> None:
|
|
3252
|
+
"""Write a transposed timeseries-layout sheet in the v2 self-describing format.
|
|
3253
|
+
|
|
3254
|
+
All _t sheets are transposed: time index in column A, data in columns C+.
|
|
3255
|
+
Header rows follow the canonical order, applied to whichever dimensions
|
|
3256
|
+
are needed:
|
|
3257
|
+
|
|
3258
|
+
description → data_type → entity → entity_dims → filter →
|
|
3259
|
+
alternative → parameter
|
|
3260
|
+
|
|
3261
|
+
For single-param sheets, description/data_type/parameter collapse into
|
|
3262
|
+
a single 'triplet row' in B1 using ``|`` separator.
|
|
3263
|
+
"""
|
|
3264
|
+
# ── Collect data columns ──────────────────────────────────────
|
|
3265
|
+
columns: list[tuple[str, tuple, str, str, Map]] = []
|
|
3266
|
+
all_time_indexes: set[str] = set()
|
|
3267
|
+
|
|
3268
|
+
for entity_class in spec.entity_classes:
|
|
3269
|
+
entities = db_contents.entities.get(entity_class, [])
|
|
3270
|
+
for entity in entities:
|
|
3271
|
+
entity_byname = entity["entity_byname"]
|
|
3272
|
+
for pname in spec.parameter_names:
|
|
3273
|
+
for alt in db_contents.alternatives:
|
|
3274
|
+
key = (entity_class, entity_byname, pname, alt)
|
|
3275
|
+
value = db_contents.parameter_values.get(key)
|
|
3276
|
+
if value is not None and _is_map(value):
|
|
3277
|
+
if not _is_time_indexed_map(value):
|
|
3278
|
+
continue
|
|
3279
|
+
columns.append(
|
|
3280
|
+
(entity_class, entity_byname, pname, alt, value)
|
|
3281
|
+
)
|
|
3282
|
+
for idx in value.indexes:
|
|
3283
|
+
all_time_indexes.add(str(_to_native(idx)))
|
|
3284
|
+
|
|
3285
|
+
columns.sort(key=lambda c: (c[3], c[1], c[2]))
|
|
3286
|
+
sorted_times = sorted(all_time_indexes)
|
|
3287
|
+
|
|
3288
|
+
single_param = len(spec.parameter_names) == 1
|
|
3289
|
+
n_entity_dims = len(spec.entity_columns)
|
|
3290
|
+
has_direction = spec.direction_column is not None
|
|
3291
|
+
_build_entity_def_label(spec)
|
|
3292
|
+
|
|
3293
|
+
# ── Build header rows following canonical order ───────────────
|
|
3294
|
+
row_types: dict[int, str] = {}
|
|
3295
|
+
cur_row = 1
|
|
3296
|
+
|
|
3297
|
+
# INFO row for all transposed sheets
|
|
3298
|
+
info_text = _info_rows.get(spec.sheet_name)
|
|
3299
|
+
if not info_text:
|
|
3300
|
+
info_text = "INFO: Add new data columns as needed by right clicking on the column name and then selecting 'Insert columns before'."
|
|
3301
|
+
ws.cell(row=cur_row, column=2, value=info_text)
|
|
3302
|
+
cur_row += 1
|
|
3303
|
+
|
|
3304
|
+
if single_param:
|
|
3305
|
+
# Triplet row: combined parameter | data_type | description
|
|
3306
|
+
default_param = spec.parameter_names[0]
|
|
3307
|
+
dtype = _get_param_data_type(default_param, spec.entity_classes, db_contents, layout="timeseries")
|
|
3308
|
+
desc = _get_param_description(default_param, spec.entity_classes, db_contents) or ""
|
|
3309
|
+
triplet = f"parameter: {default_param} | data type: {dtype}"
|
|
3310
|
+
if desc:
|
|
3311
|
+
triplet += f" | description: {desc}"
|
|
3312
|
+
default_str = _get_param_default_string(default_param, spec.entity_classes, db_contents)
|
|
3313
|
+
if default_str is not None:
|
|
3314
|
+
triplet += f" | default: {default_str}"
|
|
3315
|
+
ws.cell(row=cur_row, column=2, value=triplet)
|
|
3316
|
+
row_types[cur_row] = "param_info"
|
|
3317
|
+
cur_row += 1
|
|
3318
|
+
|
|
3319
|
+
# data type row (multi-param only — the single-param branch carries the
|
|
3320
|
+
# data type inside its triplet). Without this, the reader has no leaf
|
|
3321
|
+
# dtype for a multi-param _t sheet (e.g. node_t) and defaults to string,
|
|
3322
|
+
# so float time-series round-trip as string-valued Maps.
|
|
3323
|
+
if not single_param:
|
|
3324
|
+
ws.cell(row=cur_row, column=2, value="data type")
|
|
3325
|
+
for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
|
|
3326
|
+
dtype = _get_param_data_type(pname, [ec], db_contents, layout="timeseries")
|
|
3327
|
+
ws.cell(row=cur_row, column=col_idx, value=dtype)
|
|
3328
|
+
row_types[cur_row] = "data type"
|
|
3329
|
+
cur_row += 1
|
|
3330
|
+
|
|
3331
|
+
# entity row — simplified label (just dimension names, not full mapping)
|
|
3332
|
+
if n_entity_dims <= 1:
|
|
3333
|
+
simple_label = f"entity: {spec.entity_columns[0]}"
|
|
3334
|
+
ws.cell(row=cur_row, column=2, value=simple_label)
|
|
3335
|
+
for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
|
|
3336
|
+
if byname:
|
|
3337
|
+
ws.cell(row=cur_row, column=col_idx, value=str(_to_native(byname[0])))
|
|
3338
|
+
row_types[cur_row] = "entity"
|
|
3339
|
+
cur_row += 1
|
|
3340
|
+
else:
|
|
3341
|
+
# Multi-dim: one row per dimension
|
|
3342
|
+
for dim_idx in range(n_entity_dims):
|
|
3343
|
+
if dim_idx == 0:
|
|
3344
|
+
ws.cell(row=cur_row, column=2, value=f"entity: {', '.join(spec.entity_columns)}")
|
|
3345
|
+
else:
|
|
3346
|
+
ws.cell(row=cur_row, column=2, value=spec.entity_columns[dim_idx])
|
|
3347
|
+
for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
|
|
3348
|
+
if dim_idx < len(byname):
|
|
3349
|
+
ws.cell(row=cur_row, column=col_idx, value=str(_to_native(byname[dim_idx])))
|
|
3350
|
+
row_types[cur_row] = "entity"
|
|
3351
|
+
cur_row += 1
|
|
3352
|
+
|
|
3353
|
+
# filter row (for merged classes with direction) — simple label + dropdown
|
|
3354
|
+
filter_row_num = None
|
|
3355
|
+
if has_direction:
|
|
3356
|
+
filter_row_num = cur_row
|
|
3357
|
+
ws.cell(row=cur_row, column=2, value=spec.direction_column)
|
|
3358
|
+
filter_values = sorted(set(spec.direction_map.values()))
|
|
3359
|
+
for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
|
|
3360
|
+
direction = _get_direction(ec, spec)
|
|
3361
|
+
if direction:
|
|
3362
|
+
ws.cell(row=cur_row, column=col_idx, value=direction)
|
|
3363
|
+
row_types[cur_row] = "entity"
|
|
3364
|
+
cur_row += 1
|
|
3365
|
+
|
|
3366
|
+
# alternative row
|
|
3367
|
+
ws.cell(row=cur_row, column=2, value="alternative")
|
|
3368
|
+
for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
|
|
3369
|
+
ws.cell(row=cur_row, column=col_idx, value=alt)
|
|
3370
|
+
row_types[cur_row] = "alternative"
|
|
3371
|
+
cur_row += 1
|
|
3372
|
+
|
|
3373
|
+
# parameter row (multi-param only)
|
|
3374
|
+
param_row_num = None
|
|
3375
|
+
if not single_param:
|
|
3376
|
+
param_row_num = cur_row
|
|
3377
|
+
ws.cell(row=cur_row, column=2, value="parameter")
|
|
3378
|
+
for col_idx, (ec, byname, pname, alt, _m) in enumerate(columns, start=3):
|
|
3379
|
+
ws.cell(row=cur_row, column=col_idx, value=pname)
|
|
3380
|
+
row_types[cur_row] = "parameter"
|
|
3381
|
+
cur_row += 1
|
|
3382
|
+
|
|
3383
|
+
# default row (multi-param only, when at least one column has one)
|
|
3384
|
+
column_defaults = [
|
|
3385
|
+
_get_param_default_string(pname, [ec], db_contents)
|
|
3386
|
+
for (ec, _byname, pname, _alt, _m) in columns
|
|
3387
|
+
]
|
|
3388
|
+
if any(d is not None for d in column_defaults):
|
|
3389
|
+
ws.cell(row=cur_row, column=2, value="default")
|
|
3390
|
+
for col_idx, default_str in enumerate(column_defaults, start=3):
|
|
3391
|
+
if default_str is not None:
|
|
3392
|
+
ws.cell(row=cur_row, column=col_idx, value=default_str)
|
|
3393
|
+
row_types[cur_row] = "default"
|
|
3394
|
+
cur_row += 1
|
|
3395
|
+
|
|
3396
|
+
n_header_rows = cur_row - 1
|
|
3397
|
+
|
|
3398
|
+
# index: time label on column A of the last header row
|
|
3399
|
+
ws.cell(row=n_header_rows, column=1, value="index: time")
|
|
3400
|
+
|
|
3401
|
+
# ── Write time data rows ─────────────────────────────────────
|
|
3402
|
+
col_index_maps: list[dict[str, Any]] = []
|
|
3403
|
+
for ec, byname, pname, alt, m in columns:
|
|
3404
|
+
idx_map: dict[str, Any] = {}
|
|
3405
|
+
for mi, mv in zip(m.indexes, m.values):
|
|
3406
|
+
idx_map[str(_to_native(mi))] = _to_native(mv)
|
|
3407
|
+
col_index_maps.append(idx_map)
|
|
3408
|
+
|
|
3409
|
+
for time_row_idx, time_val in enumerate(sorted_times):
|
|
3410
|
+
row = n_header_rows + 1 + time_row_idx
|
|
3411
|
+
ws.cell(row=row, column=1, value=time_val)
|
|
3412
|
+
for col_idx, idx_map in enumerate(col_index_maps):
|
|
3413
|
+
value = idx_map.get(time_val)
|
|
3414
|
+
if value is not None:
|
|
3415
|
+
ws.cell(row=row, column=col_idx + 3, value=value)
|
|
3416
|
+
|
|
3417
|
+
add_navigate_link(ws)
|
|
3418
|
+
|
|
3419
|
+
# ── Reference section (two-column gap, no triplet) ──────────
|
|
3420
|
+
last_data_col = max(len(columns) + 2, 3)
|
|
3421
|
+
# Extend coloring to include the two-column gap before convenience
|
|
3422
|
+
color_end_col = last_data_col + 2
|
|
3423
|
+
_write_param_reference(
|
|
3424
|
+
ws, color_end_col, spec, db_contents, header_row=n_header_rows,
|
|
3425
|
+
n_data_rows=len(sorted_times), layout="timeseries",
|
|
3426
|
+
shown_params=list(spec.parameter_names),
|
|
3427
|
+
)
|
|
3428
|
+
|
|
3429
|
+
# ── Dropdowns ─────────────────────────────────────────────────
|
|
3430
|
+
from openpyxl.worksheet.datavalidation import DataValidation
|
|
3431
|
+
from openpyxl.utils import get_column_letter
|
|
3432
|
+
|
|
3433
|
+
# Parameter dropdown for multi-param transposed sheets
|
|
3434
|
+
if param_row_num is not None and len(spec.parameter_names) > 1:
|
|
3435
|
+
param_list = ",".join([""] + list(spec.parameter_names))
|
|
3436
|
+
dv = DataValidation(
|
|
3437
|
+
type="list",
|
|
3438
|
+
formula1=f'"{param_list}"',
|
|
3439
|
+
allow_blank=True,
|
|
3440
|
+
showDropDown=False,
|
|
3441
|
+
)
|
|
3442
|
+
dv.error = "Select a parameter"
|
|
3443
|
+
dv.errorTitle = "Parameter"
|
|
3444
|
+
first_col = get_column_letter(3)
|
|
3445
|
+
last_col = get_column_letter(color_end_col)
|
|
3446
|
+
dv.sqref = f"{first_col}{param_row_num}:{last_col}{param_row_num}"
|
|
3447
|
+
ws.add_data_validation(dv)
|
|
3448
|
+
|
|
3449
|
+
# Filter dropdown for merged classes (input/output etc.)
|
|
3450
|
+
if filter_row_num is not None and has_direction:
|
|
3451
|
+
filter_values = sorted(set(spec.direction_map.values()))
|
|
3452
|
+
filter_list = ",".join([""] + filter_values)
|
|
3453
|
+
dv_filter = DataValidation(
|
|
3454
|
+
type="list",
|
|
3455
|
+
formula1=f'"{filter_list}"',
|
|
3456
|
+
allow_blank=True,
|
|
3457
|
+
showDropDown=False,
|
|
3458
|
+
)
|
|
3459
|
+
dv_filter.error = f"Select: {', '.join(filter_values)}"
|
|
3460
|
+
dv_filter.errorTitle = spec.direction_column
|
|
3461
|
+
first_col = get_column_letter(3)
|
|
3462
|
+
last_col = get_column_letter(color_end_col)
|
|
3463
|
+
dv_filter.sqref = f"{first_col}{filter_row_num}:{last_col}{filter_row_num}"
|
|
3464
|
+
ws.add_data_validation(dv_filter)
|
|
3465
|
+
|
|
3466
|
+
# ── Formatting (extend to color_end_col to cover gap) ─────────
|
|
3467
|
+
format_timeseries_sheet_v2(ws, n_header_rows, single_param=single_param,
|
|
3468
|
+
row_types=row_types, last_data_col=color_end_col)
|
|
3469
|
+
|
|
3470
|
+
# Column A: index fill (green, consistent with other index columns)
|
|
3471
|
+
from openpyxl.formatting.rule import CellIsRule
|
|
3472
|
+
data_end = max(n_header_rows + len(sorted_times), n_header_rows + 1) + 100
|
|
3473
|
+
ws.cell(row=n_header_rows, column=1).fill = FILL_INDEX_HEADER
|
|
3474
|
+
ws.conditional_formatting.add(
|
|
3475
|
+
f"A{n_header_rows + 1}:A{data_end}",
|
|
3476
|
+
CellIsRule(operator="notEqual", formula=['"§§§NEVER§§§"'], fill=FILL_INDEX_DATA),
|
|
3477
|
+
)
|
|
3478
|
+
# Column B: dark grey definition column
|
|
3479
|
+
ws.conditional_formatting.add(
|
|
3480
|
+
f"B{n_header_rows + 1}:B{data_end}",
|
|
3481
|
+
CellIsRule(operator="notEqual", formula=['"§§§NEVER§§§"'], fill=FILL_DEF_COL),
|
|
3482
|
+
)
|
|
3483
|
+
|
|
3484
|
+
auto_column_width(ws, min_param_width=_min_param_width,
|
|
3485
|
+
non_param_width=_non_param_width,
|
|
3486
|
+
def_col_width=_def_col_width,
|
|
3487
|
+
index_col_width=_index_col_width,
|
|
3488
|
+
header_row=n_header_rows, def_col=3,
|
|
3489
|
+
last_data_col=last_data_col)
|
|
3490
|
+
|
|
3491
|
+
_lock_metadata_cells(
|
|
3492
|
+
ws, meta_rows=n_header_rows, meta_cols=2,
|
|
3493
|
+
data_start_row=n_header_rows + 1, data_start_col=1,
|
|
3494
|
+
)
|
|
3495
|
+
|
|
3496
|
+
|
|
3497
|
+
# ---------------------------------------------------------------------------
|
|
3498
|
+
# v2 Link sheet
|
|
3499
|
+
# ---------------------------------------------------------------------------
|
|
3500
|
+
|
|
3501
|
+
|
|
3502
|
+
def write_link_sheet_v2(
|
|
3503
|
+
ws: Worksheet,
|
|
3504
|
+
spec: SheetSpec,
|
|
3505
|
+
db_contents: DatabaseContents,
|
|
3506
|
+
) -> None:
|
|
3507
|
+
"""Write a link-only (relationship) sheet in the v2 self-describing format.
|
|
3508
|
+
|
|
3509
|
+
Layout:
|
|
3510
|
+
Row 1: navigate
|
|
3511
|
+
Row 2: 'entity: X, Y' | dim1_name | dim2_name
|
|
3512
|
+
Row 3+: (empty) | dim1_val | dim2_val
|
|
3513
|
+
"""
|
|
3514
|
+
entity_class = spec.entity_classes[0] if spec.entity_classes else None
|
|
3515
|
+
|
|
3516
|
+
# Row 1: navigate
|
|
3517
|
+
ws.cell(row=1, column=1, value="navigate")
|
|
3518
|
+
|
|
3519
|
+
# Row 2: entity definition + dimension headers
|
|
3520
|
+
entity_label = _build_entity_def_label(spec)
|
|
3521
|
+
ws.cell(row=2, column=1, value=entity_label)
|
|
3522
|
+
for col_idx, col_name in enumerate(spec.entity_columns, start=2):
|
|
3523
|
+
ws.cell(row=2, column=col_idx, value=col_name)
|
|
3524
|
+
|
|
3525
|
+
if not entity_class:
|
|
3526
|
+
format_link_sheet_v2(ws)
|
|
3527
|
+
add_navigate_link(ws)
|
|
3528
|
+
auto_column_width(ws, non_param_width=_non_param_width,
|
|
3529
|
+
def_col_width=_def_col_width,
|
|
3530
|
+
index_col_width=_index_col_width)
|
|
3531
|
+
return
|
|
3532
|
+
|
|
3533
|
+
# Collect and sort entity rows
|
|
3534
|
+
entities = db_contents.entities.get(entity_class, [])
|
|
3535
|
+
rows: list[tuple] = []
|
|
3536
|
+
for entity in entities:
|
|
3537
|
+
byname = entity["entity_byname"]
|
|
3538
|
+
rows.append(tuple(str(_to_native(v)) for v in byname))
|
|
3539
|
+
|
|
3540
|
+
rows.sort()
|
|
3541
|
+
|
|
3542
|
+
for row_idx, row_data in enumerate(rows, start=3):
|
|
3543
|
+
for col_idx, value in enumerate(row_data, start=2):
|
|
3544
|
+
ws.cell(row=row_idx, column=col_idx, value=value)
|
|
3545
|
+
|
|
3546
|
+
format_link_sheet_v2(ws)
|
|
3547
|
+
add_navigate_link(ws)
|
|
3548
|
+
auto_column_width(ws, non_param_width=_non_param_width,
|
|
3549
|
+
def_col_width=_def_col_width,
|
|
3550
|
+
index_col_width=_index_col_width)
|
|
3551
|
+
|
|
3552
|
+
# --- Lock metadata ---
|
|
3553
|
+
_lock_metadata_cells(
|
|
3554
|
+
ws,
|
|
3555
|
+
meta_rows=2,
|
|
3556
|
+
meta_cols=1,
|
|
3557
|
+
data_start_row=3,
|
|
3558
|
+
data_start_col=2,
|
|
3559
|
+
)
|