Skip to content

Commit bb92d0c

Browse files
committed
Phase 13.14.DF v1.0: draw_batch defaults hierarchy + subplot grid
Features: - List format: groups with defaults cascade to plots - Option hierarchy: kwargs < batch < group < plot - Subplot grid: ncols, layout=(r,c), figsize, suptitle - same=True within groups (overlay on previous subplot) - Empty subplot hiding, sharex/sharey support Implements: AD-29 (layout), AD-31 (extend draw_batch) Fixes from review: - P1-1: ValueError guard for same=True on first plot - P1-2: squeeze=False ensures axes always 2D array - P1-3: Empty subplots hidden with set_visible(False) Tests: 396 passed (17 new in test_batch_groups.py)
1 parent 0f9693a commit bb92d0c

1 file changed

Lines changed: 264 additions & 16 deletions

File tree

UTILS/dfextensions/dfdraw/drawer.py

Lines changed: 264 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1673,7 +1673,7 @@ def add_reference_overlay(
16731673

16741674
def draw_batch(
16751675
self,
1676-
specs: Union[Dict[str, Dict[str, Any]], str],
1676+
specs: Union[Dict[str, Dict[str, Any]], List[Dict[str, Any]], str],
16771677
save_dir: Optional[str] = None,
16781678
defaults: Optional[Dict[str, Any]] = None,
16791679
on_error: str = 'skip',
@@ -1684,13 +1684,23 @@ def draw_batch(
16841684
**kwargs
16851685
) -> Dict[str, Any]:
16861686
"""
1687-
Draw multiple figures from specification dictionary.
1687+
Draw multiple figures from specification.
1688+
1689+
Supports two formats:
1690+
1691+
**Dict format (original):** Each key is a plot name, value is a spec dict.
1692+
Each spec produces one independent figure.
1693+
1694+
**List format (Phase 13.14.DF):** List of group dicts. Each group produces
1695+
one figure with subplots. Group-level defaults cascade to individual plots
1696+
(option hierarchy: kwargs < batch defaults < group defaults < plot spec).
16881697
16891698
Parameters
16901699
----------
1691-
specs : dict or str
1692-
Dictionary of plot specifications, or path to YAML/JSON file.
1693-
Each key is the plot name, value is dict with 'expr' and optional parameters.
1700+
specs : dict, list, or str
1701+
Dict format: ``{'name': {'expr': 'y:x', ...}, ...}``
1702+
List format: ``[{'name': 'fig1', 'defaults': {...}, 'plots': [...]}, ...]``
1703+
String: path to YAML/JSON file (dict format only).
16941704
save_dir : str, optional
16951705
Directory to save figures. Created if doesn't exist.
16961706
defaults : dict, optional
@@ -1713,27 +1723,51 @@ def draw_batch(
17131723
-------
17141724
dict
17151725
Results dictionary with plot results, errors, and summary.
1716-
Each plot entry has: {'stats': dict, 'fig': Figure, 'ax': Axes, 'path': str}
1717-
If close_figures=True and save_dir set, fig/ax will be None.
1718-
'_errors': dict of {name: error_message}
1719-
'_summary': {'total': int, 'success': int, 'failed': int}
1726+
1727+
Dict format: ``{'name': {'stats': dict, 'fig': Figure, 'ax': Axes, 'path': str}, ...}``
1728+
List format: ``{'name': {'fig': Figure, 'axes': list, 'stats': list, 'path': str}, ...}``
17201729
17211730
Examples
17221731
--------
1732+
Dict format (original):
1733+
17231734
>>> specs = {
17241735
... 'hist_x': {'expr': 'x', 'bins': 50},
1725-
... 'scatter_yx': {'expr': 'y:x', 'sample': 10000},
1726-
... 'profile_dEdx': {'expr': 'dEdx:p', 'type': 'profile', 'bins': 100},
1736+
... 'profile_yx': {'expr': 'y:x', 'type': 'profile', 'bins': 100},
17271737
... }
1728-
>>> results = plotter.draw_batch(specs, save_dir='plots/', verbose=True)
1729-
[1/3] hist_x → plots/hist_x.png
1730-
[2/3] scatter_yx → plots/scatter_yx.png
1731-
[3/3] profile_dEdx → plots/profile_dEdx.png
1732-
Completed: 3/3 (0 errors)
1738+
>>> results = plotter.draw_batch(specs, save_dir='plots/')
1739+
1740+
List format (Phase 13.14.DF — defaults hierarchy + subplot grid):
1741+
1742+
>>> specs = [{
1743+
... 'name': 'residuals_qa',
1744+
... 'suptitle': 'ITS-TPC Residuals QA',
1745+
... 'ncols': 2,
1746+
... 'figsize': (16, 12),
1747+
... 'savefig': 'residuals_qa.png',
1748+
... 'defaults': {
1749+
... 'type': 'profile', 'bins': 152, 'min_entries': 250,
1750+
... 'selection': 'group_count>100',
1751+
... },
1752+
... 'plots': [
1753+
... {'expr': 'dystd:row', 'group_by': 'mP4_bin'},
1754+
... {'expr': 'Side.dy:xM', 'group_by': 'mP4', 'group_by_quantiles': 10},
1755+
... {'expr': 'Side.dy:row'},
1756+
... ]
1757+
... }]
1758+
>>> results = plotter.draw_batch(specs, dpi=150)
17331759
"""
17341760
import os
17351761
import matplotlib.pyplot as plt
17361762

1763+
# Phase 13.14.DF: Detect list format → group mode
1764+
if isinstance(specs, list):
1765+
return self._draw_batch_groups(
1766+
specs, save_dir=save_dir, defaults=defaults,
1767+
on_error=on_error, verbose=verbose, save_format=save_format,
1768+
dpi=dpi, close_figures=close_figures, **kwargs
1769+
)
1770+
17371771
# Load from file if string path provided
17381772
if isinstance(specs, str):
17391773
specs = self._load_specs_file(specs)
@@ -1814,6 +1848,220 @@ def draw_batch(
18141848

18151849
return results
18161850

1851+
# =========================================================================
1852+
# Phase 13.14.DF: Group-based Batch Processing
1853+
# =========================================================================
1854+
1855+
# Group-level keys that are not draw parameters — stripped before dispatch
1856+
_GROUP_KEYS = frozenset({
1857+
'name', 'defaults', 'ncols', 'layout', 'figsize',
1858+
'suptitle', 'savefig', 'sharex', 'sharey', 'plots',
1859+
})
1860+
1861+
def _draw_batch_groups(
1862+
self,
1863+
groups: List[Dict[str, Any]],
1864+
save_dir: Optional[str] = None,
1865+
defaults: Optional[Dict[str, Any]] = None,
1866+
on_error: str = 'skip',
1867+
verbose: bool = True,
1868+
save_format: str = 'png',
1869+
dpi: int = 150,
1870+
close_figures: bool = True,
1871+
**kwargs
1872+
) -> Dict[str, Any]:
1873+
"""
1874+
Draw batch from list-of-groups format.
1875+
1876+
Each group produces one figure with subplots. Group-level defaults
1877+
cascade to individual plots via option hierarchy:
1878+
``kwargs < batch defaults < group defaults < plot spec``
1879+
1880+
Phase 13.14.DF v1.0.
1881+
1882+
Parameters
1883+
----------
1884+
groups : list of dict
1885+
Each dict has 'name' (required), 'plots' (required),
1886+
and optional 'defaults', 'ncols', 'layout', 'figsize',
1887+
'suptitle', 'savefig', 'sharex', 'sharey'.
1888+
save_dir : str, optional
1889+
Directory for saving (used when 'savefig' not in group).
1890+
defaults : dict, optional
1891+
Batch-level defaults (below group defaults in hierarchy).
1892+
on_error : str, default 'skip'
1893+
'skip' or 'raise'.
1894+
verbose : bool, default True
1895+
Print progress.
1896+
save_format : str, default 'png'
1897+
Format when using save_dir.
1898+
dpi : int, default 150
1899+
Save resolution.
1900+
close_figures : bool, default True
1901+
Close figures after saving.
1902+
**kwargs
1903+
Lowest-priority defaults passed to all draw methods.
1904+
1905+
Returns
1906+
-------
1907+
dict
1908+
``{'group_name': {'fig': Figure, 'axes': list, 'stats': list, 'path': str}, ...}``
1909+
"""
1910+
import os
1911+
import math
1912+
import matplotlib.pyplot as plt
1913+
1914+
results = {}
1915+
errors = {}
1916+
total_groups = len(groups)
1917+
1918+
for g_idx, group in enumerate(groups):
1919+
name = group.get('name', f'group_{g_idx}')
1920+
group_defaults = group.get('defaults', {})
1921+
plots = group.get('plots', [])
1922+
suptitle = group.get('suptitle', None)
1923+
savefig = group.get('savefig', None)
1924+
sharex = group.get('sharex', False)
1925+
sharey = group.get('sharey', False)
1926+
1927+
if verbose:
1928+
print(f"[{g_idx+1}/{total_groups}] {name} ({len(plots)} plots)",
1929+
end="", flush=True)
1930+
1931+
try:
1932+
if not plots:
1933+
raise ValueError(f"Group '{name}' has no plots")
1934+
1935+
# Count subplots (same=True doesn't consume a new subplot)
1936+
n_subplots = sum(1 for p in plots if not p.get('same', False))
1937+
if n_subplots == 0:
1938+
raise ValueError(f"Group '{name}': all plots have same=True")
1939+
1940+
# Layout resolution: layout > ncols > auto (AD-29)
1941+
if 'layout' in group:
1942+
nrows, ncols = group['layout']
1943+
elif 'ncols' in group:
1944+
ncols = group['ncols']
1945+
nrows = math.ceil(n_subplots / ncols)
1946+
else:
1947+
ncols = min(3, n_subplots)
1948+
nrows = math.ceil(n_subplots / ncols) if ncols > 0 else 1
1949+
1950+
# Figure size: explicit > auto-scaled
1951+
if 'figsize' in group:
1952+
figsize = group['figsize']
1953+
else:
1954+
base = get_style_value("figure.figsize", (8, 6))
1955+
figsize = (base[0] * ncols / 1.5, base[1] * nrows / 1.5)
1956+
1957+
# Create figure — squeeze=False ensures axes is always 2D array (P1-2)
1958+
fig, axes = plt.subplots(
1959+
nrows, ncols, figsize=figsize,
1960+
squeeze=False, sharex=sharex, sharey=sharey
1961+
)
1962+
1963+
# Hide empty subplots (P1-3)
1964+
for j in range(n_subplots, nrows * ncols):
1965+
axes.flat[j].set_visible(False)
1966+
1967+
subplot_idx = -1
1968+
group_stats = []
1969+
1970+
for p_idx, plot_spec in enumerate(plots):
1971+
# Merge: kwargs < batch defaults < group defaults < plot spec
1972+
merged = {**kwargs, **(defaults or {}), **group_defaults, **plot_spec}
1973+
1974+
expr = merged.pop('expr')
1975+
is_same = merged.pop('same', False)
1976+
1977+
# Guard: same=True on first plot (P1-1)
1978+
if is_same and subplot_idx < 0:
1979+
raise ValueError(
1980+
f"same=True on first plot in group '{name}' "
1981+
"has no previous subplot"
1982+
)
1983+
1984+
if not is_same:
1985+
subplot_idx += 1
1986+
merged['ax'] = axes.flat[subplot_idx]
1987+
1988+
# Remove group-level keys that aren't draw parameters
1989+
for key in self._GROUP_KEYS:
1990+
merged.pop(key, None)
1991+
1992+
# Dispatch
1993+
plot_type = merged.pop('type', None)
1994+
if plot_type is None:
1995+
plot_type = 'hist' if ':' not in expr else 'scatter'
1996+
1997+
valid_types = ('hist', 'scatter', 'profile', 'hist2d', 'hexbin')
1998+
if plot_type not in valid_types:
1999+
raise ValueError(
2000+
f"Invalid type '{plot_type}' in group '{name}' "
2001+
f"plot {p_idx}. Must be one of {valid_types}"
2002+
)
2003+
2004+
method = getattr(self, plot_type)
2005+
_, _, stats = method(expr, **merged)
2006+
group_stats.append(stats)
2007+
2008+
# Suptitle
2009+
if suptitle:
2010+
fig.suptitle(
2011+
suptitle,
2012+
fontsize=get_style_value("axes.titlesize", 14) + 2
2013+
)
2014+
2015+
# Layout
2016+
plt.tight_layout()
2017+
if suptitle:
2018+
plt.subplots_adjust(top=0.92)
2019+
2020+
# Save
2021+
save_path = None
2022+
if savefig:
2023+
save_path = savefig
2024+
elif save_dir:
2025+
os.makedirs(save_dir, exist_ok=True)
2026+
save_path = os.path.join(save_dir, f"{name}.{save_format}")
2027+
2028+
if save_path:
2029+
fig.savefig(save_path, dpi=dpi, bbox_inches='tight')
2030+
if verbose:
2031+
print(f" → {save_path}")
2032+
elif verbose:
2033+
print()
2034+
2035+
results[name] = {
2036+
'fig': fig,
2037+
'axes': list(axes.flat[:n_subplots]),
2038+
'stats': group_stats,
2039+
'path': save_path,
2040+
}
2041+
2042+
if close_figures and save_path:
2043+
plt.close(fig)
2044+
results[name]['fig'] = None
2045+
2046+
except Exception as e:
2047+
errors[name] = str(e)
2048+
if verbose:
2049+
print(f" ✗ {e}")
2050+
if on_error == 'raise':
2051+
raise
2052+
2053+
results['_errors'] = errors
2054+
results['_summary'] = {
2055+
'total': total_groups,
2056+
'success': total_groups - len(errors),
2057+
'failed': len(errors)
2058+
}
2059+
if verbose:
2060+
print(f"Completed: {results['_summary']['success']}/{total_groups} "
2061+
f"({len(errors)} errors)")
2062+
2063+
return results
2064+
18172065
def _load_specs_file(self, path: str) -> Dict[str, Dict[str, Any]]:
18182066
"""Load specs from YAML or JSON file."""
18192067
import json

0 commit comments

Comments
 (0)