lessPython 0.2.0__py3-none-any.whl → 0.3.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.
Files changed (48) hide show
  1. lessPy/ANOVA.py +5 -5
  2. lessPy/Chart.py +386 -88
  3. lessPy/Correlation.py +2 -2
  4. lessPy/Logit.py +2 -2
  5. lessPy/Regression.py +6 -4
  6. lessPy/X.py +279 -42
  7. lessPy/XY.py +814 -158
  8. lessPy/__init__.py +12 -2
  9. lessPy/_themes.py +5007 -0
  10. lessPy/bc_items_plotly.py +31 -18
  11. lessPy/bc_plotly.py +23 -7
  12. lessPy/bubble_plotly.py +17 -2
  13. lessPy/corCFA.py +3 -3
  14. lessPy/corProp.py +2 -2
  15. lessPy/corReflect.py +2 -2
  16. lessPy/corReorder.py +2 -2
  17. lessPy/corScree.py +3 -3
  18. lessPy/data/StockPrice.csv +1401 -1386
  19. lessPy/details.py +17 -4
  20. lessPy/dn_plotly.py +65 -45
  21. lessPy/freq_poly_plotly.py +12 -5
  22. lessPy/getColors.py +247 -20
  23. lessPy/hier_plotly.py +36 -9
  24. lessPy/hs_plotly.py +16 -4
  25. lessPy/plotly_utils.py +78 -9
  26. lessPy/plt_mat_plotly.py +74 -2
  27. lessPy/plt_time.py +10 -3
  28. lessPy/prob_norm.py +3 -3
  29. lessPy/prob_tcut.py +3 -3
  30. lessPy/prob_znorm.py +3 -1
  31. lessPy/rcolors.py +461 -0
  32. lessPy/simCImean.py +3 -3
  33. lessPy/simCLT.py +3 -3
  34. lessPy/simFlips.py +3 -3
  35. lessPy/simMeans.py +3 -3
  36. lessPy/stats_out.py +257 -11
  37. lessPy/style.py +160 -0
  38. lessPy/sunflower_plotly.py +140 -0
  39. lessPy/ttest.py +5 -5
  40. lessPy/utils.py +43 -6
  41. lessPy/vbs_plotly.py +16 -5
  42. lessPy/x_console.py +763 -0
  43. {lesspython-0.2.0.dist-info → lesspython-0.3.0.dist-info}/METADATA +6 -2
  44. lesspython-0.3.0.dist-info/RECORD +89 -0
  45. lesspython-0.2.0.dist-info/RECORD +0 -84
  46. {lesspython-0.2.0.dist-info → lesspython-0.3.0.dist-info}/WHEEL +0 -0
  47. {lesspython-0.2.0.dist-info → lesspython-0.3.0.dist-info}/licenses/LICENSE +0 -0
  48. {lesspython-0.2.0.dist-info → lesspython-0.3.0.dist-info}/top_level.txt +0 -0
lessPy/XY.py CHANGED
@@ -63,7 +63,10 @@ from .plt_forecast import plt_forecast
63
63
  from .plt_plotly import plt_plotly
64
64
  from .plt_smooth import plt_smooth
65
65
  from .plt_time import plt_time
66
+ from .sunflower_plotly import sunflower_plotly
66
67
  from .plt_mat_plotly import scatter_matrix
68
+ from . import x_console as xc
69
+ from .stats_out import _stat_by_levels
67
70
  from .stats_out import (
68
71
  facet_summary, md_outliers, resolve_quiet, xy_stats)
69
72
  from .plotly_utils import (
@@ -71,12 +74,13 @@ from .plotly_utils import (
71
74
  facet_fig, facet_panels, finish_facet,
72
75
  get_tick_fmt, legend_style, make_trans, plot_border,
73
76
  plotly_style, square_layout, sym_at, to_hex, x_grid,
74
- font_scaled)
77
+ font_scaled, by_colors)
75
78
  from .utils import (
76
- category_order, get_column, get_option, pretty, resolve_facet,
79
+ STAT_FUN, STAT_LBL, category_order, get_column, get_option,
80
+ pretty, resolve_facet,
77
81
  )
78
82
 
79
- _FORMS = ("scatter", "smooth", "contour")
83
+ _FORMS = ("scatter", "smooth", "contour", "sunflower")
80
84
  _FITS = ("off", "loess", "lm", "ls", "null", "exp", "quad",
81
85
  "power", "log")
82
86
 
@@ -191,41 +195,219 @@ def _fit_new_table(groups, fit, fit_power, x_new, x_name, y_name,
191
195
  return lines
192
196
 
193
197
 
194
- def _loess(xv, yv, span):
195
- """R-style loess at the sorted x values: local quadratic
196
- regression with tri-cube weights, gaussian family, computed
197
- exactly at each x (R surface="direct"). Also the SE of fit
198
- from the equivalent-kernel rows, as predict(l.ln, se=TRUE).
199
- R analog: .plt.fit() loess(y.lv ~ x.lv, span=span)"""
198
+ def _loess_rows(xs, q_pt, span, degree, rw):
199
+ """The local fit at q_pt as linear operators on y: rows giving
200
+ the fitted value and the slope there. Tri-cube weights on the
201
+ floor(n*span) nearest points (times the robustness weights),
202
+ polynomial of the given degree centered at q_pt.
203
+ R analog: ehg127() of the loess Fortran"""
204
+ n = len(xs)
205
+ q = min(n, max(int(span * n + 1e-5), degree + 1))
206
+ d = np.abs(xs - q_pt)
207
+ h = np.partition(d, q - 1)[q - 1]
208
+ if span > 1: # window widens past data,
209
+ h *= span ** 0.5 # verified against R loess
210
+ if h > 0:
211
+ w = np.clip(1 - (d / h) ** 3, 0, 1) ** 3
212
+ else: # window is exact x ties
213
+ w = (d == 0).astype(float)
214
+ w = w * rw
215
+ nz = np.flatnonzero(w)
216
+ xc = xs[nz] - q_pt
217
+ X = np.column_stack([xc ** k for k in range(degree + 1)])
218
+ XtW = X.T * w[nz]
219
+ B = np.linalg.pinv(XtW @ X) @ XtW
220
+ val = np.zeros(n)
221
+ slope = np.zeros(n)
222
+ val[nz] = B[0]
223
+ slope[nz] = B[1]
224
+ return val, slope
225
+
226
+
227
+ def _loess_kd(xs, span, cell=0.2):
228
+ """Leaf cells of R's kd tree on one sorted x: the bounding box
229
+ widened by 0.5% each side, a cell split at its median x (moved
230
+ off ties) while it holds more than floor(n*span*cell) points.
231
+ Returns (lower, upper, split) per leaf, the split for the walk
232
+ down the tree. R analog: ehg126(), ehg124()"""
233
+ n = len(xs)
234
+ lo, hi = float(xs[0]), float(xs[-1])
235
+ mu = 0.005 * max(hi - lo, 1e-10 * max(abs(lo), abs(hi)) + 1e-30)
236
+ fc = int(np.floor(n * span * cell))
237
+ nodes = [] # (l, u, vlo, vhi), 1-based
238
+
239
+ def build(l, u, vlo, vhi):
240
+ leaf = (u - l) + 1 <= fc
241
+ if not leaf:
242
+ m = (l + u) // 2
243
+ off = 0
244
+ while l <= m + off < u:
245
+ if xs[m + off - 1] == xs[m + off]:
246
+ off = -off
247
+ if off >= 0:
248
+ off += 1
249
+ else:
250
+ m += off
251
+ break
252
+ v = float(xs[m - 1])
253
+ leaf = v == vlo or v == vhi
254
+ if leaf:
255
+ nodes.append((vlo, vhi))
256
+ return
257
+ build(l, m, vlo, v)
258
+ build(m + 1, u, v, vhi)
259
+
260
+ build(1, n, lo - mu, hi + mu)
261
+ return nodes
262
+
263
+
264
+ def _loess(xv, yv, span, degree=2, family="gaussian"):
265
+ """R's default loess at the sorted x values: local fits of the
266
+ given degree at the vertices of a kd tree, each giving a value
267
+ and a slope, joined by cubic Hermite interpolation within each
268
+ cell (surface="interpolate"). family="symmetric" reweights by
269
+ the bisquare of the residuals over 4 iterations. Also the SE of
270
+ fit from the rows of the resulting smoother, as predict(l.ln,
271
+ se=TRUE). span may be given as (span, degree, family).
272
+ R analog: .plt.fit() loess(y.lv ~ x.lv, span, degree, family)"""
273
+ if isinstance(span, tuple):
274
+ span, degree, family = span
200
275
  od = np.argsort(xv, kind="stable")
201
- xs, ys = xv[od], yv[od]
276
+ xs, ys = np.asarray(xv, float)[od], np.asarray(yv, float)[od]
202
277
  n = len(xs)
203
- q = min(n, max(int(span * n), 3)) # points in the window
204
- f = np.empty(n)
205
- l2 = np.empty(n) # ||l_i||^2, for se.fit
206
- trL = 0.0 # trace of the smoother L
207
- for i in range(n):
208
- d = np.abs(xs - xs[i])
209
- h = np.partition(d, q - 1)[q - 1]
210
- if span > 1: # window widens past data,
211
- h *= span ** 0.5 # verified against R loess
212
- if h > 0:
213
- w = np.clip(1 - (d / h) ** 3, 0, 1) ** 3
214
- else: # window is exact x ties
215
- w = (d == 0).astype(float)
216
- nz = np.flatnonzero(w)
217
- xc = xs[nz] - xs[i]
218
- X = np.column_stack((np.ones(len(nz)), xc, xc * xc))
219
- XtW = X.T * w[nz]
220
- li = (np.linalg.pinv(XtW @ X) @ XtW)[0] # equiv. kernel
221
- f[i] = li @ ys[nz]
222
- l2[i] = li @ li
223
- trL += li[np.searchsorted(nz, i)]
224
- res = ys - f
225
- dof = n - 2 * trL + l2.sum() # tr[(I-L)'(I-L)]
278
+ cells = _loess_kd(xs, span)
279
+ verts = sorted({v for c in cells for v in c})
280
+ rw = np.ones(n)
281
+ n_iter = 4 if family == "symmetric" else 1
282
+ for it in range(n_iter):
283
+ rows = {v: _loess_rows(xs, v, span, degree, rw) for v in verts}
284
+ L = np.empty((n, n))
285
+ for i, z in enumerate(xs):
286
+ for v0, v1 in cells: # x on a split goes left
287
+ if z <= v1:
288
+ break
289
+ hh = v1 - v0
290
+ u = (z - v0) / hh
291
+ g0, s0 = rows[v0]
292
+ g1, s1 = rows[v1]
293
+ L[i] = ((1 - u) ** 2 * (1 + 2 * u) * g0
294
+ + u * u * (3 - 2 * u) * g1
295
+ + (u * (1 - u) ** 2 * s0 - u * u * (1 - u) * s1) * hh)
296
+ f = L @ ys
297
+ res = ys - f
298
+ if it < n_iter - 1: # R lowesw(): bisquare weights
299
+ cmad = 6 * np.median(np.abs(res))
300
+ if cmad < np.finfo(float).tiny:
301
+ rw = np.ones(n)
302
+ else:
303
+ r = np.abs(res)
304
+ rw = np.where(r > 0.999 * cmad, 0.0,
305
+ np.where(r > 0.001 * cmad,
306
+ (1 - (r / cmad) ** 2) ** 2, 1.0))
307
+ l2 = (L ** 2).sum(axis=1)
308
+ dof = n - 2 * np.trace(L) + l2.sum() # tr[(I-L)'(I-L)]
226
309
  return xs, ys, f, np.sqrt(res @ res / dof * l2)
227
310
 
228
311
 
312
+ _QQ_LAB = {".normal": "Normal Quantiles",
313
+ ".lognormal": "Lognormal Quantiles",
314
+ ".exponential": "Exponential Quantiles",
315
+ ".uniform": "Uniform Quantiles"}
316
+
317
+
318
+ def _qq_quantiles(y, key):
319
+ """Theoretical quantiles, one per value of y in the row order of
320
+ y (NaN where y is missing): the i-th smallest y pairs with the
321
+ i-th smallest quantile of the distribution fit by y's moments.
322
+ R analog: .qq.quantiles() (qq.R)"""
323
+ y = np.asarray(y, dtype=float)
324
+ ok = ~np.isnan(y)
325
+ n = int(ok.sum())
326
+ if n < 3:
327
+ raise ValueError(
328
+ "A q-q chart requires at least 3 non-missing values of y, "
329
+ f"but only {n} found.")
330
+ yv = y[ok]
331
+ m, sd = yv.mean(), yv.std(ddof=1)
332
+ if sd == 0:
333
+ raise ValueError(
334
+ "All values of y are the same, so there is no "
335
+ "distribution\nto compare against a theoretical "
336
+ "distribution.")
337
+ if key == ".lognormal" and (yv <= 0).any():
338
+ raise ValueError(
339
+ "A lognormal distribution is defined only for positive "
340
+ f"values,\nbut y has {(yv <= 0).sum()} value(s) of zero or "
341
+ "less.\n\nFor data that include zero, consider .exponential")
342
+ if key == ".exponential" and (yv < 0).any():
343
+ raise ValueError(
344
+ "An exponential distribution is defined only for "
345
+ f"non-negative\nvalues, but y has {(yv < 0).sum()} "
346
+ "negative value(s).")
347
+ a = 3 / 8 if n <= 10 else 0.5 # R ppoints()
348
+ p = (np.arange(1, n + 1) - a) / (n + 1 - 2 * a)
349
+ if key == ".normal":
350
+ q = sps.norm.ppf(p, m, sd)
351
+ elif key == ".lognormal":
352
+ lg = np.log(yv)
353
+ q = np.exp(sps.norm.ppf(p, lg.mean(), lg.std(ddof=1)))
354
+ elif key == ".exponential":
355
+ q = -m * np.log1p(-p)
356
+ else:
357
+ q = m - np.sqrt(3) * sd + p * 2 * np.sqrt(3) * sd
358
+ out = np.full(len(y), np.nan)
359
+ rk = np.empty(n, dtype=int) # rank, ties.method="first"
360
+ rk[np.argsort(yv, kind="stable")] = np.arange(n)
361
+ out[ok] = q[rk]
362
+ return out
363
+
364
+
365
+ def _qq_reference(fig, fit_color, fit_lwd):
366
+ """The 45-degree reference of a q-q chart in every panel, from
367
+ the range of that panel's plotted values, drawn beneath the
368
+ points. R analog: plt.plotly.R qq reference"""
369
+ panels = {}
370
+ for tr in fig.data:
371
+ if tr.type != "scatter" or tr.x is None or tr.y is None:
372
+ continue
373
+ key = (tr.xaxis or "x", tr.yaxis or "y")
374
+ v = np.r_[np.asarray(tr.x, float), np.asarray(tr.y, float)]
375
+ v = v[np.isfinite(v)]
376
+ if len(v):
377
+ lo, hi = panels.get(key, (np.inf, -np.inf))
378
+ panels[key] = (min(lo, v.min()), max(hi, v.max()))
379
+ refs = [go.Scatter(x=[lo, hi], y=[lo, hi], mode="lines",
380
+ xaxis=xa, yaxis=ya,
381
+ line=dict(color=to_hex(get_option(
382
+ "fit_color", "#5C4032")
383
+ if fit_color is None else fit_color),
384
+ width=1.5 if fit_lwd is None else fit_lwd),
385
+ showlegend=False, hoverinfo="skip")
386
+ for (xa, ya), (lo, hi) in panels.items()]
387
+ k = len(refs)
388
+ fig.add_traces(refs)
389
+ fig.data = fig.data[-k:] + fig.data[:-k] if k else fig.data
390
+ return fig
391
+
392
+
393
+ def _ts_ylab(y_name, unit, aggregated, ts_agg):
394
+ """Default value-axis label of a time series: the aggregation
395
+ and the time unit, as "Total Sales by Year"; a vector of y
396
+ (y_name None) is named by its legend, so "Total by Year", and
397
+ with no aggregation to report either, no label. R analog: the
398
+ y.lab of .plt.main() for a date x"""
399
+ if unit in (None, "unknown"):
400
+ return y_name or ""
401
+ tu = "days" if unit == "days7" else unit
402
+ tu = tu[:-1] if tu.endswith("s") else tu
403
+ agg = (("Total" if ts_agg == "sum" else "Mean")
404
+ if aggregated else "")
405
+ if y_name is None and not agg:
406
+ return ""
407
+ return " ".join(p for p in (agg, y_name, "by", tu.capitalize())
408
+ if p)
409
+
410
+
229
411
  def _se_band(xs, ys, f, level):
230
412
  """SE band polygon about a least-squares line: fit +/- t * SE
231
413
  of the conditional mean. R analog: plt.main.R se bands, from
@@ -683,8 +865,7 @@ def _xy_series(x, y, data, x_multi, by, facet, form, n_row,
683
865
  elif fill is not None:
684
866
  fills = [fill] * k
685
867
  else:
686
- fills = [BASE_COLORS[i % len(BASE_COLORS)]
687
- for i in range(k)]
868
+ fills = by_colors(k) # theme's palette, as R
688
869
  if transparency is None:
689
870
  transparency = get_option("trans_pt_fill", 0.10)
690
871
  if ellipse is True:
@@ -736,13 +917,19 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
736
917
  pt_size, pt_opacity, x_lab, y_lab, main, digits_d,
737
918
  area_fill=None, area_split=0,
738
919
  facet_name=None, facet2_arr=None,
739
- facet2_order=None, facet2_name=None, n_col=1):
920
+ facet2_order=None, facet2_name=None, n_col=1,
921
+ by_arr=None, by_order=None, by_name=None,
922
+ line_width=1.5, frcsts=None, ts_PI=0.95):
740
923
  """One time-series panel per facet level on shared axes,
741
924
  following the faceted-scatter conventions (_xy_facet):
742
925
  first level on the top panel, strip labels. The x axis
743
926
  uses plotly's native date ticks, as the single-panel time
744
927
  series does. R analog: the lattice cont_cont path for a
745
- date x. facet2: the two-facet grid, rows = facet2 levels."""
928
+ date x. facet2: the two-facet grid, rows = facet2 levels.
929
+ by: several series per panel in the legend's colors, unfilled
930
+ unless area_fill is asked for. frcsts: a forecast per panel
931
+ (panel index -> plt_forecast result), every panel on one scale
932
+ that holds the prediction intervals."""
746
933
  labels, pos, sel, n_row_g, n_col = facet_panels(
747
934
  facet_arr, facet_order, facet2_arr, facet2_order,
748
935
  facet_name, facet2_name, n_col)
@@ -752,7 +939,12 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
752
939
  px = 5
753
940
  mode_pts = "lines+markers" if px > 0 else "lines"
754
941
 
755
- axT2 = pretty(float(np.nanmin(yv)), float(np.nanmax(yv)))
942
+ y_all = [np.asarray(yv, float)]
943
+ for fr in (frcsts or {}).values():
944
+ y_all += [np.asarray(fr["y_lwr"], float),
945
+ np.asarray(fr["y_upr"], float)]
946
+ y_all = np.concatenate(y_all)
947
+ axT2 = pretty(float(np.nanmin(y_all)), float(np.nanmax(y_all)))
756
948
  fmt2 = get_tick_fmt(axT2, digits_d)
757
949
  ax = {"axT1": None, "axL1": None,
758
950
  "axT2": axT2,
@@ -768,34 +960,58 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
768
960
  rng_pad = 0.04 * (axT2[-1] - axT2[0]) # panel-clipped
769
961
  base = min(max(float(area_split), axT2[0] - rng_pad),
770
962
  axT2[-1] + rng_pad)
963
+ if by_arr is None:
964
+ series = [(None, np.ones(len(xv), dtype=bool), fill0,
965
+ border0)]
966
+ else:
967
+ by_s = np.asarray(by_arr).astype(str)
968
+ cols = by_colors(len(by_order))
969
+ series = [(str(lv), by_s == str(lv), cols[g], cols[g])
970
+ for g, lv in enumerate(by_order)]
771
971
  for i in range(n_f):
772
- m = sel[i]
773
972
  row, col = pos[i]
774
- if area_fill is not None and m.any():
973
+ for nm, bm, f_c, b_c in series:
974
+ m = sel[i] & bm
975
+ if not m.any():
976
+ continue
977
+ od = np.argsort(xv[m], kind="stable")
978
+ xm, ym = xv[m][od], yv[m][od]
979
+ if area_fill is not None:
980
+ fig.add_trace(go.Scatter(
981
+ x=np.concatenate([xm, xm[-1:], xm[:1]]),
982
+ y=np.concatenate([ym, [base, base]]),
983
+ mode="none", fill="toself",
984
+ fillcolor=area_fill, hoverinfo="skip",
985
+ showlegend=False,
986
+ ), row=row, col=col)
987
+ hv = hover if nm is None else (
988
+ f"{by_name}: {nm}<br>" + hover)
775
989
  fig.add_trace(go.Scatter(
776
- x=np.concatenate([xv[m], xv[m][-1:],
777
- xv[m][:1]]),
778
- y=np.concatenate([yv[m], [base, base]]),
779
- mode="none", fill="toself",
780
- fillcolor=area_fill, hoverinfo="skip",
781
- showlegend=False,
782
- ), row=row, col=col)
783
- fig.add_trace(go.Scatter(
784
- x=xv[m], y=yv[m], mode=mode_pts,
785
- marker=(dict(symbol="circle", size=px,
786
- sizemode="diameter",
787
- color=make_trans(fill0, pt_opacity),
788
- opacity=1,
789
- line=dict(color=to_hex(border0),
790
- width=1))
791
- if "markers" in mode_pts else None),
792
- line=dict(color=to_hex(border0), width=1.5),
793
- hovertemplate=hover, showlegend=False,
794
- ), row=row, col=col) # first level top-left
990
+ x=xm, y=ym, mode=mode_pts if line_width > 0
991
+ else "markers",
992
+ marker=(dict(symbol="circle", size=px,
993
+ sizemode="diameter",
994
+ color=make_trans(f_c, pt_opacity),
995
+ opacity=1,
996
+ line=dict(color=to_hex(b_c), width=1))
997
+ if "markers" in mode_pts or line_width <= 0
998
+ else None),
999
+ line=dict(color=to_hex(b_c), width=line_width),
1000
+ name=nm, legendgroup=nm,
1001
+ hovertemplate=hv,
1002
+ showlegend=nm is not None and i == 0,
1003
+ ), row=row, col=col) # first level top-left
1004
+ if frcsts and i in frcsts:
1005
+ _forecast_traces(fig, frcsts[i], ts_PI, row=row, col=col,
1006
+ legend=i == min(frcsts))
795
1007
 
796
1008
  finish_facet(fig, labels, ax, x_lab, y_lab,
797
1009
  gridT1=None, style_opts=style_opts,
798
1010
  n_col=n_col, pos=pos)
1011
+ if by_arr is not None or frcsts:
1012
+ fig.update_layout(showlegend=True,
1013
+ legend=legend_style(by_name, style_opts)
1014
+ if by_arr is not None else None)
799
1015
  # ts panels: y spans the data; native date grid on x
800
1016
  pad = 0.04 * (axT2[-1] - axT2[0])
801
1017
  fig.update_yaxes(range=[axT2[0] - pad, axT2[-1] + pad])
@@ -880,11 +1096,14 @@ def _run_analysis(yv, digits_d, show_detail):
880
1096
  lines.append(f"\nTotal number of runs: {n_runs}")
881
1097
  lines.append("Total number of values that do not equal the "
882
1098
  f"median: {n - len(eq)}")
883
- if eq:
1099
+ if eq: # listed only with show_runs
884
1100
  if show_detail:
885
1101
  lines.append("\nValues ignored that equal the median")
886
1102
  lines += [f" #{j} {y[j - 1]:.{dd}f}" for j in eq]
887
- lines.append(f"Total number of values ignored: {len(eq)}")
1103
+ lines.append(f"Total number of values ignored: {len(eq)}")
1104
+ else:
1105
+ lines.append("Total number of values ignored that equal the "
1106
+ "median: 0")
888
1107
  return m, lines
889
1108
 
890
1109
 
@@ -914,7 +1133,8 @@ def _outlier_traces(fig, xv, yv, out_idx, labels, fill0,
914
1133
  family="Arial"))
915
1134
 
916
1135
 
917
- def _forecast_traces(fig, frcst, ts_PI):
1136
+ def _forecast_traces(fig, frcst, ts_PI, row=None, col=None,
1137
+ legend=True):
918
1138
  """Overlay the forecast on the time series display: model-fit
919
1139
  line, PI band with dotted boundaries, forecast line + points.
920
1140
  R analog: plt.plotly.R forecast section (~lines 343-451),
@@ -925,6 +1145,16 @@ def _forecast_traces(fig, frcst, ts_PI):
925
1145
  xf = np.asarray(frcst["x_fit"]) # numpy datetimes: plain
926
1146
  xh = np.asarray(frcst["x_hat"]) # Timestamps break kaleido
927
1147
  yf, yh = frcst["y_fit"], frcst["y_hat"]
1148
+ at = {} if row is None else dict(row=row, col=col)
1149
+ _add = fig.add_trace
1150
+
1151
+ def add(tr, **kw): # one legend entry per role
1152
+ if not legend:
1153
+ tr.showlegend = False
1154
+ tr.legendgroup = tr.name
1155
+ _add(tr, **at)
1156
+
1157
+ fig = _Adder(fig, add)
928
1158
  fig.add_trace(go.Scatter(
929
1159
  x=xf, y=yf, mode="lines",
930
1160
  line={"color": fit_rgba, "width": 1.5},
@@ -953,6 +1183,12 @@ def _forecast_traces(fig, frcst, ts_PI):
953
1183
  name="Forecast", showlegend=True))
954
1184
 
955
1185
 
1186
+ class _Adder:
1187
+ """fig.add_trace routed through a placement function."""
1188
+ def __init__(self, fig, add):
1189
+ self._fig, self.add_trace = fig, add
1190
+
1191
+
956
1192
  def _ts_fitted_lines(frcst, yv, x_name, y_name, ts_unit, digits_d):
957
1193
  """Table of the observed y beside the model-fitted y over the
958
1194
  historical dates, printed when ts_fitted=True. R analog: the
@@ -995,7 +1231,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
995
1231
  ID=None, ID_color="gray50", ID_size=0.6,
996
1232
  fit="off", fit_power=1, fit_se=None, fit_errors=False,
997
1233
  fit_new=None,
998
- fit_color=None, fit_lwd=None, span=0.75,
1234
+ fit_color=None, fit_lwd=None, fit_span_loess=0.75,
1235
+ fit_degree_loess=2, fit_family_loess="gaussian",
999
1236
  ellipse=0, ellipse_fill=None, ellipse_color=None,
1000
1237
  ellipse_lwd=None,
1001
1238
  ts_unit=None, ts_agg="sum", ts_stack=False,
@@ -1010,7 +1247,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1010
1247
  rotate_x=0, rotate_y=0, scale_x=None, scale_y=None,
1011
1248
  xlab=None, ylab=None, main=None, digits_d=None,
1012
1249
  quiet=None,
1013
- plot_errors=None):
1250
+ plot_errors=None, span=None):
1014
1251
  """Analytic view of the relationship between two numerical
1015
1252
  variables, optionally grouped (by=). A date x displays as a
1016
1253
  time series. Variables are strings naming columns of the
@@ -1034,14 +1271,50 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1034
1271
  (contour_n, contour_nbins, contour_points,
1035
1272
  contour_legend)):
1036
1273
  form = "contour"
1274
+ contour_n_user = contour_n # a matrix cell defaults to 8
1037
1275
  contour_n = 20 if contour_n is None else contour_n
1038
1276
  contour_nbins = 50 if contour_nbins is None else contour_nbins
1039
1277
  contour_points = bool(contour_points)
1040
1278
  contour_legend = bool(contour_legend)
1279
+ if pt_shape == "sunflower": # its earlier route, kept
1280
+ form, pt_shape = "sunflower", "circle"
1281
+ # the form is consulted before by is resolved, so a vector of by
1282
+ # gets the sunflower's own reason (XY.R)
1283
+ if form == "sunflower" and by is not None:
1284
+ raise ValueError(
1285
+ 'Parameter by is not available for form="sunflower".\n\n'
1286
+ "A petal count is defined for one set of coordinates, not\n"
1287
+ "separately within each group, so overlaid groups would\n"
1288
+ "interleave their petals at a shared coordinate.\n"
1289
+ "To stratify the same two variables, use facet, which "
1290
+ "draws\neach group in its own panel with its own "
1291
+ "coordinates.")
1292
+ if form == "sunflower" and (isinstance(x, (list, tuple))
1293
+ or isinstance(y, (list, tuple))):
1294
+ raise ValueError(
1295
+ "The petal count is defined for one set of coordinates, "
1296
+ "not\nseparately for each series, so a vector of x or y "
1297
+ 'variables\nis not available for form="sunflower". To '
1298
+ 'overlay the series,\nuse form="scatter".')
1299
+ if isinstance(by, (list, tuple)):
1300
+ raise ValueError(
1301
+ "Only one by variable is permitted, but more than one "
1302
+ f"specified:\n by = {list(by)}\n\n"
1303
+ "The groups of by are overlaid within the panel, "
1304
+ "distinguished by\ncolor and optionally symbol. That one "
1305
+ "channel is carried by the\nfirst variable, so a second "
1306
+ "has no encoding left by which to\nseparate its levels.\n"
1307
+ "To stratify by a second variable, use facet, which draws "
1308
+ "each of\nits groups in a panel of its own.")
1041
1309
  if form != "scatter" and by is not None:
1042
1310
  raise ValueError(
1043
- 'by= is active only for a scatter plot, '
1044
- 'form="scatter"')
1311
+ f'Parameter by is not available for form="{form}".\n\n'
1312
+ f"A {form} plot summarizes the joint density over the "
1313
+ "whole\nplotting region, so summaries superimposed on it "
1314
+ "would obstruct\none another rather than remain "
1315
+ "distinguishable.\nTo stratify the same two variables, "
1316
+ "use facet, which draws\neach group in its own panel with "
1317
+ "its own density.")
1045
1318
  if add is not None and form in ("contour", "smooth"):
1046
1319
  raise ValueError(
1047
1320
  'add is not active for "contour" or "smooth" plots')
@@ -1054,6 +1327,20 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1054
1327
  if center_line not in ("off", "mean", "median", "zero"):
1055
1328
  raise ValueError(
1056
1329
  'center_line: "off", "mean", "median", or "zero"')
1330
+ if span is not None:
1331
+ raise ValueError(
1332
+ "Parameter span has been renamed to fit_span_loess.\n\n"
1333
+ "It sets the span of a loess fit, so the name now says "
1334
+ "which\nfit it belongs to, alongside fit_degree_loess "
1335
+ "and fit_family_loess.")
1336
+ if fit_degree_loess not in (1, 2):
1337
+ raise ValueError("fit_degree_loess: 1 or 2")
1338
+ if fit_family_loess not in ("gaussian", "symmetric"):
1339
+ raise ValueError('fit_family_loess: "gaussian" or "symmetric"')
1340
+ # the loess settings travel together to every fit (R: loess(...,
1341
+ # span, degree, family) in .plt.fit())
1342
+ span = (float(fit_span_loess), int(fit_degree_loess),
1343
+ fit_family_loess)
1057
1344
  if plot_errors is not None:
1058
1345
  raise ValueError(
1059
1346
  "Parameter plot_errors has been renamed to fit_errors. "
@@ -1091,9 +1378,6 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1091
1378
  raise ValueError(
1092
1379
  "outlier flagging (MD_cut/out_cut) applies to "
1093
1380
  'the scatter form, form="scatter"')
1094
- if ts_unit is not None and facet is not None:
1095
- raise ValueError(
1096
- "ts_unit does not yet apply to facet plots")
1097
1381
  if (n_row is not None or n_col is not None) \
1098
1382
  and facet is None:
1099
1383
  raise ValueError("n_row and n_col lay out facet panels: "
@@ -1112,14 +1396,35 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1112
1396
  if ts_ahead > 0:
1113
1397
  if ts_source not in ("fable", "classic"):
1114
1398
  raise ValueError('ts_source: "fable" or "classic"')
1115
- if by is not None:
1116
- raise ValueError(
1117
- "Can only forecast a single time series, "
1118
- "so no by=")
1119
- if facet is not None:
1120
- raise ValueError(
1121
- "Can only forecast a single time series, "
1122
- "so no facet=")
1399
+ if by is not None: # XY.R: the overlay channel
1400
+ raise ValueError( # is not available
1401
+ "A forecast draws its fit, prediction interval, and "
1402
+ "forecast\nin the series' own colors, so the overlay "
1403
+ "of by= groups is not\navailable to it. To forecast "
1404
+ "each group, use facet=, which\nforecasts each panel "
1405
+ "on one scale.")
1406
+
1407
+ # for the report: the caller's own names, the parameters named in
1408
+ # the call (which the suggestions skip), the digits as given
1409
+ data_in = data
1410
+ digits_user = digits_d
1411
+ x_call = x # as written, keyword included
1412
+ given = {nm for nm, v in (
1413
+ ("enhance", enhance or None), ("fill", fill), ("color", color),
1414
+ ("fit", None if fit == "off" else fit),
1415
+ ("out_cut", out_cut or None), ("MD_cut", MD_cut or None),
1416
+ ("pt_shape", None if pt_shape == "circle" else pt_shape),
1417
+ ("pt_size", None if pt_size == 1 else pt_size),
1418
+ ("line_width", None if line_width == 1.5 else line_width),
1419
+ ("ts_ahead", ts_ahead or None), ("ts_unit", ts_unit),
1420
+ ("ts_agg", None if ts_agg == "sum" else ts_agg),
1421
+ ("ts_seasons", ts_seasons), ("ts_area_fill", ts_area_fill),
1422
+ ("ts_stack", ts_stack or None)) if v is not None}
1423
+ show = not resolve_quiet(quiet)
1424
+
1425
+ def say(*comps):
1426
+ if show:
1427
+ print(xc.report(*comps))
1123
1428
 
1124
1429
  if filter is not None:
1125
1430
  data = data.query(filter)
@@ -1150,16 +1455,106 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1150
1455
  return scatter_matrix(
1151
1456
  mat_df, fit=("off" if fit in ("off", "null")
1152
1457
  else fit),
1153
- digits_d=digits_d, main=main)
1458
+ digits_d=digits_d, main=main, form=form,
1459
+ contour_n=contour_n_user,
1460
+ contour_nbins=contour_nbins,
1461
+ contour_points=contour_points, smooth_bins=smooth_bins,
1462
+ smooth_power=smooth_power)
1463
+ # a vector of y on a date x: one series per variable, overlaid
1464
+ # and keyed by name in the legend, drawn as a by= series would
1465
+ # be; the value axis reports only the aggregation and time unit
1466
+ # (XY.R, Aug 2026)
1467
+ if y_multi and not x_multi and isinstance(x, str) \
1468
+ and x in data.columns:
1469
+ xd = data[x]
1470
+ if not pd.api.types.is_datetime64_any_dtype(xd) \
1471
+ and not pd.api.types.is_numeric_dtype(xd):
1472
+ from .date_infer import date_infer as _infer_dates
1473
+ try:
1474
+ xd = _infer_dates(xd)
1475
+ except Exception:
1476
+ pass
1477
+ if pd.api.types.is_datetime64_any_dtype(xd):
1478
+ if by is not None:
1479
+ raise ValueError(
1480
+ "A vector of y variables overlays its variables as "
1481
+ "the series of the\nplot, which spends the color "
1482
+ "dimension that by requires.\nTo display a "
1483
+ "grouping variable, use facet=, which panels the "
1484
+ "series.")
1485
+ fcols = ([facet] if isinstance(facet, str) else
1486
+ list(facet) if isinstance(facet, (list, tuple))
1487
+ else [])
1488
+ long = data[[x] + fcols + list(y)].copy()
1489
+ long[x] = xd
1490
+ long = long.melt(id_vars=[x] + fcols, value_vars=list(y),
1491
+ var_name="Series", value_name="Value")
1492
+ long["Series"] = pd.Categorical(long["Series"],
1493
+ categories=list(y))
1494
+ if ylab is None:
1495
+ from .plt_time import _infer_unit
1496
+ ylab = _ts_ylab(None, ts_unit if ts_unit is not None
1497
+ else _infer_unit(long[x].to_numpy()),
1498
+ ts_unit is not None, ts_agg)
1499
+ fig_l = XY(x, "Value", data=long, by="Series", facet=facet,
1500
+ ts_unit=ts_unit, ts_agg=ts_agg,
1501
+ ts_stack=ts_stack, ts_area_fill=ts_area_fill,
1502
+ ts_area_split=ts_area_split, pt_size=pt_size,
1503
+ line_width=line_width, n_row=n_row, n_col=n_col,
1504
+ fill=fill, xlab=xlab, ylab=ylab, main=main,
1505
+ digits_d=digits_d, quiet=True,
1506
+ rotate_x=rotate_x, rotate_y=rotate_y)
1507
+ fig_l.update_layout(legend_title_text="")
1508
+ pre_l, dn_l = xc.call_names(data_in)
1509
+ say(xc.suggest_series(
1510
+ pre_l, x, list(y), dn_l, given,
1511
+ "".join(f', {k}="{v}"' if isinstance(v, str)
1512
+ else f", {k}={v}"
1513
+ for k, v in (("ts_unit", ts_unit),)
1514
+ if v is not None), pt_size=pt_size))
1515
+ return fig_l
1516
+
1154
1517
  if x_multi or y_multi:
1155
- return _apply_rotate(_xy_series(
1518
+ # a vector against a categorical variable is Chart()'s dot
1519
+ # chart, named rather than drawn (XY.R)
1520
+ one = y if x_multi else x
1521
+ if (isinstance(one, str) and one in data.columns
1522
+ and not pd.api.types.is_numeric_dtype(data[one])
1523
+ and not pd.api.types.is_datetime64_any_dtype(
1524
+ data[one])):
1525
+ vec = x if x_multi else y
1526
+ vec_s = "[" + ", ".join(f"'{v}'" for v in vec) + "]"
1527
+ raise ValueError(
1528
+ "XY() requires a continuous x and a continuous y.\n"
1529
+ f"{one} is categorical.\n\nFor several values at each "
1530
+ f"level of {one}, use Chart()\nwith a vector of y "
1531
+ f"variables:\n Chart('{one}', y={vec_s}, form='dot')")
1532
+ fig_s = _apply_rotate(_xy_series(
1156
1533
  x, y, data, x_multi, by, facet, form, n_row, n_col,
1157
1534
  fill, transparency, pt_shape, pt_size,
1158
1535
  fit, fit_power, fit_se, fit_lwd, span,
1159
1536
  ellipse, ellipse_fill, ellipse_lwd,
1160
1537
  MD_cut, out_cut,
1161
- xlab, ylab, main, digits_d, quiet),
1538
+ xlab, ylab, main, digits_d, True),
1162
1539
  rotate_x, rotate_y)
1540
+ # the report: one correlation per plotted variable, each
1541
+ # headed by its name (R overwrote all but the last)
1542
+ pre, dname = xc.call_names(data_in)
1543
+ names = list(x) if x_multi else list(y)
1544
+ other = data[y if x_multi else x]
1545
+ comps = [xc.suggest_scatter(pre, x, y, dname, given)]
1546
+ if (fit in ("off", "lm")
1547
+ and pd.api.types.is_numeric_dtype(other)):
1548
+ for nm in names:
1549
+ xv_ = pd.to_numeric(data[nm], errors="coerce")
1550
+ pair = (xv_, other) if x_multi else (other, xv_)
1551
+ head = (f"Variable: {nm} with {y}" if x_multi
1552
+ else f"Variable: {x} with {nm}")
1553
+ comps.append(["", head] + xc.cor_block(
1554
+ pair[0].to_numpy(float), pair[1].to_numpy(float),
1555
+ nm if x_multi else x, y if x_multi else nm))
1556
+ say(*comps)
1557
+ return fig_s
1163
1558
 
1164
1559
  # ----- row_names: each row label identifies a single case, a
1165
1560
  # categorical axis that belongs to Chart(form="dot")
@@ -1176,21 +1571,18 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1176
1571
  f" display the values of {cont} directly with Chart():\n"
1177
1572
  f" Chart('row_names', y='{cont}', form='dot')")
1178
1573
 
1179
- # stat, sort, and the segments_x/_y droplines served the dot
1180
- # plot XY() once drew of a categorical variable, now Chart()'s;
1181
- # R's stat aggregates y within bins of x (n_bins), not ported
1574
+ # sort and the segments_x/_y droplines served the dot plot XY()
1575
+ # once drew of a categorical variable, now Chart()'s
1182
1576
  if stat is not None:
1183
- cat_, cont = "x", "y"
1184
- for a, b in ((x, y), (y, x)):
1185
- if (isinstance(a, str) and isinstance(b, str)
1186
- and a in data.columns and b in data.columns
1187
- and not pd.api.types.is_numeric_dtype(data[a])):
1188
- cat_, cont = a, b
1189
- raise ValueError(
1190
- "stat in XY() aggregates y within bins of x (n_bins), "
1191
- "which is not yet ported. For a statistic of a numerical "
1192
- "variable across the levels of a categorical one, use "
1193
- f"Chart():\n Chart('{cat_}', y='{cont}', stat='{stat}')")
1577
+ if stat not in STAT_FUN:
1578
+ raise ValueError("stat must be one of "
1579
+ + ", ".join(f'"{k}"' for k in STAT_FUN))
1580
+ if (by is not None or facet is not None
1581
+ or isinstance(x, (list, tuple))
1582
+ or isinstance(y, (list, tuple))):
1583
+ raise ValueError(
1584
+ "stat plots one statistic of y at each value of x: "
1585
+ "no by=, facet=, or vector of variables")
1194
1586
  for nm, v in (("sort", None if sort == "0" else sort),
1195
1587
  ("segments_x", segments_x),
1196
1588
  ("segments_y", segments_y)):
@@ -1208,10 +1600,40 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1208
1600
  index=data.index, name="Index")
1209
1601
  return get_column(data, name, arg)
1210
1602
 
1603
+ # a keyword in the x role names no variable, so it is matched
1604
+ # without regard to case: .index for the run chart, .normal,
1605
+ # .lognormal, .exponential, .uniform for a q-q chart (XY.R)
1606
+ qq_key = None
1607
+ if (isinstance(x, str) and x.startswith(".")
1608
+ and x not in data.columns):
1609
+ key = x.lower()
1610
+ if key == ".index":
1611
+ x = ".Index"
1612
+ elif key in _QQ_LAB:
1613
+ qq_key = key
1614
+ else:
1615
+ raise ValueError(
1616
+ "A name in the x role that begins with a period is a "
1617
+ f"keyword\nfor a generated variable, but {x} is not one "
1618
+ "of\nthe keywords, and is not a variable in the data.\n\n"
1619
+ "Available keywords, matched without regard to case:\n"
1620
+ " .index generates the consecutive integers of a run "
1621
+ "chart\n " + ", ".join(_QQ_LAB) + " each generates\n"
1622
+ " the theoretical quantiles of the corresponding "
1623
+ "q-q chart")
1624
+ if qq_key is not None and (isinstance(y, (list, tuple))
1625
+ or form != "scatter"):
1626
+ raise ValueError(
1627
+ "A q-q chart plots one y variable as a scatterplot "
1628
+ 'against its theoretical quantiles: one y, form="scatter"')
1629
+
1211
1630
  index_x = isinstance(x, str) and x == ".Index"
1212
1631
  index_y = isinstance(y, str) and y == ".Index"
1213
- x_ser = _resolve(x, "x")
1214
1632
  y_ser = _resolve(y, "y")
1633
+ x_ser = (pd.Series(np.nan, index=data.index, name=_QQ_LAB[qq_key])
1634
+ if qq_key is not None else _resolve(x, "x"))
1635
+ if qq_key is not None:
1636
+ x = _QQ_LAB[qq_key]
1215
1637
  if index_x: # for axis labels below
1216
1638
  x = "Index"
1217
1639
  if index_y:
@@ -1221,6 +1643,101 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1221
1643
  (facet_ser, facet_name,
1222
1644
  facet2_ser, facet2_name) = resolve_facet(data, facet, "XY")
1223
1645
 
1646
+ # q-q: the quantiles of y, regenerated within each group of by
1647
+ # and each panel of facet, so every group reads against its own
1648
+ # distribution; both axes then share one range (XY.R)
1649
+ if qq_key is not None:
1650
+ if not pd.api.types.is_numeric_dtype(y_ser):
1651
+ raise ValueError(
1652
+ "A q-q chart displays the distribution of a continuous "
1653
+ "variable,\nbut the y variable is not numeric.")
1654
+ grp = None
1655
+ for g in (by_ser, facet_ser, facet2_ser):
1656
+ if g is not None:
1657
+ gs = g.astype(str).where(g.notna())
1658
+ grp = gs if grp is None else grp + "\x1f" + gs
1659
+ yv = y_ser.to_numpy(dtype=float)
1660
+ if grp is None:
1661
+ qv = _qq_quantiles(yv, qq_key)
1662
+ else:
1663
+ qv = np.full(len(yv), np.nan)
1664
+ for lv in grp.dropna().unique():
1665
+ i = (grp == lv).to_numpy()
1666
+ if (~np.isnan(yv[i])).sum() >= 3:
1667
+ qv[i] = _qq_quantiles(yv[i], qq_key)
1668
+ x_ser = pd.Series(qv, index=data.index, name=x)
1669
+ if scale_x is None and scale_y is None:
1670
+ pr = pretty(float(np.nanmin(np.r_[qv, yv])),
1671
+ float(np.nanmax(np.r_[qv, yv])))
1672
+ scale_x = scale_y = (pr[0], pr[-1], len(pr) - 1)
1673
+
1674
+ # the report (x_console): ports of R's printers, built as the
1675
+ # display is; xy_stats() records the numbers for fig.stats
1676
+ pre, dname = xc.call_names(data_in)
1677
+ _lbls = ((getattr(data_in, "attrs", {}) or {})
1678
+ .get("variable_labels", {}) or {})
1679
+ x_lbl, y_lbl = _lbls.get(x), _lbls.get(y)
1680
+ dd_r = (xc.xy_digits(pd.to_numeric(y_ser, errors="coerce")
1681
+ .to_numpy(float), digits_user)
1682
+ if not isinstance(y, (list, tuple)) else 3)
1683
+
1684
+ def scatter_report(groups_, fit_stats_, md_lines_):
1685
+ xy_stats(groups_, x, y, fit_stats_, digits_d, by_name=by)
1686
+ if stat_lines is not None: # R reports the levels alone
1687
+ return [stat_lines]
1688
+ comps = [xc.suggest_scatter(pre, x_call, y, dname, given,
1689
+ by=by)]
1690
+ if md_lines_ is not None:
1691
+ comps.append(md_lines_)
1692
+ # correlation for no fit or a linear one, not with by, not
1693
+ # for a q-q chart (.plt.txt)
1694
+ if fit in ("off", "lm") and by is None and qq_key is None:
1695
+ for _, xg, yg in groups_:
1696
+ comps.append(xc.cor_block(xg, yg, x, y, x_lbl, y_lbl))
1697
+ # the fit that enhance= supplies is drawn, not reported (R)
1698
+ if (fit not in ("off", "null") and fit_stats_
1699
+ and not (enhance and "fit" not in given)):
1700
+ comps.append(xc.fit_text(fit_stats_, fit, fit_power, y,
1701
+ dd_r, by_name=by))
1702
+ return comps
1703
+
1704
+ def facet_report(xv_s):
1705
+ """XY.R's summary of x per grouping variable; x's own
1706
+ decimals, at least 2, as X() now gives them"""
1707
+ sd = max(xc.max_dd(xv_s), 2)
1708
+ summ = ["", f"---------- Summary Statistics for {x}"]
1709
+ for g, nm in ((by_ser, by), (facet_ser, facet_name),
1710
+ (facet2_ser, facet2_name)):
1711
+ if g is not None:
1712
+ summ += [""] + xc.vbs_summary(
1713
+ xv_s, g.to_numpy(), nm, sd,
1714
+ order=category_order(g))
1715
+ return summ
1716
+
1717
+ # stat: the statistic of y at each value of a continuous x, the
1718
+ # values first reported by level of x (XY.R, .ss.numeric(y, by=x))
1719
+ stat_lines = None
1720
+ if stat is not None:
1721
+ for a_, b_ in ((x, y), (y, x)):
1722
+ if not pd.api.types.is_numeric_dtype(data[a_]):
1723
+ raise ValueError(
1724
+ "stat in XY() plots a statistic of y at each value "
1725
+ "of a continuous x. For a statistic of a numerical "
1726
+ "variable across the levels of a categorical one, "
1727
+ f"use Chart():\n Chart('{a_}', y='{b_}', "
1728
+ f"stat='{stat}')")
1729
+ okx = x_ser.notna()
1730
+ stat_lines = _stat_by_levels(
1731
+ pd.to_numeric(y_ser[okx]), x_ser[okx], y, x,
1732
+ max(xc.max_dd(y_ser.to_numpy(float)) + 1, 2))
1733
+ agg = (pd.to_numeric(y_ser[okx]).groupby(x_ser[okx])
1734
+ .agg(STAT_FUN[stat]).dropna())
1735
+ data = pd.DataFrame({x: agg.index.to_numpy(float),
1736
+ y: agg.to_numpy(float)})
1737
+ x_ser, y_ser = data[x], data[y]
1738
+ if ylab is None:
1739
+ ylab = f"{STAT_LBL[stat]} of {y}"
1740
+
1224
1741
  # facet needs a categorical variable; a numeric one with more
1225
1742
  # than 25 distinct values is almost surely continuous, so stop
1226
1743
  # and point to a category conversion (XY.R ~1168). A category
@@ -1346,10 +1863,18 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1346
1863
  "(date x)")
1347
1864
 
1348
1865
  # ----- ts aggregation (ts_unit/ts_agg) --------------------------
1349
- if is_date and ts_unit is not None:
1866
+ # facets aggregate within each panel, below
1867
+ ts_agg_done = is_date and ts_unit is not None and facet_ser is None
1868
+ if ts_agg_done:
1350
1869
  x_ser, y_ser, by_ser, ts_unit = plt_time(
1351
1870
  x_ser, y_ser, by_ser, ts_unit, ts_agg,
1352
1871
  quiet=resolve_quiet(quiet))
1872
+ if is_date and ylab is None and facet_ser is None: # a given
1873
+ # ylab names the axis
1874
+ from .plt_time import _infer_unit
1875
+ ylab = _ts_ylab(
1876
+ y, ts_unit if ts_unit is not None
1877
+ else _infer_unit(x_ser.to_numpy()), ts_agg_done, ts_agg)
1353
1878
  if is_date and (ts_stack or ts_area_fill is not None) \
1354
1879
  and pt_size == 1:
1355
1880
  pt_size = 0 # R: no points with areas
@@ -1377,6 +1902,87 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1377
1902
  add_means = (form in ("scatter", "smooth")
1378
1903
  and facet is None)
1379
1904
 
1905
+ # ----- sunflower: petals where observations coincide ----------
1906
+ if form == "sunflower":
1907
+ if is_date:
1908
+ raise ValueError(
1909
+ 'form="sunflower" plots two continuous variables, '
1910
+ "not a time series")
1911
+ if ellipse is True:
1912
+ ellipse = 0.95
1913
+ f_col = to_hex(get_option("fit_color", "#5C4032")
1914
+ if fit_color is None else fit_color)
1915
+ f_lwd = get_option("fit_lwd", 2) if fit_lwd is None else fit_lwd
1916
+ e_lvls = ([] if not ellipse else
1917
+ list(ellipse) if isinstance(ellipse, (list, tuple))
1918
+ else [ellipse])
1919
+
1920
+ def overlay(fig_, xv_, yv_, row, col):
1921
+ at = {} if row is None else dict(row=row, col=col)
1922
+ for lv in e_lvls:
1923
+ ex, ey = _ellipse_region(xv_, yv_, float(lv))
1924
+ fig_.add_trace(go.Scatter(
1925
+ x=ex, y=ey, mode="lines",
1926
+ line=dict(color=to_hex(
1927
+ get_option("ellipse_color", "gray20")
1928
+ if ellipse_color is None else ellipse_color),
1929
+ width=get_option("ellipse_lwd", 1)
1930
+ if ellipse_lwd is None else ellipse_lwd),
1931
+ hoverinfo="skip", showlegend=False), **at)
1932
+ if fit not in ("off", "null"):
1933
+ if fit == "loess":
1934
+ xs_, _, f_, _ = _loess(xv_, yv_, span)
1935
+ else:
1936
+ xs_, _, f_ = _plt_fit(xv_, yv_, fit, fit_power)
1937
+ fig_.add_trace(go.Scatter(
1938
+ x=xs_, y=f_, mode="lines",
1939
+ line=dict(color=f_col, width=f_lwd),
1940
+ hoverinfo="skip", showlegend=False), **at)
1941
+
1942
+ xv_f = x_ser.to_numpy(dtype=float)
1943
+ yv_f = y_ser.to_numpy(dtype=float)
1944
+ if facet is not None:
1945
+ n_lvl = len(category_order(facet_ser))
1946
+ n_col_sf = (int(n_col) if n_col is not None else
1947
+ math.ceil(n_lvl / int(n_row)) if n_row
1948
+ is not None else math.ceil(math.sqrt(n_lvl)))
1949
+ fig = sunflower_plotly(
1950
+ xv_f, yv_f, x if xlab is None else xlab,
1951
+ y if ylab is None else ylab, main,
1952
+ facet=facet_ser.to_numpy(),
1953
+ facet_order=category_order(facet_ser),
1954
+ facet_name=facet_name,
1955
+ facet2=(None if facet2_ser is None
1956
+ else facet2_ser.to_numpy()),
1957
+ facet2_order=(None if facet2_ser is None
1958
+ else category_order(facet2_ser)),
1959
+ facet2_name=facet2_name, n_col=n_col_sf,
1960
+ pt_size=pt_size, fill=fill, color=color,
1961
+ overlay=overlay,
1962
+ digits_d=2 if digits_d is None else digits_d,
1963
+ axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
1964
+ axis_y_pre=axis_y_pre)
1965
+ xy_stats([(None, xv_f, yv_f)], x, y,
1966
+ digits_d=2 if digits_d is None else digits_d)
1967
+ say(facet_report(xv_f))
1968
+ else:
1969
+ fig = sunflower_plotly(
1970
+ xv_f, yv_f, x if xlab is None else xlab,
1971
+ y if ylab is None else ylab, main, pt_size=pt_size,
1972
+ fill=fill, color=color, overlay=overlay,
1973
+ digits_d=2 if digits_d is None else digits_d,
1974
+ axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
1975
+ axis_y_pre=axis_y_pre)
1976
+ fs = []
1977
+ if fit not in ("off", "null"):
1978
+ if fit == "loess":
1979
+ xs_, ys_, f_, _ = _loess(xv_f, yv_f, span)
1980
+ else:
1981
+ xs_, ys_, f_ = _plt_fit(xv_f, yv_f, fit, fit_power)
1982
+ fs = [(None, fit, ys_, f_, xs_)]
1983
+ say(*scatter_report([(None, xv_f, yv_f)], fs, None))
1984
+ return _apply_rotate(fig, rotate_x, rotate_y)
1985
+
1380
1986
  # ----- facet: one panel per level -----------------------------
1381
1987
  if facet is not None:
1382
1988
  facet2_arr = f2_order = None
@@ -1399,12 +2005,10 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1399
2005
  if form in ("contour", "smooth"):
1400
2006
  if ellipse is True:
1401
2007
  ellipse = 0.95
1402
- if not resolve_quiet(quiet):
1403
- print("\n".join(xy_stats(
1404
- [(None, x_ser.to_numpy(dtype=float),
1405
- y_ser.to_numpy(dtype=float))], x, y,
1406
- digits_d=2 if digits_d is None
1407
- else digits_d)))
2008
+ xy_stats([(None, x_ser.to_numpy(dtype=float),
2009
+ y_ser.to_numpy(dtype=float))], x, y,
2010
+ digits_d=2 if digits_d is None else digits_d)
2011
+ say(facet_report(x_ser.to_numpy(dtype=float)))
1408
2012
  return _apply_rotate(plt_contour_facet(
1409
2013
  x_ser.to_numpy(dtype=float),
1410
2014
  y_ser.to_numpy(dtype=float),
@@ -1434,10 +2038,54 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1434
2038
  axis_x_pre=axis_x_pre, axis_y_pre=axis_y_pre),
1435
2039
  rotate_x, rotate_y)
1436
2040
  if is_date: # time-series panels
1437
- if by is not None:
1438
- raise NotImplementedError(
1439
- "by= with facet= for a time series is not "
1440
- "yet ported")
2041
+ # ts_unit aggregates within each panel and group; the
2042
+ # forecast is of each panel's series (XY.R, Aug 2026)
2043
+ f_arr = facet_ser.to_numpy()
2044
+ ts_by = by_ser.to_numpy() if by_ser is not None else None
2045
+ agg_done = ts_unit is not None
2046
+ if agg_done:
2047
+ parts = [s_.astype(str).to_numpy() for s_ in
2048
+ (facet_ser, facet2_ser, by_ser)
2049
+ if s_ is not None]
2050
+ key = pd.Series(["\x1f".join(t) for t in zip(*parts)],
2051
+ index=x_ser.index)
2052
+ xa, ya, ka, ts_unit = plt_time(
2053
+ x_ser, y_ser, key, ts_unit, ts_agg,
2054
+ quiet=resolve_quiet(quiet))
2055
+ cols = list(zip(*[str(k).split("\x1f")
2056
+ for k in ka.astype(str)]))
2057
+ x_ser, y_ser = xa, ya
2058
+ f_arr = np.asarray(cols[0], dtype=object)
2059
+ ci = 1
2060
+ if facet2_ser is not None:
2061
+ facet2_arr = np.asarray(cols[ci], dtype=object)
2062
+ ci += 1
2063
+ if by_ser is not None:
2064
+ ts_by = np.asarray(cols[ci], dtype=object)
2065
+ f_order = [str(v) for v in category_order(facet_ser)]
2066
+ f_arr = np.asarray(f_arr).astype(str)
2067
+ if ylab is None:
2068
+ from .plt_time import _infer_unit
2069
+ ylab = _ts_ylab(y, ts_unit if ts_unit is not None
2070
+ else _infer_unit(x_ser.to_numpy()),
2071
+ agg_done, ts_agg)
2072
+ frcsts = {}
2073
+ if ts_ahead > 0:
2074
+ xs_all = x_ser.to_numpy()
2075
+ ys_all = y_ser.to_numpy(dtype=float)
2076
+ for i_f, lv in enumerate(f_order):
2077
+ mm = f_arr == lv
2078
+ if mm.sum() < 4:
2079
+ continue
2080
+ od = np.argsort(xs_all[mm], kind="stable")
2081
+ frcsts[i_f] = plt_forecast(
2082
+ xs_all[mm][od], ys_all[mm][od], x, y,
2083
+ ts_unit=ts_unit, ts_ahead=ts_ahead,
2084
+ ts_method=ts_method, ts_source=ts_source,
2085
+ ts_error=ts_error, ts_trend=ts_trend,
2086
+ ts_seasons=ts_seasons, ts_alpha=ts_alpha,
2087
+ ts_beta=ts_beta, ts_gamma=ts_gamma,
2088
+ ts_PI=ts_PI, digits_d=digits_d)
1441
2089
  if transparency is None:
1442
2090
  transparency = get_option("trans_pt_fill", 0.10)
1443
2091
  fill0 = (get_option("pt_color", "#324E5C")
@@ -1451,7 +2099,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1451
2099
  ts_fig = _ts_facet(
1452
2100
  x_ser.to_numpy(),
1453
2101
  y_ser.to_numpy(dtype=float),
1454
- facet_ser.to_numpy(), category_order(facet_ser),
2102
+ f_arr, f_order,
1455
2103
  fill0, border0, pt_size, 1 - transparency,
1456
2104
  x if xlab is None else xlab,
1457
2105
  y if ylab is None else ylab,
@@ -1460,9 +2108,22 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1460
2108
  area_split=float(ts_area_split),
1461
2109
  facet_name=facet_name, facet2_arr=facet2_arr,
1462
2110
  facet2_order=f2_order,
1463
- facet2_name=facet2_name, n_col=n_col_use)
2111
+ facet2_name=facet2_name, n_col=n_col_use,
2112
+ by_arr=ts_by,
2113
+ by_order=(None if by_ser is None else
2114
+ [str(v) for v in category_order(by_ser)]),
2115
+ by_name=by, line_width=line_width,
2116
+ frcsts=frcsts, ts_PI=ts_PI)
1464
2117
  if ts_n_x_tics is not None: # date-axis tick count
1465
2118
  ts_fig.update_xaxes(nticks=int(ts_n_x_tics))
2119
+ if show:
2120
+ for i_f, fr in frcsts.items():
2121
+ print(f"\n{facet_name}: {f_order[i_f]}")
2122
+ print("\n".join(fr["report"]))
2123
+ print("Forecast\n--------")
2124
+ print(fr["forecast"].to_string(
2125
+ float_format=lambda v:
2126
+ f"{v:.{(2 if digits_d is None else digits_d) + 2}f}"))
1466
2127
  return _apply_rotate(ts_fig, rotate_x, rotate_y)
1467
2128
  by_arr = by_order = None
1468
2129
  if by_ser is not None:
@@ -1472,8 +2133,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1472
2133
  if fill is None:
1473
2134
  fills_f = ([get_option("pt_color", "#324E5C")]
1474
2135
  if n_grp == 1
1475
- else [BASE_COLORS[i % len(BASE_COLORS)]
1476
- for i in range(n_grp)])
2136
+ else by_colors(n_grp))
1477
2137
  else:
1478
2138
  fills_f = (list(fill)
1479
2139
  if isinstance(fill, (list, tuple))
@@ -1489,28 +2149,11 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1489
2149
  else [fit_se]) if lv]
1490
2150
  if ellipse is True:
1491
2151
  ellipse = 0.95
1492
- if not resolve_quiet(quiet): # overall relationship
1493
- dd_s = 2 if digits_d is None else digits_d
1494
- xv_s = x_ser.to_numpy(dtype=float)
1495
- print("\n".join(xy_stats(
1496
- [(None, xv_s, y_ser.to_numpy(dtype=float))],
1497
- x, y, digits_d=dd_s)))
1498
- # summary table per grouping variable, ~ XY.R's
1499
- # .vbs_summary_table pivot block
1500
- print(f"\n---------- Summary Statistics for {x}")
1501
- if by_ser is not None:
1502
- print()
1503
- print(facet_summary(xv_s, by_ser.to_numpy(),
1504
- by, dd_s))
1505
- print()
1506
- print(facet_summary(xv_s, facet_ser.to_numpy(),
1507
- facet_name, dd_s))
1508
- if facet2_ser is not None:
1509
- print()
1510
- print(facet_summary(xv_s,
1511
- facet2_ser.to_numpy(),
1512
- facet2_name, dd_s))
1513
- return _apply_rotate(_xy_facet(
2152
+ xv_s = x_ser.to_numpy(dtype=float)
2153
+ xy_stats([(None, xv_s, y_ser.to_numpy(dtype=float))],
2154
+ x, y, digits_d=2 if digits_d is None else digits_d)
2155
+ say(facet_report(xv_s))
2156
+ fig_f = _apply_rotate(_xy_facet(
1514
2157
  x_ser.to_numpy(dtype=float),
1515
2158
  y_ser.to_numpy(dtype=float),
1516
2159
  by_arr, by_order,
@@ -1535,6 +2178,9 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1535
2178
  facet2_arr=facet2_arr, facet2_order=f2_order,
1536
2179
  facet2_name=facet2_name, n_col=n_col_use),
1537
2180
  rotate_x, rotate_y)
2181
+ if qq_key is not None:
2182
+ _qq_reference(fig_f, fit_color, fit_lwd)
2183
+ return fig_f
1538
2184
 
1539
2185
  # ----- groups and colors --------------------------------------
1540
2186
  xv = x_ser.to_numpy() if is_date \
@@ -1592,8 +2238,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1592
2238
  n_grp = len(groups)
1593
2239
  if fill is None:
1594
2240
  fills = ([get_option("pt_color", "#324E5C")] if n_grp == 1
1595
- else [BASE_COLORS[i % len(BASE_COLORS)]
1596
- for i in range(n_grp)])
2241
+ else by_colors(n_grp))
1597
2242
  else:
1598
2243
  fills = list(fill) if isinstance(fill, (list, tuple)) \
1599
2244
  else [fill]
@@ -1676,7 +2321,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1676
2321
  if fit == "loess": # R: bands for lm and loess
1677
2322
  xs, ys_s, f, se_f = _loess(xg, yg, span)
1678
2323
  fit_lines.append({"name": nm, "x": xs, "y": f})
1679
- fit_stats.append((nm, fit, ys_s, f))
2324
+ fit_stats.append((nm, fit, ys_s, f, xs))
1680
2325
  if fit_errors:
1681
2326
  err_lines.append((xs, ys_s, f))
1682
2327
  for lv in se_levels:
@@ -1690,7 +2335,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1690
2335
  okf = np.isfinite(f) # exp/log back-transform NaN
1691
2336
  fit_lines.append({"name": nm, "x": xs[okf],
1692
2337
  "y": f[okf]})
1693
- fit_stats.append((nm, fit, ys_s[okf], f[okf]))
2338
+ fit_stats.append((nm, fit, ys_s[okf], f[okf], xs[okf]))
1694
2339
  if fit_errors:
1695
2340
  err_lines.append((xs[okf], ys_s[okf], f[okf]))
1696
2341
  if fit == "lm":
@@ -1740,11 +2385,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1740
2385
  axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
1741
2386
  axis_y_pre=axis_y_pre)
1742
2387
  _apply_rotate(fig, rotate_x, rotate_y)
1743
- if not resolve_quiet(quiet):
1744
- print("\n".join(xy_stats(groups, x, y, fit_stats,
1745
- digits_d)))
1746
- if md_lines is not None:
1747
- print("\n" + "\n".join(md_lines))
2388
+ say(*scatter_report(groups, fit_stats, md_lines))
1748
2389
  return fig
1749
2390
 
1750
2391
  # ----- forecast (date x, ts_source="classic") -------------------
@@ -1767,7 +2408,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1767
2408
  frcst["y_lwr"], frcst["y_upr"]] if frcst else []))
1768
2409
  if scale_y is not None: # explicit y scale
1769
2410
  axT2 = np.linspace(float(scale_y[0]), float(scale_y[1]),
1770
- int(scale_y[2]))
2411
+ int(scale_y[2]) + 1) # n intervals
1771
2412
  else:
1772
2413
  axT2 = pretty(float(np.nanmin(ys_all)),
1773
2414
  float(np.nanmax(ys_all)))
@@ -1783,7 +2424,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1783
2424
  if scale_x is not None: # explicit x scale
1784
2425
  axT1 = np.linspace(float(scale_x[0]),
1785
2426
  float(scale_x[1]),
1786
- int(scale_x[2]))
2427
+ int(scale_x[2]) + 1) # n intervals
1787
2428
  else:
1788
2429
  axT1 = pretty(float(np.nanmin(xs_all)),
1789
2430
  float(np.nanmax(xs_all)))
@@ -1832,9 +2473,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1832
2473
  _apply_rotate(fig, rotate_x, rotate_y)
1833
2474
  if add_means:
1834
2475
  _add_means(fig, xv, yv)
1835
- if not resolve_quiet(quiet):
1836
- print("\n".join(xy_stats(groups, x, y, fit_stats,
1837
- digits_d)))
2476
+ say(*scatter_report(groups, fit_stats, md_lines))
1838
2477
  return fig
1839
2478
 
1840
2479
  fig = plt_plotly(
@@ -1911,6 +2550,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1911
2550
 
1912
2551
  if add_means: # enhance: mean crosshair
1913
2552
  _add_means(fig, xv, yv)
2553
+ if qq_key is not None:
2554
+ _qq_reference(fig, fit_color, fit_lwd)
1914
2555
 
1915
2556
  if scale_x is not None: # explicit axis ranges
1916
2557
  fig.update_xaxes(range=[float(scale_x[0]),
@@ -1968,28 +2609,41 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1968
2609
  dash="dash"),
1969
2610
  showlegend=True))
1970
2611
 
2612
+ # the series displays: suggestions (with the forecast report
2613
+ # and table when one is made), the run analysis of a run chart
2614
+ ser_extra = "".join(
2615
+ f", {k}={v!r}" if isinstance(v, str) else f", {k}={v}"
2616
+ for k, v in (("by", by), ("ts_unit", ts_unit),
2617
+ ("ts_ahead", ts_ahead or None))
2618
+ if v is not None).replace("'", '"')
1971
2619
  if frcst is not None:
1972
2620
  _forecast_traces(fig, frcst, ts_PI)
1973
- if not resolve_quiet(quiet):
2621
+ if show:
1974
2622
  for ln in frcst["report"]:
1975
2623
  print(ln)
1976
2624
  if ts_fitted: # observed vs model-fit table
1977
2625
  print("\n".join(_ts_fitted_lines(
1978
2626
  frcst, yv, x, y, ts_unit, digits_d)))
1979
- print("\nForecast\n--------")
2627
+ say(xc.suggest_series(pre, x, y, dname, given,
2628
+ ser_extra, pt_size=pt_size))
2629
+ print("Forecast\n--------")
1980
2630
  print(frcst["forecast"].to_string(
1981
2631
  float_format=lambda v: f"{v:.{digits_d + 2}f}"))
1982
- elif not is_date and not resolve_quiet(quiet):
1983
- print("\n".join(xy_stats(groups, x, y, fit_stats,
1984
- digits_d)))
1985
- if fit_new is not None and fit in _FIT_NEW_OK:
2632
+ elif is_date:
2633
+ say(xc.suggest_series(pre, x, y, dname, given, ser_extra,
2634
+ pt_size=pt_size))
2635
+ elif index_x: # run chart: R's run analysis
2636
+ if run_lines is None:
2637
+ _, run_lines = _run_analysis(yv, digits_d, False)
2638
+ say(xc.suggest_series(pre, ".Index", y, dname, given,
2639
+ ser_extra, run=True, pt_size=pt_size),
2640
+ xc.run_summary(yv, dd_r), run_lines[1:])
2641
+ else:
2642
+ say(*scatter_report(groups, fit_stats, md_lines))
2643
+ if fit_new is not None and fit in _FIT_NEW_OK and show:
1986
2644
  print("\n".join(_fit_new_table(
1987
2645
  groups, fit, fit_power, fit_new, x, y, digits_d)))
1988
- if run_lines is not None:
1989
- print("\n".join(run_lines))
1990
- if md_lines is not None:
1991
- print("\n" + "\n".join(md_lines))
1992
- if jitter_x > 0 or jitter_y > 0:
2646
+ if (jitter_x > 0 or jitter_y > 0) and show:
1993
2647
  print("\nSome Parameter values (can be manually set)")
1994
2648
  print("-" * 55)
1995
2649
  print(f"size: {float(pt_size):.2f} "
@@ -2004,4 +2658,6 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2004
2658
 
2005
2659
 
2006
2660
  # font_size= scales all text of the returned figure
2007
- XY = font_scaled(XY)
2661
+ from .stats_out import attach_stats as _attach_stats # noqa: E402
2662
+ from .style import with_theme as _with_theme # noqa: E402
2663
+ XY = font_scaled(_attach_stats(_with_theme(XY), capture=True))