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.
Files changed (322) hide show
  1. flextool/__init__.py +41 -0
  2. flextool/_mem_sampler.py +193 -0
  3. flextool/_resources.py +43 -0
  4. flextool/calibrate/__init__.py +51 -0
  5. flextool/calibrate/__main__.py +11 -0
  6. flextool/calibrate/_cli.py +316 -0
  7. flextool/calibrate/_db_alt.py +166 -0
  8. flextool/calibrate/_final_outputs.py +110 -0
  9. flextool/calibrate/_guard.py +151 -0
  10. flextool/calibrate/_loop.py +558 -0
  11. flextool/calibrate/_readers.py +223 -0
  12. flextool/calibrate/_report.py +263 -0
  13. flextool/calibrate/_sizing.py +699 -0
  14. flextool/calibrate/_solve.py +134 -0
  15. flextool/calibrate/_solve_status.py +495 -0
  16. flextool/cli/__init__.py +9 -0
  17. flextool/cli/_console.py +51 -0
  18. flextool/cli/_timing.py +147 -0
  19. flextool/cli/cmd_execute_flextool_workflow.py +187 -0
  20. flextool/cli/cmd_export_to_tabular.py +56 -0
  21. flextool/cli/cmd_import_sensitivities.py +75 -0
  22. flextool/cli/cmd_migrate_database.py +13 -0
  23. flextool/cli/cmd_open_results_db.py +269 -0
  24. flextool/cli/cmd_read_matpower.py +66 -0
  25. flextool/cli/cmd_read_old_flextool.py +63 -0
  26. flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
  27. flextool/cli/cmd_read_tabular_input.py +81 -0
  28. flextool/cli/cmd_run_flextool.py +1095 -0
  29. flextool/cli/cmd_scenario_results.py +284 -0
  30. flextool/cli/cmd_solve_mps.py +169 -0
  31. flextool/cli/cmd_update_flextool.py +17 -0
  32. flextool/cli/cmd_write_outputs.py +125 -0
  33. flextool/common_utils/__init__.py +1 -0
  34. flextool/common_utils/plot_mem_shape.py +77 -0
  35. flextool/common_utils/precision.py +451 -0
  36. flextool/decomposition/__init__.py +0 -0
  37. flextool/decomposition/region_decomposition.py +128 -0
  38. flextool/decomposition/region_filter.py +1261 -0
  39. flextool/engine_polars/__init__.py +110 -0
  40. flextool/engine_polars/_axis_enums.py +742 -0
  41. flextool/engine_polars/_benders.py +3462 -0
  42. flextool/engine_polars/_block_layout.py +1479 -0
  43. flextool/engine_polars/_blocks.py +1515 -0
  44. flextool/engine_polars/_commodity_ladder.py +660 -0
  45. flextool/engine_polars/_cumulative_invest.py +1165 -0
  46. flextool/engine_polars/_db_loader.py +153 -0
  47. flextool/engine_polars/_db_reader.py +127 -0
  48. flextool/engine_polars/_dc_power_flow.py +445 -0
  49. flextool/engine_polars/_delay.py +442 -0
  50. flextool/engine_polars/_derived_arithmetic.py +432 -0
  51. flextool/engine_polars/_derived_block.py +990 -0
  52. flextool/engine_polars/_derived_branch.py +769 -0
  53. flextool/engine_polars/_derived_existing.py +1353 -0
  54. flextool/engine_polars/_derived_npv.py +1297 -0
  55. flextool/engine_polars/_derived_params.py +9850 -0
  56. flextool/engine_polars/_derived_profile.py +881 -0
  57. flextool/engine_polars/_derived_walks.py +276 -0
  58. flextool/engine_polars/_determinism.py +70 -0
  59. flextool/engine_polars/_direct_params.py +2186 -0
  60. flextool/engine_polars/_dump_csvs.py +1009 -0
  61. flextool/engine_polars/_emit_arc_unions.py +1631 -0
  62. flextool/engine_polars/_emit_calc_params.py +729 -0
  63. flextool/engine_polars/_emit_chain_params.py +709 -0
  64. flextool/engine_polars/_emit_co2_accumulators.py +400 -0
  65. flextool/engine_polars/_emit_dispatchers.py +690 -0
  66. flextool/engine_polars/_emit_energy_margin.py +125 -0
  67. flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
  68. flextool/engine_polars/_emit_entity_annual.py +428 -0
  69. flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
  70. flextool/engine_polars/_emit_leaf_sets.py +550 -0
  71. flextool/engine_polars/_emit_lp_scaling.py +665 -0
  72. flextool/engine_polars/_emit_mid_sets.py +859 -0
  73. flextool/engine_polars/_emit_pdt_params.py +759 -0
  74. flextool/engine_polars/_emit_per_solve.py +774 -0
  75. flextool/engine_polars/_emit_period_calc.py +504 -0
  76. flextool/engine_polars/_emit_period_params.py +2398 -0
  77. flextool/engine_polars/_emit_provider_io.py +141 -0
  78. flextool/engine_polars/_emit_reserve.py +574 -0
  79. flextool/engine_polars/_emit_solve_time.py +311 -0
  80. flextool/engine_polars/_emit_solve_writers.py +1249 -0
  81. flextool/engine_polars/_flex_data_accumulator.py +388 -0
  82. flextool/engine_polars/_flex_data_provider.py +478 -0
  83. flextool/engine_polars/_group_slack.py +1253 -0
  84. flextool/engine_polars/_inmemory_reader.py +140 -0
  85. flextool/engine_polars/_input_source.py +336 -0
  86. flextool/engine_polars/_invest_seeds.py +191 -0
  87. flextool/engine_polars/_native_input_writer.py +100 -0
  88. flextool/engine_polars/_native_run_model.py +1348 -0
  89. flextool/engine_polars/_orchestration.py +4314 -0
  90. flextool/engine_polars/_output_writer.py +439 -0
  91. flextool/engine_polars/_param_shapes.py +1595 -0
  92. flextool/engine_polars/_parquet_bundle.py +723 -0
  93. flextool/engine_polars/_pdt_join.py +167 -0
  94. flextool/engine_polars/_pdt_lookup.py +547 -0
  95. flextool/engine_polars/_per_solve_sets.py +335 -0
  96. flextool/engine_polars/_projection_params.py +2056 -0
  97. flextool/engine_polars/_provider_keys.py +173 -0
  98. flextool/engine_polars/_provider_translators.py +225 -0
  99. flextool/engine_polars/_recursive_solve.py +703 -0
  100. flextool/engine_polars/_region_filter.py +2508 -0
  101. flextool/engine_polars/_reserve.py +649 -0
  102. flextool/engine_polars/_solve_acceptance.py +331 -0
  103. flextool/engine_polars/_solve_config.py +1001 -0
  104. flextool/engine_polars/_solve_context.py +885 -0
  105. flextool/engine_polars/_solve_handoff.py +164 -0
  106. flextool/engine_polars/_solve_state.py +232 -0
  107. flextool/engine_polars/_solver_base.py +36 -0
  108. flextool/engine_polars/_solver_dispatch.py +511 -0
  109. flextool/engine_polars/_spinedb_reader.py +1165 -0
  110. flextool/engine_polars/_stochastic.py +593 -0
  111. flextool/engine_polars/_subprocess_solve.py +1838 -0
  112. flextool/engine_polars/_timeline.py +1416 -0
  113. flextool/engine_polars/_vectorize.py +438 -0
  114. flextool/engine_polars/_warm.py +858 -0
  115. flextool/engine_polars/autoscale/__init__.py +107 -0
  116. flextool/engine_polars/autoscale/_config.py +218 -0
  117. flextool/engine_polars/autoscale/_layer2.py +1253 -0
  118. flextool/engine_polars/autoscale/_layer2_types.py +584 -0
  119. flextool/engine_polars/autoscale/_quantity_types.py +621 -0
  120. flextool/engine_polars/autoscale/_report.py +336 -0
  121. flextool/engine_polars/chain.py +259 -0
  122. flextool/engine_polars/input.py +6638 -0
  123. flextool/engine_polars/model.py +4754 -0
  124. flextool/env_check.py +388 -0
  125. flextool/export_to_tabular/__init__.py +5 -0
  126. flextool/export_to_tabular/db_reader.py +224 -0
  127. flextool/export_to_tabular/excel_writer.py +3559 -0
  128. flextool/export_to_tabular/export_settings.yaml +377 -0
  129. flextool/export_to_tabular/export_to_excel.py +227 -0
  130. flextool/export_to_tabular/formatting.py +543 -0
  131. flextool/export_to_tabular/sheet_config.py +876 -0
  132. flextool/gui/__init__.py +0 -0
  133. flextool/gui/__main__.py +118 -0
  134. flextool/gui/calibrate_commands.py +184 -0
  135. flextool/gui/calibrate_jobs.py +424 -0
  136. flextool/gui/check_tree.py +142 -0
  137. flextool/gui/cli_format.py +83 -0
  138. flextool/gui/config_parser.py +68 -0
  139. flextool/gui/data_models.py +362 -0
  140. flextool/gui/db_editor_integration.py +202 -0
  141. flextool/gui/db_version_check.py +269 -0
  142. flextool/gui/dialogs/__init__.py +0 -0
  143. flextool/gui/dialogs/add_dialog.py +1098 -0
  144. flextool/gui/dialogs/calibrate_dialog.py +1259 -0
  145. flextool/gui/dialogs/file_picker.py +473 -0
  146. flextool/gui/dialogs/group_picker.py +299 -0
  147. flextool/gui/dialogs/migration_consent_dialog.py +106 -0
  148. flextool/gui/dialogs/migration_progress_dialog.py +237 -0
  149. flextool/gui/dialogs/plot_dialog.py +459 -0
  150. flextool/gui/dialogs/plot_settings_picker.py +2184 -0
  151. flextool/gui/dialogs/project_dialog.py +426 -0
  152. flextool/gui/dialogs/update_dialog.py +212 -0
  153. flextool/gui/downsampling.py +88 -0
  154. flextool/gui/error_handling.py +50 -0
  155. flextool/gui/execution_manager.py +1715 -0
  156. flextool/gui/execution_window.py +1377 -0
  157. flextool/gui/hover_tooltip.py +111 -0
  158. flextool/gui/input_sources.py +730 -0
  159. flextool/gui/main_window.py +6181 -0
  160. flextool/gui/network_graph.py +215 -0
  161. flextool/gui/output_actions.py +393 -0
  162. flextool/gui/output_log_window.py +159 -0
  163. flextool/gui/platform_utils.py +421 -0
  164. flextool/gui/plot_cache.py +88 -0
  165. flextool/gui/plot_canvas.py +543 -0
  166. flextool/gui/plot_config_reader.py +272 -0
  167. flextool/gui/project_utils.py +100 -0
  168. flextool/gui/result_viewer.py +4394 -0
  169. flextool/gui/scenario_key.py +162 -0
  170. flextool/gui/scenario_lists.py +516 -0
  171. flextool/gui/settings_io.py +360 -0
  172. flextool/gui/solve_reader.py +103 -0
  173. flextool/gui/tree_reorder.py +88 -0
  174. flextool/gui/ui_metrics.py +420 -0
  175. flextool/input_derivation/__init__.py +281 -0
  176. flextool/input_derivation/_commodity_ladder.py +375 -0
  177. flextool/input_derivation/_commodity_ladder_sets.py +70 -0
  178. flextool/input_derivation/_dc_power_flow.py +377 -0
  179. flextool/input_derivation/_method_constants.py +77 -0
  180. flextool/input_derivation/_process_method.py +258 -0
  181. flextool/input_derivation/_specs.py +1026 -0
  182. flextool/input_derivation/_validators.py +321 -0
  183. flextool/lean_parquet.py +159 -0
  184. flextool/model_builder/__init__.py +5 -0
  185. flextool/model_builder/build_model.py +589 -0
  186. flextool/model_builder/encoding.py +67 -0
  187. flextool/model_builder/names.py +34 -0
  188. flextool/model_builder/profiles.py +129 -0
  189. flextool/plot_outputs/__init__.py +14 -0
  190. flextool/plot_outputs/axis_helpers.py +355 -0
  191. flextool/plot_outputs/color_template.py +888 -0
  192. flextool/plot_outputs/config.py +171 -0
  193. flextool/plot_outputs/format_helpers.py +345 -0
  194. flextool/plot_outputs/legend_helpers.py +143 -0
  195. flextool/plot_outputs/orchestrator.py +1141 -0
  196. flextool/plot_outputs/perf.py +37 -0
  197. flextool/plot_outputs/plan.py +1787 -0
  198. flextool/plot_outputs/plot_bars.py +1510 -0
  199. flextool/plot_outputs/plot_bars_detail.py +753 -0
  200. flextool/plot_outputs/plot_lines.py +951 -0
  201. flextool/plot_outputs/shared_manifest.py +564 -0
  202. flextool/plot_outputs/subplot_helpers.py +137 -0
  203. flextool/process_inputs/__init__.py +188 -0
  204. flextool/process_inputs/import_old_excel_input.json +4159 -0
  205. flextool/process_inputs/read_matpower.py +451 -0
  206. flextool/process_inputs/read_old_flextool.py +1288 -0
  207. flextool/process_inputs/read_self_describing_excel.py +1423 -0
  208. flextool/process_inputs/read_tabular_with_specification.py +1114 -0
  209. flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
  210. flextool/process_inputs/write_self_describing_to_db.py +977 -0
  211. flextool/process_inputs/write_to_input_db.py +269 -0
  212. flextool/process_outputs/__init__.py +7 -0
  213. flextool/process_outputs/_annualize.py +55 -0
  214. flextool/process_outputs/_inmemory_helpers.py +292 -0
  215. flextool/process_outputs/_output_meta.py +672 -0
  216. flextool/process_outputs/calc_capacity_flows.py +107 -0
  217. flextool/process_outputs/calc_connections.py +136 -0
  218. flextool/process_outputs/calc_costs.py +260 -0
  219. flextool/process_outputs/calc_group_flows.py +192 -0
  220. flextool/process_outputs/calc_slacks.py +103 -0
  221. flextool/process_outputs/calc_storage_vre.py +160 -0
  222. flextool/process_outputs/drop_levels.py +208 -0
  223. flextool/process_outputs/handoff_writers.py +1315 -0
  224. flextool/process_outputs/out_ancillary.py +544 -0
  225. flextool/process_outputs/out_capacity.py +179 -0
  226. flextool/process_outputs/out_costs.py +334 -0
  227. flextool/process_outputs/out_flowgroup.py +189 -0
  228. flextool/process_outputs/out_flows.py +301 -0
  229. flextool/process_outputs/out_group.py +475 -0
  230. flextool/process_outputs/out_node.py +190 -0
  231. flextool/process_outputs/persist_realized_slice.py +601 -0
  232. flextool/process_outputs/process_results.py +24 -0
  233. flextool/process_outputs/read_highs_solution.py +2256 -0
  234. flextool/process_outputs/read_parameters.py +1799 -0
  235. flextool/process_outputs/read_sets.py +1095 -0
  236. flextool/process_outputs/read_variables.py +553 -0
  237. flextool/process_outputs/solve_order.py +81 -0
  238. flextool/process_outputs/spinedb_replay.py +412 -0
  239. flextool/process_outputs/union_realized_slice.py +224 -0
  240. flextool/process_outputs/write_outputs.py +1286 -0
  241. flextool/process_outputs/write_spinedb.py +1267 -0
  242. flextool/representative_periods/__init__.py +5 -0
  243. flextool/representative_periods/clustering.py +165 -0
  244. flextool/representative_periods/force_include.py +563 -0
  245. flextool/representative_periods/netload.py +365 -0
  246. flextool/representative_periods/netload_inputs.py +345 -0
  247. flextool/representative_periods/netload_iterate.py +722 -0
  248. flextool/representative_periods/preprocess.py +948 -0
  249. flextool/representative_periods/scenario_stack.py +195 -0
  250. flextool/representative_periods/weights.py +124 -0
  251. flextool/scenario_comparison/__init__.py +13 -0
  252. flextool/scenario_comparison/config_builder.py +158 -0
  253. flextool/scenario_comparison/constants.py +20 -0
  254. flextool/scenario_comparison/data_models.py +222 -0
  255. flextool/scenario_comparison/db_reader.py +399 -0
  256. flextool/scenario_comparison/dispatch_data.py +1002 -0
  257. flextool/scenario_comparison/dispatch_mappings.py +205 -0
  258. flextool/scenario_comparison/dispatch_plots.py +691 -0
  259. flextool/scenario_comparison/input_entity_colors.py +319 -0
  260. flextool/scenario_comparison/orchestrator.py +453 -0
  261. flextool/scenario_comparison/plan_union.py +244 -0
  262. flextool/scenario_comparison/plot_settings_seed.py +205 -0
  263. flextool/schemas/AXIS_CONTRACT.md +71 -0
  264. flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
  265. flextool/schemas/canonical_databases/howto_connections.json +5606 -0
  266. flextool/schemas/canonical_databases/howto_demand.json +5518 -0
  267. flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
  268. flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
  269. flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
  270. flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
  271. flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
  272. flextool/schemas/canonical_databases/templates_examples.json +13532 -0
  273. flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
  274. flextool/schemas/comparison_settings_template.json +197 -0
  275. flextool/schemas/default_plot_settings.yaml +260 -0
  276. flextool/schemas/default_plots.yaml +2293 -0
  277. flextool/schemas/flextool_axis_contract.json +303 -0
  278. flextool/schemas/flextool_axis_contract.schema.json +247 -0
  279. flextool/schemas/old_flextool_import_template.json +4443 -0
  280. flextool/schemas/output_info_template.json +48 -0
  281. flextool/schemas/output_settings_template.json +256 -0
  282. flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
  283. flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
  284. flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
  285. flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
  286. flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
  287. flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
  288. flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
  289. flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
  290. flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
  291. flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
  292. flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
  293. flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
  294. flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
  295. flextool/schemas/spinedb_results_schema.json +581 -0
  296. flextool/schemas/spinedb_schema.json +4636 -0
  297. flextool/solver_config/copt.opt.template +18 -0
  298. flextool/solver_config/cplex.opt.template +25 -0
  299. flextool/solver_config/gurobi.opt.template +18 -0
  300. flextool/solver_config/highs.opt.template +18 -0
  301. flextool/solver_config/xpress.opt.template +26 -0
  302. flextool/spinedb_backend/__init__.py +26 -0
  303. flextool/spinedb_backend/_axis_enums.py +1119 -0
  304. flextool/spinedb_backend/_backend.py +1139 -0
  305. flextool/update_flextool/__init__.py +12 -0
  306. flextool/update_flextool/canonical_databases.py +251 -0
  307. flextool/update_flextool/db_migration.py +7108 -0
  308. flextool/update_flextool/ensure_settings_db.py +138 -0
  309. flextool/update_flextool/export_database.py +103 -0
  310. flextool/update_flextool/extend_tests_fixture.py +772 -0
  311. flextool/update_flextool/generate_canonical.py +274 -0
  312. flextool/update_flextool/initialize_database.py +42 -0
  313. flextool/update_flextool/install_info.py +225 -0
  314. flextool/update_flextool/self_update.py +464 -0
  315. flextool/update_flextool/sync_master_json_template.py +125 -0
  316. flextool/update_flextool/test_fixtures.py +187 -0
  317. flextool-4.0.0.dist-info/METADATA +217 -0
  318. flextool-4.0.0.dist-info/RECORD +322 -0
  319. flextool-4.0.0.dist-info/WHEEL +5 -0
  320. flextool-4.0.0.dist-info/entry_points.txt +17 -0
  321. flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
  322. 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
+ )