Skip to content

Commit 9aaa78d

Browse files
author
bgeurten
committed
feat(viz): add per-year breakpoint-value raincloud
Companion to the existing crossing-count raincloud (plot_breakpoint_raincloud). plot_breakpoint_value_raincloud plots, for each year 2021-2024, the distribution of converged animals' fitted THI (and barn-temp) breakpoint VALUES as a horizontal raincloud: half-violin KDE + jittered scatter + boxplot, one row per year. Reuses the same conventions as the crossing-count plot so the two read as a pair: - per-year Wong colour code (COLOURS["year"]); - Kruskal-Wallis across years for the headline test; - n + median annotation per row. Reads the broken_stick bs frame already loaded by viz_runner and emits SVG + PNG plus a per-(animal, year) CSV companion for reproducibility. Wired into viz_runner next to "Breakpoint raincloud".
1 parent 2fe8c1f commit 9aaa78d

2 files changed

Lines changed: 112 additions & 0 deletions

File tree

β€Žsrc/digimuh/viz_longitudinal.pyβ€Ž

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,116 @@ def plot_breakpoint_raincloud(out_dir: Path) -> None:
296296
save_figure(fig, fname, out_dir)
297297

298298

299+
# ─────────────────────────────────────────────────────────────
300+
# Β« breakpoint value raincloud per year Β»
301+
# ─────────────────────────────────────────────────────────────
302+
303+
def plot_breakpoint_value_raincloud(bs: pd.DataFrame, out_dir: Path) -> None:
304+
"""Raincloud plots of the individual breakpoint *value* per year.
305+
306+
Companion to :func:`plot_breakpoint_raincloud`, which shows crossing
307+
*counts*. Here each converged animal contributes its fitted THI (or
308+
barn-temp) breakpoint for the year, so the rows show how the herd's
309+
threshold distribution shifts across 2021-2024. Same per-year colour
310+
code and Kruskal-Wallis test as the crossing-count raincloud.
311+
"""
312+
import matplotlib.pyplot as plt
313+
from scipy.stats import gaussian_kde, kruskal
314+
setup_figure()
315+
316+
if bs.empty:
317+
log.info(" broken_stick results empty, skipping breakpoint value raincloud")
318+
return
319+
320+
for bp_col, conv_col, pred_label, fname in [
321+
("thi_breakpoint", "thi_converged", "THI breakpoint",
322+
"raincloud_breakpoint_value_thi"),
323+
("temp_breakpoint", "temp_converged", "Barn temp breakpoint (Β°C)",
324+
"raincloud_breakpoint_value_temp"),
325+
]:
326+
conv = bs[bs[conv_col] == True].dropna(subset=[bp_col])
327+
if conv.empty:
328+
continue
329+
330+
years = sorted(conv["year"].unique().astype(int))
331+
if len(years) < 2:
332+
continue
333+
334+
year_data = [conv[conv["year"] == y][bp_col].to_numpy() for y in years]
335+
336+
all_vals = np.concatenate(year_data)
337+
span = all_vals.max() - all_vals.min()
338+
pad = 0.05 * span if span > 0 else 1.0
339+
x_lo, x_hi = all_vals.min() - pad, all_vals.max() + pad
340+
341+
fig, ax = plt.subplots(figsize=(10, 1.5 + 1.4 * len(years)))
342+
343+
for i, (y, vals) in enumerate(zip(years, year_data)):
344+
y_pos = i
345+
colour = COLOURS["year"].get(y, COLOURS["below_bp"])
346+
347+
# Half-violin (KDE) β€” above row centre
348+
if len(vals) > 5:
349+
kde = gaussian_kde(vals)
350+
x_kde = np.linspace(x_lo, x_hi, 200)
351+
density = kde(x_kde)
352+
density_scaled = density / density.max() * 0.38
353+
ax.fill_between(x_kde, y_pos, y_pos + density_scaled,
354+
alpha=0.3, color=colour)
355+
ax.plot(x_kde, y_pos + density_scaled,
356+
color=colour, linewidth=1.2)
357+
358+
# Jittered scatter β€” below row centre
359+
jitter = np.random.uniform(-0.15, -0.35, len(vals))
360+
ax.scatter(vals, y_pos + jitter, s=10, alpha=0.5,
361+
color=colour, edgecolors="none", zorder=3)
362+
363+
# Boxplot β€” at row centre
364+
ax.boxplot(
365+
vals, positions=[y_pos - 0.02], widths=0.12,
366+
vert=False, patch_artist=True,
367+
boxprops=dict(facecolor=colour, alpha=0.5, edgecolor="#333"),
368+
medianprops=dict(color="#333", linewidth=2),
369+
whiskerprops=dict(color="#333"),
370+
capprops=dict(color="#333"),
371+
flierprops=dict(marker="o", markersize=2, alpha=0.3),
372+
manage_ticks=False,
373+
)
374+
375+
# n animals + median label
376+
ax.text(x_lo, y_pos - 0.38,
377+
f"n={len(vals)}, median={np.median(vals):.1f}",
378+
fontsize=8, color="#666", va="center")
379+
380+
# Kruskal-Wallis across years (same test as the crossing raincloud)
381+
valid_groups = [v for v in year_data if len(v) >= 3]
382+
if len(valid_groups) >= 2:
383+
try:
384+
stat, p = kruskal(*valid_groups)
385+
ax.text(0.99, 0.02,
386+
f"Kruskal-Wallis H={stat:.1f}, p={p:.3g}",
387+
transform=ax.transAxes, ha="right", va="bottom",
388+
fontsize=9, color="#333",
389+
bbox=dict(boxstyle="round,pad=0.3",
390+
facecolor="white", alpha=0.8))
391+
except ValueError:
392+
pass
393+
394+
ax.set_yticks(range(len(years)))
395+
ax.set_yticklabels([str(y) for y in years], fontsize=11)
396+
ax.set_xlabel(f"{pred_label} per animal")
397+
ax.set_title(f"Annual {pred_label} per animal", fontsize=13,
398+
fontweight="bold")
399+
ax.set_xlim(x_lo, x_hi)
400+
ax.invert_yaxis()
401+
fig.tight_layout()
402+
save_figure(fig, fname, out_dir)
403+
404+
# CSV companion (one row per animal-year) for reproducibility
405+
conv[["animal_id", "year", bp_col]].to_csv(
406+
resolve_output(out_dir, f"{fname}.csv"), index=False)
407+
408+
299409
# ─────────────────────────────────────────────────────────────
300410
# Β« breakpoint ICC forest plot Β»
301411
# ─────────────────────────────────────────────────────────────

β€Žsrc/digimuh/viz_runner.pyβ€Ž

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from digimuh.viz_longitudinal import (
4646
plot_breakpoint_icc,
4747
plot_breakpoint_raincloud,
48+
plot_breakpoint_value_raincloud,
4849
plot_longitudinal_breakpoints,
4950
plot_longitudinal_sankey,
5051
plot_threshold_sankey,
@@ -142,6 +143,7 @@ def main() -> None:
142143
("TNF vs yield", lambda: plot_tnf_yield(d)),
143144
("Longitudinal breakpoints", lambda: plot_longitudinal_breakpoints(bs, d)),
144145
("Breakpoint raincloud", lambda: plot_breakpoint_raincloud(d)),
146+
("Breakpoint value raincloud", lambda: plot_breakpoint_value_raincloud(bs, d)),
145147
("Breakpoint ICC forest", lambda: plot_breakpoint_icc(d)),
146148
("Longitudinal Sankey", lambda: plot_longitudinal_sankey(bs, d)),
147149
("Sankey diagrams", lambda: plot_threshold_sankey(bs, d)),

0 commit comments

Comments
Β (0)