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,34 @@
|
|
|
1
|
+
"""Solar System object names for generating node location names."""
|
|
2
|
+
|
|
3
|
+
# Curated from https://en.wikipedia.org/wiki/List_of_Solar_System_objects
|
|
4
|
+
# Planets, dwarf planets, major moons, and notable asteroids
|
|
5
|
+
SOLAR_SYSTEM_NAMES: list[str] = [
|
|
6
|
+
"Mercury", "Venus", "Earth", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune",
|
|
7
|
+
"Pluto", "Ceres", "Eris", "Haumea", "Makemake", "Sedna", "Quaoar", "Orcus",
|
|
8
|
+
"Luna", "Phobos", "Deimos", "Io", "Europa", "Ganymede", "Callisto",
|
|
9
|
+
"Titan", "Rhea", "Iapetus", "Dione", "Tethys", "Enceladus", "Mimas",
|
|
10
|
+
"Hyperion", "Phoebe", "Janus", "Epimetheus",
|
|
11
|
+
"Titania", "Oberon", "Ariel", "Umbriel", "Miranda",
|
|
12
|
+
"Triton", "Proteus", "Nereid",
|
|
13
|
+
"Charon", "Nix", "Hydra", "Kerberos", "Styx",
|
|
14
|
+
"Vesta", "Pallas", "Hygiea", "Juno", "Psyche", "Eros", "Ida", "Gaspra",
|
|
15
|
+
"Bennu", "Ryugu", "Itokawa", "Mathilde", "Lutetia", "Steins",
|
|
16
|
+
"Halley", "Hale", "Churyumov", "Borrelly", "Tempel", "Wild",
|
|
17
|
+
"Varuna", "Ixion", "Huya", "Chaos", "Rhadamanthus",
|
|
18
|
+
"Gonggong", "Salacia", "Varda", "Altjira", "Borasisi",
|
|
19
|
+
"Dysnomia", "Namaka", "Hiiaka", "Vanth", "Weywot",
|
|
20
|
+
"Arrokoth", "Albion", "Logos", "Typhon", "Lempo",
|
|
21
|
+
"Deucalion", "Crantor", "Thereus", "Echeclus", "Bienor",
|
|
22
|
+
"Chiron", "Pholus", "Nessus", "Chariklo", "Okyrhoe",
|
|
23
|
+
"Elara", "Himalia", "Lysithea", "Carme", "Sinope",
|
|
24
|
+
"Amalthea", "Thebe", "Adrastea", "Metis",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_location_names(count: int) -> list[str]:
|
|
29
|
+
"""Return a list of unique location names from the Solar System catalog."""
|
|
30
|
+
if count > len(SOLAR_SYSTEM_NAMES):
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Requested {count} names but only {len(SOLAR_SYSTEM_NAMES)} available"
|
|
33
|
+
)
|
|
34
|
+
return SOLAR_SYSTEM_NAMES[:count]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Autocorrelated time series generation for profiles and demand."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from numpy.random import Generator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def pert_sample(rng: Generator, min_val: float, mode_val: float, max_val: float, size: int = 1) -> np.ndarray:
|
|
8
|
+
"""Sample from a PERT distribution using Beta distribution transformation.
|
|
9
|
+
|
|
10
|
+
The PERT distribution is a Beta distribution rescaled to [min, max] with
|
|
11
|
+
the mode controlling the shape.
|
|
12
|
+
"""
|
|
13
|
+
if max_val == min_val:
|
|
14
|
+
return np.full(size, min_val)
|
|
15
|
+
# PERT shape parameter (lambda=4 is standard)
|
|
16
|
+
lam = 4.0
|
|
17
|
+
mu = (min_val + lam * mode_val + max_val) / (lam + 2)
|
|
18
|
+
# Avoid degenerate cases
|
|
19
|
+
if mu <= min_val or mu >= max_val:
|
|
20
|
+
mu = np.clip(mu, min_val + 1e-10, max_val - 1e-10)
|
|
21
|
+
alpha = (mu - min_val) / (max_val - min_val) * (
|
|
22
|
+
(mu - min_val) * (max_val - mu) / ((max_val - min_val) ** 2 / (lam + 2 + 1)) - 1
|
|
23
|
+
)
|
|
24
|
+
# Simplified PERT alpha/beta calculation
|
|
25
|
+
range_val = max_val - min_val
|
|
26
|
+
alpha = 1 + lam * (mu - min_val) / range_val
|
|
27
|
+
beta = 1 + lam * (max_val - mu) / range_val
|
|
28
|
+
alpha = max(alpha, 0.1)
|
|
29
|
+
beta = max(beta, 0.1)
|
|
30
|
+
samples = rng.beta(alpha, beta, size=size)
|
|
31
|
+
return min_val + samples * range_val
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def pert_integer_sample(rng: Generator, min_val: int, mode_val: int, max_val: int, size: int = 1) -> np.ndarray:
|
|
35
|
+
"""Sample integers from a PERT distribution (round the continuous sample)."""
|
|
36
|
+
continuous = pert_sample(rng, float(min_val), float(mode_val), float(max_val), size)
|
|
37
|
+
return np.round(continuous).astype(int)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def generate_autocorrelated_series(
|
|
41
|
+
rng: Generator,
|
|
42
|
+
length: int,
|
|
43
|
+
pattern_length_avg: float,
|
|
44
|
+
pattern_length_std: float,
|
|
45
|
+
dist_min: float,
|
|
46
|
+
dist_mode: float,
|
|
47
|
+
dist_max: float,
|
|
48
|
+
) -> np.ndarray:
|
|
49
|
+
"""Generate an autocorrelated time series using AR(1) with pattern-length control.
|
|
50
|
+
|
|
51
|
+
The autocorrelation coefficient is derived from the desired pattern length:
|
|
52
|
+
a higher pattern_length means values persist longer (stronger autocorrelation).
|
|
53
|
+
|
|
54
|
+
Values are then scaled to approximately match the PERT distribution,
|
|
55
|
+
with soft clipping (scale first, clip ~5% of values).
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
rng: numpy random generator
|
|
59
|
+
length: number of timesteps
|
|
60
|
+
pattern_length_avg: average duration of a pattern in timesteps (hours)
|
|
61
|
+
pattern_length_std: standard deviation of pattern length
|
|
62
|
+
dist_min: minimum value of the target distribution
|
|
63
|
+
dist_mode: mode value of the target distribution
|
|
64
|
+
dist_max: maximum value of the target distribution
|
|
65
|
+
"""
|
|
66
|
+
if pattern_length_avg <= 0:
|
|
67
|
+
pattern_length_avg = 1.0
|
|
68
|
+
|
|
69
|
+
# AR(1) coefficient from pattern length: phi = exp(-1/pattern_length)
|
|
70
|
+
# This gives autocorrelation that decays with the desired timescale
|
|
71
|
+
if pattern_length_std > 0:
|
|
72
|
+
# Vary the pattern length itself
|
|
73
|
+
actual_pattern_length = max(
|
|
74
|
+
1.0, rng.normal(pattern_length_avg, pattern_length_std)
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
actual_pattern_length = pattern_length_avg
|
|
78
|
+
|
|
79
|
+
phi = np.exp(-1.0 / actual_pattern_length)
|
|
80
|
+
phi = np.clip(phi, 0.0, 0.999)
|
|
81
|
+
|
|
82
|
+
# Generate AR(1) process: x[t] = phi * x[t-1] + (1-phi^2)^0.5 * noise
|
|
83
|
+
noise_scale = np.sqrt(1 - phi**2)
|
|
84
|
+
series = np.empty(length)
|
|
85
|
+
series[0] = rng.normal(0, 1)
|
|
86
|
+
for t in range(1, length):
|
|
87
|
+
# Optionally vary pattern length over time
|
|
88
|
+
if pattern_length_std > 0 and t % int(actual_pattern_length) == 0:
|
|
89
|
+
actual_pattern_length = max(
|
|
90
|
+
1.0, rng.normal(pattern_length_avg, pattern_length_std)
|
|
91
|
+
)
|
|
92
|
+
phi = np.exp(-1.0 / actual_pattern_length)
|
|
93
|
+
phi = np.clip(phi, 0.0, 0.999)
|
|
94
|
+
noise_scale = np.sqrt(max(0, 1 - phi**2))
|
|
95
|
+
series[t] = phi * series[t - 1] + noise_scale * rng.normal(0, 1)
|
|
96
|
+
|
|
97
|
+
# Transform from standard normal to target PERT range
|
|
98
|
+
# Use CDF-based approach: map percentiles to PERT distribution
|
|
99
|
+
# First, convert to uniform [0,1] via normal CDF
|
|
100
|
+
from scipy.stats import norm
|
|
101
|
+
|
|
102
|
+
uniform = norm.cdf(series)
|
|
103
|
+
|
|
104
|
+
# Map uniform to PERT range using inverse PERT CDF (via beta)
|
|
105
|
+
lam = 4.0
|
|
106
|
+
range_val = dist_max - dist_min
|
|
107
|
+
if range_val == 0:
|
|
108
|
+
return np.full(length, dist_min)
|
|
109
|
+
|
|
110
|
+
mu = (dist_min + lam * dist_mode + dist_max) / (lam + 2)
|
|
111
|
+
mu = np.clip(mu, dist_min + 1e-10, dist_max - 1e-10)
|
|
112
|
+
alpha = 1 + lam * (mu - dist_min) / range_val
|
|
113
|
+
beta_param = 1 + lam * (dist_max - mu) / range_val
|
|
114
|
+
alpha = max(alpha, 0.1)
|
|
115
|
+
beta_param = max(beta_param, 0.1)
|
|
116
|
+
|
|
117
|
+
from scipy.stats import beta as beta_dist
|
|
118
|
+
|
|
119
|
+
result = beta_dist.ppf(uniform, alpha, beta_param) * range_val + dist_min
|
|
120
|
+
|
|
121
|
+
# Soft clipping: allow ~5% of values outside range, clip the rest
|
|
122
|
+
result = np.clip(result, dist_min, dist_max)
|
|
123
|
+
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def generate_constant_series(value: float, length: int) -> np.ndarray:
|
|
128
|
+
"""Generate a constant time series."""
|
|
129
|
+
return np.full(length, value)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plot Outputs Module
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Entry point : orchestrator.plot_dict_of_dataframes()
|
|
6
|
+
Config : config.PlotConfig, config.DIMENSION_RULES
|
|
7
|
+
Line plots : plot_lines
|
|
8
|
+
Bar charts : plot_bars (orchestration), plot_bars_detail (rendering)
|
|
9
|
+
Shared util : subplot_helpers, axis_helpers, legend_helpers, format_helpers
|
|
10
|
+
Performance : perf
|
|
11
|
+
"""
|
|
12
|
+
from flextool.plot_outputs.orchestrator import plot_dict_of_dataframes, prepare_plot_data, compute_all_plot_plans
|
|
13
|
+
|
|
14
|
+
__all__ = ['plot_dict_of_dataframes', 'prepare_plot_data', 'compute_all_plot_plans']
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Axis formatting utilities shared across all plot types."""
|
|
2
|
+
import re
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _is_datetime_format(s: str) -> bool:
|
|
7
|
+
"""Check if a string matches ISO datetime pattern like 2023-01-01T00:00:00."""
|
|
8
|
+
return bool(re.match(r'\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}', str(s)))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _normalize_axis_bounds(raw) -> list | str | None:
|
|
12
|
+
"""Convert axis_bounds setting to a list of (min, max) | None entries, or a string keyword.
|
|
13
|
+
|
|
14
|
+
Accepts:
|
|
15
|
+
'shared' → return 'shared' (resolved later with actual data)
|
|
16
|
+
'independent' or None → return None (each subplot auto-scales independently)
|
|
17
|
+
[min, max] → single pair applied to all subplots
|
|
18
|
+
[[min, max], [], [0, 1]] → per-subplot; empty list means auto-scale
|
|
19
|
+
Returns None if raw is falsy or 'independent'.
|
|
20
|
+
"""
|
|
21
|
+
if raw is None:
|
|
22
|
+
return None
|
|
23
|
+
if isinstance(raw, str):
|
|
24
|
+
lower = raw.strip().lower()
|
|
25
|
+
if lower == 'shared':
|
|
26
|
+
return 'shared'
|
|
27
|
+
if lower == 'independent':
|
|
28
|
+
return None
|
|
29
|
+
return None
|
|
30
|
+
if not raw:
|
|
31
|
+
return None
|
|
32
|
+
if isinstance(raw[0], (int, float)):
|
|
33
|
+
return [(raw[0], raw[1])]
|
|
34
|
+
result = []
|
|
35
|
+
for item in raw:
|
|
36
|
+
result.append((item[0], item[1]) if item else None)
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Backward-compatible aliases
|
|
41
|
+
_normalize_axis_scale = _normalize_axis_bounds
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _subplot_axis_bounds(axis_bounds: list | None, idx: int) -> tuple | None:
|
|
45
|
+
"""Return the (min, max) bounds for subplot idx, or None for auto."""
|
|
46
|
+
if not axis_bounds:
|
|
47
|
+
return None
|
|
48
|
+
if len(axis_bounds) == 1:
|
|
49
|
+
return axis_bounds[0]
|
|
50
|
+
return axis_bounds[idx] if idx < len(axis_bounds) else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Backward-compatible alias
|
|
54
|
+
_subplot_axis_scale = _subplot_axis_bounds
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Gap (inches) between the tick-label region and the y-axis label's right
|
|
58
|
+
# edge, and the approximate half-thickness (inches) of the rotated ylabel text
|
|
59
|
+
# so its RIGHT edge — not its anchor — clears the ticks. Used by the explicit
|
|
60
|
+
# set_label_coords positioning below.
|
|
61
|
+
YLABEL_TICK_GAP_IN = 0.12
|
|
62
|
+
YLABEL_HALF_THICKNESS_IN = 0.07
|
|
63
|
+
# Tick labels render at labelsize 10 while CHAR_WIDTH is calibrated at font-9,
|
|
64
|
+
# so the reserved tick width under-estimates the true rendered width by
|
|
65
|
+
# ~12-15%. Inflate the reserved width by this factor before positioning the
|
|
66
|
+
# ylabel so its right edge clears the widest tick label.
|
|
67
|
+
YLABEL_TICK_WIDTH_SAFETY = 1.2
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ylabel_axes_x(tick_width_in: float,
|
|
71
|
+
axes_width_in: float, left_margin_in: float | None = None) -> float:
|
|
72
|
+
"""Axes-fraction x for the rotated horizontal-bar ylabel via set_label_coords.
|
|
73
|
+
|
|
74
|
+
Returns a negative axes-width fraction placing the ylabel a controlled gap
|
|
75
|
+
to the LEFT of the tick-label region (the expand-axis group is folded into
|
|
76
|
+
that tick label). matplotlib's auto-positioning of the ylabel is
|
|
77
|
+
environment-dependent (version / font / canvas), so we pin the position
|
|
78
|
+
explicitly instead of relying on labelpad.
|
|
79
|
+
|
|
80
|
+
``left_margin_in`` is the inches reserved between the figure's left edge
|
|
81
|
+
and the axes' left spine (tick labels + ylabel reservation). When given,
|
|
82
|
+
the leftward offset is clamped so the ylabel's anchor never crosses the
|
|
83
|
+
figure's left edge (its text thickness still extends a touch further left,
|
|
84
|
+
so we leave ``YLABEL_HALF_THICKNESS_IN`` of headroom). Without the clamp a
|
|
85
|
+
very wide tick reservation (long category labels) would push the ylabel
|
|
86
|
+
off-canvas.
|
|
87
|
+
|
|
88
|
+
Guards ``axes_width_in <= 0`` by returning a small fixed fraction.
|
|
89
|
+
"""
|
|
90
|
+
offset_in = (
|
|
91
|
+
tick_width_in * YLABEL_TICK_WIDTH_SAFETY
|
|
92
|
+
+ YLABEL_TICK_GAP_IN
|
|
93
|
+
+ YLABEL_HALF_THICKNESS_IN
|
|
94
|
+
)
|
|
95
|
+
if left_margin_in is not None and left_margin_in > 0:
|
|
96
|
+
max_offset = max(0.0, left_margin_in - YLABEL_HALF_THICKNESS_IN)
|
|
97
|
+
offset_in = min(offset_in, max_offset)
|
|
98
|
+
if axes_width_in <= 0:
|
|
99
|
+
return -0.05
|
|
100
|
+
return -offset_in / axes_width_in
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _apply_subplot_label(ax, xlabel, ylabel, idx: int, row: int, col: int, n_rows: int,
|
|
104
|
+
expand_label_pad: float = 0,
|
|
105
|
+
ylabel_axes_x: float | None = None) -> None:
|
|
106
|
+
"""Apply xlabel/ylabel to ax, supporting both str (positional) and list (per-subplot).
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
expand_label_pad : float
|
|
111
|
+
Legacy labelpad (points) retained only as a fallback when
|
|
112
|
+
``ylabel_axes_x`` is not supplied (e.g. vertical-bar callers).
|
|
113
|
+
ylabel_axes_x : float | None
|
|
114
|
+
When supplied (horizontal bars), the ylabel position is pinned
|
|
115
|
+
explicitly with ``ax.yaxis.set_label_coords(ylabel_axes_x, 0.5)`` —
|
|
116
|
+
an axes-fraction x (negative = left of the spine), deterministic and
|
|
117
|
+
independent of matplotlib's auto-positioning. y=0.5 centers the label
|
|
118
|
+
on THIS subplot's axes (correct for per-subplot col==0 labels).
|
|
119
|
+
"""
|
|
120
|
+
ylabel_pad = expand_label_pad if expand_label_pad else 0
|
|
121
|
+
|
|
122
|
+
def _place_ylabel(text: str) -> None:
|
|
123
|
+
if ylabel_axes_x is not None:
|
|
124
|
+
ax.set_ylabel(text)
|
|
125
|
+
ax.yaxis.set_label_coords(ylabel_axes_x, 0.5)
|
|
126
|
+
else:
|
|
127
|
+
ax.set_ylabel(text, labelpad=ylabel_pad)
|
|
128
|
+
|
|
129
|
+
if isinstance(ylabel, list):
|
|
130
|
+
val = ylabel[idx] if idx < len(ylabel) else None
|
|
131
|
+
if val:
|
|
132
|
+
_place_ylabel(val)
|
|
133
|
+
elif ylabel and col == 0:
|
|
134
|
+
_place_ylabel(ylabel)
|
|
135
|
+
|
|
136
|
+
if isinstance(xlabel, list):
|
|
137
|
+
val = xlabel[idx] if idx < len(xlabel) else None
|
|
138
|
+
if val:
|
|
139
|
+
ax.set_xlabel(val, labelpad=2)
|
|
140
|
+
elif xlabel and row == n_rows - 1:
|
|
141
|
+
ax.set_xlabel(xlabel)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _set_calendar_xticks(ax, time_index, plot_width_inches: float) -> None:
|
|
145
|
+
"""Set x-tick labels for non-datetime (calendar-like) string indices."""
|
|
146
|
+
max_label_len = max(len(str(s)) for s in time_index)
|
|
147
|
+
label_width_inches = max_label_len * 0.08 + 0.3 # ~0.08in per char + gap
|
|
148
|
+
max_labels = max(2, int(plot_width_inches / label_width_inches))
|
|
149
|
+
|
|
150
|
+
# Minimum interval needed between ticks (in data points)
|
|
151
|
+
min_interval = max(1, len(time_index) // max_labels)
|
|
152
|
+
|
|
153
|
+
# Round up to next "nice" calendar-like interval
|
|
154
|
+
nice_intervals = [1, 2, 4, 6, 12, 24, 48, 168, 336, 672, 1344, 2688, 8760]
|
|
155
|
+
interval = nice_intervals[-1]
|
|
156
|
+
for ni in nice_intervals:
|
|
157
|
+
if ni >= min_interval:
|
|
158
|
+
interval = ni
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
tick_positions = list(range(0, len(time_index), interval))
|
|
162
|
+
if not tick_positions:
|
|
163
|
+
tick_positions = [0]
|
|
164
|
+
ax.set_xticks(tick_positions)
|
|
165
|
+
ax.set_xticklabels([time_index[i] for i in tick_positions], rotation=0, ha='left')
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _set_datetime_xticks(ax, time_index, plot_width_inches: float) -> None:
|
|
169
|
+
"""Set x-tick labels for datetime string indices, with smart spacing and minor ticks."""
|
|
170
|
+
dt = pd.to_datetime(time_index)
|
|
171
|
+
formatted = dt.strftime('%m-%dT%H:%M')
|
|
172
|
+
|
|
173
|
+
# Estimate how many labels fit
|
|
174
|
+
min_spacing_inches = 1.1 # label width (~0.8in) + gap
|
|
175
|
+
max_labels = max(2, int(plot_width_inches / min_spacing_inches))
|
|
176
|
+
|
|
177
|
+
# Calculate data resolution in hours from first two points
|
|
178
|
+
if len(dt) >= 2:
|
|
179
|
+
resolution_hours = (dt[1] - dt[0]).total_seconds() / 3600
|
|
180
|
+
else:
|
|
181
|
+
resolution_hours = 1.0
|
|
182
|
+
|
|
183
|
+
# Minimum interval needed between ticks (in hours)
|
|
184
|
+
total_hours = len(time_index) * resolution_hours
|
|
185
|
+
min_interval_hours = total_hours / max_labels
|
|
186
|
+
|
|
187
|
+
# Round up to next "nice" interval
|
|
188
|
+
nice_intervals = [1, 2, 3, 4, 6, 8, 12, 24, 48, 72, 168, 336, 720]
|
|
189
|
+
interval_hours = nice_intervals[-1]
|
|
190
|
+
for ni in nice_intervals:
|
|
191
|
+
if ni >= min_interval_hours:
|
|
192
|
+
interval_hours = ni
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
# Convert interval from hours to number of data points
|
|
196
|
+
interval_points = max(1, round(interval_hours / resolution_hours))
|
|
197
|
+
|
|
198
|
+
# Find aligned starting position
|
|
199
|
+
if interval_hours >= 24:
|
|
200
|
+
# Align to midnight
|
|
201
|
+
start = next((i for i, t in enumerate(dt) if t.hour == 0 and t.minute == 0), 0)
|
|
202
|
+
else:
|
|
203
|
+
# Align to even hour boundaries
|
|
204
|
+
start = next(
|
|
205
|
+
(i for i, t in enumerate(dt) if t.hour % interval_hours == 0 and t.minute == 0),
|
|
206
|
+
0,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
positions = list(range(start, len(time_index), interval_points))
|
|
210
|
+
if not positions:
|
|
211
|
+
positions = [0]
|
|
212
|
+
|
|
213
|
+
ax.set_xticks(positions)
|
|
214
|
+
ax.set_xticklabels([formatted[i] for i in positions], rotation=0, ha='left')
|
|
215
|
+
|
|
216
|
+
# When label interval is a multiple of 24h and > 24h, add minor ticks every 24h
|
|
217
|
+
if interval_hours > 24 and interval_hours % 24 == 0:
|
|
218
|
+
daily_points = max(1, round(24 / resolution_hours))
|
|
219
|
+
minor_start = next(
|
|
220
|
+
(i for i, t in enumerate(dt) if t.hour == 0 and t.minute == 0), 0
|
|
221
|
+
)
|
|
222
|
+
minor_positions = [i for i in range(minor_start, len(time_index), daily_points)
|
|
223
|
+
if i not in positions]
|
|
224
|
+
ax.set_xticks(minor_positions, minor=True)
|
|
225
|
+
ax.grid(True, which='minor', alpha=0.15)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
_VALUE_TICK_CHAR_WIDTH = 0.08 # Approximate width per character (inches) at tick font size
|
|
229
|
+
_VALUE_TICK_LABEL_HEIGHT = 0.35 # Minimum comfortable spacing between y-axis tick labels (inches)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _estimate_value_nbins(
|
|
233
|
+
data_min: float, data_max: float,
|
|
234
|
+
axis_length_inches: float,
|
|
235
|
+
formatter,
|
|
236
|
+
is_horizontal_axis: bool = True,
|
|
237
|
+
min_nbins: int = 3,
|
|
238
|
+
max_nbins: int = 8,
|
|
239
|
+
) -> int:
|
|
240
|
+
"""Estimate how many value-axis ticks fit without overlapping labels.
|
|
241
|
+
|
|
242
|
+
For horizontal value axes (labels spaced horizontally): estimates label
|
|
243
|
+
width from formatted sample values.
|
|
244
|
+
For vertical value axes (labels spaced vertically): uses a fixed label
|
|
245
|
+
height estimate.
|
|
246
|
+
"""
|
|
247
|
+
if axis_length_inches <= 0:
|
|
248
|
+
return min_nbins
|
|
249
|
+
|
|
250
|
+
if is_horizontal_axis:
|
|
251
|
+
sample_values = [data_min, data_max]
|
|
252
|
+
mid = (data_min + data_max) / 2
|
|
253
|
+
if mid != data_min and mid != data_max:
|
|
254
|
+
sample_values.append(mid)
|
|
255
|
+
max_chars = max(len(formatter(v, 0)) for v in sample_values)
|
|
256
|
+
label_size = max_chars * _VALUE_TICK_CHAR_WIDTH + 0.15
|
|
257
|
+
else:
|
|
258
|
+
label_size = _VALUE_TICK_LABEL_HEIGHT
|
|
259
|
+
|
|
260
|
+
nbins = int(axis_length_inches / label_size)
|
|
261
|
+
return max(min_nbins, min(max_nbins, nbins))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def set_smart_xticks(
|
|
265
|
+
ax,
|
|
266
|
+
time_index,
|
|
267
|
+
plot_width_inches: float,
|
|
268
|
+
period_labels: list[str] | None = None,
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Set x-tick labels smartly based on whether the index contains datetime strings.
|
|
271
|
+
|
|
272
|
+
For datetime strings: parse, shorten labels, and space ticks based on plot width.
|
|
273
|
+
For non-datetime strings: estimate label width and choose spacing from calendar-like
|
|
274
|
+
intervals (24, 168, 336, 720) based on how many labels fit.
|
|
275
|
+
|
|
276
|
+
If *period_labels* is provided, adds a secondary row of period tick marks
|
|
277
|
+
and labels below the time tick labels.
|
|
278
|
+
"""
|
|
279
|
+
if len(time_index) == 0:
|
|
280
|
+
return
|
|
281
|
+
if _is_datetime_format(time_index[0]):
|
|
282
|
+
_set_datetime_xticks(ax, time_index, plot_width_inches)
|
|
283
|
+
else:
|
|
284
|
+
_set_calendar_xticks(ax, time_index, plot_width_inches)
|
|
285
|
+
|
|
286
|
+
if period_labels:
|
|
287
|
+
_add_period_ticks(ax, period_labels, plot_width_inches)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ── Period tick marks ─────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
_PERIOD_TICK_COLOR = "#1a3a6b" # dark blue
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _add_period_ticks(ax, period_labels: list[str], plot_width_inches: float) -> None:
|
|
296
|
+
"""Add period tick marks and labels below the time tick labels.
|
|
297
|
+
|
|
298
|
+
A tick is placed at position 0 (left edge, always labelled) and at
|
|
299
|
+
every position where the period changes. When two adjacent period
|
|
300
|
+
labels would overlap, the later label is omitted but the tick mark
|
|
301
|
+
is kept.
|
|
302
|
+
"""
|
|
303
|
+
if not period_labels:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
# Build list of (x_position, period_name) for boundaries
|
|
307
|
+
boundaries: list[tuple[int, str]] = [(0, period_labels[0])]
|
|
308
|
+
for i in range(1, len(period_labels)):
|
|
309
|
+
if period_labels[i] != period_labels[i - 1]:
|
|
310
|
+
boundaries.append((i, period_labels[i]))
|
|
311
|
+
|
|
312
|
+
if not boundaries:
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
# Max label width in data-point units (for overlap check)
|
|
316
|
+
max_label_len = max(len(lbl) for _, lbl in boundaries)
|
|
317
|
+
char_width_points = max_label_len * 0.08 + 0.3 # approx inches per label
|
|
318
|
+
n_points = len(period_labels)
|
|
319
|
+
if n_points > 0 and plot_width_inches > 0:
|
|
320
|
+
points_per_inch = n_points / plot_width_inches
|
|
321
|
+
min_gap_points = char_width_points * points_per_inch
|
|
322
|
+
else:
|
|
323
|
+
min_gap_points = 0
|
|
324
|
+
|
|
325
|
+
# Draw tick marks at all boundary positions (always)
|
|
326
|
+
tick_positions = [pos for pos, _ in boundaries]
|
|
327
|
+
|
|
328
|
+
# Decide which labels to show (skip if overlapping)
|
|
329
|
+
labels_to_show: list[tuple[int, str]] = []
|
|
330
|
+
last_labelled_pos = -float('inf')
|
|
331
|
+
for pos, lbl in boundaries:
|
|
332
|
+
if pos == 0 or (pos - last_labelled_pos) >= min_gap_points:
|
|
333
|
+
labels_to_show.append((pos, lbl))
|
|
334
|
+
last_labelled_pos = pos
|
|
335
|
+
else:
|
|
336
|
+
labels_to_show.append((pos, "")) # tick but no label
|
|
337
|
+
|
|
338
|
+
# Use a secondary x-axis for period ticks (below the primary)
|
|
339
|
+
ax2 = ax.secondary_xaxis('bottom')
|
|
340
|
+
ax2.set_xticks(tick_positions)
|
|
341
|
+
ax2.set_xticklabels(
|
|
342
|
+
[lbl for _, lbl in labels_to_show],
|
|
343
|
+
fontsize='medium',
|
|
344
|
+
color=_PERIOD_TICK_COLOR,
|
|
345
|
+
ha='left',
|
|
346
|
+
)
|
|
347
|
+
ax2.tick_params(
|
|
348
|
+
axis='x',
|
|
349
|
+
direction='out',
|
|
350
|
+
length=6,
|
|
351
|
+
width=1,
|
|
352
|
+
color=_PERIOD_TICK_COLOR,
|
|
353
|
+
pad=14, # push labels below the time tick labels
|
|
354
|
+
zorder=1, # behind time ticks (default zorder=2.5)
|
|
355
|
+
)
|