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,564 @@
|
|
|
1
|
+
"""Cross-scenario axis-bounds manifest for the result viewer.
|
|
2
|
+
|
|
3
|
+
Background
|
|
4
|
+
----------
|
|
5
|
+
:class:`~flextool.plot_outputs.plan.PlotPlan` records a per-scenario
|
|
6
|
+
``subplot_y_ranges`` list so the y-axis stays stable while the user
|
|
7
|
+
scrolls the time slider **within** a scenario. Switching scenarios in
|
|
8
|
+
single-viewer mode loads a different plan whose y-ranges are computed
|
|
9
|
+
from that scenario alone, so the axis can still jump.
|
|
10
|
+
|
|
11
|
+
This module produces a side-car manifest, written once per batch run at
|
|
12
|
+
``<project_path>/output_parquet/_axis_bounds.json``, that stores
|
|
13
|
+
each scenario's per-subplot y-ranges. The viewer takes the union at
|
|
14
|
+
read time, filtered to the scenarios the user has currently selected —
|
|
15
|
+
so checking/unchecking scenarios in the executed list naturally adjusts
|
|
16
|
+
the shared axis without a manifest rewrite.
|
|
17
|
+
|
|
18
|
+
Schema
|
|
19
|
+
------
|
|
20
|
+
|
|
21
|
+
.. code-block:: json
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
"<result_key>": {
|
|
25
|
+
"<sub_config>": {
|
|
26
|
+
"<subplot_key>": {
|
|
27
|
+
"<scenario_name>": [min, max],
|
|
28
|
+
...
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
``<subplot_key>`` is chosen as the subplot *title*. See the comment on
|
|
35
|
+
:meth:`ManifestAccumulator.add_plan` for the rationale and the fallback
|
|
36
|
+
used for untitled (single-subplot) plans.
|
|
37
|
+
|
|
38
|
+
Bar-chart plans are intentionally skipped — bars don't use time-scrolling
|
|
39
|
+
so cross-scenario y-axis stability isn't a concern for them.
|
|
40
|
+
|
|
41
|
+
Atomicity
|
|
42
|
+
---------
|
|
43
|
+
:meth:`ManifestAccumulator.write` serialises via a temp-file + rename so
|
|
44
|
+
concurrent readers (e.g. the viewer polling the file) never observe a
|
|
45
|
+
half-written JSON document.
|
|
46
|
+
|
|
47
|
+
Backward compatibility
|
|
48
|
+
----------------------
|
|
49
|
+
An older version of this module stored bounds as 2-element lists directly
|
|
50
|
+
under the subplot title (``{"subplot": [min, max]}``). Such entries are
|
|
51
|
+
silently ignored on load — the next batch run rewrites the file in the
|
|
52
|
+
new per-scenario schema.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import json
|
|
58
|
+
import logging
|
|
59
|
+
import tempfile
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
from typing import TYPE_CHECKING
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING:
|
|
64
|
+
from flextool.plot_outputs.plan import PlotPlan
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Sentinel for subplots with no title (single-subplot plans where no
|
|
71
|
+
# subplot levels are present). These plans always have exactly one
|
|
72
|
+
# subplot, so a single sentinel per (result_key, sub_config) is safe.
|
|
73
|
+
_UNTITLED_KEY = "<untitled>"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ManifestAccumulator:
|
|
77
|
+
"""Collects per-subplot y-axis bounds across scenarios.
|
|
78
|
+
|
|
79
|
+
One accumulator instance lives for the entire batch run. The caller
|
|
80
|
+
invokes :meth:`add_plan` with a ``scenario_name`` so the per-scenario
|
|
81
|
+
slice is recorded distinctly. Subsequent runs of the same scenario
|
|
82
|
+
replace that slice (rather than union it), making the newest
|
|
83
|
+
computation authoritative for that scenario.
|
|
84
|
+
|
|
85
|
+
The accumulator seeds from any existing on-disk manifest so that
|
|
86
|
+
re-running a single scenario's batch doesn't wipe out other
|
|
87
|
+
scenarios' entries.
|
|
88
|
+
|
|
89
|
+
The target path is
|
|
90
|
+
``<project_path>/output_parquet/_axis_bounds.json``. The
|
|
91
|
+
``output_parquet`` directory is created on :meth:`write` if missing.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, project_path: Path):
|
|
95
|
+
self.project_path = Path(project_path)
|
|
96
|
+
self._manifest_path = (
|
|
97
|
+
self.project_path / "output_parquet" / "_axis_bounds.json"
|
|
98
|
+
)
|
|
99
|
+
# Four-level nested dict:
|
|
100
|
+
# result_key -> sub_config -> subplot_key -> scenario_name -> [lo, hi]
|
|
101
|
+
self._data: dict[str, dict[str, dict[str, dict[str, list[float]]]]] = {}
|
|
102
|
+
# Scenarios this accumulator has seen during the current batch.
|
|
103
|
+
# Any previously-stored entries for these scenarios are cleared on
|
|
104
|
+
# first add so the new plans fully replace the old slice.
|
|
105
|
+
self._replaced_scenarios: set[str] = set()
|
|
106
|
+
# Seed accumulator with any bounds already on disk so re-running a
|
|
107
|
+
# single scenario doesn't wipe the others' slices.
|
|
108
|
+
self._load_existing()
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Public API
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def add_plan(
|
|
115
|
+
self,
|
|
116
|
+
result_key: str,
|
|
117
|
+
sub_config: str,
|
|
118
|
+
plan: "PlotPlan",
|
|
119
|
+
scenario_name: str,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Record one plan's subplot_y_ranges under *scenario_name*.
|
|
122
|
+
|
|
123
|
+
Subplot keying: we use the subplot *title* (first element of each
|
|
124
|
+
``effective_plot_specs`` tuple). Rationale:
|
|
125
|
+
|
|
126
|
+
- ``title`` is a plain string or ``None`` — directly JSON-safe.
|
|
127
|
+
- When the same entity is present in two scenarios, the title is
|
|
128
|
+
identical, so the reader's union picks up both ranges.
|
|
129
|
+
- When one scenario drops an entity (e.g. zero-filtered), its
|
|
130
|
+
subplot simply doesn't contribute — the other scenario's
|
|
131
|
+
bounds still govern, which is the desired behaviour.
|
|
132
|
+
- Subplot *indices* would misalign in exactly that dropped-entity
|
|
133
|
+
case, so we avoid index-based keying.
|
|
134
|
+
- ``None`` titles (single-subplot plans without subplot levels)
|
|
135
|
+
are mapped to a single ``<untitled>`` sentinel, which is safe
|
|
136
|
+
because such plans always have exactly one subplot.
|
|
137
|
+
|
|
138
|
+
Bar-chart plans are skipped — see module docstring.
|
|
139
|
+
|
|
140
|
+
Replacement semantics: the first call for a given
|
|
141
|
+
``scenario_name`` within this accumulator's lifetime clears any
|
|
142
|
+
previously-stored entries for that scenario across *all*
|
|
143
|
+
(result_key, sub_config, subplot) triples. Subsequent calls for
|
|
144
|
+
the same scenario simply add/overwrite entries for the specific
|
|
145
|
+
subplots touched. This way a re-run authoritative picture of the
|
|
146
|
+
scenario doesn't leave stale subplot entries lingering from a
|
|
147
|
+
previous run.
|
|
148
|
+
"""
|
|
149
|
+
if plan.chart_type == 'bar':
|
|
150
|
+
return
|
|
151
|
+
if not plan.subplot_y_ranges:
|
|
152
|
+
return
|
|
153
|
+
if not scenario_name:
|
|
154
|
+
logger.warning(
|
|
155
|
+
"ManifestAccumulator.add_plan called with empty scenario_name; "
|
|
156
|
+
"dropping entries for %s/%s",
|
|
157
|
+
result_key, sub_config,
|
|
158
|
+
)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# effective_plot_specs and subplot_y_ranges are parallel lists of
|
|
162
|
+
# the same length (see _compute_time_plan). Guard anyway to avoid
|
|
163
|
+
# silent misalignment if that invariant ever breaks.
|
|
164
|
+
specs = plan.effective_plot_specs
|
|
165
|
+
ranges = plan.subplot_y_ranges
|
|
166
|
+
if len(specs) != len(ranges):
|
|
167
|
+
logger.warning(
|
|
168
|
+
"PlotPlan %s/%s: effective_plot_specs (%d) and "
|
|
169
|
+
"subplot_y_ranges (%d) length mismatch — skipping",
|
|
170
|
+
result_key, sub_config, len(specs), len(ranges),
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# On first touch of this scenario in this accumulator lifetime,
|
|
175
|
+
# drop any disk-seeded entries that belong to it — the current
|
|
176
|
+
# batch is now the authoritative picture of the scenario.
|
|
177
|
+
if scenario_name not in self._replaced_scenarios:
|
|
178
|
+
self._purge_scenario_in_memory(scenario_name)
|
|
179
|
+
self._replaced_scenarios.add(scenario_name)
|
|
180
|
+
|
|
181
|
+
result_entry = self._data.setdefault(result_key, {})
|
|
182
|
+
sub_entry = result_entry.setdefault(sub_config, {})
|
|
183
|
+
|
|
184
|
+
for (title, _selector), (lo, hi) in zip(specs, ranges):
|
|
185
|
+
key = _UNTITLED_KEY if title is None else str(title)
|
|
186
|
+
try:
|
|
187
|
+
lo_f = float(lo)
|
|
188
|
+
hi_f = float(hi)
|
|
189
|
+
except (TypeError, ValueError):
|
|
190
|
+
continue
|
|
191
|
+
subplot_entry = sub_entry.setdefault(key, {})
|
|
192
|
+
subplot_entry[scenario_name] = [lo_f, hi_f]
|
|
193
|
+
|
|
194
|
+
def write(self) -> None:
|
|
195
|
+
"""Atomically write the accumulated manifest to disk.
|
|
196
|
+
|
|
197
|
+
Creates the ``output_parquet`` directory if missing. Writes to a
|
|
198
|
+
temp file in the same directory, then ``os.replace``-renames it
|
|
199
|
+
into place so concurrent readers never see a partial document.
|
|
200
|
+
"""
|
|
201
|
+
if not self._data:
|
|
202
|
+
# Nothing to write — don't create an empty file.
|
|
203
|
+
return
|
|
204
|
+
_atomic_write_manifest(self._manifest_path, self._data)
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
# Introspection helpers (primarily for tests)
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def manifest_path(self) -> Path:
|
|
212
|
+
return self._manifest_path
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def data(self) -> dict[str, dict[str, dict[str, dict[str, list[float]]]]]:
|
|
216
|
+
"""Read-only view of the accumulated data. Don't mutate."""
|
|
217
|
+
return self._data
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
# Internal
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def _purge_scenario_in_memory(self, scenario_name: str) -> None:
|
|
224
|
+
"""Drop *scenario_name*'s entries from ``self._data`` in place.
|
|
225
|
+
|
|
226
|
+
Removes empty subplot / sub_config / result_key entries as it
|
|
227
|
+
prunes. Used when a scenario's first ``add_plan`` call arrives
|
|
228
|
+
and the disk-seeded data for that scenario needs to be cleared
|
|
229
|
+
before the new batch's values take its place.
|
|
230
|
+
"""
|
|
231
|
+
for result_key in list(self._data.keys()):
|
|
232
|
+
sub_map = self._data[result_key]
|
|
233
|
+
for sub_config in list(sub_map.keys()):
|
|
234
|
+
subplot_map = sub_map[sub_config]
|
|
235
|
+
for subplot_key in list(subplot_map.keys()):
|
|
236
|
+
scen_map = subplot_map[subplot_key]
|
|
237
|
+
if scenario_name in scen_map:
|
|
238
|
+
del scen_map[scenario_name]
|
|
239
|
+
if not scen_map:
|
|
240
|
+
del subplot_map[subplot_key]
|
|
241
|
+
if not subplot_map:
|
|
242
|
+
del sub_map[sub_config]
|
|
243
|
+
if not sub_map:
|
|
244
|
+
del self._data[result_key]
|
|
245
|
+
|
|
246
|
+
def _load_existing(self) -> None:
|
|
247
|
+
"""Seed the accumulator from an on-disk manifest if present.
|
|
248
|
+
|
|
249
|
+
Tolerant of malformed files and the legacy flat schema — anything
|
|
250
|
+
that doesn't fit the four-level per-scenario layout is silently
|
|
251
|
+
dropped. A corrupt previous write can't permanently break the
|
|
252
|
+
next batch run; a legacy manifest is simply ignored and will be
|
|
253
|
+
rewritten in the new schema on the next :meth:`write`.
|
|
254
|
+
"""
|
|
255
|
+
loaded = load_axis_bounds_manifest(self.project_path)
|
|
256
|
+
if loaded is None:
|
|
257
|
+
return
|
|
258
|
+
saw_legacy = False
|
|
259
|
+
# Shape-validate as we copy in so downstream code can trust the structure.
|
|
260
|
+
for rk, rk_val in loaded.items():
|
|
261
|
+
if not isinstance(rk_val, dict):
|
|
262
|
+
continue
|
|
263
|
+
rk_entry: dict[str, dict[str, dict[str, list[float]]]] = {}
|
|
264
|
+
for sc, sc_val in rk_val.items():
|
|
265
|
+
if not isinstance(sc_val, dict):
|
|
266
|
+
continue
|
|
267
|
+
sc_entry: dict[str, dict[str, list[float]]] = {}
|
|
268
|
+
for sp, sp_val in sc_val.items():
|
|
269
|
+
# Legacy schema: sp_val is a 2-element list [lo, hi].
|
|
270
|
+
# Ignore (and flag) — the per-scenario attribution is
|
|
271
|
+
# missing, so there's no safe way to retain it.
|
|
272
|
+
if isinstance(sp_val, (list, tuple)):
|
|
273
|
+
saw_legacy = True
|
|
274
|
+
continue
|
|
275
|
+
if not isinstance(sp_val, dict):
|
|
276
|
+
continue
|
|
277
|
+
scen_entry: dict[str, list[float]] = {}
|
|
278
|
+
for scenario_name, bounds in sp_val.items():
|
|
279
|
+
if not isinstance(scenario_name, str) or not scenario_name:
|
|
280
|
+
continue
|
|
281
|
+
if (
|
|
282
|
+
isinstance(bounds, (list, tuple))
|
|
283
|
+
and len(bounds) == 2
|
|
284
|
+
):
|
|
285
|
+
try:
|
|
286
|
+
scen_entry[scenario_name] = [
|
|
287
|
+
float(bounds[0]), float(bounds[1]),
|
|
288
|
+
]
|
|
289
|
+
except (TypeError, ValueError):
|
|
290
|
+
continue
|
|
291
|
+
if scen_entry:
|
|
292
|
+
sc_entry[str(sp)] = scen_entry
|
|
293
|
+
if sc_entry:
|
|
294
|
+
rk_entry[str(sc)] = sc_entry
|
|
295
|
+
if rk_entry:
|
|
296
|
+
self._data[str(rk)] = rk_entry
|
|
297
|
+
if saw_legacy:
|
|
298
|
+
logger.warning(
|
|
299
|
+
"Shared axis manifest %s uses the legacy (pre-per-scenario) "
|
|
300
|
+
"schema; those entries are being dropped and will be "
|
|
301
|
+
"rewritten on the next batch run.",
|
|
302
|
+
self._manifest_path,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
# Reader API (used by the result viewer — Chunk C)
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
def load_axis_bounds_manifest(project_path: Path) -> dict | None:
|
|
311
|
+
"""Load the shared axis-bounds manifest for *project_path*.
|
|
312
|
+
|
|
313
|
+
Returns the parsed JSON object (a nested ``dict``) or ``None`` when the
|
|
314
|
+
manifest does not exist, cannot be read, or is malformed. The function
|
|
315
|
+
never raises — a corrupt or missing manifest simply means "no
|
|
316
|
+
cross-scenario axis data available", and the viewer falls back to the
|
|
317
|
+
per-plan y-ranges.
|
|
318
|
+
|
|
319
|
+
The expected manifest path is
|
|
320
|
+
``<project_path>/output_parquet/_axis_bounds.json``.
|
|
321
|
+
"""
|
|
322
|
+
manifest_path = (
|
|
323
|
+
Path(project_path) / "output_parquet" / "_axis_bounds.json"
|
|
324
|
+
)
|
|
325
|
+
if not manifest_path.is_file():
|
|
326
|
+
return None
|
|
327
|
+
try:
|
|
328
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
329
|
+
loaded = json.load(f)
|
|
330
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
331
|
+
logger.warning(
|
|
332
|
+
"Ignoring unreadable shared manifest %s: %s",
|
|
333
|
+
manifest_path, exc,
|
|
334
|
+
)
|
|
335
|
+
return None
|
|
336
|
+
if not isinstance(loaded, dict):
|
|
337
|
+
logger.warning(
|
|
338
|
+
"Shared manifest %s is not a JSON object — ignoring",
|
|
339
|
+
manifest_path,
|
|
340
|
+
)
|
|
341
|
+
return None
|
|
342
|
+
return loaded
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def apply_manifest_to_plan(
|
|
346
|
+
plan: "PlotPlan",
|
|
347
|
+
manifest: dict | None,
|
|
348
|
+
result_key: str,
|
|
349
|
+
sub_config: str,
|
|
350
|
+
scenarios: set[str] | list[str] | None = None,
|
|
351
|
+
) -> bool:
|
|
352
|
+
"""Override ``plan.subplot_y_ranges`` with values from *manifest*.
|
|
353
|
+
|
|
354
|
+
Mutates *plan* in-place. Returns ``True`` when at least one subplot
|
|
355
|
+
range was replaced, ``False`` when nothing changed (missing manifest,
|
|
356
|
+
bar plan, no matching entry, filtered-out scenarios, etc.).
|
|
357
|
+
|
|
358
|
+
*scenarios* controls which scenarios contribute to the union:
|
|
359
|
+
|
|
360
|
+
- ``None`` — union over *all* scenarios present in the manifest (the
|
|
361
|
+
full cross-scenario fallback). Use this only when the caller has
|
|
362
|
+
no explicit scope; in the result viewer's comparison mode the
|
|
363
|
+
caller should always pass the **viewer scenarios** set so the y-axis
|
|
364
|
+
stays frozen while the user toggles individual scenarios.
|
|
365
|
+
- A non-empty ``set``/``list`` — union only over the named scenarios.
|
|
366
|
+
- An empty ``set``/``list`` — no scenarios to union from; the call
|
|
367
|
+
becomes a no-op (the plan's per-scenario y-ranges remain in effect).
|
|
368
|
+
|
|
369
|
+
Subplot keying: for each ``(title, _)`` in
|
|
370
|
+
``plan.effective_plot_specs``, if the manifest has a per-scenario map
|
|
371
|
+
keyed by that title (with ``None`` mapped to ``_UNTITLED_KEY`` exactly
|
|
372
|
+
as the writer does), the entries for the active scenarios are unioned
|
|
373
|
+
and written to ``plan.subplot_y_ranges`` at the matching index.
|
|
374
|
+
Subplots without a manifest entry keep their per-scenario range.
|
|
375
|
+
|
|
376
|
+
Bar-chart plans are skipped (matching the writer). Legacy flat-schema
|
|
377
|
+
entries (``list``/``tuple`` instead of per-scenario ``dict``) are
|
|
378
|
+
ignored — the next batch run rewrites them. Extra entries in the
|
|
379
|
+
manifest that don't correspond to any subplot in *plan* are ignored.
|
|
380
|
+
"""
|
|
381
|
+
if manifest is None or plan is None:
|
|
382
|
+
return False
|
|
383
|
+
if plan.chart_type == "bar":
|
|
384
|
+
return False
|
|
385
|
+
if not plan.effective_plot_specs:
|
|
386
|
+
return False
|
|
387
|
+
# Normalise scenarios into a set (or None) for fast membership checks.
|
|
388
|
+
scenarios_set: set[str] | None
|
|
389
|
+
if scenarios is None:
|
|
390
|
+
scenarios_set = None
|
|
391
|
+
else:
|
|
392
|
+
scenarios_set = set(scenarios)
|
|
393
|
+
if not scenarios_set:
|
|
394
|
+
return False
|
|
395
|
+
sub_entry = manifest.get(result_key)
|
|
396
|
+
if not isinstance(sub_entry, dict):
|
|
397
|
+
return False
|
|
398
|
+
title_map = sub_entry.get(sub_config)
|
|
399
|
+
if not isinstance(title_map, dict):
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
# Pad subplot_y_ranges if shorter than effective_plot_specs so we can
|
|
403
|
+
# overwrite any index. (In practice they are parallel lists, but the
|
|
404
|
+
# plan file format is JSON — defend against asymmetric loads.)
|
|
405
|
+
ranges: list[tuple[float, float]] = [
|
|
406
|
+
tuple(r) for r in plan.subplot_y_ranges
|
|
407
|
+
]
|
|
408
|
+
while len(ranges) < len(plan.effective_plot_specs):
|
|
409
|
+
ranges.append((0.0, 0.0))
|
|
410
|
+
|
|
411
|
+
changed = False
|
|
412
|
+
for i, (title, _selector) in enumerate(plan.effective_plot_specs):
|
|
413
|
+
key = _UNTITLED_KEY if title is None else str(title)
|
|
414
|
+
scen_map = title_map.get(key)
|
|
415
|
+
if not isinstance(scen_map, dict) or not scen_map:
|
|
416
|
+
# Missing, legacy (list/tuple), or empty — no override.
|
|
417
|
+
continue
|
|
418
|
+
new_range = _union_scenarios(scen_map, scenarios_set)
|
|
419
|
+
if new_range is None:
|
|
420
|
+
continue
|
|
421
|
+
if ranges[i] != new_range:
|
|
422
|
+
ranges[i] = new_range
|
|
423
|
+
changed = True
|
|
424
|
+
|
|
425
|
+
if changed:
|
|
426
|
+
plan.subplot_y_ranges = ranges
|
|
427
|
+
return changed
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _union_scenarios(
|
|
431
|
+
scen_map: dict, scenarios: set[str] | None,
|
|
432
|
+
) -> tuple[float, float] | None:
|
|
433
|
+
"""Union per-scenario bounds, filtering to *scenarios*.
|
|
434
|
+
|
|
435
|
+
Returns ``None`` when no valid bounds remain after filtering.
|
|
436
|
+
"""
|
|
437
|
+
los: list[float] = []
|
|
438
|
+
his: list[float] = []
|
|
439
|
+
for scenario_name, bounds in scen_map.items():
|
|
440
|
+
if scenarios is not None and scenario_name not in scenarios:
|
|
441
|
+
continue
|
|
442
|
+
if not isinstance(bounds, (list, tuple)) or len(bounds) != 2:
|
|
443
|
+
continue
|
|
444
|
+
try:
|
|
445
|
+
lo = float(bounds[0])
|
|
446
|
+
hi = float(bounds[1])
|
|
447
|
+
except (TypeError, ValueError):
|
|
448
|
+
continue
|
|
449
|
+
los.append(lo)
|
|
450
|
+
his.append(hi)
|
|
451
|
+
if not los:
|
|
452
|
+
return None
|
|
453
|
+
return (min(los), max(his))
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
# Scenario removal helper
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
def remove_scenario_from_manifest(
|
|
461
|
+
project_path: Path, scenario_name: str,
|
|
462
|
+
) -> bool:
|
|
463
|
+
"""Strip all entries for *scenario_name* from the on-disk manifest.
|
|
464
|
+
|
|
465
|
+
Atomically rewrites ``<project_path>/output_parquet/_axis_bounds.json``
|
|
466
|
+
with the scenario's slice removed. Empty subplot / sub_config /
|
|
467
|
+
result_key entries are pruned. Returns ``True`` if the file was
|
|
468
|
+
modified (including being deleted when empty), ``False`` when the
|
|
469
|
+
manifest is missing, unreadable, or already free of the scenario.
|
|
470
|
+
|
|
471
|
+
Safe to call when the manifest doesn't exist — it just returns
|
|
472
|
+
``False``. Intended for callers that delete a scenario's output so
|
|
473
|
+
its slice doesn't continue to pull y-range data in the viewer.
|
|
474
|
+
"""
|
|
475
|
+
if not scenario_name:
|
|
476
|
+
return False
|
|
477
|
+
manifest_path = (
|
|
478
|
+
Path(project_path) / "output_parquet" / "_axis_bounds.json"
|
|
479
|
+
)
|
|
480
|
+
if not manifest_path.is_file():
|
|
481
|
+
return False
|
|
482
|
+
|
|
483
|
+
loaded = load_axis_bounds_manifest(project_path)
|
|
484
|
+
if not isinstance(loaded, dict):
|
|
485
|
+
return False
|
|
486
|
+
|
|
487
|
+
changed = False
|
|
488
|
+
for result_key in list(loaded.keys()):
|
|
489
|
+
sub_map = loaded.get(result_key)
|
|
490
|
+
if not isinstance(sub_map, dict):
|
|
491
|
+
continue
|
|
492
|
+
for sub_config in list(sub_map.keys()):
|
|
493
|
+
subplot_map = sub_map.get(sub_config)
|
|
494
|
+
if not isinstance(subplot_map, dict):
|
|
495
|
+
continue
|
|
496
|
+
for subplot_key in list(subplot_map.keys()):
|
|
497
|
+
scen_map = subplot_map.get(subplot_key)
|
|
498
|
+
# Legacy flat entries have no scenario dimension; skip.
|
|
499
|
+
if not isinstance(scen_map, dict):
|
|
500
|
+
continue
|
|
501
|
+
if scenario_name in scen_map:
|
|
502
|
+
del scen_map[scenario_name]
|
|
503
|
+
changed = True
|
|
504
|
+
if not scen_map:
|
|
505
|
+
del subplot_map[subplot_key]
|
|
506
|
+
if not subplot_map:
|
|
507
|
+
del sub_map[sub_config]
|
|
508
|
+
if not sub_map:
|
|
509
|
+
del loaded[result_key]
|
|
510
|
+
|
|
511
|
+
if not changed:
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
if not loaded:
|
|
515
|
+
# Nothing left — drop the file entirely.
|
|
516
|
+
try:
|
|
517
|
+
manifest_path.unlink()
|
|
518
|
+
except OSError as exc:
|
|
519
|
+
logger.warning(
|
|
520
|
+
"Failed to delete now-empty manifest %s: %s",
|
|
521
|
+
manifest_path, exc,
|
|
522
|
+
)
|
|
523
|
+
return False
|
|
524
|
+
return True
|
|
525
|
+
|
|
526
|
+
_atomic_write_manifest(manifest_path, loaded)
|
|
527
|
+
return True
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# ---------------------------------------------------------------------------
|
|
531
|
+
# Shared atomic writer
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
def _atomic_write_manifest(manifest_path: Path, data: dict) -> None:
|
|
535
|
+
"""Temp-file + rename write shared by the accumulator and remove helper."""
|
|
536
|
+
parent_dir = manifest_path.parent
|
|
537
|
+
try:
|
|
538
|
+
parent_dir.mkdir(parents=True, exist_ok=True)
|
|
539
|
+
except OSError as exc:
|
|
540
|
+
logger.warning(
|
|
541
|
+
"Failed to create manifest parent directory %s: %s",
|
|
542
|
+
parent_dir, exc,
|
|
543
|
+
)
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
548
|
+
dir=str(parent_dir),
|
|
549
|
+
prefix="axis_bounds_",
|
|
550
|
+
suffix=".tmp",
|
|
551
|
+
)
|
|
552
|
+
tmp_path = Path(tmp_path)
|
|
553
|
+
try:
|
|
554
|
+
with open(fd, "w", encoding="utf-8") as f:
|
|
555
|
+
json.dump(data, f, indent=2, sort_keys=True)
|
|
556
|
+
tmp_path.replace(manifest_path)
|
|
557
|
+
except BaseException:
|
|
558
|
+
tmp_path.unlink(missing_ok=True)
|
|
559
|
+
raise
|
|
560
|
+
except Exception as exc:
|
|
561
|
+
logger.warning(
|
|
562
|
+
"Failed to write shared axis-bounds manifest %s: %s",
|
|
563
|
+
manifest_path, exc,
|
|
564
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared utilities for subplot grid layout and data extraction.
|
|
3
|
+
|
|
4
|
+
Functions
|
|
5
|
+
---------
|
|
6
|
+
_calculate_grid_layout Grid dimensions (n_rows, n_cols) from n_subs and subplots_per_row.
|
|
7
|
+
_get_unique_levels Unique subplot values from DataFrame column index (integer indices).
|
|
8
|
+
_extract_subplot_data Unified .xs() extraction returning always a DataFrame, never a Series.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class BarLayoutParams:
|
|
17
|
+
"""Pre-computed horizontal layout measurements for bar charts (inches).
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
bar_label_width: Width for the longest bar tick label (the
|
|
21
|
+
expand-axis group is folded into this label).
|
|
22
|
+
total_label_width: Total horizontal width reserved for category labels.
|
|
23
|
+
legend_width: Width reserved for the legend column (0 if not needed).
|
|
24
|
+
legend_height: Height needed for the legend (0 if not needed).
|
|
25
|
+
base_bar_length: The drawing-area width (value axis extent).
|
|
26
|
+
value_axis_width: Width for value-axis tick labels (vertical bar plots).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
bar_label_width: float
|
|
30
|
+
total_label_width: float
|
|
31
|
+
legend_width: float
|
|
32
|
+
legend_height: float
|
|
33
|
+
base_bar_length: float
|
|
34
|
+
value_axis_width: float
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class LineLayoutParams:
|
|
39
|
+
"""Pre-computed layout measurements for line/area charts (inches).
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
value_label_width: Width for the longest y-axis value tick label.
|
|
43
|
+
legend_width: Width reserved for the legend column (0 if not needed).
|
|
44
|
+
base_width: The drawing-area width.
|
|
45
|
+
subplot_height: The drawing-area height.
|
|
46
|
+
"""
|
|
47
|
+
value_label_width: float
|
|
48
|
+
legend_width: float
|
|
49
|
+
base_width: float
|
|
50
|
+
subplot_height: float
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _calculate_grid_layout(n_subs: int, subplots_per_row: int) -> tuple[int, int]:
|
|
54
|
+
"""Return (n_rows, n_cols) for a grid of n_subs subplots.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
n_subs: Total number of subplots.
|
|
58
|
+
subplots_per_row: Maximum columns per row.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
(n_rows, n_cols) as integers.
|
|
62
|
+
"""
|
|
63
|
+
n_cols = min(subplots_per_row, n_subs)
|
|
64
|
+
n_rows = (n_subs + n_cols - 1) // n_cols # Ceiling division
|
|
65
|
+
return n_rows, n_cols
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _sort_subs(subs: list) -> list:
|
|
69
|
+
"""Return subplot identifiers sorted alphabetically (case-insensitive).
|
|
70
|
+
|
|
71
|
+
Handles the three shapes :func:`_get_unique_levels` may return:
|
|
72
|
+
|
|
73
|
+
* ``[None]`` — a single un-split subplot; returned as-is.
|
|
74
|
+
* list of scalars — sorted by lowercased string form.
|
|
75
|
+
* list of tuples — sorted element-wise by lowercased string form so
|
|
76
|
+
multi-level subplot titles order like "a | x", "a | y", "b | x".
|
|
77
|
+
|
|
78
|
+
Used wherever subplot identifiers drive the final display order (both
|
|
79
|
+
the viewer's PlotPlan and the PNG builders) so the two render paths
|
|
80
|
+
agree.
|
|
81
|
+
"""
|
|
82
|
+
def key(sub):
|
|
83
|
+
if sub is None:
|
|
84
|
+
return ("",)
|
|
85
|
+
if isinstance(sub, tuple):
|
|
86
|
+
return tuple(str(v).lower() for v in sub)
|
|
87
|
+
return (str(sub).lower(),)
|
|
88
|
+
return sorted(subs, key=key)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _get_unique_levels(df_columns: pd.Index, level_indices: list) -> list:
|
|
92
|
+
"""Return unique subplot values from DataFrame column index.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
df_columns: The .columns attribute of a DataFrame.
|
|
96
|
+
level_indices: Integer position(s) of the level(s) to extract.
|
|
97
|
+
Empty list → returns [None] (single plot, no subplotting).
|
|
98
|
+
Single element → returns list of scalars.
|
|
99
|
+
Multiple elements → returns list of tuples.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
List of subplot values (scalars, tuples, or [None]).
|
|
103
|
+
"""
|
|
104
|
+
if not level_indices:
|
|
105
|
+
return [None]
|
|
106
|
+
if len(level_indices) == 1:
|
|
107
|
+
return df_columns.get_level_values(level_indices[0]).unique().tolist()
|
|
108
|
+
sub_df = df_columns.to_frame().iloc[:, level_indices].drop_duplicates()
|
|
109
|
+
return [tuple(row) for row in sub_df.values]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _extract_subplot_data(df: pd.DataFrame, sub, sub_levels: list) -> pd.DataFrame:
|
|
113
|
+
"""Extract data for one subplot value, always returning a DataFrame.
|
|
114
|
+
|
|
115
|
+
Handles MultiIndex and single-level columns uniformly. Converts any
|
|
116
|
+
Series result of .xs() back to a single-column DataFrame.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
df: Source DataFrame with MultiIndex or single-level columns.
|
|
120
|
+
sub: Subplot value (scalar or tuple) to select, or None for the
|
|
121
|
+
whole frame (when sub_levels is empty / subs == [None]).
|
|
122
|
+
sub_levels: Integer level indices that identify this subplot level.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
DataFrame slice for the requested subplot value.
|
|
126
|
+
"""
|
|
127
|
+
if sub is None:
|
|
128
|
+
return df
|
|
129
|
+
if len(sub_levels) == 1 and not isinstance(df.columns, pd.MultiIndex):
|
|
130
|
+
result = df[sub]
|
|
131
|
+
elif len(sub_levels) == 1:
|
|
132
|
+
result = df.xs(sub, level=sub_levels[0], axis=1)
|
|
133
|
+
else:
|
|
134
|
+
result = df.xs(sub, level=sub_levels, axis=1)
|
|
135
|
+
if isinstance(result, pd.Series):
|
|
136
|
+
result = result.to_frame()
|
|
137
|
+
return result
|