@@ -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