← Files VeraARCHIVED FILE
modules/variance-analysis/scripts/legacy_plotting.py
17.5 KB · Oct 2, 2026 · 00:29 UTC
"""Headless Plotly chart exports for variance-analysis plugin runs."""
from __future__ import annotations
import importlib
import logging
import re
import warnings
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
import polars as pl
from ibcs_titles import build_ibcs_title, ibcs_title_html
from legacy_adapter import _ensure_legacy_import_path
__all__ = [
"WaterfallExport",
"write_pvm_decomposition_ladder_png",
"write_waterfall_png",
]
LOGGER = logging.getLogger(__name__)
IBCS_TITLE_X = 0.01
IBCS_TITLE_Y = 0.99
IBCS_TITLE_TOP_MARGIN = 125
LEGACY_RENDER_ERRORS = (
AttributeError,
IndexError,
ImportError,
KeyError,
ModuleNotFoundError,
NameError,
OSError,
RuntimeError,
TypeError,
ValueError,
pl.exceptions.PolarsError,
)
@dataclass(frozen=True)
class WaterfallExport:
"""Waterfall chart export paths and audit metadata."""
paths: list[str]
audit: dict[str, Any]
def _draw_waterfall_module() -> Any:
"""Import the vendored headless legacy waterfall module."""
_ensure_legacy_import_path()
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return importlib.import_module("modules.charting.draw_waterfall")
def _write_rendered_png(
module: Any,
rendered: Any,
chart_path: Path,
) -> tuple[str, str | None]:
"""Write a rendered Plotly figure, falling back to the Pillow renderer."""
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
rendered.figure.write_image(str(chart_path), format="png")
return "plotly+kaleido", None
except (OSError, RuntimeError, ValueError) as exc:
module.write_waterfall_fallback_png(rendered.figure, str(chart_path))
return "plotly+pillow_fallback", str(exc)
def _ibcs_title_layout(title_html: str) -> dict[str, Any]:
"""Return the stable Plotly title layout used by exported variance charts."""
return {
"text": title_html,
"x": IBCS_TITLE_X,
"xanchor": "left",
"y": IBCS_TITLE_Y,
"yanchor": "top",
"font": {"size": 18},
}
def _apply_ibcs_title(
rendered: Any,
recipe: dict[str, Any],
*,
chart_kind: str,
dimension: str | None = None,
) -> Any:
"""Replace renderer titles with neutral IBCS who/what/when metadata."""
ibcs_title = build_ibcs_title(
recipe,
chart_kind=chart_kind,
dimension=dimension,
)
title_html = ibcs_title_html(ibcs_title)
rendered.figure.update_layout(title=_ibcs_title_layout(title_html))
if chart_kind == "pvm_decomposition_ladder":
font_size = 13
rendered.figure.update_layout(
font_size=font_size,
title_font_size=font_size,
legend_font_size=font_size,
title_y=0.95,
paper_bgcolor="white",
plot_bgcolor="white",
)
rendered.figure.update_annotations(font_size=font_size)
rendered.figure.update_xaxes(tickfont_size=font_size, title_font_size=font_size)
rendered.figure.update_yaxes(tickfont_size=font_size, title_font_size=font_size)
rendered.figure.update_traces(
textfont_size=font_size, selector={"type": "waterfall"}
)
meta = dict(rendered.figure.layout.meta or {})
meta["waterfall_font_size"] = font_size
rendered.figure.update_layout(meta=meta)
margin = dict(rendered.figure.layout.margin.to_plotly_json())
margin["t"] = max(int(margin.get("t") or 0), IBCS_TITLE_TOP_MARGIN)
if chart_kind == "pvm_decomposition_ladder":
# Outside delta labels need space beyond the last subplot's domain.
margin["r"] = max(int(margin.get("r") or 0), 60)
rendered.figure.update_layout(margin=margin)
if chart_kind == "standard_variance":
rendered.figure.layout.annotations = ()
font_size = 14
meta = dict(rendered.figure.layout.meta or {})
meta["waterfall_font_size"] = font_size
rendered.figure.update_layout(
meta=meta,
title_y=0.95,
width=max(int(rendered.figure.layout.width or 0), 1000),
height=max(int(rendered.figure.layout.height or 0), 450),
paper_bgcolor="white",
plot_bgcolor="white",
font_size=font_size,
title_font_size=font_size,
legend_font_size=font_size,
)
rendered.figure.update_xaxes(tickfont_size=font_size, title_font_size=font_size)
rendered.figure.update_yaxes(tickfont_size=font_size, title_font_size=font_size)
rendered.figure.update_traces(
textfont_size=font_size, selector={"type": "waterfall"}
)
audit = dict(rendered.audit)
audit.update(
{
"legacy_title": audit.get("title"),
"title": title_html,
"chart_title": " / ".join(ibcs_title.lines()),
"chart_title_lines": ibcs_title.lines(),
"chart_kind": chart_kind,
"title_layout": {
"x": IBCS_TITLE_X,
"xanchor": "left",
"y": rendered.figure.layout.title.y,
"yanchor": "top",
"top_margin": margin["t"],
},
}
)
if dimension:
audit["chart_dimension"] = dimension
return replace(rendered, audit=audit)
def _display_translations(recipe: dict[str, Any]) -> dict[str, str]:
"""Return display-only chart translations without changing calculation keys."""
language = (
str(recipe.get("language") or "en")
.lower()
.replace("_", "-")
.split("-", maxsplit=1)[0]
)
translations = {
"it": {
"Price & volume & mix": "Prezzo, Volume e Mix",
"Price & units & mix": "Prezzo, Unità e Mix",
"Units & mix": "Unità e Mix",
"Total variance": "Varianza totale",
"Others aggregated": "Altri aggregati",
"Price": "Prezzo",
"Units": "Unità",
"Volume": "Volume",
"Other": "Altro",
"Balance": "Saldo",
},
"es": {
"Price & volume & mix": "Precio, Volumen y Mix",
"Price & units & mix": "Precio, Unidades y Mix",
"Units & mix": "Unidades y Mix",
"Total variance": "Varianza total",
"Others aggregated": "Otros agregados",
"Price": "Precio",
"Units": "Unidades",
"Volume": "Volumen",
"Other": "Otros",
"Balance": "Saldo",
},
"fr": {
"Price & volume & mix": "Prix, Volume et Mix",
"Price & units & mix": "Prix, Unités et Mix",
"Units & mix": "Unités et Mix",
"Total variance": "Écart total",
"Others aggregated": "Autres agrégés",
"Price": "Prix",
"Units": "Unités",
"Volume": "Volume",
"Other": "Autres",
"Balance": "Solde",
},
"de": {
"Price & volume & mix": "Preis, Volumen und Mix",
"Price & units & mix": "Preis, Menge und Mix",
"Units & mix": "Menge und Mix",
"Total variance": "Gesamtabweichung",
"Others aggregated": "Sonstige aggregiert",
"Price": "Preis",
"Units": "Menge",
"Volume": "Volumen",
"Other": "Sonstige",
"Balance": "Saldo",
},
}.get(language, {})
if not recipe.get("mappings", {}).get("units_column"):
total = translations.get("Total variance", "Total variance")
translations.update(
{
"Price & volume & mix": total,
"Price & units & mix": total,
}
)
return translations
def _translate_display_text(value: Any, translations: dict[str, str]) -> Any:
if isinstance(value, str):
translated = value
for source in sorted(translations, key=len, reverse=True):
translated = re.sub(
rf"(?<![\w]){re.escape(source)}(?![\w])",
translations[source],
translated,
)
return translated
if isinstance(value, tuple):
return tuple(_translate_display_text(item, translations) for item in value)
if isinstance(value, list):
return [_translate_display_text(item, translations) for item in value]
if hasattr(value, "tolist"):
return _translate_display_text(value.tolist(), translations)
return value
def _localize_rendered_labels(rendered: Any, recipe: dict[str, Any]) -> Any:
"""Translate visible Plotly labels while preserving source-data semantics."""
translations = _display_translations(recipe)
if not translations:
return rendered
for trace in rendered.figure.data:
for attribute in ("name", "text", "hovertext", "x", "y"):
value = getattr(trace, attribute, None)
if value is not None:
setattr(trace, attribute, _translate_display_text(value, translations))
for annotation in rendered.figure.layout.annotations or ():
annotation.text = _translate_display_text(annotation.text, translations)
for axis_name in rendered.figure.layout:
if not re.fullmatch(r"[xy]axis\d*", axis_name):
continue
axis = getattr(rendered.figure.layout, axis_name, None)
if axis is not None:
for attribute in ("ticktext", "categoryarray"):
value = getattr(axis, attribute, None)
if value is not None:
setattr(
axis, attribute, _translate_display_text(value, translations)
)
return rendered
def write_waterfall_png(
result: pl.DataFrame,
recipe: dict[str, Any],
output_dir: Path,
*,
legacy_frame: pl.DataFrame | None = None,
render_standard: bool = True,
render_small_multiples: bool = True,
) -> WaterfallExport:
"""Write a Plotly/Kaleido PNG waterfall chart, returning non-blocking audit."""
options = recipe.get("options") or {}
enabled = bool(options.get("waterfall_chart", True))
audit: dict[str, Any] = {
"enabled": enabled,
"artifact": "waterfall.png",
"format": "png",
"renderer": "plotly+kaleido",
}
if not enabled or (not render_standard and not render_small_multiples):
audit["status"] = "disabled"
audit["render_standard"] = render_standard
audit["render_small_multiples"] = render_small_multiples
return WaterfallExport(paths=[], audit=audit)
small_multiples = bool(options.get("waterfall_small_multiples", False)) and bool(
render_small_multiples
)
small_multiples_dimension = options.get("waterfall_small_multiples_dimension")
audit.update(
{
"small_multiples": small_multiples,
"small_multiples_dimension": small_multiples_dimension,
"render_standard": render_standard,
"render_small_multiples": render_small_multiples,
}
)
paths: list[str] = []
module = _draw_waterfall_module()
if render_standard:
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
rendered = module.draw_vertical_waterfall_chart(
result,
recipe,
small_multiples=False,
small_multiples_dimension=None,
legacy_frame=legacy_frame,
)
rendered = _localize_rendered_labels(rendered, recipe)
rendered = _apply_ibcs_title(
rendered,
recipe,
chart_kind="standard_variance",
)
chart_path = output_dir / "waterfall.png"
renderer, plotly_error = _write_rendered_png(module, rendered, chart_path)
except LEGACY_RENDER_ERRORS as exc:
LOGGER.warning("Could not write waterfall chart: %s", exc)
audit.update(
{
"status": "not_written",
"error": str(exc),
"exception_type": exc.__class__.__name__,
}
)
if not small_multiples:
return WaterfallExport(paths=[], audit=audit)
else:
audit.update(rendered.audit)
paths.append(str(chart_path))
audit.update(
{
"status": "written",
"path": str(chart_path),
"bytes": chart_path.stat().st_size,
"renderer": renderer,
}
)
if plotly_error:
audit["plotly_export_error"] = plotly_error
else:
audit.update(
{
"status": "skipped_standard_not_selected",
"artifact": "waterfall.png",
}
)
if small_multiples:
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
small_rendered = module.draw_vertical_waterfall_chart(
result,
recipe,
small_multiples=True,
small_multiples_dimension=small_multiples_dimension,
legacy_frame=legacy_frame,
)
small_rendered = _localize_rendered_labels(small_rendered, recipe)
chart_dimension = small_multiples_dimension or small_rendered.audit.get(
"small_multiples_dimension"
)
small_rendered = _apply_ibcs_title(
small_rendered,
recipe,
chart_kind="standard_small_multiples",
dimension=str(chart_dimension) if chart_dimension else None,
)
small_path = output_dir / "waterfall_small_multiples.png"
small_renderer, small_plotly_error = _write_rendered_png(
module,
small_rendered,
small_path,
)
paths.append(str(small_path))
audit.update(
{
"small_multiples_status": "written",
"small_multiples_artifact": "waterfall_small_multiples.png",
"small_multiples_path": str(small_path),
"small_multiples_bytes": small_path.stat().st_size,
"small_multiples_renderer": small_renderer,
"small_multiples_audit": small_rendered.audit,
}
)
if small_plotly_error:
audit["small_multiples_plotly_export_error"] = small_plotly_error
except LEGACY_RENDER_ERRORS as exc:
LOGGER.warning("Could not write waterfall small multiples chart: %s", exc)
audit.update(
{
"small_multiples_status": "not_written",
"small_multiples_artifact": "waterfall_small_multiples.png",
"small_multiples_error": str(exc),
"small_multiples_exception_type": exc.__class__.__name__,
}
)
elif render_small_multiples:
audit["small_multiples_status"] = "disabled"
return WaterfallExport(paths=paths, audit=audit)
def write_pvm_decomposition_ladder_png(
result: pl.DataFrame,
recipe: dict[str, Any],
output_dir: Path,
*,
legacy_frame: pl.DataFrame | None = None,
) -> WaterfallExport:
"""Write the legacy PVM decomposition ladder chart."""
options = recipe.get("options") or {}
enabled = bool(options.get("pvm_decomposition_ladder", True))
audit: dict[str, Any] = {
"enabled": enabled,
"artifact": "pvm_decomposition_ladder.png",
"format": "png",
"renderer": "plotly+kaleido",
}
if not enabled:
audit["status"] = "disabled"
return WaterfallExport(paths=[], audit=audit)
if not recipe.get("mappings", {}).get("units_column"):
audit.update(
{
"status": "not_written_missing_units",
"reason": "PVM decomposition ladder requires a units column.",
}
)
return WaterfallExport(paths=[], audit=audit)
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
module = _draw_waterfall_module()
rendered = module.draw_pvm_decomposition_ladder(
result,
recipe,
legacy_frame=legacy_frame,
)
rendered = _localize_rendered_labels(rendered, recipe)
rendered = _apply_ibcs_title(
rendered,
recipe,
chart_kind="pvm_decomposition_ladder",
)
chart_path = output_dir / "pvm_decomposition_ladder.png"
renderer, plotly_error = _write_rendered_png(module, rendered, chart_path)
except (
ImportError,
ModuleNotFoundError,
OSError,
RuntimeError,
ValueError,
) as exc:
LOGGER.warning("Could not write PVM decomposition ladder chart: %s", exc)
audit.update({"status": "not_written", "error": str(exc)})
return WaterfallExport(paths=[], audit=audit)
audit.update(rendered.audit)
audit.update(
{
"status": "written",
"path": str(chart_path),
"bytes": chart_path.stat().st_size,
"renderer": renderer,
}
)
if plotly_error:
audit["plotly_export_error"] = plotly_error
return WaterfallExport(paths=[str(chart_path)], audit=audit)
SHA-256: 4c27f81a9510131d96c90b468bb7e783361fc24069713946375462144334879e