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,321 @@
|
|
|
1
|
+
"""SpineDB → Provider pipeline validators.
|
|
2
|
+
|
|
3
|
+
Each validator consumes a :class:`spinedb_api.DatabaseMapping` (or a
|
|
4
|
+
:class:`SpineDBBackend` exposing one as ``._db``) and either:
|
|
5
|
+
|
|
6
|
+
* raises :class:`FlexToolConfigError` for hard misconfigurations that
|
|
7
|
+
would silently corrupt the solve (e.g. zero ``step_duration``), or
|
|
8
|
+
* logs a warning for soft inconsistencies (e.g. ``output_*: yes`` set
|
|
9
|
+
on a group with no members of the required class).
|
|
10
|
+
|
|
11
|
+
The validators live as their own module because:
|
|
12
|
+
|
|
13
|
+
1. They are pure code paths with no derivation outputs — they don't
|
|
14
|
+
``provider.put`` anything.
|
|
15
|
+
2. They are called from :func:`flextool.input_derivation.run` early
|
|
16
|
+
enough that a hard failure surfaces before the cascade builds any
|
|
17
|
+
frames against malformed input.
|
|
18
|
+
3. They share the :func:`_get_commodity_price_methods` helper but are
|
|
19
|
+
otherwise unrelated to the EAV → tabular materialiser specs in
|
|
20
|
+
:mod:`flextool.input_derivation._specs`.
|
|
21
|
+
|
|
22
|
+
Pre-Step-2.5 these functions lived in the 2356-LOC ``input_writer.py``
|
|
23
|
+
monolith; Step 2.5 item 6 extracted them here.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
|
|
29
|
+
from flextool.engine_polars._solve_state import FlexToolConfigError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"validate_timeline_timestep_duration",
|
|
34
|
+
"validate_capacity_margin_groups",
|
|
35
|
+
"validate_ladder_methods",
|
|
36
|
+
"validate_group_output_memberships",
|
|
37
|
+
"validate_connection_node_memberships",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_commodity_price_methods(db) -> dict[str, str]:
|
|
42
|
+
"""Return ``{commodity: price_method}`` for every commodity whose
|
|
43
|
+
``price_method`` is set. Commodities without the param default to
|
|
44
|
+
``'price'`` in the mod (and do not appear here).
|
|
45
|
+
"""
|
|
46
|
+
out: dict[str, str] = {}
|
|
47
|
+
for pv in db.find_parameter_values(
|
|
48
|
+
entity_class_name="commodity",
|
|
49
|
+
parameter_definition_name="price_method",
|
|
50
|
+
):
|
|
51
|
+
if pv["type"] is None:
|
|
52
|
+
continue
|
|
53
|
+
out[pv["entity_byname"][0]] = str(pv["parsed_value"])
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_timeline_timestep_duration(db) -> None:
|
|
58
|
+
"""Raise FlexToolConfigError if any timeline entity is missing its
|
|
59
|
+
``timestep_duration`` map. Without it, ``step_duration`` silently
|
|
60
|
+
falls to 0 throughout the model and every time-weighted quantity
|
|
61
|
+
(balances, costs, ramps) collapses to zero. There is no sensible
|
|
62
|
+
default, so the value must be present on every timeline.
|
|
63
|
+
"""
|
|
64
|
+
timelines = [ent["entity_byname"][0]
|
|
65
|
+
for ent in db.find_entities(entity_class_name="timeline")]
|
|
66
|
+
if not timelines:
|
|
67
|
+
return
|
|
68
|
+
have_duration: set[str] = set()
|
|
69
|
+
for pv in db.find_parameter_values(
|
|
70
|
+
entity_class_name="timeline",
|
|
71
|
+
parameter_definition_name="timestep_duration",
|
|
72
|
+
):
|
|
73
|
+
if pv["type"] is None:
|
|
74
|
+
continue
|
|
75
|
+
have_duration.add(pv["entity_byname"][0])
|
|
76
|
+
missing = [t for t in timelines if t not in have_duration]
|
|
77
|
+
if missing:
|
|
78
|
+
raise FlexToolConfigError(
|
|
79
|
+
"timeline 'timestep_duration' is not set for: "
|
|
80
|
+
+ ", ".join(sorted(missing))
|
|
81
|
+
+ ". Every timeline needs a Map(timestep -> duration_in_hours); "
|
|
82
|
+
"without it all time-weighted quantities collapse to zero."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate_capacity_margin_groups(db, logger: logging.Logger) -> None:
|
|
87
|
+
"""Storage nodes are excluded from the capacity-margin constraint.
|
|
88
|
+
Raise if any capacity_margin_method='manual' group contains *only*
|
|
89
|
+
storage nodes (constraint would have no valid members); warn if a
|
|
90
|
+
mix is present.
|
|
91
|
+
"""
|
|
92
|
+
capacity_margin_groups: dict[str, list[str]] = {}
|
|
93
|
+
for pv in db.find_parameter_values(
|
|
94
|
+
entity_class_name="group", parameter_definition_name="capacity_margin_method",
|
|
95
|
+
):
|
|
96
|
+
if pv["parsed_value"] == "manual":
|
|
97
|
+
capacity_margin_groups[pv["entity_byname"][0]] = []
|
|
98
|
+
if not capacity_margin_groups:
|
|
99
|
+
return
|
|
100
|
+
for ent in db.find_entities(entity_class_name="group__node"):
|
|
101
|
+
g, n = ent["entity_byname"][0], ent["entity_byname"][1]
|
|
102
|
+
if g in capacity_margin_groups:
|
|
103
|
+
capacity_margin_groups[g].append(n)
|
|
104
|
+
storage_nodes: set[str] = set()
|
|
105
|
+
for pv in db.find_parameter_values(
|
|
106
|
+
entity_class_name="node", parameter_definition_name="node_type",
|
|
107
|
+
):
|
|
108
|
+
if pv["parsed_value"] == "storage":
|
|
109
|
+
storage_nodes.add(pv["entity_byname"][0])
|
|
110
|
+
for g, nodes in capacity_margin_groups.items():
|
|
111
|
+
storage_in_group = [n for n in nodes if n in storage_nodes]
|
|
112
|
+
if storage_in_group and len(storage_in_group) == len(nodes):
|
|
113
|
+
raise FlexToolConfigError(
|
|
114
|
+
f"Capacity margin group '{g}' contains only storage nodes "
|
|
115
|
+
f"({', '.join(storage_in_group)}). The capacity margin constraint "
|
|
116
|
+
f"excludes storage nodes, so this group has no valid nodes."
|
|
117
|
+
)
|
|
118
|
+
elif storage_in_group:
|
|
119
|
+
logger.warning(
|
|
120
|
+
"Capacity margin group '%s' contains storage nodes (%s) which will "
|
|
121
|
+
"be excluded from the capacity margin constraint.",
|
|
122
|
+
g, ', '.join(storage_in_group),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def validate_ladder_methods(db, logger: logging.Logger) -> None:
|
|
127
|
+
"""Raise FlexToolConfigError if any commodity declares a ladder
|
|
128
|
+
``price_method`` but does not have the corresponding ladder parameter
|
|
129
|
+
set. Runs before the ladder writers so errors name the offending
|
|
130
|
+
commodity and expected parameter.
|
|
131
|
+
"""
|
|
132
|
+
methods = _get_commodity_price_methods(db)
|
|
133
|
+
ladder_methods = {"price_ladder_annual", "price_ladder_cumulative"}
|
|
134
|
+
commodities_needing_ladder = {
|
|
135
|
+
c: m for c, m in methods.items() if m in ladder_methods
|
|
136
|
+
}
|
|
137
|
+
if not commodities_needing_ladder:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Collect commodities that HAVE each ladder param (non-None, non-empty).
|
|
141
|
+
have_cumulative: set[str] = set()
|
|
142
|
+
have_annual: set[str] = set()
|
|
143
|
+
for pv in db.find_parameter_values(
|
|
144
|
+
entity_class_name="commodity",
|
|
145
|
+
parameter_definition_name="price_ladder_cumulative",
|
|
146
|
+
):
|
|
147
|
+
if pv["type"] is None:
|
|
148
|
+
continue
|
|
149
|
+
have_cumulative.add(pv["entity_byname"][0])
|
|
150
|
+
for pv in db.find_parameter_values(
|
|
151
|
+
entity_class_name="commodity",
|
|
152
|
+
parameter_definition_name="price_ladder_annual",
|
|
153
|
+
):
|
|
154
|
+
if pv["type"] is None:
|
|
155
|
+
continue
|
|
156
|
+
have_annual.add(pv["entity_byname"][0])
|
|
157
|
+
|
|
158
|
+
for commodity, method in commodities_needing_ladder.items():
|
|
159
|
+
expected_param = method # parameter name matches method name
|
|
160
|
+
if method == "price_ladder_cumulative" and commodity not in have_cumulative:
|
|
161
|
+
raise FlexToolConfigError(
|
|
162
|
+
f"commodity '{commodity}' has "
|
|
163
|
+
f"price_method='price_ladder_cumulative' but no "
|
|
164
|
+
f"'{expected_param}' value is set. Add a "
|
|
165
|
+
f"Map(tier -> {{price, quantity}}) on that parameter."
|
|
166
|
+
)
|
|
167
|
+
if method == "price_ladder_annual" and commodity not in have_annual:
|
|
168
|
+
raise FlexToolConfigError(
|
|
169
|
+
f"commodity '{commodity}' has "
|
|
170
|
+
f"price_method='price_ladder_annual' but no "
|
|
171
|
+
f"'{expected_param}' value is set. Add either a 2d "
|
|
172
|
+
f"Map(tier -> {{price, quantity}}) or a 3d "
|
|
173
|
+
f"Map(period -> Map(tier -> {{price, quantity}}))."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def validate_group_output_memberships(db, logger: logging.Logger) -> None:
|
|
178
|
+
"""Warn when a group-level output flag is set but the group lacks
|
|
179
|
+
the membership class required for that output to produce any data.
|
|
180
|
+
|
|
181
|
+
Three silent-no-op cases are detected (v58 vocabulary):
|
|
182
|
+
|
|
183
|
+
* ``group.print_dispatch: yes`` with no ``group__node`` row
|
|
184
|
+
* ``group.print_indicators: yes`` with no ``group__node`` row
|
|
185
|
+
* ``flowGroup.flow_aggregator`` set to a non-``none`` method with no
|
|
186
|
+
``flowGroup__unit__node`` **or** ``flowGroup__connection__node`` row
|
|
187
|
+
|
|
188
|
+
Additionally, a double-counting guard warns when a single flow arc
|
|
189
|
+
``(process, node)`` belongs to two or more *dispatch-bound* flowGroups
|
|
190
|
+
(``flow_aggregator`` in ``{dispatch_plots_only, both}``): the arc would
|
|
191
|
+
be summed once per such flowGroup into the nodeGroup dispatch table.
|
|
192
|
+
|
|
193
|
+
Only warnings are emitted — a user may deliberately stage a partial
|
|
194
|
+
configuration.
|
|
195
|
+
"""
|
|
196
|
+
# Collect groups that are members of the relevant entity classes.
|
|
197
|
+
groups_with_node_members: set[str] = set()
|
|
198
|
+
for ent in db.find_entities(entity_class_name="group__node"):
|
|
199
|
+
byname = ent["entity_byname"]
|
|
200
|
+
if byname:
|
|
201
|
+
groups_with_node_members.add(byname[0])
|
|
202
|
+
|
|
203
|
+
flowgroups_with_flow_members: set[str] = set()
|
|
204
|
+
for cls in ("flowGroup__unit__node", "flowGroup__connection__node"):
|
|
205
|
+
for ent in db.find_entities(entity_class_name=cls):
|
|
206
|
+
byname = ent["entity_byname"]
|
|
207
|
+
if byname:
|
|
208
|
+
flowgroups_with_flow_members.add(byname[0])
|
|
209
|
+
|
|
210
|
+
# nodeGroup output flags: "yes" on the ``group`` class, need group__node.
|
|
211
|
+
node_checks: list[tuple[str, str, set[str]]] = [
|
|
212
|
+
("print_dispatch", "group__node", groups_with_node_members),
|
|
213
|
+
("print_indicators", "group__node", groups_with_node_members),
|
|
214
|
+
]
|
|
215
|
+
for param_name, required_members, membership_set in node_checks:
|
|
216
|
+
for pv in db.find_parameter_values(
|
|
217
|
+
entity_class_name="group", parameter_definition_name=param_name
|
|
218
|
+
):
|
|
219
|
+
if pv["type"] is None:
|
|
220
|
+
continue
|
|
221
|
+
if pv["parsed_value"] != "yes":
|
|
222
|
+
continue
|
|
223
|
+
group_name = pv["entity_byname"][0]
|
|
224
|
+
if group_name not in membership_set:
|
|
225
|
+
logger.warning(
|
|
226
|
+
"Group '%s' has %s: yes but no %s members — output will be empty.",
|
|
227
|
+
group_name, param_name, required_members,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# flow_aggregator: an enum METHOD on the ``flowGroup`` class. Any value
|
|
231
|
+
# other than ``none`` requests dispatch bands and/or standalone
|
|
232
|
+
# indicators, both of which need flowGroup__*__node members to produce
|
|
233
|
+
# any data.
|
|
234
|
+
#
|
|
235
|
+
# We also record which flowGroups request dispatch *bands* — the values
|
|
236
|
+
# ``dispatch_plots_only`` and ``both`` — for the overlap check below.
|
|
237
|
+
_DISPATCH_BOUND = {"dispatch_plots_only", "both"}
|
|
238
|
+
dispatch_bound_flowgroups: set[str] = set()
|
|
239
|
+
for pv in db.find_parameter_values(
|
|
240
|
+
entity_class_name="flowGroup", parameter_definition_name="flow_aggregator"
|
|
241
|
+
):
|
|
242
|
+
if pv["type"] is None:
|
|
243
|
+
continue
|
|
244
|
+
if pv["parsed_value"] in (None, "none"):
|
|
245
|
+
continue
|
|
246
|
+
group_name = pv["entity_byname"][0]
|
|
247
|
+
if pv["parsed_value"] in _DISPATCH_BOUND:
|
|
248
|
+
dispatch_bound_flowgroups.add(group_name)
|
|
249
|
+
if group_name not in flowgroups_with_flow_members:
|
|
250
|
+
logger.warning(
|
|
251
|
+
"flowGroup '%s' has flow_aggregator: %s but no "
|
|
252
|
+
"flowGroup__unit__node or flowGroup__connection__node members "
|
|
253
|
+
"— output will be empty.",
|
|
254
|
+
group_name, pv["parsed_value"],
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Double-counting guard: a single flow arc ``(process, node)`` that
|
|
258
|
+
# belongs to two (or more) dispatch-bound flowGroups (flow_aggregator in
|
|
259
|
+
# {dispatch_plots_only, both}) contributes its flow as a band in the
|
|
260
|
+
# nodeGroup dispatch table once per such flowGroup, so the dispatch
|
|
261
|
+
# balance double-counts that arc. Standalone-only overlap is legitimate
|
|
262
|
+
# (separate aggregator series), so it is excluded here. Only a warning
|
|
263
|
+
# is emitted — the user may have intended overlapping membership.
|
|
264
|
+
pn_to_aggregators: dict[tuple[str, str], list[str]] = {}
|
|
265
|
+
for cls in ("flowGroup__unit__node", "flowGroup__connection__node"):
|
|
266
|
+
for ent in db.find_entities(entity_class_name=cls):
|
|
267
|
+
byname = ent["entity_byname"]
|
|
268
|
+
if not byname or len(byname) < 3:
|
|
269
|
+
continue
|
|
270
|
+
flowgroup, process, node = byname[0], byname[1], byname[2]
|
|
271
|
+
if flowgroup not in dispatch_bound_flowgroups:
|
|
272
|
+
continue
|
|
273
|
+
pn_to_aggregators.setdefault((process, node), []).append(flowgroup)
|
|
274
|
+
for (process, node), flowgroups in pn_to_aggregators.items():
|
|
275
|
+
if len(flowgroups) >= 2:
|
|
276
|
+
logger.warning(
|
|
277
|
+
"Flow (%s, %s) belongs to multiple flowGroups (%s) in effect "
|
|
278
|
+
"double counting the flow in dispatch plots. Check "
|
|
279
|
+
"flow_aggregator parameter.",
|
|
280
|
+
process, node, ", ".join(sorted(flowgroups)),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def validate_connection_node_memberships(db, logger: logging.Logger) -> None:
|
|
285
|
+
"""Warn when a ``connection`` entity has no ``connection__node__node``
|
|
286
|
+
relationship defining its two endpoints.
|
|
287
|
+
|
|
288
|
+
A connection's purpose is to transfer between two nodes; without a
|
|
289
|
+
``connection__node__node`` member it carries no source/sink rows, never
|
|
290
|
+
enters ``process_source_sink``, and contributes nothing to any node
|
|
291
|
+
balance. If it nonetheless has an ``invest_method`` it still becomes a
|
|
292
|
+
(degenerate, always-zero) investment variable in the LP, surfacing as a
|
|
293
|
+
``v_invest`` column the output post-processor has to tolerate (see
|
|
294
|
+
``read_parameters._entity_universe``). ``db`` here is already
|
|
295
|
+
scenario-filtered, so this fires either because the connection was
|
|
296
|
+
never wired to its nodes, or because one of its endpoint nodes is not
|
|
297
|
+
active in the current scenario (so the ``connection__node__node`` row
|
|
298
|
+
is filtered out while the connection itself survives). We cannot tell
|
|
299
|
+
the two apart from the filtered set — the connection→node mapping
|
|
300
|
+
lived only in the now-absent ``connection__node__node`` row — so the
|
|
301
|
+
warning names both possibilities and tells the user to check the data.
|
|
302
|
+
|
|
303
|
+
Only a warning is emitted: the solve proceeds, treating the connection
|
|
304
|
+
as a no-op.
|
|
305
|
+
"""
|
|
306
|
+
connected: set[str] = set()
|
|
307
|
+
for ent in db.find_entities(entity_class_name="connection__node__node"):
|
|
308
|
+
byname = ent["entity_byname"]
|
|
309
|
+
if byname:
|
|
310
|
+
connected.add(byname[0])
|
|
311
|
+
for ent in db.find_entities(entity_class_name="connection"):
|
|
312
|
+
byname = ent["entity_byname"]
|
|
313
|
+
if not byname:
|
|
314
|
+
continue
|
|
315
|
+
name = byname[0]
|
|
316
|
+
if name not in connected:
|
|
317
|
+
logger.warning(
|
|
318
|
+
"Connection '%s' excluded from this scenario: missing one/both endpoint "
|
|
319
|
+
"nodes or its connection__node__node — '%s' not included. Check your data!",
|
|
320
|
+
name, name,
|
|
321
|
+
)
|
flextool/lean_parquet.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Lean parquet I/O: strip bulky pandas metadata, store compact MultiIndex info.
|
|
2
|
+
|
|
3
|
+
Standard ``df.to_parquet()`` embeds a large JSON blob (often 10-20 KB) in the
|
|
4
|
+
parquet footer so that pandas can perfectly round-trip MultiIndex structures.
|
|
5
|
+
For small summary tables this metadata can be 10-20x larger than the actual
|
|
6
|
+
data.
|
|
7
|
+
|
|
8
|
+
These helpers replace that blob with a ~60-byte ``flextool`` metadata entry
|
|
9
|
+
that records only the index and column level names — enough to reconstruct
|
|
10
|
+
both row and column MultiIndex on read. The resulting DataFrames are
|
|
11
|
+
completely normal pandas objects.
|
|
12
|
+
|
|
13
|
+
Backward-compatible: :func:`read_lean_parquet` detects old files that have
|
|
14
|
+
standard pandas metadata and falls back to ``pd.read_parquet()``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import pandas as pd
|
|
24
|
+
import pyarrow as pa
|
|
25
|
+
import pyarrow.parquet as pq
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_lean_parquet(
|
|
29
|
+
df: pd.DataFrame, path: str | Path, *, index: bool = True,
|
|
30
|
+
column_metadata: "dict | None" = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Write *df* to parquet with minimal metadata.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
df : DataFrame
|
|
37
|
+
The data to write. May have MultiIndex on rows, columns, or both.
|
|
38
|
+
path : str or Path
|
|
39
|
+
Destination file path.
|
|
40
|
+
index : bool
|
|
41
|
+
If ``True`` (default), preserve the row index. Pass ``False`` for
|
|
42
|
+
flat tables where the index is meaningless (e.g. timeline_breaks).
|
|
43
|
+
column_metadata : dict, optional
|
|
44
|
+
``{leaf_column_name: {"unit", "semantics", "tooltip", "formula",
|
|
45
|
+
"docs"}}`` describing each logical column. Stored as a file-level
|
|
46
|
+
``flextool_columns`` JSON blob (keyed by *leaf* name, robust to the
|
|
47
|
+
MultiIndex/scenario flattening that arrow applies to field names).
|
|
48
|
+
Read it back with :func:`read_lean_parquet_column_meta`.
|
|
49
|
+
"""
|
|
50
|
+
# Don't preserve RangeIndex as a physical column — it carries no data
|
|
51
|
+
# and would create a stray ``__index_level_0__`` column without the
|
|
52
|
+
# pandas metadata blob to tell the reader to drop it.
|
|
53
|
+
preserve = index and not isinstance(df.index, pd.RangeIndex)
|
|
54
|
+
table = pa.Table.from_pandas(df, preserve_index=preserve)
|
|
55
|
+
|
|
56
|
+
info: dict[str, list | None] = {}
|
|
57
|
+
|
|
58
|
+
# Record row-index level names so we can reconstruct on read.
|
|
59
|
+
if preserve:
|
|
60
|
+
if isinstance(df.index, pd.MultiIndex):
|
|
61
|
+
info["idx"] = list(df.index.names)
|
|
62
|
+
else:
|
|
63
|
+
info["idx"] = [df.index.name]
|
|
64
|
+
|
|
65
|
+
# Record column index level names. For a MultiIndex we store the
|
|
66
|
+
# full list; for a plain single-level ``Index`` we store the single
|
|
67
|
+
# ``.name`` under a distinct key so the old MultiIndex reconstruction
|
|
68
|
+
# path is untouched. ``None`` names are skipped (nothing to restore).
|
|
69
|
+
if isinstance(df.columns, pd.MultiIndex):
|
|
70
|
+
info["col"] = list(df.columns.names)
|
|
71
|
+
elif df.columns.name is not None:
|
|
72
|
+
info["col_name"] = df.columns.name
|
|
73
|
+
|
|
74
|
+
# Swap the bulky pandas blob for our compact one.
|
|
75
|
+
meta = dict(table.schema.metadata or {})
|
|
76
|
+
meta.pop(b"pandas", None)
|
|
77
|
+
meta[b"flextool"] = json.dumps(info).encode()
|
|
78
|
+
if column_metadata:
|
|
79
|
+
meta[b"flextool_columns"] = json.dumps(column_metadata).encode()
|
|
80
|
+
table = table.replace_schema_metadata(meta)
|
|
81
|
+
|
|
82
|
+
pq.write_table(table, str(path))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def read_lean_parquet_column_meta(path: str | Path) -> dict | None:
|
|
86
|
+
"""Return the per-column metadata blob written by :func:`write_lean_parquet`.
|
|
87
|
+
|
|
88
|
+
Reads only the file footer (cheap). Returns ``{leaf_column: {...}}`` or
|
|
89
|
+
``None`` when the file carries no ``flextool_columns`` metadata.
|
|
90
|
+
"""
|
|
91
|
+
file_meta = pq.ParquetFile(str(path)).schema_arrow.metadata or {}
|
|
92
|
+
blob = file_meta.get(b"flextool_columns")
|
|
93
|
+
return json.loads(blob) if blob else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_lean_parquet(path: str | Path) -> pd.DataFrame:
|
|
97
|
+
"""Read a parquet file, reconstructing any MultiIndex.
|
|
98
|
+
|
|
99
|
+
If the file contains ``flextool`` metadata (written by
|
|
100
|
+
:func:`write_lean_parquet`), uses the compact reconstruction logic.
|
|
101
|
+
Otherwise falls back to ``pd.read_parquet()`` for full backward
|
|
102
|
+
compatibility with files written by plain ``df.to_parquet()``.
|
|
103
|
+
"""
|
|
104
|
+
path_str = str(path)
|
|
105
|
+
|
|
106
|
+
# Peek at file-level key-value metadata (cheap — reads only the footer).
|
|
107
|
+
pf = pq.ParquetFile(path_str)
|
|
108
|
+
file_meta = pf.schema_arrow.metadata or {}
|
|
109
|
+
|
|
110
|
+
if b"flextool" not in file_meta:
|
|
111
|
+
# Old-format file with standard pandas metadata.
|
|
112
|
+
return pd.read_parquet(path_str)
|
|
113
|
+
|
|
114
|
+
info: dict = json.loads(file_meta[b"flextool"])
|
|
115
|
+
|
|
116
|
+
# Read all columns as a flat DataFrame (no pandas metadata to guide it).
|
|
117
|
+
# pyarrow will create a default RangeIndex.
|
|
118
|
+
table = pq.read_table(path_str)
|
|
119
|
+
df = table.to_pandas()
|
|
120
|
+
|
|
121
|
+
# --- Reconstruct row index ---
|
|
122
|
+
idx_names: list | None = info.get("idx")
|
|
123
|
+
if idx_names:
|
|
124
|
+
# Map None level names to the __index_level_N__ placeholders that
|
|
125
|
+
# pyarrow uses when preserve_index=True encounters unnamed levels.
|
|
126
|
+
col_lookup: list[str] = []
|
|
127
|
+
for i, name in enumerate(idx_names):
|
|
128
|
+
if name is None:
|
|
129
|
+
col_lookup.append(f"__index_level_{i}__")
|
|
130
|
+
else:
|
|
131
|
+
col_lookup.append(name)
|
|
132
|
+
|
|
133
|
+
present = [c for c in col_lookup if c in df.columns]
|
|
134
|
+
if present:
|
|
135
|
+
df = df.set_index(present)
|
|
136
|
+
# Restore the original names (including None).
|
|
137
|
+
if isinstance(df.index, pd.MultiIndex):
|
|
138
|
+
df.index.names = idx_names
|
|
139
|
+
else:
|
|
140
|
+
df.index.name = idx_names[0]
|
|
141
|
+
|
|
142
|
+
# --- Reconstruct column MultiIndex ---
|
|
143
|
+
col_level_names: list | None = info.get("col")
|
|
144
|
+
if col_level_names:
|
|
145
|
+
# pyarrow stores MultiIndex column names as string representations
|
|
146
|
+
# of tuples — ``"('a', 'b')"`` for 2+ levels, ``"('a',)"`` for 1.
|
|
147
|
+
tuples = [ast.literal_eval(c) for c in df.columns]
|
|
148
|
+
df.columns = pd.MultiIndex.from_tuples(
|
|
149
|
+
tuples, names=col_level_names,
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
# Single-level column Index — restore the ``.name`` if one was
|
|
153
|
+
# recorded at write time. Only touches files that actually carry
|
|
154
|
+
# the ``col_name`` field, so older parquets are unaffected.
|
|
155
|
+
single_col_name = info.get("col_name")
|
|
156
|
+
if single_col_name is not None:
|
|
157
|
+
df.columns.name = single_col_name
|
|
158
|
+
|
|
159
|
+
return df
|