Source code for mergen.output

"""
mergen.output
=============
Visualisation and export for SamplingResult.

Plot types
----------
    result.plot('pairplot')     — scatter matrix, all parameter pairs
    result.plot('1d')           — marginal KDE + strip per parameter
    result.plot('2d')           — scatter for selected pair(s)
    result.plot('distances')    — pairwise distance distribution
    result.plot('correlation')  — parameter correlation heatmap
    result.plot('quality')      — quality metrics bar chart
    result.plot('all')          — all of the above

Export formats
--------------
    result.to_csv('design.csv')
    result.to_json('design.json')
    result.to_excel('design.xlsx')
    result.to_markdown('report.md')
    result.to_latex('report.tex')
    result.to_html('report.html')

All text-based exports (markdown, latex, html) include:
    1. Package banner
    2. Design summary
    3. Parameter space description
    4. Quality metrics
    5. Design points table (per point type, separately)
    6. Validation set table
    7. Extra sets tables

All tabular exports (csv, excel) include all points in a single table
with a point_type column for filtering.
"""

from __future__ import annotations

import datetime
import os
import warnings
from itertools import combinations
from typing import List, Optional, Union

import numpy as np
import pandas as pd

try:
    import matplotlib.pyplot as plt
    from matplotlib.lines import Line2D
    _MPL = True
except ImportError:
    _MPL = False

try:
    from scipy.stats import gaussian_kde
    _SCIPY = True
except ImportError:
    _SCIPY = False


# ======================================================================
# Style constants
# ======================================================================

_STYLE = {
    "Optimised" : dict(color="#3a86ff", marker="o", ms=70, z=2, alpha=0.85, lw=0.5, ec="white"),
    "Focus"     : dict(color="#2a9d8f", marker="o", ms=70, z=4, alpha=0.90, lw=0.5, ec="white"),
    "Prescribed": dict(color="#e07b39", marker="o", ms=70, z=5, alpha=0.95, lw=0.5, ec="white"),
    "Validation": dict(color="#e63946", marker="o", ms=70, z=6, alpha=0.90, lw=0.5, ec="white"),
}

_DRAW   = ["Optimised", "Validation", "Focus", "Prescribed"]
_LEGEND = ["Prescribed", "Focus", "Optimised", "Validation"]

# Layout constants (inches)
_PS     = 3.0    # base panel size
_LGND   = 1.5    # legend column width
_LPAD   = 0.65
_RPAD   = 0.20
_TPAD_T = 0.45
_TPAD_N = 0.10
_BPAD   = 0.55
_HGAP   = 0.45
_VGAP   = 0.45
_MAX_W  = 10.0
_MAX_H  = 9.5
_MIN_PS = 0.8

_POOL_MS         = 25
_POOL_MAX_LEVELS = 50

# Candidate-pool alpha is scaled by the number of unique projected
# points: alpha = clip(K / n_unique, MIN, MAX). Sparse grids stay
# solid; dense grids fade so the design markers remain visible.
_POOL_ALPHA_K    = 120.0
_POOL_ALPHA_MIN  = 0.05
_POOL_ALPHA_MAX  = 0.50

_cnt: dict = {}


# ======================================================================
# Utilities
# ======================================================================

def _require_mpl() -> None:
    if not _MPL:
        raise ImportError(
            "matplotlib is required for plotting. "
            "Install it with: pip install matplotlib"
        )


def _outdir(result) -> str:
    return getattr(result, 'output_dir', 'outputs')


def _resolve_filename(kind: str, filename: Optional[str], fmt: Optional[str]) -> str:
    if filename:
        return filename
    ext = fmt.lstrip('.').lower() if fmt else 'png'
    _cnt[kind] = _cnt.get(kind, 0) + 1
    return f"{kind}_{_cnt[kind]}.{ext}"


def _savefig(fig, result, filename: str, dpi: int) -> None:
    d = _outdir(result)
    os.makedirs(d, exist_ok=True)
    p = os.path.join(d, filename)
    fig.savefig(p, dpi=dpi, bbox_inches='tight')
    print(f"  Saved: {p}")


def _collect(result) -> pd.DataFrame:
    """Combine all sets into a single DataFrame."""
    frames = [result.samples]
    if len(result.validation):
        frames.append(result.validation)
    for df in getattr(result, 'sets', {}).values():
        if len(df):
            frames.append(df)
    out = pd.concat(frames, ignore_index=True)
    # Focus points are part of the optimised design; plots show them with
    # the same colour and legend entry. Exports keep the Focus label.
    out.loc[out["point_type"] == "Focus", "point_type"] = "Optimised"
    return out


def _present(df: pd.DataFrame) -> list:
    types_in = df["point_type"].unique()
    base  = [t for t in _DRAW if t in types_in]
    extra = [t for t in types_in if t not in _DRAW]
    return base + extra


def _extra_colors(result) -> dict:
    ec = {
        name: sdf["color"].iloc[0]
        for name, sdf in getattr(result, 'sets', {}).items()
        if len(sdf) and "color" in sdf.columns
    }
    # Custom-labelled design points (e.g. from Sampler.load_design) may
    # carry their colour in the samples frame itself.
    df = result.samples
    if "color" in df.columns:
        for t, sub in df.groupby("point_type"):
            if t not in _STYLE and len(sub):
                ec.setdefault(t, sub["color"].iloc[0])
    return ec


def _pool_visible(space, xc: str, yc: str) -> bool:
    """Pool is always visible — large spaces are sampled."""
    return xc in space._parameters and yc in space._parameters


# ======================================================================
# Figure builder
# ======================================================================

def _legend_handles(present: list, ec: dict, show_pool: bool = False):
    handles, lbls = [], []
    if show_pool:
        handles.append(Line2D([0], [0], marker='o', color='w',
                               markerfacecolor='#aaaaaa',
                               markeredgecolor='none',
                               markersize=np.sqrt(_POOL_MS) * 0.9,
                               linestyle='none'))
        lbls.append("Candidate pool")
    for t in _LEGEND:
        if t not in present:
            continue
        st = _STYLE[t]
        handles.append(Line2D([0], [0], marker='o', color='w',
                               markerfacecolor=st['color'],
                               markeredgecolor=st['ec'],
                               markeredgewidth=st['lw'],
                               markersize=np.sqrt(st['ms']) * 0.9,
                               linestyle='none'))
        lbls.append(t)
    for t, color in ec.items():
        if t not in present:
            continue
        handles.append(Line2D([0], [0], marker='o', color='w',
                               markerfacecolor=color,
                               markeredgecolor='white',
                               markeredgewidth=0.5,
                               markersize=np.sqrt(70) * 0.9,
                               linestyle='none'))
        lbls.append(t)
    return handles, lbls


def _make_fig(nrows: int, ncols: int, title: str,
              present: list, ec: dict, show_pool: bool = False):
    tpad  = _TPAD_T if title else _TPAD_N
    ps_w  = (_MAX_W - _LPAD - (ncols - 1) * _HGAP - _LGND - _RPAD) / max(ncols, 1)
    ps_h  = (_MAX_H - tpad  - (nrows - 1) * _VGAP - _BPAD)          / max(nrows, 1)
    ps    = min(_PS, ps_w, ps_h)

    if ps < _MIN_PS:
        warnings.warn(
            f"Plot panels are very small ({ps:.2f} in). "
            f"Consider using plot('2d', params=[...]) for specific pairs.",
            UserWarning, stacklevel=5
        )

    fig_w = _LPAD + ncols * ps + (ncols - 1) * _HGAP + _LGND + _RPAD
    fig_h = tpad  + nrows * ps + (nrows - 1) * _VGAP + _BPAD
    fig   = plt.figure(figsize=(fig_w, fig_h))

    axes = np.empty((nrows, ncols), dtype=object)
    for r in range(nrows):
        for c in range(ncols):
            left   = (_LPAD + c * (ps + _HGAP)) / fig_w
            bottom = (_BPAD + (nrows - 1 - r) * (ps + _VGAP)) / fig_h
            axes[r, c] = fig.add_axes([left, bottom, ps / fig_w, ps / fig_h])

    if title:
        cx  = (_LPAD + (ncols * ps + (ncols - 1) * _HGAP) / 2) / fig_w
        top = (_BPAD + nrows * ps + (nrows - 1) * _VGAP) / fig_h
        fig.text(cx, top + 0.015, title, ha='center', va='bottom', fontsize=11)

    lgnd_left = (_LPAD + ncols * (ps + _HGAP) - _HGAP + 0.15) / fig_w
    lgnd_mid  = (_BPAD + (nrows * ps + (nrows - 1) * _VGAP) / 2) / fig_h
    handles, lbls = _legend_handles(present, ec, show_pool)
    fig.legend(handles, lbls,
               title="Point type", loc='center left',
               bbox_to_anchor=(lgnd_left, lgnd_mid),
               fontsize=8, title_fontsize=8,
               framealpha=0.92, borderpad=0.7,
               handlelength=1.5, handletextpad=0.5)

    return fig, axes


# ======================================================================
# Core drawing helpers
# ======================================================================

def _scatter(ax, df, xc: str, yc: str, present: list,
             space, show_pool: bool, ec: dict) -> None:
    if show_pool and _pool_visible(space, xc, yc):
        pool  = space.candidate_pool
        names = space.names
        xi, yi = names.index(xc), names.index(yc)
        # Project onto the (xc, yc) plane and keep each grid location
        # once. A d-dimensional grid projects many rows onto the same
        # 2D point; drawing them all would stack translucent markers
        # and make some cells look darker purely because of overlap,
        # not because they carry more information. De-duplicating gives
        # every visible candidate an identical, honest weight.
        proj = np.unique(pool[:, [xi, yi]], axis=0)
        # Scale alpha to the number of *unique* projected points so a
        # dense grid stays legible without washing out the design
        # markers. This is a display heuristic, not a statistical
        # claim: alpha decays inversely with point count, clipped to a
        # readable band.
        alpha = float(np.clip(_POOL_ALPHA_K / max(len(proj), 1),
                              _POOL_ALPHA_MIN, _POOL_ALPHA_MAX))
        ax.scatter(proj[:, 0], proj[:, 1],
                   facecolors='#aaaaaa', edgecolors='none',
                   s=_POOL_MS, alpha=alpha, zorder=1, linewidths=0)

    for t in _DRAW:
        if t not in present:
            continue
        sub = df[df["point_type"] == t]
        if sub.empty:
            continue
        st = _STYLE[t]
        ax.scatter(sub[xc], sub[yc],
                   s=st['ms'], c=st['color'], marker=st['marker'],
                   edgecolors=st['ec'], linewidths=st['lw'],
                   alpha=st['alpha'], zorder=st['z'])

    for t, color in ec.items():
        if t not in present:
            continue
        sub = df[df["point_type"] == t]
        if sub.empty:
            continue
        ax.scatter(sub[xc], sub[yc],
                   s=70, c=color, marker='o',
                   edgecolors='white', linewidths=0.5,
                   alpha=0.90, zorder=8)

    for dim, setter in [(xc, ax.set_xlim), (yc, ax.set_ylim)]:
        if dim in space._parameters:
            v   = space._parameters[dim]
            pad = (v.max() - v.min()) * 0.07 or 0.5
            setter(float(v.min()) - pad, float(v.max()) + pad)

    ax.set_xlabel(xc, fontsize=11)
    ax.set_ylabel(yc, fontsize=11)
    ax.tick_params(labelsize=9)


def _kde_strip(ax, df, param: str, present: list, space, ec: dict) -> None:
    if param not in space._parameters:
        ax.set_visible(False)
        return

    v   = space._parameters[param]
    xlo = float(v.min())
    xhi = float(v.max())
    xg  = np.linspace(xlo, xhi, 400)

    all_types = list(present)

    def _color(t):
        if t in _STYLE:
            return _STYLE[t]['color']
        return ec.get(t, '#888888')

    kdes = {}
    if _SCIPY:
        for t in all_types:
            s = df[df["point_type"] == t][param].dropna().values
            if len(s) < 2:
                continue
            try:
                kdes[t] = gaussian_kde(s, bw_method='scott')(xg)
            except Exception:
                pass

    gmax = max((y.max() for y in kdes.values()), default=1.0) or 1.0

    for t in all_types:
        c = _color(t)
        if t in kdes:
            yn = kdes[t] / gmax
            ax.fill_between(xg, 0, yn, alpha=0.18, color=c)
            ax.plot(xg, yn, color=c, lw=1.4, alpha=0.82)
        else:
            for uv in np.unique(df[df["point_type"] == t][param].dropna().values):
                ax.axvline(uv, color=c, lw=1.4, alpha=0.82, linestyle='--')

    rng = np.random.default_rng(seed=44)
    for t in all_types:
        vals = df[df["point_type"] == t][param].dropna().values
        if len(vals) == 0:
            continue
        c   = _color(t)
        st  = _STYLE.get(t)
        # Jitter strip-plot points vertically to reduce overplotting.
        ms  = (st['ms']    if st else 70) * 0.55
        lw  = (st['lw']    if st else 0.5)
        eca = (st['ec']    if st else 'white')
        alp = (st['alpha'] if st else 0.90)
        z   = (st['z']     if st else 3)
        jit = rng.uniform(-0.025, 0.025, len(vals))
        ax.scatter(vals, np.full(len(vals), -0.07) + jit,
                   c=c, marker='o', s=ms, alpha=alp,
                   edgecolors=eca, linewidths=lw, zorder=z, clip_on=False)

    ax.axhline(0, color='#bbbbbb', lw=0.5, ls='--')
    ax.set_xlim(xlo, xhi)
    ax.set_ylim(-0.18, 1.12)
    ax.set_yticks([0, 0.5, 1.0])
    ax.set_yticklabels(['0', '0.5', '1'], fontsize=9)
    ax.set_xlabel(param, fontsize=11)
    ax.tick_params(labelsize=9)


def _apply_log(ax, xc: str, yc: str, log_params: list) -> set:
    if not log_params:
        return set()
    skipped = set()
    for p in log_params:
        if p == xc:
            ax.set_xscale('log')
        elif p == yc:
            ax.set_yscale('log')
        else:
            skipped.add(p)
    return skipped


# ======================================================================
# Plot functions
# ======================================================================

[docs] def plot_pairplot(result, show_pool: bool = False, title: bool = True, log: Optional[list] = None, show: bool = True, save: bool = False, filename: Optional[str] = None, fmt: Optional[str] = None, dpi: int = 150) -> None: """ Scatter matrix of all parameter pairs. Parameters ---------- show_pool : bool — overlay the candidate pool (default False). When True, the grid is projected onto each panel and de-duplicated so every candidate is shown once with an equal, density-scaled opacity. title : bool — show figure title log : list of parameter names to display on log scale show, save, filename, fmt, dpi — output options """ _require_mpl() space = result.space names = space.names nd = len(names) df = _collect(result) present = _present(df) ec = _extra_colors(result) if nd > 8: raise ValueError( f"pairplot: {nd} parameters exceeds the maximum (8). " f"Use plot('2d', params=[...]) to show specific pairs." ) if nd >= 6: warnings.warn( f"pairplot: {nd} parameters — panels will be small. " f"Consider plot('2d', params=[...]) for specific pairs.", UserWarning, stacklevel=2 ) tstr = "Pairplot" if title else "" fig, axes = _make_fig(nd, nd, tstr, present, ec, show_pool) rev_names = list(reversed(names)) all_skipped: set = set() for i, yc in enumerate(rev_names): for j, xc in enumerate(names): ax = axes[i, j] is_self = (xc == yc) _scatter(ax, df, xc, yc, present, space, show_pool and not is_self, ec) ax.set_xlabel(xc if i == nd - 1 else "", fontsize=11) ax.set_ylabel(yc if j == 0 else "", fontsize=11) if is_self: if xc in space._parameters: v = space._parameters[xc] pad = (v.max() - v.min()) * 0.07 or 0.5 lim = (float(v.min()) - pad, float(v.max()) + pad) ax.set_xlim(lim) ax.set_ylim(lim) ax.plot(lim, lim, color='#cccccc', lw=0.8, ls='--', zorder=0) if log: all_skipped |= _apply_log(ax, xc, yc, log) if log: skipped = all_skipped - set(names) if skipped: warnings.warn(f"plot('pairplot'): log scale skipped for {sorted(skipped)}", UserWarning, stacklevel=2) fn = _resolve_filename("pairplot", filename, fmt) if save: _savefig(fig, result, fn, dpi) if show: plt.show() plt.close(fig)
[docs] def plot_1d(result, params: Optional[Union[str, list]] = None, title: bool = True, log: Optional[list] = None, show: bool = True, save: bool = False, filename: Optional[str] = None, fmt: Optional[str] = None, dpi: int = 150) -> None: """ Marginal KDE + strip chart for each parameter. Parameters ---------- params : str, list, or None — parameters to plot (default: all) """ _require_mpl() space = result.space df = _collect(result) present = _present(df) ec = _extra_colors(result) selected = (space.names if params is None else [params] if isinstance(params, str) else list(params)) n = len(selected) tstr = "1D Distribution" if title else "" fig, axes = _make_fig(1, n, tstr, present, ec) for j, param in enumerate(selected): _kde_strip(axes[0, j], df, param, present, space, ec) if j > 0: axes[0, j].set_ylabel("") if log and param in log: axes[0, j].set_xscale('log') fn = _resolve_filename("1d", filename, fmt) if save: _savefig(fig, result, fn, dpi) if show: plt.show() plt.close(fig)
[docs] def plot_2d(result, params: Optional[Union[list, List[list]]] = None, show_pool: bool = False, title: bool = True, log: Optional[list] = None, show: bool = True, save: bool = False, filename: Optional[str] = None, fmt: Optional[str] = None, dpi: int = 150) -> None: """ Scatter plot for selected parameter pair(s). Parameters ---------- params : [x, y] or [[x1,y1], [x2,y2], ...] — pairs to plot """ _require_mpl() space = result.space names = space.names df = _collect(result) present = _present(df) ec = _extra_colors(result) if params is None: pairs = list(combinations(names, 2)) elif (isinstance(params, list) and len(params) == 2 and isinstance(params[0], str)): pairs = [tuple(params)] else: pairs = [tuple(p) for p in params] n = len(pairs) ncols = min(n, 3) nrows = (n + ncols - 1) // ncols tstr = "2D Scatter" if title else "" fig, axes = _make_fig(nrows, ncols, tstr, present, ec, show_pool) plotted: set = set() for idx, (xc, yc) in enumerate(pairs): ax = axes[idx // ncols, idx % ncols] _scatter(ax, df, xc, yc, present, space, show_pool, ec) ax.set_xlabel(xc, fontsize=11) ax.set_ylabel(yc, fontsize=11) if log: _apply_log(ax, xc, yc, log) plotted |= {xc, yc} for idx in range(n, nrows * ncols): axes[idx // ncols, idx % ncols].set_visible(False) fn = _resolve_filename("2d", filename, fmt) if save: _savefig(fig, result, fn, dpi) if show: plt.show() plt.close(fig)
[docs] def plot_distances(result, title: bool = True, show: bool = True, save: bool = False, filename: Optional[str] = None, fmt: Optional[str] = None, dpi: int = 150) -> None: """Pairwise distance distribution for all sets.""" _require_mpl() space = result.space gmins = space.gmins granges = space.granges def _d(pts): if len(pts) < 2: return np.array([]) norm = (pts - gmins) / granges out = [] for i in range(len(norm) - 1): out.extend(np.linalg.norm(norm[i + 1:] - norm[i], axis=1).tolist()) return np.array(out) sets = [("Design", result.samples[space.names].values, "#3a86ff")] if len(result.validation): sets.append(("Validation", result.validation[space.names].values, "#e63946")) for name, sdf in getattr(result, 'sets', {}).items(): if len(sdf): c = sdf["color"].iloc[0] if "color" in sdf.columns else "#888888" sets.append((name, sdf[space.names].values, c)) tpad = _TPAD_T if title else _TPAD_N pw = min(_PS * 1.6, _MAX_W - _LPAD - _LGND - _RPAD) ph = min(_PS, _MAX_H - tpad - _BPAD) fw = _LPAD + pw + _LGND + _RPAD fh = tpad + ph + _BPAD fig = plt.figure(figsize=(fw, fh)) ax = fig.add_axes([_LPAD / fw, _BPAD / fh, pw / fw, ph / fh]) all_d = [d for _, pts, _ in sets if len(d := _d(pts)) > 0] if not all_d: plt.close(fig) return xmin = min(d.min() for d in all_d) xmax = max(d.max() for d in all_d) xg = np.linspace(xmin, xmax, 300) handles, lbls = [], [] for name, pts, color in sets: dists = _d(pts) if len(dists) == 0: continue if _SCIPY and len(dists) >= 3: try: y = gaussian_kde(dists, bw_method='scott')(xg) ax.fill_between(xg, y, alpha=0.20, color=color) ax.plot(xg, y, color=color, lw=1.8) except Exception: ax.hist(dists, bins=20, alpha=0.35, color=color, density=True) else: ax.hist(dists, bins=20, alpha=0.35, color=color, density=True) handles.append(Line2D([0], [0], color=color, lw=2)) lbls.append(name) ax.set_xlim(xmin, xmax) ax.set_ylim(bottom=0) ax.set_xlabel("Normalised pairwise distance", fontsize=11) ax.set_ylabel("Density", fontsize=11) ax.tick_params(labelsize=9) if title: cx = (_LPAD + pw / 2) / fw top = (_BPAD + ph) / fh fig.text(cx, top + 0.015, "Pairwise Distance Distribution", ha='center', va='bottom', fontsize=11) lgnd_left = (_LPAD + pw + 0.15) / fw lgnd_mid = (_BPAD + ph / 2) / fh fig.legend(handles, lbls, title="Set", loc='center left', bbox_to_anchor=(lgnd_left, lgnd_mid), fontsize=8, title_fontsize=8, framealpha=0.92, borderpad=0.7, handlelength=2.0, handletextpad=0.5) fn = _resolve_filename("distances", filename, fmt) if save: _savefig(fig, result, fn, dpi) if show: plt.show() plt.close(fig)
[docs] def plot_correlation(result, title: bool = True, show: bool = True, save: bool = False, filename: Optional[str] = None, fmt: Optional[str] = None, dpi: int = 150) -> None: """ Parameter correlation heatmap for the design points. Shows absolute pairwise correlations between all parameters. Values close to 0 indicate good orthogonality. """ _require_mpl() space = result.space names = space.names gmins = space.gmins granges = space.granges d = len(names) pts = result.samples[names].values.astype(float) X_norm = (pts - gmins) / granges # Correlation matrix — diagonal set to 1.0 (self-correlation) Xc = X_norm - X_norm.mean(axis=0) norms = np.sqrt((Xc ** 2).sum(axis=0)) norms[norms < 1e-10] = 1.0 Xn = Xc / norms corr = np.abs(Xn.T @ Xn) np.fill_diagonal(corr, 1.0) _MAX_SIZE = 10.0 size = min(_MAX_SIZE, max(3.5, d * 0.7)) cell_fs = max(5, int(9 - d * 0.3)) # shrink font as d grows tick_fs = max(6, int(9 - d * 0.2)) fig, ax = plt.subplots(figsize=(size, size)) ax.imshow(corr, vmin=0, vmax=1, cmap='RdYlGn_r', aspect='auto') ax.set_xticks(range(d)) ax.set_xticklabels(names, rotation=45, ha='right', fontsize=tick_fs) ax.set_yticks(range(d)) ax.set_yticklabels(names, fontsize=tick_fs) for i in range(d): for j in range(d): val = corr[i, j] ax.text(j, i, f"{val:.2f}", ha='center', va='center', fontsize=cell_fs, color='white' if val > 0.5 else 'black') if title: ax.set_title("Parameter Correlation", fontsize=11, pad=10) fig.tight_layout() fn = _resolve_filename("correlation", filename, fmt) if save: _savefig(fig, result, fn, dpi) if show: plt.show() plt.close(fig)
[docs] def plot_quality(result, title: bool = True, show: bool = True, save: bool = False, filename: Optional[str] = None, fmt: Optional[str] = None, dpi: int = 150) -> None: """ Quality metrics bar chart. All default metrics are drawn as horizontal bars on a single axis that auto-ranges to the data, so a small value is still visible rather than collapsing to nothing. Each bar shows the true metric value (annotated with the value and its percentile rank); the dashed vertical marker on each bar is that metric's Monte Carlo baseline (the median over random designs). Bars are coloured green (good) to red (poor) by percentile rank against that baseline. Metrics are on different scales, so bar lengths are meaningful per metric (versus its own baseline), not for comparing one metric against another. """ _require_mpl() from . import metrics as _metrics stats = result.quality_report(mc_samples=200, verbose=False) metric_names = _metrics._DEFAULT_METRICS labels = [_metrics.metric_latex(m) for m in metric_names] values = [stats.get(m, np.nan) for m in metric_names] ranks = [stats.get(f'{m}_percentile_rank', None) for m in metric_names] baselines = [stats.get(f'{m}_baseline_median', None) for m in metric_names] fig, ax = plt.subplots(figsize=(9, max(3.5, len(metric_names) * 0.7))) colors = [] for r in ranks: if r is None or np.isnan(r): colors.append('#aaaaaa') elif r >= 75: colors.append('#2a9d8f') elif r >= 50: colors.append('#e9c46a') else: colors.append('#e63946') y_pos = list(range(len(metric_names))) ax.barh(y_pos, values, color=colors, alpha=0.85, height=0.6) # Monte Carlo baseline markers: a vertical dashed line at each # metric's baseline median, drawn in data coordinates. bar_height = 0.6 marker_height = bar_height * 1.1 for i, (bl, v) in enumerate(zip(baselines, values)): if bl is not None and not np.isnan(bl): ax.plot([bl, bl], [i - marker_height / 2, i + marker_height / 2], color='#333333', lw=1.2, ls='--', alpha=0.7) # Auto-ranged axis: include values and baselines so nothing is # clipped, with head-room for the annotations. x_max = max((x for x in values + baselines if x is not None and not np.isnan(x)), default=1.0) # Place each value label a fixed pixel offset past its bar end. bar_pad_px = 4.0 ax.set_xlim(0, x_max * 1.02) texts = [] for i, (v, r) in enumerate(zip(values, ranks)): if not np.isnan(v): label = f"{v:.4f}" if r is not None and not np.isnan(r): label += f" ({r:.0f}th pct)" t = ax.annotate(label, xy=(v, i), xytext=(bar_pad_px, 0), textcoords='offset points', va='center', ha='left', fontsize=8) texts.append(t) # Expand the x-axis until every label's right edge is comfortably # inside the right spine. Because the pixel<->data ratio itself # depends on the current xlim, iterate a couple of times so the # final xlim accounts for the label widths at that same xlim. margin_px = 10.0 for _ in range(3): fig.canvas.draw() inv = ax.transData.inverted() x0_data, _ = inv.transform((0, 0)) right_data = x_max for t in texts: bb = t.get_window_extent() x1_data, _ = inv.transform((bb.x1 + margin_px, 0)) right_data = max(right_data, x1_data) if abs(ax.get_xlim()[1] - right_data) < x_max * 1e-3: break ax.set_xlim(0, right_data) ax.set_yticks(y_pos) ax.set_yticklabels(labels, fontsize=10) ax.set_xlabel("Metric value", fontsize=11) ax.tick_params(labelsize=9) ax.invert_yaxis() if title: ax.set_title("Design Quality Metrics", fontsize=11, pad=10) from matplotlib.patches import Patch legend_elements = [ Patch(facecolor='#2a9d8f', label='>= 75th percentile'), Patch(facecolor='#e9c46a', label='50-75th percentile'), Patch(facecolor='#e63946', label='< 50th percentile'), Line2D([0], [0], color='#333333', lw=1.2, ls='--', label='Monte Carlo baseline'), ] ax.legend(handles=legend_elements, fontsize=8, loc='upper left', bbox_to_anchor=(1.01, 1.0), framealpha=0.9, borderpad=0.6) fig.tight_layout() fn = _resolve_filename("quality", filename, fmt) if save: _savefig(fig, result, fn, dpi) if show: plt.show() plt.close(fig)
[docs] def plot_comparison(result, title: bool = True, show: bool = True, save: bool = False, filename: Optional[str] = None, fmt: Optional[str] = None, dpi: int = 150) -> None: """ Bar chart comparing the criterion score of each optimisation algorithm. Only meaningful when :meth:`Sampler.run` was called with ``algorithm=[...]``. The best (lowest-scoring) algorithm is highlighted; bar heights show the raw criterion value, lower is better. Elapsed time is annotated above each bar. Parameters ---------- result : SamplingResult Must contain at least one entry in ``result.algorithm_results``. title, show, save, filename, fmt, dpi Standard plotting controls (see other ``plot_*`` functions). Raises ------ ValueError If no algorithm results are present. """ _require_mpl() alg_results = getattr(result, 'algorithm_results', {}) or {} if not alg_results: raise ValueError( "plot_comparison requires result.algorithm_results to be " "populated. Call Sampler.run(algorithm=[...]) with multiple " "algorithms to compare them." ) # Sort by score (best first) ranked = sorted(alg_results.items(), key=lambda kv: kv[1].score) names = [name for name, _ in ranked] scores = [res.score for _, res in ranked] times = [res.elapsed for _, res in ranked] best = getattr(result, 'best_algorithm', names[0]) # Dynamic figure width: scales with number of algorithms, capped to # a sensible upper bound to keep label spacing comfortable. n_alg = len(names) fig_w = min(14.0, max(5.0, 1.1 * n_alg + 3.5)) fig, ax = plt.subplots(figsize=(fig_w, 4.5)) colors = ['#2a9d8f' if name == best else '#aaaaaa' for name in names] bars = ax.bar(names, scores, color=colors, alpha=0.9, width=0.6, edgecolor='#333333', linewidth=0.6) # Score + elapsed time labels above each bar s_max = max(scores) if scores else 1.0 ax.set_ylim(0, s_max * 1.18) for bar, score, t in zip(bars, scores, times): h = bar.get_height() ax.text(bar.get_x() + bar.get_width() / 2, h + s_max * 0.02, f"{score:.4g}\n({t:.2f}s)", ha='center', va='bottom', fontsize=8.5, fontweight='normal') crit_name = result._meta.get('criteria', 'criterion') ax.set_ylabel(f"{crit_name} score (lower is better)", fontsize=11) # x label intentionally omitted — algorithm names speak for themselves ax.tick_params(labelsize=9) ax.grid(axis='y', alpha=0.25, linestyle=':') ax.set_axisbelow(True) if title: ax.set_title(f"Algorithm comparison -- best = {best}", fontsize=11, pad=10) fig.tight_layout() fn = _resolve_filename("comparison", filename, fmt) if save: _savefig(fig, result, fn, dpi) if show: plt.show() plt.close(fig)
[docs] def plot_comparison_matrix(comparison, title: bool = True, show: bool = True, save: bool = False, filename: Optional[str] = None, fmt: Optional[str] = None, dpi: int = 150) -> None: """ Heat map of a ComparisonResult's percentile-rank table. Rows are (criterion, algorithm) combinations, columns are the quality metrics, and the cell colour encodes the percentile rank (green good, red poor) against the shared Monte Carlo baseline. The winning combination's row label is marked with a star. """ _require_mpl() table = comparison.table metric_cols = [col for col in table.columns if col not in ('best', 'criterion', 'algorithm')] n_rows = len(table) n_cols = len(metric_cols) from .criteria import criterion_latex row_labels = [] for r in table.itertuples(): star = '* ' if getattr(r, 'best', '').strip() else ' ' clab = criterion_latex(r.criterion) row_labels.append(f"{star}{clab} / {r.algorithm}") data = table[metric_cols].to_numpy(dtype=float) fig, ax = plt.subplots( figsize=(max(6.0, 1.1 * n_cols + 3.0), max(2.4, 0.5 * n_rows + 1.4))) # Green-yellow-red colormap keyed to percentile (0-100). from matplotlib.colors import LinearSegmentedColormap cmap = LinearSegmentedColormap.from_list( 'mergen_pct', ['#e63946', '#e9c46a', '#2a9d8f']) im = ax.imshow(data, cmap=cmap, vmin=0, vmax=100, aspect='auto') ax.set_xticks(range(n_cols)) from . import metrics as _metrics ax.set_xticklabels([_metrics.metric_latex(m) for m in metric_cols], rotation=30, ha='right', fontsize=10) ax.set_yticks(range(n_rows)) ax.set_yticklabels(row_labels, fontsize=10, fontfamily='monospace') # Annotate each cell with its percentile. for i in range(n_rows): for j in range(n_cols): v = data[i, j] ax.text(j, i, f"{v:.0f}", ha='center', va='center', fontsize=8, color='white' if (v < 33 or v > 80) else '#222222') cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) cbar.set_label('percentile rank', fontsize=11) cbar.ax.tick_params(labelsize=9) if title: ax.set_title("Criterion / Algorithm Comparison", fontsize=11, pad=10) fig.tight_layout() fn = _resolve_filename("comparison_matrix", filename, fmt) if save: _savefig(fig, comparison, fn, dpi) if show: plt.show() plt.close(fig)
[docs] def plot_all(result, show_pool: bool = False, title: bool = True, log: Optional[list] = None, show: bool = True, save: bool = False, fmt: Optional[str] = None, dpi: int = 150) -> None: """Produce all standard plots.""" names = result.space.names if len(names) <= 8: plot_pairplot(result, show_pool=show_pool, title=title, log=log, show=show, save=save, fmt=fmt, dpi=dpi) for p in names: plot_1d(result, params=[p], title=title, log=log, show=show, save=save, fmt=fmt, dpi=dpi) for pair in combinations(names, 2): plot_2d(result, params=[list(pair)], show_pool=show_pool, title=title, log=log, show=show, save=save, fmt=fmt, dpi=dpi) plot_distances(result, title=title, show=show, save=save, fmt=fmt, dpi=dpi) plot_correlation(result, title=title, show=show, save=save, fmt=fmt, dpi=dpi) plot_quality(result, title=title, show=show, save=save, fmt=fmt, dpi=dpi) # Multi-algorithm bar chart — only when more than one was run if len(getattr(result, 'algorithm_results', {}) or {}) > 1: plot_comparison(result, title=title, show=show, save=save, fmt=fmt, dpi=dpi)
# ── Dispatcher ────────────────────────────────────────────────────────────
[docs] def plot(result, kind: str = 'pairplot', **kwargs) -> None: """ Visualise the design. Parameters ---------- kind : str Plot type. One of: - ``'pairplot'``: scatter matrix, all parameter pairs - ``'1d'``: marginal KDE + strip per parameter - ``'2d'``: scatter for selected pair(s) - ``'distances'``: pairwise distance distribution - ``'correlation'``: parameter correlation heatmap - ``'quality'``: quality metrics bar chart - ``'comparison'``: bar chart of per-algorithm scores (only when multiple algorithms were run) - ``'all'``: all of the above **kwargs show_pool, title, log, params, show, save, filename, fmt, dpi """ dispatch = { 'pairplot' : plot_pairplot, '1d' : plot_1d, '2d' : plot_2d, 'distances' : plot_distances, 'correlation': plot_correlation, 'quality' : plot_quality, 'comparison' : plot_comparison, 'all' : plot_all, } if kind not in dispatch: raise ValueError( f"Unknown plot kind '{kind}'. " f"Available: {list(dispatch.keys())}" ) dispatch[kind](result, **kwargs)
# ====================================================================== # Report helpers # ====================================================================== def _report_header(result) -> str: """Package banner + design summary for text-based exports.""" import mergen space = result.space meta = result._meta lines = [ mergen._banner(), "", f"Generated : {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", "", "Parameter Space", "─" * 40, ] for name, vals in zip(space.names, space.values): ptype = space._param_types.get(name, 'discrete') lines.append( f" {name:<24} {ptype:<12} " f"{len(vals):>5} levels " f"[{float(vals.min()):.4g}, {float(vals.max()):.4g}]" ) lines += [ f" {'Candidates':<24} {space.n_candidates:>5,}", "", "Design Summary", "─" * 40, ] vc = result.samples["point_type"].value_counts() for lbl in ("Prescribed", "Focus", "Optimised"): n = vc.get(lbl, 0) if n: lines.append(f" {lbl:<24} {n:>5}") lines.append(f" {'Total design':<24} {len(result.samples):>5}") if len(result.validation): lines.append(f" {'Validation':<24} {len(result.validation):>5}") for sname, sdf in getattr(result, 'sets', {}).items(): lines.append(f" {sname:<24} {len(sdf):>5}") lines += [ "", "Run Settings", "─" * 40, f" {'Criterion':<24} {meta.get('criteria', '?')}", f" {'Seed':<24} {meta.get('seed', '?')}", ] # Algorithm reporting: single or multi alg = meta.get('algorithm', None) if isinstance(alg, list): lines.append(f" {'Algorithms':<24} {', '.join(alg)}") if meta.get('best_algorithm'): lines.append(f" {'Best algorithm':<24} {meta['best_algorithm']}") elif alg is not None: lines.append(f" {'Algorithm':<24} {alg}") # Per-algorithm scores table when multiple were run alg_results = getattr(result, 'algorithm_results', {}) or {} if len(alg_results) > 1: lines += [ "", "Per-algorithm Scores", "─" * 40, ] ranked = sorted(alg_results.items(), key=lambda kv: kv[1].score) for name, res in ranked: mark = "*" if name == getattr(result, 'best_algorithm', None) else " " lines.append( f" {mark} {name:<8} score={res.score:<12.6g} " f"elapsed={res.elapsed:.2f}s n_iter={res.n_iter}" ) # Quality metrics — full table format (same as terminal) try: import io import sys buf = io.StringIO() old_stdout = sys.stdout sys.stdout = buf result.quality_report(mc_samples=200, verbose=True) sys.stdout = old_stdout table_str = buf.getvalue() # Strip ANSI colour codes for text-based exports import re table_str = re.sub(r'\033\[[0-9;]*m', '', table_str) if table_str.strip(): lines += ["", table_str.rstrip()] except Exception: pass return "\n".join(lines) # ====================================================================== # Export functions # ======================================================================
[docs] def export_csv(result, filename: str = 'design.csv') -> None: """Save all points to CSV (single table, point_type column).""" d = _outdir(result) os.makedirs(d, exist_ok=True) p = os.path.join(d, filename) df = _full_df(result) df.to_csv(p, index=True, float_format='%.10g') print(f" Saved: {p} ({len(df)} rows)")
[docs] def export_json(result, filename: str = 'design.json') -> None: """Save all points to JSON (single table).""" d = _outdir(result) os.makedirs(d, exist_ok=True) p = os.path.join(d, filename) df = _full_df(result) df.to_json(p, orient='records', indent=2, double_precision=10) print(f" Saved: {p} ({len(df)} rows)")
[docs] def export_excel(result, filename: str = 'design.xlsx') -> None: """ Save to Excel — single sheet with all points and point_type column. Columns are auto-sized and header row is formatted. """ try: import openpyxl # noqa: F401 (availability check) except ImportError: raise ImportError( "openpyxl is required for Excel export. " "Install it with: pip install openpyxl" ) d = _outdir(result) os.makedirs(d, exist_ok=True) p = os.path.join(d, filename) df = _full_df(result) with pd.ExcelWriter(p, engine='openpyxl') as writer: df.to_excel(writer, sheet_name='Design', index=True, float_format='%.10g') ws = writer.sheets['Design'] # Auto-size columns for col in ws.columns: max_len = max(len(str(cell.value or '')) for cell in col) ws.column_dimensions[col[0].column_letter].width = min(max_len + 2, 30) print(f" Saved: {p} ({len(df)} rows)")
[docs] def export_markdown(result, filename: str = 'report.md') -> None: """ Save a full Markdown report: banner → metadata → parameter space → design summary → run settings → quality metrics → design tables. """ import mergen import re import io import sys as _sys d = _outdir(result) os.makedirs(d, exist_ok=True) p = os.path.join(d, filename) space = result.space names = space.names meta = result._meta with open(p, 'w', encoding='utf-8') as f: # ── Banner ──────────────────────────────────────────────────── f.write("# Mergen Design Report\n\n") banner_lines = mergen._banner().split('\n') for bl in banner_lines: f.write(f"> {bl} \n") f.write(f"> Generated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} \n") f.write("\n---\n\n") # ── Parameter Space ─────────────────────────────────────────── f.write("## Parameter Space\n\n") f.write("| Parameter | Type | Levels | Range |\n") f.write("|-----------|------|-------:|-------|\n") for name, vals in zip(space.names, space.values): ptype = space._param_types.get(name, 'discrete') f.write(f"| {name} | {ptype} | {len(vals)} | [{float(vals.min()):.4g}, {float(vals.max()):.4g}] |\n") f.write(f"| **Feasible candidates** | | {space.n_candidates} | |\n") f.write("\n---\n\n") # ── Design Summary ──────────────────────────────────────────── f.write("## Design Summary\n\n") vc = result.samples["point_type"].value_counts() for lbl in ("Prescribed", "Focus", "Optimised"): n = vc.get(lbl, 0) if n: f.write(f"- **{lbl}**: {n}\n") f.write(f"- **Total design**: {len(result.samples)}\n") if len(result.validation): f.write(f"- **Validation**: {len(result.validation)}\n") for sname, sdf in getattr(result, 'sets', {}).items(): f.write(f"- **{sname}**: {len(sdf)}\n") f.write("\n") # ── Run Settings ────────────────────────────────────────────── f.write("## Run Settings\n\n") from .criteria import criterion_latex as _clx _crit = meta.get('criteria', '?') f.write(f"- **Criterion**: {_clx(_crit) if isinstance(_crit, str) else _crit}\n") f.write(f"- **Restarts**: {meta.get('n_restarts', '?')}\n") f.write(f"- **Seed**: {meta.get('seed', '?')}\n") f.write("\n---\n\n") # ── Quality Metrics — same as terminal ──────────────────────── f.write("## Quality Metrics\n\n") try: buf = io.StringIO() old_stdout = _sys.stdout _sys.stdout = buf result.quality_report(mc_samples=200, verbose=True) _sys.stdout = old_stdout table_str = buf.getvalue() # Strip ANSI colour codes table_str = re.sub(r'\033\[[0-9;]*m', '', table_str) # Strip [METRICS] progress lines lines_clean = [l for l in table_str.split('\n') if '[METRICS]' not in l] table_str = '\n'.join(lines_clean).strip() if table_str: f.write("```\n" + table_str + "\n```\n\n") except Exception: pass f.write("---\n\n") # ── Design Points ───────────────────────────────────────────── f.write("## Design Points\n\n") for lbl in ("Prescribed", "Focus", "Optimised"): sub = result.samples[result.samples["point_type"] == lbl] if sub.empty: continue f.write(f"### {lbl} ({len(sub)} points)\n\n") try: f.write(sub[names].to_markdown(index=True, floatfmt=".10g") + "\n\n") except Exception: f.write(sub[names].to_string(index=True, float_format=lambda x: f"{x:.10g}") + "\n\n") # ── Validation ──────────────────────────────────────────────── if len(result.validation): f.write(f"## Validation Set ({len(result.validation)} points)\n\n") try: f.write(result.validation[names].to_markdown(index=True, floatfmt='.10g') + "\n\n") except Exception: f.write(result.validation[names].to_string(index=True, float_format=lambda x: f"{x:.10g}") + "\n\n") # ── Extra sets ──────────────────────────────────────────────── for sname, sdf in getattr(result, 'sets', {}).items(): if len(sdf): f.write(f"## {sname} ({len(sdf)} points)\n\n") try: f.write(sdf[names].to_markdown(index=True, floatfmt='.10g') + "\n\n") except Exception: f.write(sdf[names].to_string(index=True, float_format=lambda x: f"{x:.10g}") + "\n\n") print(f" Saved: {p}")
[docs] def export_latex(result, filename: str = 'report.tex') -> None: """ Save a LaTeX report: banner → parameter space → design summary → run settings → quality metrics → design tables per point type. """ import mergen import re import io import sys as _sys d = _outdir(result) os.makedirs(d, exist_ok=True) p = os.path.join(d, filename) space = result.space names = space.names meta = result._meta with open(p, 'w', encoding='utf-8') as f: # Banner as comment for line in mergen._banner().split('\n'): f.write(f"% {line}\n") f.write(f"% Generated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n") f.write("% Requires \\usepackage{booktabs} in the document preamble.\n\n") f.write("\\section*{Mergen Design Report}\n\n") # Parameter Space f.write("\\subsection*{Parameter Space}\n\n") f.write("\\begin{tabular}{llrl}\n") f.write("\\toprule\nParameter & Type & Levels & Range \\\\\n\\midrule\n") for name, vals in zip(space.names, space.values): ptype = space._param_types.get(name, 'discrete') f.write(f"{name} & {ptype} & {len(vals)} & " f"[{float(vals.min()):.4g}, {float(vals.max()):.4g}] \\\\\n") f.write("\\midrule\n") f.write(f"Feasible candidates & & {space.n_candidates} & \\\\\n") f.write("\\bottomrule\n\\end{tabular}\n\n") # Design Summary f.write("\\subsection*{Design Summary}\n\n") vc = result.samples["point_type"].value_counts() f.write("\\begin{tabular}{lr}\n\\toprule\n") for lbl in ("Prescribed", "Focus", "Optimised"): n = vc.get(lbl, 0) if n: f.write(f"{lbl} & {n} \\\\\n") f.write(f"Total design & {len(result.samples)} \\\\\n") if len(result.validation): f.write(f"Validation & {len(result.validation)} \\\\\n") f.write("\\bottomrule\n\\end{tabular}\n\n") # Run Settings f.write("\\subsection*{Run Settings}\n\n") f.write("\\begin{tabular}{ll}\n\\toprule\n") from .criteria import criterion_latex as _clx _crit = meta.get('criteria', '?') f.write(f"Criterion & {_clx(_crit) if isinstance(_crit, str) else _crit} \\\\\n") f.write(f"Restarts & {meta.get('n_restarts', '?')} \\\\\n") f.write(f"Seed & {meta.get('seed', '?')} \\\\\n") f.write("\\bottomrule\n\\end{tabular}\n\n") # Quality Metrics f.write("\\subsection*{Quality Metrics}\n\n") try: buf = io.StringIO() old_stdout = _sys.stdout _sys.stdout = buf result.quality_report(mc_samples=200, verbose=True) _sys.stdout = old_stdout table_str = buf.getvalue() table_str = re.sub(r'\033\[[0-9;]*m', '', table_str) lines_clean = [l for l in table_str.split('\n') if '[METRICS]' not in l] f.write("\\begin{verbatim}\n") f.write('\n'.join(lines_clean).strip()) f.write("\n\\end{verbatim}\n\n") except Exception: pass # Design points per type for lbl in ("Prescribed", "Focus", "Optimised"): sub = result.samples[result.samples["point_type"] == lbl] if sub.empty: continue f.write(f"\\subsection*{{{lbl} ({len(sub)} points)}}\n\n") f.write(sub[names].to_latex(index=True, float_format='%.10g') + "\n") # Validation if len(result.validation): f.write(f"\\subsection*{{Validation Set ({len(result.validation)} points)}}\n\n") f.write(result.validation[names].to_latex(index=True, float_format='%.10g') + "\n") # Extra sets for sname, sdf in getattr(result, 'sets', {}).items(): if len(sdf): f.write(f"\\subsection*{{{sname} ({len(sdf)} points)}}\n\n") f.write(sdf[names].to_latex(index=True, float_format='%.10g') + "\n") print(f" Saved: {p}")
[docs] def export_html(result, filename: str = 'report.html') -> None: """ Save a self-contained HTML report with styled tables. """ import mergen import re import io import sys as _sys d = _outdir(result) os.makedirs(d, exist_ok=True) p = os.path.join(d, filename) space = result.space names = space.names meta = result._meta css = """ <style> :root { --ink: #1f2933; --muted: #6b7280; --rule: #e5e7eb; --accent: #3a86ff; --zebra: #fafbfc; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; max-width: 880px; margin: 2.5rem auto; padding: 0 1.25rem; color: var(--ink); line-height: 1.65; font-size: 15px; } h1 { font-weight: 600; font-size: 1.7rem; letter-spacing: -0.01em; margin-bottom: 0.2rem; padding-bottom: 0.4rem; border-bottom: 2px solid var(--accent); } h2 { font-weight: 600; font-size: 1.2rem; margin-top: 2.2rem; color: var(--ink); } h3 { font-weight: 600; font-size: 1.0rem; color: var(--muted); } blockquote { border-left: 3px solid var(--accent); margin: 1rem 0; padding: 0.4rem 1rem; color: var(--muted); font-size: 0.9rem; } pre { background: #f6f8fa; padding: 1rem; border-radius: 6px; font-size: 0.82rem; line-height: 1.5; overflow-x: auto; border: 1px solid var(--rule); } table { border-collapse: collapse; width: 100%; margin: 1.2rem 0; font-size: 0.9rem; } th { background: #f3f4f6; color: var(--ink); font-weight: 600; text-align: left; padding: 0.55rem 0.8rem; border-bottom: 2px solid var(--rule); } td { padding: 0.45rem 0.8rem; border-bottom: 1px solid var(--rule); } td:not(:first-child), th:not(:first-child) { text-align: right; font-variant-numeric: tabular-nums; } tbody tr:nth-child(even) td { background: var(--zebra); } tbody tr:hover td { background: #eef4ff; } hr { border: none; border-top: 1px solid var(--rule); margin: 2rem 0; } @media print { body { max-width: none; margin: 0; font-size: 11pt; color: #000; } h1 { border-color: #000; } thead th { background: #eee; } tbody tr:hover td { background: transparent; } } </style> """ def _df_to_html(df): return df.to_html(index=True, border=0, classes='', float_format='%.10g') def _get_metrics_text(): try: buf = io.StringIO() old_stdout = _sys.stdout _sys.stdout = buf result.quality_report(mc_samples=200, verbose=True) _sys.stdout = old_stdout table_str = buf.getvalue() table_str = re.sub(r'\033\[[0-9;]*m', '', table_str) lines_clean = [l for l in table_str.split('\n') if '[METRICS]' not in l] return '\n'.join(lines_clean).strip() except Exception: return '' with open(p, 'w', encoding='utf-8') as f: mathjax = ( "<script>window.MathJax={tex:{inlineMath:[['$','$']]}};</script>\n" "<script async " "src='https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js'>" "</script>") f.write(f"<!DOCTYPE html>\n<html lang='en'>\n<head>\n" f"<meta charset='UTF-8'>\n" f"<meta name='viewport' content='width=device-width, initial-scale=1'>\n" f"<title>Mergen Design Report</title>\n{css}\n{mathjax}\n" f"</head>\n<body>\n") # Banner f.write("<h1>Mergen Design Report</h1>\n") banner_lines = mergen._banner().split('\n') f.write("<blockquote>\n") for bl in banner_lines: f.write(f" {bl}<br>\n") f.write(f" Generated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") f.write("</blockquote>\n<hr>\n") # Parameter space f.write("<h2>Parameter Space</h2>\n") f.write("<table><tr><th>Parameter</th><th>Type</th><th>Levels</th><th>Range</th></tr>\n") for name, vals in zip(space.names, space.values): ptype = space._param_types.get(name, 'discrete') f.write(f"<tr><td>{name}</td><td>{ptype}</td><td>{len(vals)}</td>" f"<td>[{float(vals.min()):.4g}, {float(vals.max()):.4g}]</td></tr>\n") f.write(f"<tr><td><strong>Feasible candidates</strong></td><td></td>" f"<td>{space.n_candidates}</td><td></td></tr>\n") f.write("</table>\n<hr>\n") # Design summary f.write("<h2>Design Summary</h2>\n<ul>\n") vc = result.samples["point_type"].value_counts() for lbl in ("Prescribed", "Focus", "Optimised"): n = vc.get(lbl, 0) if n: f.write(f" <li><strong>{lbl}</strong>: {n}</li>\n") f.write(f" <li><strong>Total design</strong>: {len(result.samples)}</li>\n") if len(result.validation): f.write(f" <li><strong>Validation</strong>: {len(result.validation)}</li>\n") for sname, sdf in getattr(result, 'sets', {}).items(): f.write(f" <li><strong>{sname}</strong>: {len(sdf)}</li>\n") f.write("</ul>\n") # Run settings f.write("<h2>Run Settings</h2>\n<ul>\n") from .criteria import criterion_latex as _clx _crit = meta.get('criteria', '?') f.write(f" <li><strong>Criterion</strong>: {_clx(_crit) if isinstance(_crit, str) else _crit}</li>\n") f.write(f" <li><strong>Restarts</strong>: {meta.get('n_restarts', '?')}</li>\n") f.write(f" <li><strong>Seed</strong>: {meta.get('seed', '?')}</li>\n") f.write("</ul>\n<hr>\n") # Quality metrics f.write("<h2>Quality Metrics</h2>\n") metrics_text = _get_metrics_text() if metrics_text: f.write(f"<pre>{metrics_text}</pre>\n") f.write("<hr>\n") # Design points f.write("<h2>Design Points</h2>\n") for lbl in ("Prescribed", "Focus", "Optimised"): sub = result.samples[result.samples["point_type"] == lbl] if sub.empty: continue f.write(f"<h3>{lbl} ({len(sub)} points)</h3>\n") f.write(_df_to_html(sub[names]) + "\n") if len(result.validation): f.write(f"<h2>Validation Set ({len(result.validation)} points)</h2>\n") f.write(_df_to_html(result.validation[names]) + "\n") for sname, sdf in getattr(result, 'sets', {}).items(): if len(sdf): f.write(f"<h2>{sname} ({len(sdf)} points)</h2>\n") f.write(_df_to_html(sdf[names]) + "\n") f.write("</body>\n</html>\n") print(f" Saved: {p}")
# ── Internal: full DataFrame ─────────────────────────────────────────────── def _full_df(result) -> pd.DataFrame: """All points in a single DataFrame with point_type column.""" frames = [result.samples] if len(result.validation): df = result.validation.copy() frames.append(df) for sdf in getattr(result, 'sets', {}).values(): if len(sdf): frames.append(sdf.copy()) return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()