@@ -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# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
0 commit comments