lessPython 0.3.0__py3-none-any.whl → 0.3.1__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 (50) hide show
  1. lessPy/ANOVA.py +70 -11
  2. lessPy/Chart.py +49 -4
  3. lessPy/Correlation.py +39 -10
  4. lessPy/Flows.py +3 -3
  5. lessPy/Logit.py +14 -10
  6. lessPy/Regression.py +22 -18
  7. lessPy/X.py +3 -2
  8. lessPy/XY.py +528 -91
  9. lessPy/bc_items_plotly.py +2 -2
  10. lessPy/bc_plotly.py +6 -5
  11. lessPy/bubble_plotly.py +16 -15
  12. lessPy/corCFA.py +7 -3
  13. lessPy/corProp.py +7 -3
  14. lessPy/corReflect.py +7 -3
  15. lessPy/corReorder.py +7 -3
  16. lessPy/corScree.py +10 -6
  17. lessPy/dn_plotly.py +9 -9
  18. lessPy/dot_plotly.py +9 -8
  19. lessPy/freq_poly_plotly.py +6 -6
  20. lessPy/hier_plotly.py +5 -5
  21. lessPy/hs_plotly.py +7 -7
  22. lessPy/pie_plotly.py +1 -1
  23. lessPy/pivot.py +39 -9
  24. lessPy/plotly_utils.py +90 -26
  25. lessPy/plt_add.py +2 -7
  26. lessPy/plt_contour.py +6 -6
  27. lessPy/plt_contour_facet.py +3 -3
  28. lessPy/plt_mat_plotly.py +11 -10
  29. lessPy/plt_plotly.py +34 -18
  30. lessPy/plt_smooth.py +4 -4
  31. lessPy/prob_norm.py +5 -1
  32. lessPy/prob_tcut.py +5 -1
  33. lessPy/prob_znorm.py +5 -1
  34. lessPy/profile_plotly.py +5 -4
  35. lessPy/radar_plotly.py +4 -3
  36. lessPy/simCImean.py +5 -1
  37. lessPy/simCLT.py +5 -1
  38. lessPy/simFlips.py +5 -1
  39. lessPy/simMeans.py +5 -1
  40. lessPy/style.py +8 -1
  41. lessPy/sunflower_plotly.py +6 -5
  42. lessPy/ttest.py +13 -9
  43. lessPy/utils.py +103 -0
  44. lessPy/vbs_plotly.py +23 -22
  45. {lesspython-0.3.0.dist-info → lesspython-0.3.1.dist-info}/METADATA +8 -1
  46. lesspython-0.3.1.dist-info/RECORD +89 -0
  47. {lesspython-0.3.0.dist-info → lesspython-0.3.1.dist-info}/WHEEL +1 -1
  48. lesspython-0.3.0.dist-info/RECORD +0 -89
  49. {lesspython-0.3.0.dist-info → lesspython-0.3.1.dist-info}/licenses/LICENSE +0 -0
  50. {lesspython-0.3.0.dist-info → lesspython-0.3.1.dist-info}/top_level.txt +0 -0
lessPy/XY.py CHANGED
@@ -73,7 +73,7 @@ from .plotly_utils import (
73
73
  BASE_COLORS, as_plotly_color, axis_format, axis_num,
74
74
  facet_fig, facet_panels, finish_facet,
75
75
  get_tick_fmt, legend_style, make_trans, plot_border,
76
- plotly_style, square_layout, sym_at, to_hex, x_grid,
76
+ plotly_style, square_layout, marks, to_hex, x_grid,
77
77
  font_scaled, by_colors)
78
78
  from .utils import (
79
79
  STAT_FUN, STAT_LBL, category_order, get_column, get_option,
@@ -213,14 +213,18 @@ def _loess_rows(xs, q_pt, span, degree, rw):
213
213
  w = (d == 0).astype(float)
214
214
  w = w * rw
215
215
  nz = np.flatnonzero(w)
216
- xc = xs[nz] - q_pt
216
+ # x in units of the window, so the powers of a large-valued x,
217
+ # such as dates as days, keep pinv well conditioned; the slope
218
+ # back in units of x
219
+ sc = h if h > 0 else 1.0
220
+ xc = (xs[nz] - q_pt) / sc
217
221
  X = np.column_stack([xc ** k for k in range(degree + 1)])
218
222
  XtW = X.T * w[nz]
219
223
  B = np.linalg.pinv(XtW @ X) @ XtW
220
224
  val = np.zeros(n)
221
225
  slope = np.zeros(n)
222
226
  val[nz] = B[0]
223
- slope[nz] = B[1]
227
+ slope[nz] = B[1] / sc
224
228
  return val, slope
225
229
 
226
230
 
@@ -306,6 +310,11 @@ def _loess(xv, yv, span, degree=2, family="gaussian"):
306
310
  (1 - (r / cmad) ** 2) ** 2, 1.0))
307
311
  l2 = (L ** 2).sum(axis=1)
308
312
  dof = n - 2 * np.trace(L) + l2.sum() # tr[(I-L)'(I-L)]
313
+ # the t quantile of the band takes the fit's lookup degrees of
314
+ # freedom, delta1^2 / delta2. R analog: predict.loess()$df
315
+ IL = np.eye(n) - L
316
+ M = IL.T @ IL
317
+ _loess.df = dof ** 2 / (M * M.T).sum() # tr(M^2)
309
318
  return xs, ys, f, np.sqrt(res @ res / dof * l2)
310
319
 
311
320
 
@@ -378,7 +387,7 @@ def _qq_reference(fig, fit_color, fit_lwd):
378
387
  panels[key] = (min(lo, v.min()), max(hi, v.max()))
379
388
  refs = [go.Scatter(x=[lo, hi], y=[lo, hi], mode="lines",
380
389
  xaxis=xa, yaxis=ya,
381
- line=dict(color=to_hex(get_option(
390
+ line=dict(color=as_plotly_color(get_option(
382
391
  "fit_color", "#5C4032")
383
392
  if fit_color is None else fit_color),
384
393
  width=1.5 if fit_lwd is None else fit_lwd),
@@ -411,17 +420,137 @@ def _ts_ylab(y_name, unit, aggregated, ts_agg):
411
420
  def _se_band(xs, ys, f, level):
412
421
  """SE band polygon about a least-squares line: fit +/- t * SE
413
422
  of the conditional mean. R analog: plt.main.R se bands, from
414
- predict(se=TRUE) and qt(prb, n-1)."""
423
+ predict(se=TRUE) and qt(prb, df), df the residual df. None
424
+ when the band is undefined, fewer than 3 points or a single x
425
+ value, where R's se is NaN and no band is drawn."""
415
426
  n = len(xs)
427
+ if n < 3 or np.ptp(xs) == 0:
428
+ return None
416
429
  mse = ((ys - f) ** 2).sum() / (n - 2)
417
430
  xbar = xs.mean()
418
431
  sxx = ((xs - xbar) ** 2).sum()
419
432
  se = np.sqrt(mse * (1 / n + (xs - xbar) ** 2 / sxx))
420
- tq = sps.t.ppf((1 + level) / 2, n - 1)
433
+ tq = sps.t.ppf((1 + level) / 2, n - 2) # the residual df
421
434
  return (np.concatenate([xs, xs[::-1]]),
422
435
  np.concatenate([f + tq * se, (f - tq * se)[::-1]]))
423
436
 
424
437
 
438
+ def _plt_bins(x_ser, y_ser, x, y, n_bins, stat, by, facet, pt_size,
439
+ ylab, digits_d, show):
440
+ """Bin x into n_bins equal intervals, R's cut() with the range
441
+ extended by 1/1000, and take the mean or median of y in each.
442
+ Returns the midpoints and statistics of the nonempty bins as the
443
+ new x and y, the y label, each point's pixel diameter, and the
444
+ bin counts, and prints R's two tables. R analog: .plt.bins()"""
445
+ st = "mean" if stat is None else stat
446
+ if st not in ("mean", "median"):
447
+ raise ValueError(
448
+ "stat can only take two values for binning: mean and "
449
+ "median")
450
+ if by is not None or facet is not None:
451
+ raise ValueError("n_bins bins one x variable: no by or facet")
452
+ if (not pd.api.types.is_numeric_dtype(x_ser)
453
+ or not pd.api.types.is_numeric_dtype(y_ser)):
454
+ raise ValueError("n_bins: both variables must be numeric")
455
+ n_bins = int(n_bins)
456
+ if n_bins < 2:
457
+ raise ValueError("Need at least two bins set by n_bins")
458
+ xv = x_ser.to_numpy(dtype=float)
459
+ yv = y_ser.to_numpy(dtype=float)
460
+ ok_x = np.isfinite(xv)
461
+ if len(np.unique(xv[ok_x])) < n_bins and show:
462
+ print(f"You specified {n_bins} bins, but only "
463
+ f"{len(np.unique(xv[ok_x]))} values of {x}\n"
464
+ "Empty bins will appear in the solution\n")
465
+ lo, hi = float(np.nanmin(xv)), float(np.nanmax(xv))
466
+ dx = hi - lo
467
+ bns = np.linspace(lo, hi, n_bins + 1)
468
+ bns[0], bns[-1] = lo - dx / 1000, hi + dx / 1000
469
+ mid = bns[:-1] + (bns[1:] - bns[:-1]) / 2
470
+ n = np.zeros(n_bins, dtype=int)
471
+ m = np.full(n_bins, np.nan)
472
+ for i in range(n_bins):
473
+ ys = yv[(xv > bns[i]) & (xv <= bns[i + 1])]
474
+ ys = ys[np.isfinite(ys)]
475
+ n[i] = len(ys)
476
+ if len(ys):
477
+ m[i] = ys.mean() if st == "mean" else np.median(ys)
478
+
479
+ if show:
480
+ def f_(v, d):
481
+ return f"{v:.{d}f}"
482
+ dx_ = 0 if np.all(xv[ok_x] == np.round(xv[ok_x])) else digits_d
483
+ ok_y = np.isfinite(yv)
484
+ dy_ = 0 if np.all(yv[ok_y] == np.round(yv[ok_y])) else digits_d
485
+ rows = [("n", str(ok_x.sum()), str(ok_y.sum())),
486
+ ("n.miss", str((~ok_x).sum()), str((~ok_y).sum())),
487
+ ("min", f_(np.nanmin(xv), dx_), f_(np.nanmin(yv), dy_)),
488
+ ("max", f_(np.nanmax(xv), dx_), f_(np.nanmax(yv), dy_)),
489
+ ("mean", f_(np.nanmean(xv), digits_d),
490
+ f_(np.nanmean(yv), digits_d))]
491
+ w1 = max(len(x), max(len(r[1]) for r in rows))
492
+ w2 = max(len(y), max(len(r[2]) for r in rows))
493
+ print("\nSummary Stats\n")
494
+ print(f"{'':7} {x:>{w1}} {y:>{w2}}")
495
+ for r in rows:
496
+ print(f"{r[0]:7} {r[1]:>{w1}} {r[2]:>{w2}}")
497
+ brk = [f"{b:.{digits_d}f}" for b in bns]
498
+ labs = [f"({brk[i]},{brk[i + 1]}]" for i in range(n_bins)]
499
+ labs[0] = "[" + labs[0][1:]
500
+ tbl = pd.DataFrame({
501
+ "bin": labs, "n": n,
502
+ "midpt": [f"{v:.{digits_d}f}" for v in mid],
503
+ st: ["NaN" if not np.isfinite(v) else f"{v:.{digits_d}f}"
504
+ for v in m]}, index=range(1, n_bins + 1))
505
+ print(f"\n{st} of {y} for levels of {x}\n")
506
+ print(tbl.to_string())
507
+
508
+ keep = n > 0
509
+ if isinstance(pt_size, (int, float)) and pt_size != 1:
510
+ cex = np.full(keep.sum(), float(pt_size))
511
+ else: # R: max size about 10
512
+ cex = (n[keep] / (n.max() / 10)) ** 0.6
513
+ x_new = pd.Series(mid[keep], name=x_ser.name)
514
+ y_new = pd.Series(m[keep], name=y_ser.name)
515
+ ylab = f"{st} of {y}" if ylab is None else ylab
516
+ return x_new, y_new, ylab, cex * 7.25, n[keep]
517
+
518
+
519
+ def _as_years(s):
520
+ """A run of consecutive integers from 10 to 9999 as the dates of
521
+ Jan 1 of those years, else None. R analog: XY.R ~733"""
522
+ if (not pd.api.types.is_numeric_dtype(s)
523
+ or pd.api.types.is_bool_dtype(s) or len(s) < 2):
524
+ return None
525
+ v = s.to_numpy(dtype=float)
526
+ if (np.isnan(v).any() or (v != np.round(v)).any()
527
+ or not (np.diff(v) == 1).all()
528
+ or v.min() < 10 or v.max() > 9999):
529
+ return None
530
+ out = pd.Series(pd.to_datetime([f"{int(t):04d}-01-01" for t in v]),
531
+ index=s.index, name=s.name)
532
+ return out
533
+
534
+
535
+ def _ellipse_levels(ellipse):
536
+ """The confidence levels of the data ellipses: True is 0.95, a
537
+ number one level, a list several, as R's ellipse=c(.3,.5,.9).
538
+ False, None, or 0 is none."""
539
+ if ellipse is True:
540
+ return [0.95]
541
+ if ellipse is None or ellipse is False:
542
+ return []
543
+ lvls = (list(ellipse) if isinstance(ellipse, (list, tuple,
544
+ np.ndarray))
545
+ else [ellipse])
546
+ lvls = [float(lv) for lv in lvls if lv]
547
+ for lv in lvls:
548
+ if not 0 < lv < 1:
549
+ raise ValueError("ellipse is the confidence level of "
550
+ "a data ellipse, between 0 and 1")
551
+ return lvls
552
+
553
+
425
554
  def _ellipse_region(xv, yv, level, npoints=100):
426
555
  """Bivariate-normal data ellipse, the Murdoch and Chow
427
556
  construction of the ellipse package that XY() cites.
@@ -442,7 +571,7 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
442
571
  ellipse, ellipse_fill, ellipse_color, ellipse_lwd,
443
572
  x_lab, y_lab, main, digits_d,
444
573
  facet2_arr=None, facet2_order=None,
445
- facet2_name=None, n_col=1):
574
+ facet2_name=None, n_col=1, fit_errors=False):
446
575
  """One scatter panel per facet level on shared axes,
447
576
  following the faceted-histogram conventions (_hs_facet):
448
577
  first level on the top panel, strip labels, legend
@@ -473,9 +602,10 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
473
602
  if fit == "loess":
474
603
  xs, ys_s, f, se_f = _loess(xg, yg, span)
475
604
  fit_lines.append({"name": gname, "x": xs,
476
- "y": f, "g": g})
605
+ "y": f, "g": g,
606
+ "err": (xs, ys_s, f)})
477
607
  for lv in se_levels:
478
- tq = sps.t.ppf((1 + lv) / 2, len(xs) - 1)
608
+ tq = sps.t.ppf((1 + lv) / 2, _loess.df)
479
609
  se_polys.append((
480
610
  np.concatenate([xs, xs[::-1]]),
481
611
  np.concatenate(
@@ -485,14 +615,17 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
485
615
  xs, ys_s, f = _plt_fit(xg, yg, fit, fit_power)
486
616
  okf = np.isfinite(f)
487
617
  fit_lines.append({"name": gname, "x": xs[okf],
488
- "y": f[okf], "g": g})
618
+ "y": f[okf], "g": g,
619
+ "err": (xs[okf], ys_s[okf],
620
+ f[okf])})
489
621
  if fit == "lm":
490
622
  for lv in se_levels:
491
- se_polys.append(
492
- _se_band(xs, ys_s, f, lv))
493
- if ellipse and len(xg) >= 3:
494
- ellipses.append((g, *_ellipse_region(xg, yg,
495
- ellipse)))
623
+ band = _se_band(xs, ys_s, f, lv)
624
+ if band is not None:
625
+ se_polys.append(band)
626
+ if len(xg) >= 3:
627
+ for lv in _ellipse_levels(ellipse):
628
+ ellipses.append((g, *_ellipse_region(xg, yg, lv)))
496
629
  panels.append((pts, fit_lines, se_polys, ellipses))
497
630
  for fl in fit_lines:
498
631
  xs_all.append(fl["x"]); ys_all.append(fl["y"])
@@ -517,22 +650,22 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
517
650
  row, col = pos[i]
518
651
  for g, (gname, xg, yg) in enumerate(pts):
519
652
  fig.add_trace(go.Scatter(
520
- x=xg, y=yg, mode="markers",
653
+ x=xg, y=yg,
521
654
  name=None if gname is None else str(gname),
522
655
  legendgroup=(None if gname is None
523
656
  else str(gname)),
524
- marker=dict(
525
- symbol=sym_at(shape, g), size=px,
526
- sizemode="diameter",
657
+ **marks(shape, g, "markers", dict(
658
+ size=px, sizemode="diameter",
527
659
  color=make_trans(fills[g], pt_opacity),
528
660
  opacity=1,
529
- line=dict(color=to_hex(fills[g]), width=1)),
661
+ line=dict(color=as_plotly_color(fills[g]),
662
+ width=1))),
530
663
  hoverinfo="x+y" + ("+name" if has_groups else ""),
531
664
  showlegend=has_groups and i == 0,
532
665
  ), row=row, col=col)
533
666
  for g, ex, ey in ellipses:
534
- edge = (to_hex(fills[g]) if has_groups
535
- else to_hex(ellipse_color))
667
+ edge = (as_plotly_color(fills[g]) if has_groups
668
+ else as_plotly_color(ellipse_color))
536
669
  fig.add_trace(go.Scatter(
537
670
  x=ex, y=ey, mode="lines",
538
671
  line=dict(color=edge, width=ellipse_lwd),
@@ -546,6 +679,18 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
546
679
  fillcolor=as_plotly_color(se_fill),
547
680
  hoverinfo="skip", showlegend=False,
548
681
  ), row=row, col=col)
682
+ # fit_errors: each point's residual segment to its fitted
683
+ # value, as on the single panel (R plt.main.R ~1403)
684
+ for fl in (fit_lines if fit_errors else []):
685
+ ex, ey = [], []
686
+ for xe, ye, fe in zip(*fl["err"]):
687
+ ex += [xe, xe, None]
688
+ ey += [ye, fe, None]
689
+ fig.add_trace(go.Scatter(
690
+ x=ex, y=ey, mode="lines",
691
+ line=dict(color="#822823", width=1),
692
+ hoverinfo="skip", showlegend=False,
693
+ ), row=row, col=col)
549
694
  for fl in fit_lines:
550
695
  if len(fl["x"]) < 2:
551
696
  continue
@@ -555,8 +700,8 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
555
700
  name="Fit" if single else f"Fit: {fl['name']}",
556
701
  legendgroup=("fit" if single
557
702
  else str(fl["name"])),
558
- line=dict(color=(to_hex(fit_color) if single
559
- else to_hex(fills[fl["g"]])),
703
+ line=dict(color=(as_plotly_color(fit_color) if single
704
+ else as_plotly_color(fills[fl["g"]])),
560
705
  width=fit_lwd),
561
706
  hoverinfo="skip",
562
707
  showlegend=i == 0,
@@ -614,7 +759,7 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
614
759
  xs, ys_s, f, se_f = _loess(xg, yg, span)
615
760
  fit_lines.append((xs, f))
616
761
  for lv in se_levels:
617
- tq = sps.t.ppf((1 + lv) / 2, len(xs) - 1)
762
+ tq = sps.t.ppf((1 + lv) / 2, _loess.df)
618
763
  se_polys.append((
619
764
  np.concatenate([xs, xs[::-1]]),
620
765
  np.concatenate([f + tq * se_f,
@@ -625,9 +770,12 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
625
770
  fit_lines.append((xs[okf], f[okf]))
626
771
  if fit == "lm":
627
772
  for lv in se_levels:
628
- se_polys.append(_se_band(xs, ys_s, f, lv))
629
- if ellipse and len(xg) >= 3:
630
- ellipses.append(_ellipse_region(xg, yg, ellipse))
773
+ band = _se_band(xs, ys_s, f, lv)
774
+ if band is not None:
775
+ se_polys.append(band)
776
+ if len(xg) >= 3:
777
+ for lv in _ellipse_levels(ellipse):
778
+ ellipses.append(_ellipse_region(xg, yg, lv))
631
779
  art.append((xg, yg, fit_lines, se_polys, ellipses))
632
780
  xs_all += [xg] + [fl[0] for fl in fit_lines] \
633
781
  + [ex for ex, _ in ellipses]
@@ -649,7 +797,7 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
649
797
  for ex, ey in ellipses:
650
798
  fig.add_trace(go.Scatter(
651
799
  x=ex, y=ey, mode="lines",
652
- line=dict(color=to_hex(fills[g]),
800
+ line=dict(color=as_plotly_color(fills[g]),
653
801
  width=ellipse_lwd),
654
802
  fill="toself",
655
803
  fillcolor=as_plotly_color(ellipse_fill),
@@ -660,14 +808,14 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
660
808
  fillcolor=as_plotly_color(se_fill),
661
809
  hoverinfo="skip", showlegend=False))
662
810
  fig.add_trace(go.Scatter(
663
- x=xg, y=yg, mode="markers", name=str(nm),
811
+ x=xg, y=yg, name=str(nm),
664
812
  legendgroup=str(nm),
665
- marker=dict(symbol=sym_at(shape, g), size=px,
666
- sizemode="diameter",
667
- color=make_trans(fills[g], pt_opacity),
668
- opacity=1,
669
- line=dict(color=to_hex(fills[g]),
670
- width=1)),
813
+ **marks(shape, g, "markers", dict(
814
+ size=px, sizemode="diameter",
815
+ color=make_trans(fills[g], pt_opacity),
816
+ opacity=1,
817
+ line=dict(color=as_plotly_color(fills[g]),
818
+ width=1))),
671
819
  hoverinfo="x+y+name", showlegend=True))
672
820
  for fx_l, fy_l in fit_lines:
673
821
  if len(fx_l) < 2:
@@ -675,7 +823,7 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
675
823
  fig.add_trace(go.Scatter(
676
824
  x=fx_l, y=fy_l, mode="lines",
677
825
  legendgroup=str(nm),
678
- line=dict(color=to_hex(fills[g]),
826
+ line=dict(color=as_plotly_color(fills[g]),
679
827
  width=fit_lwd),
680
828
  hoverinfo="skip", showlegend=False))
681
829
 
@@ -684,14 +832,14 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
684
832
  pad = 0.04 * (axT2[-1] - axT2[0])
685
833
  ax_y["range"] = [axT2[0] - pad, axT2[-1] + pad]
686
834
  ax_y.update(showgrid=True,
687
- gridcolor=to_hex(style_opts["grid_col"]),
835
+ gridcolor=as_plotly_color(style_opts["grid_col"]),
688
836
  gridwidth=1, griddash="dot")
689
837
  fig.update_layout(
690
838
  xaxis=ax_x, yaxis=ax_y,
691
839
  shapes=x_grid(axT1) + plot_border(),
692
840
  template=None,
693
- plot_bgcolor=to_hex(style_opts["panel_fill"]),
694
- paper_bgcolor=to_hex(style_opts["window_fill"]),
841
+ plot_bgcolor=as_plotly_color(style_opts["panel_fill"]),
842
+ paper_bgcolor=as_plotly_color(style_opts["window_fill"]),
695
843
  legend=legend_style("", style_opts),
696
844
  **square_layout(main=bool(main)))
697
845
  if main:
@@ -770,7 +918,7 @@ def _facet_series(V, names, shared, on, facet_arr, facet_order,
770
918
  color=make_trans(fills[g],
771
919
  pt_opacity),
772
920
  opacity=1,
773
- line=dict(color=to_hex(fills[g]),
921
+ line=dict(color=as_plotly_color(fills[g]),
774
922
  width=1)),
775
923
  hoverinfo="x+y+name",
776
924
  showlegend=i == 0,
@@ -781,7 +929,7 @@ def _facet_series(V, names, shared, on, facet_arr, facet_order,
781
929
  fig.add_trace(go.Scatter(
782
930
  x=fx_l, y=fy_l, mode="lines",
783
931
  legendgroup=str(names[g]),
784
- line=dict(color=to_hex(fills[g]),
932
+ line=dict(color=as_plotly_color(fills[g]),
785
933
  width=fit_lwd),
786
934
  hoverinfo="skip", showlegend=False,
787
935
  ), row=row, col=col)
@@ -860,6 +1008,7 @@ def _xy_series(x, y, data, x_multi, by, facet, form, n_row,
860
1008
  if ylab is None else ylab)
861
1009
 
862
1010
  k = len(names)
1011
+ fill = _expand_palette(fill, k)
863
1012
  if isinstance(fill, (list, tuple)):
864
1013
  fills = [fill[i % len(fill)] for i in range(k)]
865
1014
  elif fill is not None:
@@ -951,8 +1100,10 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
951
1100
  "axL2": axis_format(axT2, digits_d)}
952
1101
 
953
1102
  ypart = f"%{{y:{fmt2}}}" if fmt2 else "%{y}"
954
- hover = (f"Date: %{{x|%Y-%m-%d}}<br>{y_lab.strip()}: "
955
- f"{ypart}<extra></extra>")
1103
+ xpart = ("Date: %{x|%Y-%m-%d}"
1104
+ if np.issubdtype(np.asarray(xv).dtype, np.datetime64)
1105
+ else f"{x_lab}: %{{x}}")
1106
+ hover = f"{xpart}<br>{y_lab.strip()}: {ypart}<extra></extra>"
956
1107
 
957
1108
  style_opts = plotly_style()
958
1109
  fig = facet_fig(n_row_g, n_col)
@@ -993,10 +1144,13 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
993
1144
  sizemode="diameter",
994
1145
  color=make_trans(f_c, pt_opacity),
995
1146
  opacity=1,
996
- line=dict(color=to_hex(b_c), width=1))
1147
+ line=dict(color=as_plotly_color(b_c), width=1))
997
1148
  if "markers" in mode_pts or line_width <= 0
998
1149
  else None),
999
- line=dict(color=to_hex(b_c), width=line_width),
1150
+ line=dict(color=as_plotly_color(
1151
+ style_opts["segment_color"]
1152
+ if nm is None and get_option("segment_color_set")
1153
+ else b_c), width=line_width),
1000
1154
  name=nm, legendgroup=nm,
1001
1155
  hovertemplate=hv,
1002
1156
  showlegend=nm is not None and i == 0,
@@ -1016,7 +1170,7 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
1016
1170
  pad = 0.04 * (axT2[-1] - axT2[0])
1017
1171
  fig.update_yaxes(range=[axT2[0] - pad, axT2[-1] + pad])
1018
1172
  fig.update_xaxes(showgrid=True,
1019
- gridcolor=to_hex(style_opts["grid_col"]),
1173
+ gridcolor=as_plotly_color(style_opts["grid_col"]),
1020
1174
  gridwidth=1)
1021
1175
  if main:
1022
1176
  title_size = round(16 * get_option("main_size", 1))
@@ -1026,6 +1180,16 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
1026
1180
  return fig
1027
1181
 
1028
1182
 
1183
+ def _expand_palette(col, n):
1184
+ """A palette name, such as fill="blues", as n of its colors,
1185
+ one per series or by group, as R's fill="blues". Any other
1186
+ value is returned unchanged."""
1187
+ from .Chart import _color_range, _palette_names
1188
+ if isinstance(col, str) and col in _palette_names():
1189
+ return _color_range(col, n)
1190
+ return col
1191
+
1192
+
1029
1193
  def _area_color(col, opacity):
1030
1194
  """Area fill color: "on" is the violin_fill option; an
1031
1195
  explicit #RRGGBBAA keeps its own alpha; otherwise the point
@@ -1122,14 +1286,14 @@ def _outlier_traces(fig, xv, yv, out_idx, labels, fill0,
1122
1286
  sizemode="diameter",
1123
1287
  color=make_trans(fill0, pt_opacity),
1124
1288
  opacity=1,
1125
- line=dict(color=to_hex(fill0), width=1.5)),
1289
+ line=dict(color=as_plotly_color(fill0), width=1.5)),
1126
1290
  hoverinfo="x+y", showlegend=False))
1127
1291
  font_sz = max(9, round(float(ID_size) * 14))
1128
1292
  for i in out_idx:
1129
1293
  fig.add_annotation(
1130
1294
  x=xv[i], y=yv[i], text=str(labels[i]),
1131
1295
  showarrow=False, yshift=-12,
1132
- font=dict(color=to_hex(ID_color), size=font_sz,
1296
+ font=dict(color=as_plotly_color(ID_color), size=font_sz,
1133
1297
  family="Arial"))
1134
1298
 
1135
1299
 
@@ -1216,16 +1380,17 @@ def _ts_fitted_lines(frcst, yv, x_name, y_name, ts_unit, digits_d):
1216
1380
  def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1217
1381
  n_row=None, n_col=None,
1218
1382
  form="scatter", stat=None, sort="0", show_runs=False,
1219
- center_line="off",
1383
+ center_line=None,
1220
1384
  contour_n=None, contour_nbins=None, contour_points=None,
1221
1385
  contour_legend=None,
1222
1386
  smooth_points=100, smooth_size=1, smooth_power=0.25,
1223
- smooth_bins=128,
1387
+ smooth_bins=128, n_bins=None,
1224
1388
  enhance=False,
1225
1389
  segments_x=None, segments_y=None,
1226
1390
  add=None, x1=None, y1=None, x2=None, y2=None,
1227
1391
  fill=None, color=None, transparency=None,
1228
1392
  pt_size=1, pt_shape="circle", line_width=1.5,
1393
+ radius=None, power=None,
1229
1394
  jitter_x=None, jitter_y=None,
1230
1395
  MD_cut=0, out_cut=0, out_shape="circle-open", out_size=1,
1231
1396
  ID=None, ID_color="gray50", ID_size=0.6,
@@ -1276,6 +1441,10 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1276
1441
  contour_nbins = 50 if contour_nbins is None else contour_nbins
1277
1442
  contour_points = bool(contour_points)
1278
1443
  contour_legend = bool(contour_legend)
1444
+ # fill="on" is the violin_fill color, as R (XY.R ~332)
1445
+ if isinstance(fill, str) and fill == "on" or (
1446
+ isinstance(fill, (list, tuple)) and "on" in fill):
1447
+ fill = get_option("violin_fill", "#7485975A")
1279
1448
  if pt_shape == "sunflower": # its earlier route, kept
1280
1449
  form, pt_shape = "sunflower", "circle"
1281
1450
  # the form is consulted before by is resolved, so a vector of by
@@ -1324,7 +1493,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1324
1493
  "no facet=")
1325
1494
  if axis_fmt not in ("K", ",", ".", ""):
1326
1495
  raise ValueError('axis_fmt: "K", ",", ".", or ""')
1327
- if center_line not in ("off", "mean", "median", "zero"):
1496
+ if center_line not in (None, "off", "mean", "median", "zero"):
1328
1497
  raise ValueError(
1329
1498
  'center_line: "off", "mean", "median", or "zero"')
1330
1499
  if span is not None:
@@ -1347,7 +1516,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1347
1516
  "The segments it draws join each point to the fitted line, "
1348
1517
  "so they are the errors of the fit, and the name now says "
1349
1518
  "which fit they belong to, as fit, fit_color, and fit_se do.")
1350
- if center_line != "off" and facet is not None:
1519
+ if center_line not in (None, "off") and facet is not None:
1351
1520
  raise ValueError(
1352
1521
  "center_line draws on a single panel: no facet=")
1353
1522
  if fit_errors:
@@ -1357,9 +1526,6 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1357
1526
  "line: specify fit=")
1358
1527
  if form != "scatter":
1359
1528
  raise ValueError('fit_errors applies to form="scatter"')
1360
- if facet is not None:
1361
- raise ValueError(
1362
- "fit_errors draws on a single panel: no facet=")
1363
1529
  if (scale_x is not None or scale_y is not None) \
1364
1530
  and facet is not None:
1365
1531
  raise ValueError(
@@ -1384,7 +1550,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1384
1550
  "specify facet=")
1385
1551
  if ts_agg not in ("sum", "mean"):
1386
1552
  raise ValueError('ts_agg: "sum" or "mean"')
1387
- if ts_stack and by is None:
1553
+ if ts_stack and by is None and not isinstance(y, (list, tuple)):
1388
1554
  raise ValueError(
1389
1555
  "ts_stack stacks multiple time series: specify by=")
1390
1556
  if (by is not None and ts_area_fill is not None
@@ -1464,6 +1630,31 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1464
1630
  # and keyed by name in the legend, drawn as a by= series would
1465
1631
  # be; the value axis reports only the aggregation and time unit
1466
1632
  # (XY.R, Aug 2026)
1633
+ # a vector of y over .Index: one run chart per variable, overlaid,
1634
+ # each numbered by row, as the date x above (XY.R run, n.ycol > 1)
1635
+ if (y_multi and not x_multi and isinstance(x, str)
1636
+ and x.lower() == ".index" and x not in data.columns):
1637
+ if by is not None or facet is not None:
1638
+ raise ValueError(
1639
+ "A vector of y variables over .Index overlays its "
1640
+ "variables as the\nseries of the run chart, so by and "
1641
+ "facet do not apply.")
1642
+ long = data[list(y)].copy()
1643
+ long[".Index"] = np.arange(1, len(data) + 1)
1644
+ long = long.melt(id_vars=[".Index"], value_vars=list(y),
1645
+ var_name="Series", value_name="Value")
1646
+ long["Series"] = pd.Categorical(long["Series"],
1647
+ categories=list(y))
1648
+ fig_l = XY(".Index", "Value", data=long, by="Series",
1649
+ center_line=("off" if center_line is None
1650
+ else center_line),
1651
+ pt_size=pt_size, line_width=line_width, fill=fill,
1652
+ xlab=xlab, ylab=("" if ylab is None else ylab),
1653
+ main=main, digits_d=digits_d, quiet=True,
1654
+ rotate_x=rotate_x, rotate_y=rotate_y)
1655
+ fig_l.update_layout(legend_title_text="")
1656
+ return fig_l
1657
+
1467
1658
  if y_multi and not x_multi and isinstance(x, str) \
1468
1659
  and x in data.columns:
1469
1660
  xd = data[x]
@@ -1596,6 +1787,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1596
1787
  # chart: synthesize it rather than looking up a column
1597
1788
  def _resolve(name, arg):
1598
1789
  if isinstance(name, str) and name == ".Index":
1790
+ if ".Index" in data.columns: # numbered within series
1791
+ return data[".Index"].rename("Index")
1599
1792
  return pd.Series(range(1, len(data) + 1),
1600
1793
  index=data.index, name="Index")
1601
1794
  return get_column(data, name, arg)
@@ -1640,6 +1833,30 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1640
1833
  y = "Index"
1641
1834
 
1642
1835
  by_ser = get_column(data, by, "by") if by is not None else None
1836
+
1837
+ # ----- bubbles: pt_size names a variable that sizes each point,
1838
+ # its area by size^power, the largest radius inches across, as
1839
+ # R's bubble plot (XY.R ~489, plt.plotly bubbles)
1840
+ size_ser = size_name = None
1841
+ if isinstance(pt_size, str):
1842
+ if (facet is not None or form != "scatter"
1843
+ or index_x or qq_key is not None):
1844
+ raise ValueError(
1845
+ f"pt_size='{pt_size}' sizes the points of a "
1846
+ "scatterplot by a variable, a bubble plot,\nwhich "
1847
+ "draws on one panel: no facet, form, run chart, or "
1848
+ "q-q chart")
1849
+ size_ser = get_column(data, pt_size, "pt_size")
1850
+ if not pd.api.types.is_numeric_dtype(size_ser):
1851
+ raise TypeError(
1852
+ f"pt_size='{pt_size}' sizes the bubbles by the values "
1853
+ "of a numeric variable")
1854
+ size_name, pt_size = pt_size, 1
1855
+ elif radius is not None or power is not None:
1856
+ raise ValueError(
1857
+ "radius and power size the bubbles of a bubble plot, "
1858
+ "where pt_size names a\nnumeric variable, such as "
1859
+ "pt_size='Pre'")
1643
1860
  (facet_ser, facet_name,
1644
1861
  facet2_ser, facet2_name) = resolve_facet(data, facet, "XY")
1645
1862
 
@@ -1716,6 +1933,17 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1716
1933
 
1717
1934
  # stat: the statistic of y at each value of a continuous x, the
1718
1935
  # values first reported by level of x (XY.R, .ss.numeric(y, by=x))
1936
+ # n_bins: the mean (or median) of y within each of n_bins equal
1937
+ # bins of x, at the bin midpoints, the point sized by the count
1938
+ # and the points joined (R .plt.bins)
1939
+ bin_diam = bin_n = None
1940
+ if n_bins is not None:
1941
+ x_ser, y_ser, ylab, bin_diam, bin_n = _plt_bins(
1942
+ x_ser, y_ser, x, y, n_bins, stat, by, facet, pt_size,
1943
+ ylab, 3 if digits_d is None else digits_d, show)
1944
+ data = pd.DataFrame({x: x_ser, y: y_ser})
1945
+ stat = None
1946
+
1719
1947
  stat_lines = None
1720
1948
  if stat is not None:
1721
1949
  for a_, b_ in ((x, y), (y, x)):
@@ -1776,6 +2004,16 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1776
2004
  except (ValueError, TypeError): # not a date column
1777
2005
  pass
1778
2006
 
2007
+ # consecutive integer years become dates, Jan 1 of each, as XY.R
2008
+ # ~733, so they drive the time-series display and a forecast
2009
+ yrs = None if index_x else _as_years(x_ser)
2010
+ if yrs is not None:
2011
+ x_ser = yrs
2012
+ if show:
2013
+ print(f"\n>>> Note: Variable {x} assumed to be annual years\n"
2014
+ "Converted to a type Date variable\n"
2015
+ "If forecasting, there will be no seasonality.\n")
2016
+
1779
2017
  is_date = pd.api.types.is_datetime64_any_dtype(x_ser)
1780
2018
  # XY() displays the relationship of two continuous variables; a
1781
2019
  # categorical x or y belongs to another view, so name its call
@@ -1819,10 +2057,12 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1819
2057
  y_ser = y_ser.fillna(ts_NA)
1820
2058
 
1821
2059
  used = [s for s in (x_ser, y_ser, by_ser, facet_ser,
1822
- facet2_ser)
2060
+ facet2_ser, size_ser)
1823
2061
  if s is not None]
1824
2062
  keep = ~pd.concat(used, axis=1).isna().any(axis=1)
1825
2063
  x_ser, y_ser = x_ser[keep], y_ser[keep]
2064
+ if size_ser is not None:
2065
+ size_ser = size_ser[keep]
1826
2066
  if by_ser is not None:
1827
2067
  by_ser = by_ser[keep]
1828
2068
  if facet_ser is not None:
@@ -1830,11 +2070,50 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1830
2070
  if facet2_ser is not None:
1831
2071
  facet2_ser = facet2_ser[keep]
1832
2072
 
2073
+ if is_date and size_ser is not None:
2074
+ raise ValueError(
2075
+ f"pt_size='{size_name}' sizes the points of a scatterplot, "
2076
+ "not of a time series")
1833
2077
  if is_date and form != "scatter":
1834
2078
  raise ValueError(
1835
2079
  f'form="{form}" describes the joint density of two '
1836
2080
  "numerical variables, not a time series")
1837
2081
 
2082
+ if is_date and show:
2083
+ # the line connects the points in date order within each
2084
+ # series. A date repeated within a series, unless ts_unit
2085
+ # aggregates it, means more than one series is present;
2086
+ # otherwise rows out of order are reordered for the plot
2087
+ # below, the data unchanged. R analog: XY.R date.var check
2088
+ g_nm = [nm for nm, sr in ((by, by_ser), (facet_name, facet_ser),
2089
+ (facet2_name, facet2_ser))
2090
+ if sr is not None]
2091
+ g_ser = [sr.astype(str) for sr in (by_ser, facet_ser, facet2_ser)
2092
+ if sr is not None]
2093
+ key = (pd.concat(g_ser, axis=1).agg("\x1f".join, axis=1)
2094
+ if g_ser else pd.Series("", index=x_ser.index))
2095
+ in_grp = (f" within each level of {', '.join(g_nm)}"
2096
+ if g_nm else "")
2097
+ rep = pd.DataFrame({"k": key, "x": x_ser}).groupby(
2098
+ ["k", "x"]).size()
2099
+ if ts_unit is not None:
2100
+ pass # aggregated by period: any order
2101
+ elif (rep > 1).any():
2102
+ print(f"\n>>> Note\nThe dates of {x} repeat{in_grp}, up "
2103
+ f"to {int(rep.max())} times, so\n the data hold more "
2104
+ "than one series, and the line connects\n points "
2105
+ "from different series. Separate the series with by "
2106
+ "or facet,\n or select one with filter.\n")
2107
+ elif not all(grp.is_monotonic_increasing for _, grp in
2108
+ x_ser.groupby(key, sort=False)):
2109
+ ob = g_nm + [x]
2110
+ ob = (f"'{x}'" if len(ob) == 1
2111
+ else "[" + ", ".join(f"'{v}'" for v in ob) + "]")
2112
+ print(f"\n>>> Note\nThe rows are not in order of {x}{in_grp},"
2113
+ f"\n so they are sorted by {x} for this plot. The "
2114
+ "data frame is unchanged.\n To sort it, enter:\n"
2115
+ f" d = lp.order_by(d, by={ob})\n")
2116
+
1838
2117
  if is_date: # time series: date order
1839
2118
  od = x_ser.sort_values(kind="stable").index
1840
2119
  x_ser, y_ser = x_ser.loc[od], y_ser.loc[od]
@@ -1844,10 +2123,15 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1844
2123
  facet_ser = facet_ser.loc[od]
1845
2124
  if facet2_ser is not None:
1846
2125
  facet2_ser = facet2_ser.loc[od]
1847
- if fit != "off" or (ellipse is not False and ellipse):
1848
- raise NotImplementedError(
1849
- "fit and ellipse for a time series (date x) are "
1850
- "not yet ported")
2126
+ if ellipse is not False and ellipse:
2127
+ raise ValueError(
2128
+ "An ellipse describes the joint distribution of two "
2129
+ "continuous variables,\nnot a time series: no ellipse "
2130
+ "with a date x")
2131
+ if fit != "off" and facet_ser is not None:
2132
+ raise ValueError(
2133
+ "fit on a time series draws on a single panel: no "
2134
+ "facet=")
1851
2135
  if MD_cut > 0 or out_cut > 0:
1852
2136
  raise ValueError(
1853
2137
  "outlier flagging (MD_cut/out_cut) applies to a "
@@ -1857,10 +2141,14 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1857
2141
  "forecasting (ts_ahead) requires a date variable "
1858
2142
  "for x")
1859
2143
 
1860
- if not is_date and (ts_stack or ts_area_fill is not None):
2144
+ if not is_date and not index_x and (
2145
+ ts_stack or ts_area_fill is not None):
1861
2146
  raise ValueError(
1862
2147
  "ts_stack and ts_area_fill apply to a time series "
1863
- "(date x)")
2148
+ "(date x) or a run chart (x='.Index')")
2149
+ if index_x and ts_stack:
2150
+ raise ValueError(
2151
+ "ts_stack stacks several time series over a date x")
1864
2152
 
1865
2153
  # ----- ts aggregation (ts_unit/ts_agg) --------------------------
1866
2154
  # facets aggregate within each panel, below
@@ -1875,7 +2163,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1875
2163
  ylab = _ts_ylab(
1876
2164
  y, ts_unit if ts_unit is not None
1877
2165
  else _infer_unit(x_ser.to_numpy()), ts_agg_done, ts_agg)
1878
- if is_date and (ts_stack or ts_area_fill is not None) \
2166
+ if (is_date or index_x) and (ts_stack or ts_area_fill is not None) \
1879
2167
  and pt_size == 1:
1880
2168
  pt_size = 0 # R: no points with areas
1881
2169
 
@@ -1910,8 +2198,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1910
2198
  "not a time series")
1911
2199
  if ellipse is True:
1912
2200
  ellipse = 0.95
1913
- f_col = to_hex(get_option("fit_color", "#5C4032")
1914
- if fit_color is None else fit_color)
2201
+ f_col = as_plotly_color(get_option("fit_color", "#5C4032")
2202
+ if fit_color is None else fit_color)
1915
2203
  f_lwd = get_option("fit_lwd", 2) if fit_lwd is None else fit_lwd
1916
2204
  e_lvls = ([] if not ellipse else
1917
2205
  list(ellipse) if isinstance(ellipse, (list, tuple))
@@ -1923,7 +2211,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1923
2211
  ex, ey = _ellipse_region(xv_, yv_, float(lv))
1924
2212
  fig_.add_trace(go.Scatter(
1925
2213
  x=ex, y=ey, mode="lines",
1926
- line=dict(color=to_hex(
2214
+ line=dict(color=as_plotly_color(
1927
2215
  get_option("ellipse_color", "gray20")
1928
2216
  if ellipse_color is None else ellipse_color),
1929
2217
  width=get_option("ellipse_lwd", 1)
@@ -2037,7 +2325,9 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2037
2325
  n_col=n_col_use, axis_fmt=axis_fmt,
2038
2326
  axis_x_pre=axis_x_pre, axis_y_pre=axis_y_pre),
2039
2327
  rotate_x, rotate_y)
2040
- if is_date: # time-series panels
2328
+ # time-series panels; a filled run chart draws its panels
2329
+ # the same way, on the row index (XY.R lattice area)
2330
+ if is_date or (index_x and ts_area_fill is not None):
2041
2331
  # ts_unit aggregates within each panel and group; the
2042
2332
  # forecast is of each panel's series (XY.R, Aug 2026)
2043
2333
  f_arr = facet_ser.to_numpy()
@@ -2064,7 +2354,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2064
2354
  ts_by = np.asarray(cols[ci], dtype=object)
2065
2355
  f_order = [str(v) for v in category_order(facet_ser)]
2066
2356
  f_arr = np.asarray(f_arr).astype(str)
2067
- if ylab is None:
2357
+ if ylab is None and is_date:
2068
2358
  from .plt_time import _infer_unit
2069
2359
  ylab = _ts_ylab(y, ts_unit if ts_unit is not None
2070
2360
  else _infer_unit(x_ser.to_numpy()),
@@ -2142,8 +2432,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2142
2432
  for i in range(n_grp)]
2143
2433
  if transparency is None:
2144
2434
  transparency = get_option("trans_pt_fill", 0.10)
2145
- if fit_se is None:
2146
- fit_se = 0.95 if by is None else 0
2435
+ if fit_se is None: # residuals, not a band
2436
+ fit_se = 0 if fit_errors else (0.95 if by is None else 0)
2147
2437
  se_lv = [lv for lv in (
2148
2438
  fit_se if isinstance(fit_se, (list, tuple))
2149
2439
  else [fit_se]) if lv]
@@ -2176,7 +2466,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2176
2466
  y if ylab is None else ylab,
2177
2467
  main, 2 if digits_d is None else digits_d,
2178
2468
  facet2_arr=facet2_arr, facet2_order=f2_order,
2179
- facet2_name=facet2_name, n_col=n_col_use),
2469
+ facet2_name=facet2_name, n_col=n_col_use,
2470
+ fit_errors=fit_errors),
2180
2471
  rotate_x, rotate_y)
2181
2472
  if qq_key is not None:
2182
2473
  _qq_reference(fig_f, fit_color, fit_lwd)
@@ -2194,6 +2485,27 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2194
2485
  groups = [(nm, xv[bv == nm], yv[bv == nm])
2195
2486
  for nm in by_order]
2196
2487
 
2488
+ # bubble diameters in pixels: 2 * (v / max)^power * radius at
2489
+ # 96 dpi, radius .12 inches by default (R plt.plotly bubbles)
2490
+ sizes = None
2491
+ if bin_diam is not None: # bins sized by their counts
2492
+ sizes, size_name = [(bin_diam, bin_n)], "n"
2493
+ if size_ser is not None:
2494
+ sv = size_ser.to_numpy(dtype=float)
2495
+ rad = 0.12 if radius is None else float(radius)
2496
+ pw = 0.5 if power is None else float(power)
2497
+ sz = np.clip(sv, 0, None) ** pw
2498
+ mx = float(sz.max()) if len(sz) else 0.0
2499
+ diam = (2 * (sz / mx) * rad * 96 if mx > 0
2500
+ else np.zeros_like(sz))
2501
+ sizes = ([(diam, sv)] if by_ser is None else
2502
+ [(diam[bv == nm], sv[bv == nm]) for nm in by_order])
2503
+ if show:
2504
+ print("\nSome Parameter values (can be manually set)")
2505
+ print("-" * 55)
2506
+ print(f"radius: {rad:.2f} size of largest bubble")
2507
+ print(f"power: {pw:.2f} relative bubble sizes")
2508
+
2197
2509
  # ----- jitter: the display coordinates only --------------------
2198
2510
  # auto-jitter an axis with few discrete values (R plt.main.R
2199
2511
  # ~634-659); the fit, ellipse, and statistics use the
@@ -2240,6 +2552,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2240
2552
  fills = ([get_option("pt_color", "#324E5C")] if n_grp == 1
2241
2553
  else by_colors(n_grp))
2242
2554
  else:
2555
+ fill = _expand_palette(fill, n_grp)
2243
2556
  fills = list(fill) if isinstance(fill, (list, tuple)) \
2244
2557
  else [fill]
2245
2558
  borders = None if color is None else color
@@ -2261,6 +2574,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2261
2574
  "ts_stack requires the same dates at every "
2262
2575
  "level of by=")
2263
2576
  if ts_area_fill is not None:
2577
+ ts_area_fill = _expand_palette(ts_area_fill, n_grp)
2264
2578
  a_base = (list(ts_area_fill)
2265
2579
  if isinstance(ts_area_fill, (list, tuple))
2266
2580
  else [ts_area_fill])
@@ -2287,7 +2601,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2287
2601
  cum = yc
2288
2602
  groups = stacked
2289
2603
  disp_groups = stacked
2290
- elif is_date and ts_area_fill is not None:
2604
+ elif (is_date or index_x) and ts_area_fill is not None:
2291
2605
  nm0, xg, yg = groups[0]
2292
2606
  lo, hi = float(yg.min()), float(yg.max())
2293
2607
  pad_a = 0.04 * (hi - lo) if hi > lo else 1.0
@@ -2315,7 +2629,12 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2315
2629
  fit_lines, se_polys, fit_stats = [], [], []
2316
2630
  err_lines = [] # fit_errors residual segments
2317
2631
  if fit != "off":
2318
- for nm, xg, yg in groups:
2632
+ # a date x is fit as days, the fit drawn back on the dates
2633
+ day = 86400e9 # nanoseconds
2634
+ for nm, xg, yg in (
2635
+ [(n_, xg_.astype("datetime64[ns]").astype("int64") / day,
2636
+ yg_) for n_, xg_, yg_ in groups]
2637
+ if is_date else groups):
2319
2638
  if len(xg) < 2:
2320
2639
  continue
2321
2640
  if fit == "loess": # R: bands for lm and loess
@@ -2325,7 +2644,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2325
2644
  if fit_errors:
2326
2645
  err_lines.append((xs, ys_s, f))
2327
2646
  for lv in se_levels:
2328
- tq = sps.t.ppf((1 + lv) / 2, len(xs) - 1)
2647
+ tq = sps.t.ppf((1 + lv) / 2, _loess.df)
2329
2648
  se_polys.append((
2330
2649
  np.concatenate([xs, xs[::-1]]),
2331
2650
  np.concatenate([f + tq * se_f,
@@ -2340,23 +2659,36 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2340
2659
  err_lines.append((xs[okf], ys_s[okf], f[okf]))
2341
2660
  if fit == "lm":
2342
2661
  for lv in se_levels:
2343
- se_polys.append(_se_band(xs, ys_s, f, lv))
2662
+ band = _se_band(xs, ys_s, f, lv)
2663
+ if band is not None:
2664
+ se_polys.append(band)
2665
+
2666
+ if is_date and fit != "off": # days back to dates
2667
+ def _dates(v):
2668
+ v = np.asarray(v, dtype=float)
2669
+ return (v * day).round().astype("int64").astype(
2670
+ "datetime64[ns]")
2671
+ for fl in fit_lines:
2672
+ fl["x"] = _dates(fl["x"])
2673
+ se_polys = [(_dates(bx), by_) for bx, by_ in se_polys]
2674
+ err_lines = [(_dates(xe), ye, fe) for xe, ye, fe in err_lines]
2344
2675
 
2345
2676
  # ----- ellipses ------------------------------------------------
2346
2677
  if ellipse is True:
2347
2678
  ellipse = 0.95
2348
2679
  ellipses = []
2349
- if ellipse:
2350
- if not 0 < ellipse < 1:
2351
- raise ValueError("ellipse is the confidence level of "
2352
- "a data ellipse, between 0 and 1")
2680
+ e_levels = _ellipse_levels(ellipse)
2681
+ for nm, xg, yg in (groups if e_levels else []):
2682
+ if len(xg) < 3:
2683
+ who = "" if nm is None else f" for group '{nm}'"
2684
+ raise ValueError(
2685
+ f"ellipse: need at least 3 points{who}, "
2686
+ f"found {len(xg)}")
2687
+ # level by level, each over the groups, so ellipse k belongs to
2688
+ # group k % n_grp
2689
+ for lv in e_levels:
2353
2690
  for nm, xg, yg in groups:
2354
- if len(xg) < 3:
2355
- who = "" if nm is None else f" for group '{nm}'"
2356
- raise ValueError(
2357
- f"ellipse: need at least 3 points{who}, "
2358
- f"found {len(xg)}")
2359
- ellipses.append(_ellipse_region(xg, yg, ellipse))
2691
+ ellipses.append(_ellipse_region(xg, yg, lv))
2360
2692
 
2361
2693
  if digits_d is None:
2362
2694
  digits_d = 2
@@ -2479,11 +2811,13 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2479
2811
  fig = plt_plotly(
2480
2812
  disp_groups, by_name=by,
2481
2813
  fill=fills, border=borders, shape=pt_shape, pt_size=pt_size,
2814
+ sizes=sizes, size_name=size_name,
2482
2815
  x_lab=x if xlab is None else xlab,
2483
2816
  y_lab=y if ylab is None else ylab,
2484
2817
  ax=ax, gridT1=gridT1, gridT2=ax["axT2"],
2485
2818
  main=main, digits_d=digits_d,
2486
- connect=is_date or show_runs or index_x, ln_width=line_width,
2819
+ connect=(is_date or show_runs or index_x
2820
+ or bin_diam is not None), ln_width=line_width,
2487
2821
  is_date=is_date,
2488
2822
  pt_opacity=1 - transparency,
2489
2823
  area_polys=area_polys,
@@ -2521,6 +2855,17 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2521
2855
  # runs test: connect the points and report the run analysis
2522
2856
  # below; the run chart's center line is the median, so a run
2523
2857
  # defaults center_line to "median" (XY.R ~809)
2858
+ # unspecified, a run chart shows its median unless the values
2859
+ # seldom cross their mean, fewer than 15% of the steps (XY.R ~2382)
2860
+ if center_line is None:
2861
+ center_line = "off"
2862
+ yc = np.asarray(yv, dtype=float)
2863
+ yc = yc[np.isfinite(yc)]
2864
+ if index_x and len(yc) > 1:
2865
+ m_c = yc.mean()
2866
+ n_change = int(np.sum((yc[1:] > m_c) != (yc[:-1] > m_c)))
2867
+ if n_change / (len(yc) - 1) >= 0.15:
2868
+ center_line = "median"
2524
2869
  run_lines = None
2525
2870
  if show_runs:
2526
2871
  m_run, run_lines = _run_analysis(yv, digits_d, True)
@@ -2536,7 +2881,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2536
2881
  m_cl, lbl = float(np.median(yv)), " median"
2537
2882
  else: # "zero": line but no label
2538
2883
  m_cl, lbl = 0.0, ""
2539
- gray = to_hex("gray50")
2884
+ gray = as_plotly_color("gray50")
2540
2885
  fig.add_hline(y=m_cl, line=dict(color=gray, dash="dash",
2541
2886
  width=1))
2542
2887
  if lbl:
@@ -2601,7 +2946,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2601
2946
  fig.add_trace(go.Scatter(
2602
2947
  x=xs2, y=f2, mode="lines",
2603
2948
  name="Fit (no outliers)",
2604
- line=dict(color=to_hex(
2949
+ line=dict(color=as_plotly_color(
2605
2950
  get_option("fit_color", "#5C4032")
2606
2951
  if fit_color is None else fit_color),
2607
2952
  width=get_option("fit_lwd", 2)
@@ -2638,6 +2983,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2638
2983
  say(xc.suggest_series(pre, ".Index", y, dname, given,
2639
2984
  ser_extra, run=True, pt_size=pt_size),
2640
2985
  xc.run_summary(yv, dd_r), run_lines[1:])
2986
+ elif bin_diam is not None: # the bin tables are the report
2987
+ pass
2641
2988
  else:
2642
2989
  say(*scatter_report(groups, fit_stats, md_lines))
2643
2990
  if fit_new is not None and fit in _FIT_NEW_OK and show:
@@ -2660,4 +3007,94 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2660
3007
  # font_size= scales all text of the returned figure
2661
3008
  from .stats_out import attach_stats as _attach_stats # noqa: E402
2662
3009
  from .style import with_theme as _with_theme # noqa: E402
2663
- XY = font_scaled(_attach_stats(_with_theme(XY), capture=True))
3010
+ from .utils import file_output # noqa: E402
3011
+ def _banking(x, y, data, filter, facet):
3012
+ """lattice's aspect="xy": banking to 45 degrees, the median of
3013
+ |dx/dy| between x-adjacent points, each difference scaled by the
3014
+ range of its variable, the median over the facet panels.
3015
+ R analog: lattice::banking() of prepanel.default.xyplot"""
3016
+ d = data.query(filter) if filter is not None else data
3017
+ fcols = ([] if facet is None else
3018
+ [facet] if isinstance(facet, str) else list(facet))
3019
+ d = d[[x, y] + fcols].dropna()
3020
+ xn = d[x]
3021
+ if pd.api.types.is_datetime64_any_dtype(xn):
3022
+ xn = xn.astype("datetime64[ns]").astype("int64")
3023
+ xn = xn.to_numpy(dtype=float)
3024
+ yn = d[y].to_numpy(dtype=float)
3025
+ rx, ry = np.ptp(xn), np.ptp(yn)
3026
+ if rx == 0 or ry == 0:
3027
+ return 1.0
3028
+ keys = ([np.zeros(len(d), int)] if not fcols else
3029
+ [d[c].astype(str).to_numpy() for c in fcols])
3030
+ key = pd.Series(list(zip(*keys)))
3031
+ banks = []
3032
+ for k in key.unique():
3033
+ m = (key == k).to_numpy()
3034
+ od = np.argsort(xn[m], kind="stable")
3035
+ dx = np.diff(xn[m][od]) / rx
3036
+ dy = np.diff(yn[m][od]) / ry
3037
+ ok = (dx != 0) & (dy != 0)
3038
+ if ok.any():
3039
+ banks.append(float(np.median(np.abs(dx[ok] / dy[ok]))))
3040
+ return float(np.median(banks)) if banks else 1.0
3041
+
3042
+
3043
+ def _set_aspect(fig, ratio, width=700):
3044
+ """Size the figure so each panel's height is ratio times its
3045
+ width, from the panel's share of the plotting area and the
3046
+ margins, at width pixels unless the figure sets its own."""
3047
+ lay = fig.layout
3048
+ W = lay.width or width
3049
+ m = lay.margin
3050
+ ml = 80 if m.l is None else m.l
3051
+ mr = 80 if m.r is None else m.r
3052
+ mt = 100 if m.t is None else m.t
3053
+ mb = 80 if m.b is None else m.b
3054
+ xd = lay.xaxis.domain or (0, 1)
3055
+ yd = lay.yaxis.domain or (0, 1)
3056
+ panel_w = (xd[1] - xd[0]) * (W - ml - mr)
3057
+ H = ratio * panel_w / (yd[1] - yd[0]) + mt + mb
3058
+ fig.update_layout(width=W, height=round(H))
3059
+
3060
+
3061
+ def _with_aspect(func):
3062
+ """aspect= the shape of the panels as their height over width,
3063
+ lattice's aspect: a number, "xy" to bank the median slope to 45
3064
+ degrees, or "fill", the default, the panels filling the figure."""
3065
+ import functools
3066
+ import inspect
3067
+ sig = inspect.signature(func)
3068
+
3069
+ @functools.wraps(func)
3070
+ def wrapper(*args, aspect="fill", **kwargs):
3071
+ if not (aspect in ("fill", "xy") or (
3072
+ isinstance(aspect, (int, float))
3073
+ and not isinstance(aspect, bool) and aspect > 0)):
3074
+ raise ValueError(
3075
+ 'aspect is the panel height over its width: a positive '
3076
+ 'number, "xy" to bank\nto 45 degrees, or "fill"')
3077
+ fig = func(*args, **kwargs)
3078
+ if aspect == "fill" or not hasattr(fig, "layout"):
3079
+ return fig
3080
+ if aspect == "xy":
3081
+ b = sig.bind_partial(*args, **kwargs).arguments
3082
+ x, y = b.get("x"), b.get("y")
3083
+ if not (isinstance(x, str) and isinstance(y, str)
3084
+ and b.get("data") is not None
3085
+ and x in b["data"] and y in b["data"]):
3086
+ raise ValueError(
3087
+ 'aspect="xy" banks the slopes of one x variable '
3088
+ "and one y variable of the data")
3089
+ ratio = _banking(x, y, b["data"], b.get("filter"),
3090
+ b.get("facet"))
3091
+ else:
3092
+ ratio = float(aspect)
3093
+ _set_aspect(fig, ratio)
3094
+ return fig
3095
+
3096
+ return wrapper
3097
+
3098
+
3099
+ XY = file_output(font_scaled(_attach_stats(_with_theme(_with_aspect(XY)),
3100
+ capture=True)))