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,1288 @@
|
|
|
1
|
+
"""Read old-format FlexTool Excel files (.xlsm) and return structured data.
|
|
2
|
+
|
|
3
|
+
This module parses the 18-sheet .xlsm format used by earlier versions of FlexTool
|
|
4
|
+
and returns an OldFlexToolData dataclass containing all parsed information, ready
|
|
5
|
+
for database import.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import openpyxl
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Sheets that should be skipped entirely
|
|
19
|
+
_SKIP_SHEETS = frozenset({
|
|
20
|
+
"info", "calc", "dropdown values", "Getting Started",
|
|
21
|
+
"Sensitivity scenarios", "Sensitivity definitions",
|
|
22
|
+
"Settings and filters", "Tool Information",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
# Mapping from normalized unit-sheet column headers to UnitInstance field names.
|
|
26
|
+
# Headers not listed here go into extra_params.
|
|
27
|
+
_UNIT_COLUMN_MAP: dict[str, str] = {
|
|
28
|
+
"unitgroup": "unit_group",
|
|
29
|
+
"unit type": "unit_type",
|
|
30
|
+
"fuel": "fuel",
|
|
31
|
+
"cf profile": "cf_profile",
|
|
32
|
+
"inflow": "inflow_profile",
|
|
33
|
+
# Grid/node columns — may appear as separate columns or combined with "/"
|
|
34
|
+
"input grid": "input_grid",
|
|
35
|
+
"input node": "input_node",
|
|
36
|
+
"output grid": "output_grid",
|
|
37
|
+
"output node": "output_node",
|
|
38
|
+
"output2 grid": "output2_grid",
|
|
39
|
+
"output2 node": "output2_node",
|
|
40
|
+
# Capacity and investment
|
|
41
|
+
"capacity (mw)": "capacity_mw",
|
|
42
|
+
"invested capacity (mw)": "invested_capacity_mw",
|
|
43
|
+
"max invest (mw)": "max_invest_mw",
|
|
44
|
+
"storage (mwh)": "storage_mwh",
|
|
45
|
+
"invested storage (mwh)": "invested_storage_mwh",
|
|
46
|
+
"max invest (mwh)": "max_invest_mwh",
|
|
47
|
+
"storage start": "storage_start",
|
|
48
|
+
"storage start/finish": "storage_start",
|
|
49
|
+
"storage finish": "storage_finish",
|
|
50
|
+
"reserve increase ratio": "reserve_increase_ratio",
|
|
51
|
+
"use efficiency time series": "use_efficiency_ts",
|
|
52
|
+
# Output2 / CHP constraint coefficients
|
|
53
|
+
"output2 eq coeff": "output2_eq_coeff",
|
|
54
|
+
"output2 eq constant": "output2_eq_constant",
|
|
55
|
+
"output2 gt coeff": "output2_gt_coeff",
|
|
56
|
+
"output2 gt constant": "output2_gt_constant",
|
|
57
|
+
"output2 lt coeff": "output2_lt_coeff",
|
|
58
|
+
"output2 lt constant": "output2_lt_constant",
|
|
59
|
+
"output2 max capacity": "output2_max_capacity",
|
|
60
|
+
"output2 max capacity (mw)": "output2_max_capacity",
|
|
61
|
+
"output2 max capacity ratio": "output2_max_capacity",
|
|
62
|
+
"fueluse increase eq x output2": "fueluse_increase_eq",
|
|
63
|
+
"inflow multiplier": "inflow_multiplier",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Columns that need special grid/node splitting
|
|
67
|
+
_UNIT_GRID_NODE_COLS: dict[str, tuple[str, str]] = {
|
|
68
|
+
"input grid/node": ("input_grid", "input_node"),
|
|
69
|
+
"output grid/node": ("output_grid", "output_node"),
|
|
70
|
+
"output2 grid/node": ("output2_grid", "output2_node"),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# String-typed fields in UnitInstance (everything else is float | None)
|
|
74
|
+
_UNIT_STRING_FIELDS = frozenset({
|
|
75
|
+
"unit_group", "unit_type", "fuel", "cf_profile", "inflow_profile",
|
|
76
|
+
"input_grid", "input_node", "output_grid", "output_node",
|
|
77
|
+
"output2_grid", "output2_node",
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Dataclasses
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class SensitivityOverride:
|
|
87
|
+
"""A single parameter override from the Sensitivity definitions sheet."""
|
|
88
|
+
section: str # "master", "nodeGroup", "gridNode", "unit_type", "fuel", "unitGroup", "units", "nodeNode"
|
|
89
|
+
entity_ids: dict[str, str] # entity identifier columns, e.g. {"nodeGroup": "reserve1"}
|
|
90
|
+
param_name: str # column header name, e.g. "co2_cost", "invested capacity (MW)"
|
|
91
|
+
value: float | str # the override value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class MasterParams:
|
|
96
|
+
"""Global model parameters from 'master' sheet."""
|
|
97
|
+
params: dict[str, float]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class GridNode:
|
|
102
|
+
"""A node definition from 'gridNode' sheet."""
|
|
103
|
+
grid: str
|
|
104
|
+
node: str
|
|
105
|
+
node_groups: list[str]
|
|
106
|
+
demand_mwh: float | None
|
|
107
|
+
import_mwh: float | None
|
|
108
|
+
capacity_margin_mw: float | None
|
|
109
|
+
non_synchronous_share: float | None
|
|
110
|
+
use_ts_reserve: float | None
|
|
111
|
+
use_dynamic_reserve: float | None
|
|
112
|
+
print_results: float | None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class NodeGroup:
|
|
117
|
+
"""A node group from 'nodeGroup' sheet."""
|
|
118
|
+
name: str
|
|
119
|
+
capacity_margin_mw: float | None
|
|
120
|
+
non_synchronous_share: float | None
|
|
121
|
+
inertia_limit_mws: float | None
|
|
122
|
+
use_ts_reserve: float | None
|
|
123
|
+
use_dynamic_reserve: float | None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class UnitType:
|
|
128
|
+
"""A technology template from 'unit_type' sheet."""
|
|
129
|
+
name: str
|
|
130
|
+
params: dict[str, float | None]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class Fuel:
|
|
135
|
+
"""A fuel definition from 'fuel' sheet."""
|
|
136
|
+
name: str
|
|
137
|
+
price_per_mwh: float
|
|
138
|
+
co2_content: float
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class UnitGroup:
|
|
143
|
+
"""A unit group from 'unitGroup' sheet."""
|
|
144
|
+
name: str
|
|
145
|
+
max_invest_mw: float | None
|
|
146
|
+
min_invest_mw: float | None
|
|
147
|
+
max_invest_mwh: float | None
|
|
148
|
+
min_invest_mwh: float | None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass
|
|
152
|
+
class UnitInstance:
|
|
153
|
+
"""A unit instance from 'units' sheet."""
|
|
154
|
+
unit_group: str | None
|
|
155
|
+
unit_type: str
|
|
156
|
+
fuel: str | None
|
|
157
|
+
cf_profile: str | None
|
|
158
|
+
inflow_profile: str | None
|
|
159
|
+
input_grid: str | None
|
|
160
|
+
input_node: str | None
|
|
161
|
+
output_grid: str | None
|
|
162
|
+
output_node: str | None
|
|
163
|
+
capacity_mw: float | None
|
|
164
|
+
invested_capacity_mw: float | None
|
|
165
|
+
max_invest_mw: float | None
|
|
166
|
+
storage_mwh: float | None
|
|
167
|
+
invested_storage_mwh: float | None
|
|
168
|
+
max_invest_mwh: float | None
|
|
169
|
+
storage_start: float | None
|
|
170
|
+
storage_finish: float | None
|
|
171
|
+
reserve_increase_ratio: float | None
|
|
172
|
+
use_efficiency_ts: float | None
|
|
173
|
+
output2_grid: str | None
|
|
174
|
+
output2_node: str | None
|
|
175
|
+
output2_eq_coeff: float | None
|
|
176
|
+
output2_eq_constant: float | None
|
|
177
|
+
output2_gt_coeff: float | None
|
|
178
|
+
output2_gt_constant: float | None
|
|
179
|
+
output2_lt_coeff: float | None
|
|
180
|
+
output2_lt_constant: float | None
|
|
181
|
+
output2_max_capacity: float | None
|
|
182
|
+
fueluse_increase_eq: float | None
|
|
183
|
+
inflow_multiplier: float | None
|
|
184
|
+
extra_params: dict[str, float | None] = field(default_factory=dict)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class NodeNodeConnection:
|
|
189
|
+
"""A transmission connection from 'nodeNode' sheet."""
|
|
190
|
+
grid: str
|
|
191
|
+
node1: str
|
|
192
|
+
node2: str
|
|
193
|
+
cap_rightward_mw: float | None
|
|
194
|
+
cap_leftward_mw: float | None
|
|
195
|
+
invested_capacity_mw: float | None
|
|
196
|
+
max_invest_mw: float | None
|
|
197
|
+
loss: float | None
|
|
198
|
+
invest_cost_per_kw: float | None
|
|
199
|
+
lifetime: float | None
|
|
200
|
+
interest: float | None
|
|
201
|
+
is_hvdc: bool
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class TimeSeriesData:
|
|
206
|
+
"""A time series: dict mapping time_id (str) to value (float)."""
|
|
207
|
+
name: str
|
|
208
|
+
data: dict[str, float]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class UnitTimeSeries:
|
|
213
|
+
"""Time series for a specific unit parameter."""
|
|
214
|
+
grid: str
|
|
215
|
+
node: str
|
|
216
|
+
unit: str
|
|
217
|
+
param_name: str
|
|
218
|
+
data: dict[str, float]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass
|
|
222
|
+
class DemandTimeSeries:
|
|
223
|
+
"""Demand time series for a specific grid/node."""
|
|
224
|
+
grid: str
|
|
225
|
+
node: str
|
|
226
|
+
data: dict[str, float]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class TimeStep:
|
|
231
|
+
"""A time step definition from 'ts_time' sheet."""
|
|
232
|
+
time_id: str
|
|
233
|
+
in_use: bool
|
|
234
|
+
time_jump: float
|
|
235
|
+
in_use_invest: bool
|
|
236
|
+
time_jump_invest: float
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class OldFlexToolData:
|
|
241
|
+
"""Complete parsed data from one old FlexTool .xlsm file."""
|
|
242
|
+
master: MasterParams
|
|
243
|
+
grid_nodes: list[GridNode]
|
|
244
|
+
node_groups: list[NodeGroup]
|
|
245
|
+
unit_types: list[UnitType]
|
|
246
|
+
fuels: list[Fuel]
|
|
247
|
+
unit_groups: list[UnitGroup]
|
|
248
|
+
units: list[UnitInstance]
|
|
249
|
+
connections: list[NodeNodeConnection]
|
|
250
|
+
cf_profiles: list[TimeSeriesData]
|
|
251
|
+
inflow_profiles: list[TimeSeriesData]
|
|
252
|
+
demand_ts: list[DemandTimeSeries]
|
|
253
|
+
import_ts: list[DemandTimeSeries]
|
|
254
|
+
reserve_node_ts: list[TimeSeriesData]
|
|
255
|
+
reserve_group_ts: list[TimeSeriesData]
|
|
256
|
+
unit_ts: list[UnitTimeSeries]
|
|
257
|
+
time_steps: list[TimeStep]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Helpers
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def _safe_float(value: Any) -> float | None:
|
|
265
|
+
"""Convert a cell value to float, returning None for empty/non-numeric cells."""
|
|
266
|
+
if value is None:
|
|
267
|
+
return None
|
|
268
|
+
try:
|
|
269
|
+
return float(value)
|
|
270
|
+
except (ValueError, TypeError):
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _safe_str(value: Any) -> str | None:
|
|
275
|
+
"""Convert a cell value to a stripped string, returning None for empty cells."""
|
|
276
|
+
if value is None:
|
|
277
|
+
return None
|
|
278
|
+
s = str(value).strip()
|
|
279
|
+
return s if s else None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _row_is_empty(row: tuple[Any, ...]) -> bool:
|
|
283
|
+
"""Return True if every value in the row is None."""
|
|
284
|
+
return all(v is None for v in row)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _split_grid_node(value: Any) -> tuple[str | None, str | None]:
|
|
288
|
+
"""Split a 'grid/node' string into (grid, node). Returns (None, None) for empty."""
|
|
289
|
+
if value is None:
|
|
290
|
+
return None, None
|
|
291
|
+
s = str(value).strip()
|
|
292
|
+
if not s:
|
|
293
|
+
return None, None
|
|
294
|
+
if "/" in s:
|
|
295
|
+
parts = s.split("/", 1)
|
|
296
|
+
return parts[0].strip(), parts[1].strip()
|
|
297
|
+
return s, None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _get_sheet(wb: openpyxl.Workbook, name: str) -> Any | None:
|
|
301
|
+
"""Get a worksheet by name, returning None if it doesn't exist."""
|
|
302
|
+
if name in wb.sheetnames:
|
|
303
|
+
return wb[name]
|
|
304
|
+
logger.warning("Sheet '%s' not found in workbook; skipping.", name)
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# Sheet readers
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def _read_master(wb: openpyxl.Workbook) -> MasterParams:
|
|
313
|
+
"""Read the 'master' sheet into MasterParams."""
|
|
314
|
+
ws = _get_sheet(wb, "master")
|
|
315
|
+
params: dict[str, float] = {}
|
|
316
|
+
if ws is None:
|
|
317
|
+
return MasterParams(params=params)
|
|
318
|
+
|
|
319
|
+
first = True
|
|
320
|
+
for row in ws.iter_rows(values_only=True):
|
|
321
|
+
if first:
|
|
322
|
+
first = False
|
|
323
|
+
continue # skip header
|
|
324
|
+
if _row_is_empty(row):
|
|
325
|
+
continue
|
|
326
|
+
param_name = _safe_str(row[0]) if len(row) > 0 else None
|
|
327
|
+
raw_value = row[1] if len(row) > 1 else None
|
|
328
|
+
if param_name is not None:
|
|
329
|
+
val = _safe_float(raw_value)
|
|
330
|
+
if val is not None:
|
|
331
|
+
params[param_name] = val
|
|
332
|
+
else:
|
|
333
|
+
logger.warning("master: non-numeric value for '%s': %r", param_name, raw_value)
|
|
334
|
+
return MasterParams(params=params)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _read_grid_nodes(wb: openpyxl.Workbook) -> list[GridNode]:
|
|
338
|
+
"""Read the 'gridNode' sheet."""
|
|
339
|
+
ws = _get_sheet(wb, "gridNode")
|
|
340
|
+
if ws is None:
|
|
341
|
+
return []
|
|
342
|
+
|
|
343
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
344
|
+
if not rows:
|
|
345
|
+
return []
|
|
346
|
+
|
|
347
|
+
# Header row
|
|
348
|
+
headers = [_safe_str(h) for h in rows[0]]
|
|
349
|
+
result: list[GridNode] = []
|
|
350
|
+
|
|
351
|
+
def _col(name: str) -> int | None:
|
|
352
|
+
"""Find column index by header name (case-insensitive)."""
|
|
353
|
+
name_lower = name.lower()
|
|
354
|
+
for i, h in enumerate(headers):
|
|
355
|
+
if h is not None and h.lower() == name_lower:
|
|
356
|
+
return i
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
def _get(row: tuple[Any, ...], col_idx: int | None) -> Any:
|
|
360
|
+
if col_idx is None or col_idx >= len(row):
|
|
361
|
+
return None
|
|
362
|
+
return row[col_idx]
|
|
363
|
+
|
|
364
|
+
c_grid = _col("grid")
|
|
365
|
+
c_node = _col("node")
|
|
366
|
+
c_ng1 = _col("nodeGroup")
|
|
367
|
+
c_ng2 = _col("nodeGroup2")
|
|
368
|
+
c_ng3 = _col("nodeGroup3")
|
|
369
|
+
c_demand = _col("demand (MWh)")
|
|
370
|
+
c_import = _col("import (MWh)")
|
|
371
|
+
c_cap_margin = _col("capacity margin (MW)")
|
|
372
|
+
c_nonsync = _col("non synchronous share")
|
|
373
|
+
c_ts_reserve = _col("use ts_reserve")
|
|
374
|
+
c_dyn_reserve = _col("use dynamic reserve")
|
|
375
|
+
c_print = _col("print results")
|
|
376
|
+
|
|
377
|
+
for row in rows[1:]:
|
|
378
|
+
if _row_is_empty(row):
|
|
379
|
+
continue
|
|
380
|
+
grid = _safe_str(_get(row, c_grid))
|
|
381
|
+
node = _safe_str(_get(row, c_node))
|
|
382
|
+
if grid is None or node is None:
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
groups: list[str] = []
|
|
386
|
+
for c in (c_ng1, c_ng2, c_ng3):
|
|
387
|
+
g = _safe_str(_get(row, c))
|
|
388
|
+
if g:
|
|
389
|
+
groups.append(g)
|
|
390
|
+
|
|
391
|
+
result.append(GridNode(
|
|
392
|
+
grid=grid,
|
|
393
|
+
node=node,
|
|
394
|
+
node_groups=groups,
|
|
395
|
+
demand_mwh=_safe_float(_get(row, c_demand)),
|
|
396
|
+
import_mwh=_safe_float(_get(row, c_import)),
|
|
397
|
+
capacity_margin_mw=_safe_float(_get(row, c_cap_margin)),
|
|
398
|
+
non_synchronous_share=_safe_float(_get(row, c_nonsync)),
|
|
399
|
+
use_ts_reserve=_safe_float(_get(row, c_ts_reserve)),
|
|
400
|
+
use_dynamic_reserve=_safe_float(_get(row, c_dyn_reserve)),
|
|
401
|
+
print_results=_safe_float(_get(row, c_print)),
|
|
402
|
+
))
|
|
403
|
+
return result
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _read_node_groups(wb: openpyxl.Workbook) -> list[NodeGroup]:
|
|
407
|
+
"""Read the 'nodeGroup' sheet."""
|
|
408
|
+
ws = _get_sheet(wb, "nodeGroup")
|
|
409
|
+
if ws is None:
|
|
410
|
+
return []
|
|
411
|
+
|
|
412
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
413
|
+
if not rows:
|
|
414
|
+
return []
|
|
415
|
+
|
|
416
|
+
headers = [_safe_str(h) for h in rows[0]]
|
|
417
|
+
result: list[NodeGroup] = []
|
|
418
|
+
|
|
419
|
+
def _col(name: str) -> int | None:
|
|
420
|
+
name_lower = name.lower()
|
|
421
|
+
for i, h in enumerate(headers):
|
|
422
|
+
if h is not None and h.lower() == name_lower:
|
|
423
|
+
return i
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
def _get(row: tuple[Any, ...], col_idx: int | None) -> Any:
|
|
427
|
+
if col_idx is None or col_idx >= len(row):
|
|
428
|
+
return None
|
|
429
|
+
return row[col_idx]
|
|
430
|
+
|
|
431
|
+
c_name = _col("nodeGroup")
|
|
432
|
+
c_cap = _col("capacity margin (MW)")
|
|
433
|
+
c_nonsync = _col("non synchronous share")
|
|
434
|
+
c_inertia = _col("inertia limit (MWs)")
|
|
435
|
+
c_ts_reserve = _col("use ts_reserve")
|
|
436
|
+
c_dyn_reserve = _col("use dynamic reserve")
|
|
437
|
+
|
|
438
|
+
for row in rows[1:]:
|
|
439
|
+
if _row_is_empty(row):
|
|
440
|
+
continue
|
|
441
|
+
name = _safe_str(_get(row, c_name))
|
|
442
|
+
if name is None:
|
|
443
|
+
continue
|
|
444
|
+
result.append(NodeGroup(
|
|
445
|
+
name=name,
|
|
446
|
+
capacity_margin_mw=_safe_float(_get(row, c_cap)),
|
|
447
|
+
non_synchronous_share=_safe_float(_get(row, c_nonsync)),
|
|
448
|
+
inertia_limit_mws=_safe_float(_get(row, c_inertia)),
|
|
449
|
+
use_ts_reserve=_safe_float(_get(row, c_ts_reserve)),
|
|
450
|
+
use_dynamic_reserve=_safe_float(_get(row, c_dyn_reserve)),
|
|
451
|
+
))
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _read_unit_types(wb: openpyxl.Workbook) -> list[UnitType]:
|
|
456
|
+
"""Read the 'unit_type' sheet."""
|
|
457
|
+
ws = _get_sheet(wb, "unit_type")
|
|
458
|
+
if ws is None:
|
|
459
|
+
return []
|
|
460
|
+
|
|
461
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
462
|
+
if not rows:
|
|
463
|
+
return []
|
|
464
|
+
|
|
465
|
+
headers = [_safe_str(h) for h in rows[0]]
|
|
466
|
+
result: list[UnitType] = []
|
|
467
|
+
|
|
468
|
+
# First column is the unit type name; remaining columns are parameters
|
|
469
|
+
for row in rows[1:]:
|
|
470
|
+
if _row_is_empty(row):
|
|
471
|
+
continue
|
|
472
|
+
name = _safe_str(row[0]) if len(row) > 0 else None
|
|
473
|
+
if name is None:
|
|
474
|
+
continue
|
|
475
|
+
params: dict[str, float | None] = {}
|
|
476
|
+
for i in range(1, min(len(headers), len(row))):
|
|
477
|
+
header = headers[i]
|
|
478
|
+
if header is not None:
|
|
479
|
+
key = header.strip().lower()
|
|
480
|
+
params[key] = _safe_float(row[i])
|
|
481
|
+
result.append(UnitType(name=name, params=params))
|
|
482
|
+
return result
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _read_fuels(wb: openpyxl.Workbook) -> list[Fuel]:
|
|
486
|
+
"""Read the 'fuel' sheet."""
|
|
487
|
+
ws = _get_sheet(wb, "fuel")
|
|
488
|
+
if ws is None:
|
|
489
|
+
return []
|
|
490
|
+
|
|
491
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
492
|
+
if not rows:
|
|
493
|
+
return []
|
|
494
|
+
|
|
495
|
+
result: list[Fuel] = []
|
|
496
|
+
for row in rows[1:]:
|
|
497
|
+
if _row_is_empty(row):
|
|
498
|
+
continue
|
|
499
|
+
name = _safe_str(row[0]) if len(row) > 0 else None
|
|
500
|
+
if name is None:
|
|
501
|
+
continue
|
|
502
|
+
price = _safe_float(row[1] if len(row) > 1 else None)
|
|
503
|
+
co2 = _safe_float(row[2] if len(row) > 2 else None)
|
|
504
|
+
result.append(Fuel(
|
|
505
|
+
name=name,
|
|
506
|
+
price_per_mwh=price if price is not None else 0.0,
|
|
507
|
+
co2_content=co2 if co2 is not None else 0.0,
|
|
508
|
+
))
|
|
509
|
+
return result
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _read_unit_groups(wb: openpyxl.Workbook) -> list[UnitGroup]:
|
|
513
|
+
"""Read the 'unitGroup' sheet."""
|
|
514
|
+
ws = _get_sheet(wb, "unitGroup")
|
|
515
|
+
if ws is None:
|
|
516
|
+
return []
|
|
517
|
+
|
|
518
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
519
|
+
if not rows:
|
|
520
|
+
return []
|
|
521
|
+
|
|
522
|
+
headers = [_safe_str(h) for h in rows[0]]
|
|
523
|
+
result: list[UnitGroup] = []
|
|
524
|
+
|
|
525
|
+
def _col(name: str) -> int | None:
|
|
526
|
+
name_lower = name.lower()
|
|
527
|
+
for i, h in enumerate(headers):
|
|
528
|
+
if h is not None and h.lower() == name_lower:
|
|
529
|
+
return i
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
def _get(row: tuple[Any, ...], col_idx: int | None) -> Any:
|
|
533
|
+
if col_idx is None or col_idx >= len(row):
|
|
534
|
+
return None
|
|
535
|
+
return row[col_idx]
|
|
536
|
+
|
|
537
|
+
c_name = _col("unitGroup")
|
|
538
|
+
c_max_mw = _col("max invest MW")
|
|
539
|
+
c_min_mw = _col("min invest MW")
|
|
540
|
+
c_max_mwh = _col("max invest MWh")
|
|
541
|
+
c_min_mwh = _col("min invest MWh")
|
|
542
|
+
|
|
543
|
+
for row in rows[1:]:
|
|
544
|
+
if _row_is_empty(row):
|
|
545
|
+
continue
|
|
546
|
+
name = _safe_str(_get(row, c_name))
|
|
547
|
+
if name is None:
|
|
548
|
+
continue
|
|
549
|
+
result.append(UnitGroup(
|
|
550
|
+
name=name,
|
|
551
|
+
max_invest_mw=_safe_float(_get(row, c_max_mw)),
|
|
552
|
+
min_invest_mw=_safe_float(_get(row, c_min_mw)),
|
|
553
|
+
max_invest_mwh=_safe_float(_get(row, c_max_mwh)),
|
|
554
|
+
min_invest_mwh=_safe_float(_get(row, c_min_mwh)),
|
|
555
|
+
))
|
|
556
|
+
return result
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _read_units(wb: openpyxl.Workbook) -> list[UnitInstance]:
|
|
560
|
+
"""Read the 'units' sheet."""
|
|
561
|
+
ws = _get_sheet(wb, "units")
|
|
562
|
+
if ws is None:
|
|
563
|
+
return []
|
|
564
|
+
|
|
565
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
566
|
+
if not rows:
|
|
567
|
+
return []
|
|
568
|
+
|
|
569
|
+
# Build header-to-index mapping
|
|
570
|
+
raw_headers = rows[0]
|
|
571
|
+
headers: list[str | None] = [_safe_str(h) for h in raw_headers]
|
|
572
|
+
normalized_headers: list[str | None] = [
|
|
573
|
+
h.strip().lower() if h is not None else None for h in headers
|
|
574
|
+
]
|
|
575
|
+
|
|
576
|
+
result: list[UnitInstance] = []
|
|
577
|
+
|
|
578
|
+
for row in rows[1:]:
|
|
579
|
+
# Stop reading when all values in a row are None
|
|
580
|
+
if _row_is_empty(row):
|
|
581
|
+
continue
|
|
582
|
+
|
|
583
|
+
# Build a dict of field_name -> value for this row
|
|
584
|
+
field_values: dict[str, Any] = {
|
|
585
|
+
"unit_group": None,
|
|
586
|
+
"unit_type": "",
|
|
587
|
+
"fuel": None,
|
|
588
|
+
"cf_profile": None,
|
|
589
|
+
"inflow_profile": None,
|
|
590
|
+
"input_grid": None,
|
|
591
|
+
"input_node": None,
|
|
592
|
+
"output_grid": None,
|
|
593
|
+
"output_node": None,
|
|
594
|
+
"capacity_mw": None,
|
|
595
|
+
"invested_capacity_mw": None,
|
|
596
|
+
"max_invest_mw": None,
|
|
597
|
+
"storage_mwh": None,
|
|
598
|
+
"invested_storage_mwh": None,
|
|
599
|
+
"max_invest_mwh": None,
|
|
600
|
+
"storage_start": None,
|
|
601
|
+
"storage_finish": None,
|
|
602
|
+
"reserve_increase_ratio": None,
|
|
603
|
+
"use_efficiency_ts": None,
|
|
604
|
+
"output2_grid": None,
|
|
605
|
+
"output2_node": None,
|
|
606
|
+
"output2_eq_coeff": None,
|
|
607
|
+
"output2_eq_constant": None,
|
|
608
|
+
"output2_gt_coeff": None,
|
|
609
|
+
"output2_gt_constant": None,
|
|
610
|
+
"output2_lt_coeff": None,
|
|
611
|
+
"output2_lt_constant": None,
|
|
612
|
+
"output2_max_capacity": None,
|
|
613
|
+
"fueluse_increase_eq": None,
|
|
614
|
+
"inflow_multiplier": None,
|
|
615
|
+
}
|
|
616
|
+
extra: dict[str, float | None] = {}
|
|
617
|
+
|
|
618
|
+
for col_idx, norm_h in enumerate(normalized_headers):
|
|
619
|
+
if norm_h is None or col_idx >= len(row):
|
|
620
|
+
continue
|
|
621
|
+
cell_value = row[col_idx]
|
|
622
|
+
|
|
623
|
+
# Check for combined grid/node columns (e.g. "input grid/node")
|
|
624
|
+
if norm_h in _UNIT_GRID_NODE_COLS:
|
|
625
|
+
grid_field, node_field = _UNIT_GRID_NODE_COLS[norm_h]
|
|
626
|
+
g, n = _split_grid_node(cell_value)
|
|
627
|
+
field_values[grid_field] = g
|
|
628
|
+
field_values[node_field] = n
|
|
629
|
+
elif norm_h in _UNIT_COLUMN_MAP:
|
|
630
|
+
field_name = _UNIT_COLUMN_MAP[norm_h]
|
|
631
|
+
if field_name in _UNIT_STRING_FIELDS:
|
|
632
|
+
field_values[field_name] = _safe_str(cell_value)
|
|
633
|
+
else:
|
|
634
|
+
field_values[field_name] = _safe_float(cell_value)
|
|
635
|
+
else:
|
|
636
|
+
# Extra / unknown columns
|
|
637
|
+
original_header = headers[col_idx]
|
|
638
|
+
if original_header is not None:
|
|
639
|
+
extra[original_header] = _safe_float(cell_value)
|
|
640
|
+
|
|
641
|
+
# Skip rows where unit_type is missing
|
|
642
|
+
if not field_values["unit_type"]:
|
|
643
|
+
continue
|
|
644
|
+
|
|
645
|
+
result.append(UnitInstance(
|
|
646
|
+
unit_group=field_values["unit_group"],
|
|
647
|
+
unit_type=field_values["unit_type"],
|
|
648
|
+
fuel=field_values["fuel"],
|
|
649
|
+
cf_profile=field_values["cf_profile"],
|
|
650
|
+
inflow_profile=field_values["inflow_profile"],
|
|
651
|
+
input_grid=field_values["input_grid"],
|
|
652
|
+
input_node=field_values["input_node"],
|
|
653
|
+
output_grid=field_values["output_grid"],
|
|
654
|
+
output_node=field_values["output_node"],
|
|
655
|
+
capacity_mw=field_values["capacity_mw"],
|
|
656
|
+
invested_capacity_mw=field_values["invested_capacity_mw"],
|
|
657
|
+
max_invest_mw=field_values["max_invest_mw"],
|
|
658
|
+
storage_mwh=field_values["storage_mwh"],
|
|
659
|
+
invested_storage_mwh=field_values["invested_storage_mwh"],
|
|
660
|
+
max_invest_mwh=field_values["max_invest_mwh"],
|
|
661
|
+
storage_start=field_values["storage_start"],
|
|
662
|
+
storage_finish=field_values["storage_finish"],
|
|
663
|
+
reserve_increase_ratio=field_values["reserve_increase_ratio"],
|
|
664
|
+
use_efficiency_ts=field_values["use_efficiency_ts"],
|
|
665
|
+
output2_grid=field_values["output2_grid"],
|
|
666
|
+
output2_node=field_values["output2_node"],
|
|
667
|
+
output2_eq_coeff=field_values["output2_eq_coeff"],
|
|
668
|
+
output2_eq_constant=field_values["output2_eq_constant"],
|
|
669
|
+
output2_gt_coeff=field_values["output2_gt_coeff"],
|
|
670
|
+
output2_gt_constant=field_values["output2_gt_constant"],
|
|
671
|
+
output2_lt_coeff=field_values["output2_lt_coeff"],
|
|
672
|
+
output2_lt_constant=field_values["output2_lt_constant"],
|
|
673
|
+
output2_max_capacity=field_values["output2_max_capacity"],
|
|
674
|
+
fueluse_increase_eq=field_values["fueluse_increase_eq"],
|
|
675
|
+
inflow_multiplier=field_values["inflow_multiplier"],
|
|
676
|
+
extra_params=extra,
|
|
677
|
+
))
|
|
678
|
+
return result
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _read_connections(wb: openpyxl.Workbook) -> list[NodeNodeConnection]:
|
|
682
|
+
"""Read the 'nodeNode' sheet."""
|
|
683
|
+
ws = _get_sheet(wb, "nodeNode")
|
|
684
|
+
if ws is None:
|
|
685
|
+
return []
|
|
686
|
+
|
|
687
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
688
|
+
if not rows:
|
|
689
|
+
return []
|
|
690
|
+
|
|
691
|
+
headers = [_safe_str(h) for h in rows[0]]
|
|
692
|
+
result: list[NodeNodeConnection] = []
|
|
693
|
+
|
|
694
|
+
def _col(name: str) -> int | None:
|
|
695
|
+
name_lower = name.lower()
|
|
696
|
+
for i, h in enumerate(headers):
|
|
697
|
+
if h is not None and h.lower() == name_lower:
|
|
698
|
+
return i
|
|
699
|
+
return None
|
|
700
|
+
|
|
701
|
+
def _get(row: tuple[Any, ...], col_idx: int | None) -> Any:
|
|
702
|
+
if col_idx is None or col_idx >= len(row):
|
|
703
|
+
return None
|
|
704
|
+
return row[col_idx]
|
|
705
|
+
|
|
706
|
+
c_grid = _col("grid")
|
|
707
|
+
c_n1 = _col("node1")
|
|
708
|
+
c_n2 = _col("node2")
|
|
709
|
+
c_cap_r = _col("cap.rightward (MW)")
|
|
710
|
+
c_cap_l = _col("cap.leftward (MW)")
|
|
711
|
+
c_inv_cap = _col("invested capacity (MW)")
|
|
712
|
+
c_max_inv = _col("max invest (MW)")
|
|
713
|
+
c_loss = _col("loss")
|
|
714
|
+
c_inv_cost = _col("inv.cost/kW")
|
|
715
|
+
c_life = _col("lifetime")
|
|
716
|
+
c_interest = _col("interest")
|
|
717
|
+
_col("annuity")
|
|
718
|
+
c_hvdc = _col("HVDC")
|
|
719
|
+
|
|
720
|
+
for row in rows[1:]:
|
|
721
|
+
if _row_is_empty(row):
|
|
722
|
+
continue
|
|
723
|
+
grid = _safe_str(_get(row, c_grid))
|
|
724
|
+
n1 = _safe_str(_get(row, c_n1))
|
|
725
|
+
n2 = _safe_str(_get(row, c_n2))
|
|
726
|
+
if grid is None or n1 is None or n2 is None:
|
|
727
|
+
continue
|
|
728
|
+
|
|
729
|
+
hvdc_val = _safe_float(_get(row, c_hvdc))
|
|
730
|
+
is_hvdc = bool(hvdc_val) if hvdc_val is not None else False
|
|
731
|
+
|
|
732
|
+
result.append(NodeNodeConnection(
|
|
733
|
+
grid=grid,
|
|
734
|
+
node1=n1,
|
|
735
|
+
node2=n2,
|
|
736
|
+
cap_rightward_mw=_safe_float(_get(row, c_cap_r)),
|
|
737
|
+
cap_leftward_mw=_safe_float(_get(row, c_cap_l)),
|
|
738
|
+
invested_capacity_mw=_safe_float(_get(row, c_inv_cap)),
|
|
739
|
+
max_invest_mw=_safe_float(_get(row, c_max_inv)),
|
|
740
|
+
loss=_safe_float(_get(row, c_loss)),
|
|
741
|
+
invest_cost_per_kw=_safe_float(_get(row, c_inv_cost)),
|
|
742
|
+
lifetime=_safe_float(_get(row, c_life)),
|
|
743
|
+
interest=_safe_float(_get(row, c_interest)),
|
|
744
|
+
is_hvdc=is_hvdc,
|
|
745
|
+
))
|
|
746
|
+
return result
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _read_profile_ts(
|
|
750
|
+
wb: openpyxl.Workbook,
|
|
751
|
+
sheet_name: str,
|
|
752
|
+
) -> list[TimeSeriesData]:
|
|
753
|
+
"""Read a profile time-series sheet (ts_cf, ts_inflow).
|
|
754
|
+
|
|
755
|
+
Layout:
|
|
756
|
+
Row 0: col 1 has a label, cols 2+ have profile names.
|
|
757
|
+
Row 1: col 0 has "time" / "Time".
|
|
758
|
+
Rows 2+: col 0 has time ids, cols 2+ have values.
|
|
759
|
+
"""
|
|
760
|
+
ws = _get_sheet(wb, sheet_name)
|
|
761
|
+
if ws is None:
|
|
762
|
+
return []
|
|
763
|
+
|
|
764
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
765
|
+
if len(rows) < 3:
|
|
766
|
+
logger.warning("Sheet '%s' has fewer than 3 rows; skipping.", sheet_name)
|
|
767
|
+
return []
|
|
768
|
+
|
|
769
|
+
# Row 0: profile names starting at col index that has data
|
|
770
|
+
header_row = rows[0]
|
|
771
|
+
# Determine the first data column (skip cols 0 and possibly 1 if it's a label)
|
|
772
|
+
profile_names: dict[int, str] = {}
|
|
773
|
+
for col_idx in range(1, len(header_row)):
|
|
774
|
+
name = _safe_str(header_row[col_idx])
|
|
775
|
+
if name is not None:
|
|
776
|
+
profile_names[col_idx] = name
|
|
777
|
+
|
|
778
|
+
if not profile_names:
|
|
779
|
+
logger.warning("Sheet '%s': no profile names found in header row.", sheet_name)
|
|
780
|
+
return []
|
|
781
|
+
|
|
782
|
+
# Determine the starting data column: smallest col_idx that has a profile name
|
|
783
|
+
# But the actual profile names may be labels in col 1 -- find the first numeric col
|
|
784
|
+
first_data_col = min(profile_names.keys())
|
|
785
|
+
|
|
786
|
+
# Build time series, skipping header rows (rows 0 and 1)
|
|
787
|
+
series_data: dict[int, dict[str, float]] = {ci: {} for ci in profile_names}
|
|
788
|
+
|
|
789
|
+
for row in rows[2:]:
|
|
790
|
+
if _row_is_empty(row):
|
|
791
|
+
continue
|
|
792
|
+
time_id = _safe_str(row[0]) if len(row) > 0 else None
|
|
793
|
+
if time_id is None:
|
|
794
|
+
continue
|
|
795
|
+
for col_idx in profile_names:
|
|
796
|
+
if col_idx < len(row):
|
|
797
|
+
val = _safe_float(row[col_idx])
|
|
798
|
+
if val is not None:
|
|
799
|
+
series_data[col_idx][time_id] = val
|
|
800
|
+
|
|
801
|
+
result: list[TimeSeriesData] = []
|
|
802
|
+
for col_idx, name in sorted(profile_names.items()):
|
|
803
|
+
# Skip if name looks like a label for the header row itself
|
|
804
|
+
# (e.g. "cf_profile", "inflow") -- these are column 1 labels, not profile names
|
|
805
|
+
if col_idx == first_data_col and first_data_col == 1:
|
|
806
|
+
# Check if there are profiles at higher indices; if so, col 1 is likely a label
|
|
807
|
+
if len(profile_names) > 1:
|
|
808
|
+
continue
|
|
809
|
+
data = series_data[col_idx]
|
|
810
|
+
if data:
|
|
811
|
+
result.append(TimeSeriesData(name=name, data=data))
|
|
812
|
+
return result
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _read_demand_ts(
|
|
816
|
+
wb: openpyxl.Workbook,
|
|
817
|
+
sheet_name: str,
|
|
818
|
+
) -> list[DemandTimeSeries]:
|
|
819
|
+
"""Read a demand-style time-series sheet (ts_energy, ts_import).
|
|
820
|
+
|
|
821
|
+
Layout:
|
|
822
|
+
Row 0: col 1 has "grid", cols 2+ have grid names.
|
|
823
|
+
Row 1: col 1 has "node", cols 2+ have node names.
|
|
824
|
+
Row 2: col 0 has "time".
|
|
825
|
+
Rows 3+: col 0 has time ids, cols 2+ have values.
|
|
826
|
+
"""
|
|
827
|
+
ws = _get_sheet(wb, sheet_name)
|
|
828
|
+
if ws is None:
|
|
829
|
+
return []
|
|
830
|
+
|
|
831
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
832
|
+
if len(rows) < 4:
|
|
833
|
+
logger.warning("Sheet '%s' has fewer than 4 rows; skipping.", sheet_name)
|
|
834
|
+
return []
|
|
835
|
+
|
|
836
|
+
grid_row = rows[0]
|
|
837
|
+
node_row = rows[1]
|
|
838
|
+
|
|
839
|
+
# Build column -> (grid, node) mapping
|
|
840
|
+
col_map: dict[int, tuple[str, str]] = {}
|
|
841
|
+
for col_idx in range(2, max(len(grid_row), len(node_row))):
|
|
842
|
+
grid = _safe_str(grid_row[col_idx]) if col_idx < len(grid_row) else None
|
|
843
|
+
node = _safe_str(node_row[col_idx]) if col_idx < len(node_row) else None
|
|
844
|
+
if grid is not None and node is not None:
|
|
845
|
+
col_map[col_idx] = (grid, node)
|
|
846
|
+
|
|
847
|
+
if not col_map:
|
|
848
|
+
logger.warning("Sheet '%s': no grid/node pairs found.", sheet_name)
|
|
849
|
+
return []
|
|
850
|
+
|
|
851
|
+
# Read data rows (row 3+)
|
|
852
|
+
series_data: dict[int, dict[str, float]] = {ci: {} for ci in col_map}
|
|
853
|
+
|
|
854
|
+
for row in rows[3:]:
|
|
855
|
+
if _row_is_empty(row):
|
|
856
|
+
continue
|
|
857
|
+
time_id = _safe_str(row[0]) if len(row) > 0 else None
|
|
858
|
+
if time_id is None:
|
|
859
|
+
continue
|
|
860
|
+
for col_idx in col_map:
|
|
861
|
+
if col_idx < len(row):
|
|
862
|
+
val = _safe_float(row[col_idx])
|
|
863
|
+
if val is not None:
|
|
864
|
+
series_data[col_idx][time_id] = val
|
|
865
|
+
|
|
866
|
+
result: list[DemandTimeSeries] = []
|
|
867
|
+
for col_idx, (grid, node) in sorted(col_map.items()):
|
|
868
|
+
data = series_data[col_idx]
|
|
869
|
+
if data:
|
|
870
|
+
result.append(DemandTimeSeries(grid=grid, node=node, data=data))
|
|
871
|
+
return result
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _read_reserve_ts(
|
|
875
|
+
wb: openpyxl.Workbook,
|
|
876
|
+
sheet_name: str,
|
|
877
|
+
) -> list[TimeSeriesData]:
|
|
878
|
+
"""Read a reserve time-series sheet (ts_reserve_node, ts_reserve_nodeGroup).
|
|
879
|
+
|
|
880
|
+
Layout:
|
|
881
|
+
Row 0: col 1 has "node"/"nodeGroup", cols 2+ have names.
|
|
882
|
+
Row 1: col 0 has "Time"/"time".
|
|
883
|
+
Rows 2+: col 0 has time ids, cols 2+ have values.
|
|
884
|
+
"""
|
|
885
|
+
ws = _get_sheet(wb, sheet_name)
|
|
886
|
+
if ws is None:
|
|
887
|
+
return []
|
|
888
|
+
|
|
889
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
890
|
+
if len(rows) < 3:
|
|
891
|
+
logger.warning("Sheet '%s' has fewer than 3 rows; skipping.", sheet_name)
|
|
892
|
+
return []
|
|
893
|
+
|
|
894
|
+
header_row = rows[0]
|
|
895
|
+
# Profile names at col 2+
|
|
896
|
+
name_map: dict[int, str] = {}
|
|
897
|
+
for col_idx in range(2, len(header_row)):
|
|
898
|
+
name = _safe_str(header_row[col_idx])
|
|
899
|
+
if name is not None:
|
|
900
|
+
name_map[col_idx] = name
|
|
901
|
+
|
|
902
|
+
if not name_map:
|
|
903
|
+
# Try starting from col 1 in case there's no label col
|
|
904
|
+
for col_idx in range(1, len(header_row)):
|
|
905
|
+
name = _safe_str(header_row[col_idx])
|
|
906
|
+
if name is not None:
|
|
907
|
+
name_map[col_idx] = name
|
|
908
|
+
|
|
909
|
+
if not name_map:
|
|
910
|
+
logger.warning("Sheet '%s': no names found in header row.", sheet_name)
|
|
911
|
+
return []
|
|
912
|
+
|
|
913
|
+
series_data: dict[int, dict[str, float]] = {ci: {} for ci in name_map}
|
|
914
|
+
|
|
915
|
+
for row in rows[2:]:
|
|
916
|
+
if _row_is_empty(row):
|
|
917
|
+
continue
|
|
918
|
+
time_id = _safe_str(row[0]) if len(row) > 0 else None
|
|
919
|
+
if time_id is None:
|
|
920
|
+
continue
|
|
921
|
+
for col_idx in name_map:
|
|
922
|
+
if col_idx < len(row):
|
|
923
|
+
val = _safe_float(row[col_idx])
|
|
924
|
+
if val is not None:
|
|
925
|
+
series_data[col_idx][time_id] = val
|
|
926
|
+
|
|
927
|
+
result: list[TimeSeriesData] = []
|
|
928
|
+
for col_idx, name in sorted(name_map.items()):
|
|
929
|
+
data = series_data[col_idx]
|
|
930
|
+
if data:
|
|
931
|
+
result.append(TimeSeriesData(name=name, data=data))
|
|
932
|
+
return result
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _read_unit_ts(wb: openpyxl.Workbook) -> list[UnitTimeSeries]:
|
|
936
|
+
"""Read the 'ts_unit' sheet.
|
|
937
|
+
|
|
938
|
+
Layout:
|
|
939
|
+
Row 0: col 1 has "grid", cols 2+ have grid names.
|
|
940
|
+
Row 1: col 1 has "node", cols 2+ have node names.
|
|
941
|
+
Row 2: col 1 has "unit", cols 2+ have unit names.
|
|
942
|
+
Row 3: col 1 has "unit_ts_param", cols 2+ have parameter names.
|
|
943
|
+
Row 4: col 0 has "Time".
|
|
944
|
+
Rows 5+: col 0 has time ids, cols 2+ have values.
|
|
945
|
+
"""
|
|
946
|
+
ws = _get_sheet(wb, "ts_unit")
|
|
947
|
+
if ws is None:
|
|
948
|
+
return []
|
|
949
|
+
|
|
950
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
951
|
+
if len(rows) < 6:
|
|
952
|
+
logger.warning("Sheet 'ts_unit' has fewer than 6 rows; skipping.")
|
|
953
|
+
return []
|
|
954
|
+
|
|
955
|
+
grid_row = rows[0]
|
|
956
|
+
node_row = rows[1]
|
|
957
|
+
unit_row = rows[2]
|
|
958
|
+
param_row = rows[3]
|
|
959
|
+
|
|
960
|
+
# Build column -> (grid, node, unit, param) mapping
|
|
961
|
+
col_map: dict[int, tuple[str, str, str, str]] = {}
|
|
962
|
+
max_len = max(len(grid_row), len(node_row), len(unit_row), len(param_row))
|
|
963
|
+
for col_idx in range(2, max_len):
|
|
964
|
+
grid = _safe_str(grid_row[col_idx]) if col_idx < len(grid_row) else None
|
|
965
|
+
node = _safe_str(node_row[col_idx]) if col_idx < len(node_row) else None
|
|
966
|
+
unit = _safe_str(unit_row[col_idx]) if col_idx < len(unit_row) else None
|
|
967
|
+
param = _safe_str(param_row[col_idx]) if col_idx < len(param_row) else None
|
|
968
|
+
if grid is not None and node is not None and unit is not None and param is not None:
|
|
969
|
+
col_map[col_idx] = (grid, node, unit, param)
|
|
970
|
+
|
|
971
|
+
if not col_map:
|
|
972
|
+
logger.warning("Sheet 'ts_unit': no unit time series columns found.")
|
|
973
|
+
return []
|
|
974
|
+
|
|
975
|
+
series_data: dict[int, dict[str, float]] = {ci: {} for ci in col_map}
|
|
976
|
+
|
|
977
|
+
for row in rows[5:]:
|
|
978
|
+
if _row_is_empty(row):
|
|
979
|
+
continue
|
|
980
|
+
time_id = _safe_str(row[0]) if len(row) > 0 else None
|
|
981
|
+
if time_id is None:
|
|
982
|
+
continue
|
|
983
|
+
for col_idx in col_map:
|
|
984
|
+
if col_idx < len(row):
|
|
985
|
+
val = _safe_float(row[col_idx])
|
|
986
|
+
if val is not None:
|
|
987
|
+
series_data[col_idx][time_id] = val
|
|
988
|
+
|
|
989
|
+
result: list[UnitTimeSeries] = []
|
|
990
|
+
for col_idx, (grid, node, unit, param) in sorted(col_map.items()):
|
|
991
|
+
data = series_data[col_idx]
|
|
992
|
+
if data:
|
|
993
|
+
result.append(UnitTimeSeries(
|
|
994
|
+
grid=grid, node=node, unit=unit, param_name=param, data=data,
|
|
995
|
+
))
|
|
996
|
+
return result
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _read_time_steps(wb: openpyxl.Workbook) -> list[TimeStep]:
|
|
1000
|
+
"""Read the 'ts_time' sheet."""
|
|
1001
|
+
ws = _get_sheet(wb, "ts_time")
|
|
1002
|
+
if ws is None:
|
|
1003
|
+
return []
|
|
1004
|
+
|
|
1005
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
1006
|
+
if not rows:
|
|
1007
|
+
return []
|
|
1008
|
+
|
|
1009
|
+
headers = [_safe_str(h) for h in rows[0]]
|
|
1010
|
+
result: list[TimeStep] = []
|
|
1011
|
+
|
|
1012
|
+
def _col(name: str) -> int | None:
|
|
1013
|
+
name_lower = name.lower()
|
|
1014
|
+
for i, h in enumerate(headers):
|
|
1015
|
+
if h is not None and h.lower() == name_lower:
|
|
1016
|
+
return i
|
|
1017
|
+
return None
|
|
1018
|
+
|
|
1019
|
+
def _get(row: tuple[Any, ...], col_idx: int | None) -> Any:
|
|
1020
|
+
if col_idx is None or col_idx >= len(row):
|
|
1021
|
+
return None
|
|
1022
|
+
return row[col_idx]
|
|
1023
|
+
|
|
1024
|
+
c_time = _col("time")
|
|
1025
|
+
c_in_use = _col("in_use")
|
|
1026
|
+
c_jump = _col("time_jump")
|
|
1027
|
+
c_in_use_inv = _col("in_use_invest")
|
|
1028
|
+
c_jump_inv = _col("time_jump_invest")
|
|
1029
|
+
|
|
1030
|
+
for row in rows[1:]:
|
|
1031
|
+
if _row_is_empty(row):
|
|
1032
|
+
continue
|
|
1033
|
+
time_id = _safe_str(_get(row, c_time))
|
|
1034
|
+
if time_id is None:
|
|
1035
|
+
continue
|
|
1036
|
+
|
|
1037
|
+
in_use_val = _safe_float(_get(row, c_in_use))
|
|
1038
|
+
in_use_inv_val = _safe_float(_get(row, c_in_use_inv))
|
|
1039
|
+
jump_val = _safe_float(_get(row, c_jump))
|
|
1040
|
+
jump_inv_val = _safe_float(_get(row, c_jump_inv))
|
|
1041
|
+
|
|
1042
|
+
result.append(TimeStep(
|
|
1043
|
+
time_id=time_id,
|
|
1044
|
+
in_use=bool(in_use_val) if in_use_val is not None else False,
|
|
1045
|
+
time_jump=jump_val if jump_val is not None else 0.0,
|
|
1046
|
+
in_use_invest=bool(in_use_inv_val) if in_use_inv_val is not None else False,
|
|
1047
|
+
time_jump_invest=jump_inv_val if jump_inv_val is not None else 0.0,
|
|
1048
|
+
))
|
|
1049
|
+
return result
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
# ---------------------------------------------------------------------------
|
|
1053
|
+
# Main entry point
|
|
1054
|
+
# ---------------------------------------------------------------------------
|
|
1055
|
+
|
|
1056
|
+
def read_old_flextool(file_path: str) -> OldFlexToolData:
|
|
1057
|
+
"""Read an old-format FlexTool Excel file and return structured data.
|
|
1058
|
+
|
|
1059
|
+
Args:
|
|
1060
|
+
file_path: Path to the .xlsm file.
|
|
1061
|
+
|
|
1062
|
+
Returns:
|
|
1063
|
+
An OldFlexToolData instance with all parsed sheets.
|
|
1064
|
+
"""
|
|
1065
|
+
logger.info("Reading old FlexTool file: %s", file_path)
|
|
1066
|
+
wb = openpyxl.load_workbook(file_path, read_only=True, data_only=True)
|
|
1067
|
+
|
|
1068
|
+
try:
|
|
1069
|
+
# Log which sheets are present and which will be skipped
|
|
1070
|
+
for name in wb.sheetnames:
|
|
1071
|
+
if name in _SKIP_SHEETS:
|
|
1072
|
+
logger.debug("Skipping sheet: %s", name)
|
|
1073
|
+
|
|
1074
|
+
master = _read_master(wb)
|
|
1075
|
+
grid_nodes = _read_grid_nodes(wb)
|
|
1076
|
+
node_groups = _read_node_groups(wb)
|
|
1077
|
+
unit_types = _read_unit_types(wb)
|
|
1078
|
+
fuels = _read_fuels(wb)
|
|
1079
|
+
unit_groups = _read_unit_groups(wb)
|
|
1080
|
+
units = _read_units(wb)
|
|
1081
|
+
connections = _read_connections(wb)
|
|
1082
|
+
cf_profiles = _read_profile_ts(wb, "ts_cf")
|
|
1083
|
+
inflow_profiles = _read_profile_ts(wb, "ts_inflow")
|
|
1084
|
+
demand_ts = _read_demand_ts(wb, "ts_energy")
|
|
1085
|
+
import_ts = _read_demand_ts(wb, "ts_import")
|
|
1086
|
+
reserve_node_ts = _read_reserve_ts(wb, "ts_reserve_node")
|
|
1087
|
+
reserve_group_ts = _read_reserve_ts(wb, "ts_reserve_nodeGroup")
|
|
1088
|
+
unit_ts = _read_unit_ts(wb)
|
|
1089
|
+
time_steps = _read_time_steps(wb)
|
|
1090
|
+
|
|
1091
|
+
logger.info(
|
|
1092
|
+
"Parsed: %d nodes, %d unit types, %d units, %d connections, "
|
|
1093
|
+
"%d cf profiles, %d time steps",
|
|
1094
|
+
len(grid_nodes), len(unit_types), len(units), len(connections),
|
|
1095
|
+
len(cf_profiles), len(time_steps),
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
return OldFlexToolData(
|
|
1099
|
+
master=master,
|
|
1100
|
+
grid_nodes=grid_nodes,
|
|
1101
|
+
node_groups=node_groups,
|
|
1102
|
+
unit_types=unit_types,
|
|
1103
|
+
fuels=fuels,
|
|
1104
|
+
unit_groups=unit_groups,
|
|
1105
|
+
units=units,
|
|
1106
|
+
connections=connections,
|
|
1107
|
+
cf_profiles=cf_profiles,
|
|
1108
|
+
inflow_profiles=inflow_profiles,
|
|
1109
|
+
demand_ts=demand_ts,
|
|
1110
|
+
import_ts=import_ts,
|
|
1111
|
+
reserve_node_ts=reserve_node_ts,
|
|
1112
|
+
reserve_group_ts=reserve_group_ts,
|
|
1113
|
+
unit_ts=unit_ts,
|
|
1114
|
+
time_steps=time_steps,
|
|
1115
|
+
)
|
|
1116
|
+
finally:
|
|
1117
|
+
wb.close()
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
# ---------------------------------------------------------------------------
|
|
1121
|
+
# Entity identifier columns by section type
|
|
1122
|
+
# ---------------------------------------------------------------------------
|
|
1123
|
+
|
|
1124
|
+
_SENSITIVITY_ENTITY_ID_COLS: dict[str, set[str]] = {
|
|
1125
|
+
"master": set(),
|
|
1126
|
+
"nodeGroup": {"nodegroup"},
|
|
1127
|
+
"gridNode": {"grid", "node", "nodegroup", "nodegroup2", "nodegroup3"},
|
|
1128
|
+
"unit_type": {"unit type"},
|
|
1129
|
+
"fuel": {"fuel"},
|
|
1130
|
+
"unitGroup": {"unitgroup"},
|
|
1131
|
+
"units": {
|
|
1132
|
+
"unitgroup", "unittype", "fuel", "cf profile", "inflow",
|
|
1133
|
+
"input grid", "input node", "output grid", "output node",
|
|
1134
|
+
},
|
|
1135
|
+
"nodeNode": {"grid", "node1", "node2"},
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
# String-typed entity ID columns (stay as strings, never float-convert)
|
|
1139
|
+
_SENSITIVITY_STRING_COLS: set[str] = {
|
|
1140
|
+
"nodegroup", "nodegroup2", "nodegroup3",
|
|
1141
|
+
"grid", "node", "node1", "node2",
|
|
1142
|
+
"unit type", "unittype", "unitgroup",
|
|
1143
|
+
"fuel", "cf profile", "inflow",
|
|
1144
|
+
"input grid", "input node", "output grid", "output node",
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def read_old_flextool_sensitivities(
|
|
1149
|
+
file_path: str,
|
|
1150
|
+
) -> dict[str, list[SensitivityOverride]]:
|
|
1151
|
+
"""Read the 'Sensitivity definitions' sheet and return per-scenario overrides.
|
|
1152
|
+
|
|
1153
|
+
The sheet contains multiple sections separated by empty rows. Each section
|
|
1154
|
+
starts with a header row like "Scenario definitions master:" followed by
|
|
1155
|
+
column headers and data rows.
|
|
1156
|
+
|
|
1157
|
+
Args:
|
|
1158
|
+
file_path: Path to the old FlexTool .xlsm file.
|
|
1159
|
+
|
|
1160
|
+
Returns:
|
|
1161
|
+
A dict mapping scenario_name -> list of SensitivityOverride objects.
|
|
1162
|
+
"""
|
|
1163
|
+
logger.info("Reading sensitivities from: %s", file_path)
|
|
1164
|
+
wb = openpyxl.load_workbook(file_path, read_only=True, data_only=True)
|
|
1165
|
+
|
|
1166
|
+
try:
|
|
1167
|
+
ws = _get_sheet(wb, "Sensitivity definitions")
|
|
1168
|
+
if ws is None:
|
|
1169
|
+
logger.warning("No 'Sensitivity definitions' sheet found.")
|
|
1170
|
+
return {}
|
|
1171
|
+
|
|
1172
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
1173
|
+
if not rows:
|
|
1174
|
+
return {}
|
|
1175
|
+
|
|
1176
|
+
result: dict[str, list[SensitivityOverride]] = {}
|
|
1177
|
+
_parse_sensitivity_sections(rows, result)
|
|
1178
|
+
|
|
1179
|
+
total = sum(len(v) for v in result.values())
|
|
1180
|
+
logger.info(
|
|
1181
|
+
"Parsed %d sensitivity scenarios with %d total overrides.",
|
|
1182
|
+
len(result), total,
|
|
1183
|
+
)
|
|
1184
|
+
return result
|
|
1185
|
+
|
|
1186
|
+
finally:
|
|
1187
|
+
wb.close()
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _parse_sensitivity_sections(
|
|
1191
|
+
rows: list[tuple[Any, ...]],
|
|
1192
|
+
result: dict[str, list[SensitivityOverride]],
|
|
1193
|
+
) -> None:
|
|
1194
|
+
"""Parse all sections from the Sensitivity definitions sheet rows."""
|
|
1195
|
+
i = 0
|
|
1196
|
+
while i < len(rows):
|
|
1197
|
+
row = rows[i]
|
|
1198
|
+
# Detect section header: col 0 starts with "Scenario definitions"
|
|
1199
|
+
cell0 = _safe_str(row[0]) if len(row) > 0 else None
|
|
1200
|
+
if cell0 is not None and cell0.lower().startswith("scenario definitions"):
|
|
1201
|
+
# This row is the header row with column names in col 2+
|
|
1202
|
+
headers = [_safe_str(c) for c in row]
|
|
1203
|
+
# Extract the section type from the header text after ":"
|
|
1204
|
+
# e.g. "Scenario definitions master:" -> determine from data rows
|
|
1205
|
+
i += 1
|
|
1206
|
+
# Parse data rows until next empty row or next section header
|
|
1207
|
+
i = _parse_sensitivity_data_rows(rows, i, headers, result)
|
|
1208
|
+
else:
|
|
1209
|
+
i += 1
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def _parse_sensitivity_data_rows(
|
|
1213
|
+
rows: list[tuple[Any, ...]],
|
|
1214
|
+
start: int,
|
|
1215
|
+
headers: list[str | None],
|
|
1216
|
+
result: dict[str, list[SensitivityOverride]],
|
|
1217
|
+
) -> int:
|
|
1218
|
+
"""Parse data rows for one section. Returns the index after the section."""
|
|
1219
|
+
i = start
|
|
1220
|
+
while i < len(rows):
|
|
1221
|
+
row = rows[i]
|
|
1222
|
+
if _row_is_empty(row):
|
|
1223
|
+
# Empty row signals end of section
|
|
1224
|
+
return i + 1
|
|
1225
|
+
|
|
1226
|
+
# Check if this is the start of a new section header
|
|
1227
|
+
cell0 = _safe_str(row[0]) if len(row) > 0 else None
|
|
1228
|
+
if cell0 is not None and cell0.lower().startswith("scenario definitions"):
|
|
1229
|
+
return i # Don't advance — let the caller re-process this row
|
|
1230
|
+
|
|
1231
|
+
# Col 0: scenario name, Col 1: sheet/section type
|
|
1232
|
+
scenario_name = cell0
|
|
1233
|
+
section_type = _safe_str(row[1]) if len(row) > 1 else None
|
|
1234
|
+
|
|
1235
|
+
# Skip rows where col 0 is empty (placeholder rows with just sheet name)
|
|
1236
|
+
if scenario_name is None or section_type is None:
|
|
1237
|
+
i += 1
|
|
1238
|
+
continue
|
|
1239
|
+
|
|
1240
|
+
# Determine entity ID vs parameter columns based on section type
|
|
1241
|
+
entity_id_col_names = _SENSITIVITY_ENTITY_ID_COLS.get(section_type, set())
|
|
1242
|
+
|
|
1243
|
+
# Build entity_ids from the ID columns
|
|
1244
|
+
entity_ids: dict[str, str] = {}
|
|
1245
|
+
for col_idx in range(2, min(len(headers), len(row))):
|
|
1246
|
+
header = headers[col_idx]
|
|
1247
|
+
if header is None:
|
|
1248
|
+
continue
|
|
1249
|
+
header_lower = header.strip().lower()
|
|
1250
|
+
if header_lower in entity_id_col_names:
|
|
1251
|
+
val = _safe_str(row[col_idx])
|
|
1252
|
+
if val is not None:
|
|
1253
|
+
entity_ids[header.strip()] = val
|
|
1254
|
+
|
|
1255
|
+
# Create overrides for each non-empty parameter cell
|
|
1256
|
+
for col_idx in range(2, min(len(headers), len(row))):
|
|
1257
|
+
header = headers[col_idx]
|
|
1258
|
+
if header is None:
|
|
1259
|
+
continue
|
|
1260
|
+
header_lower = header.strip().lower()
|
|
1261
|
+
if header_lower in entity_id_col_names:
|
|
1262
|
+
continue # Skip entity ID columns
|
|
1263
|
+
|
|
1264
|
+
raw_value = row[col_idx]
|
|
1265
|
+
if raw_value is None:
|
|
1266
|
+
continue
|
|
1267
|
+
|
|
1268
|
+
# Try float first, fall back to string
|
|
1269
|
+
float_val = _safe_float(raw_value)
|
|
1270
|
+
if float_val is not None:
|
|
1271
|
+
value: float | str = float_val
|
|
1272
|
+
else:
|
|
1273
|
+
str_val = _safe_str(raw_value)
|
|
1274
|
+
if str_val is None:
|
|
1275
|
+
continue
|
|
1276
|
+
value = str_val
|
|
1277
|
+
|
|
1278
|
+
override = SensitivityOverride(
|
|
1279
|
+
section=section_type,
|
|
1280
|
+
entity_ids=dict(entity_ids), # copy
|
|
1281
|
+
param_name=header.strip(),
|
|
1282
|
+
value=value,
|
|
1283
|
+
)
|
|
1284
|
+
result.setdefault(scenario_name, []).append(override)
|
|
1285
|
+
|
|
1286
|
+
i += 1
|
|
1287
|
+
|
|
1288
|
+
return i
|