@@ -321,23 +321,20 @@ def draw_profile(
321321
322322 _resolved_quantile_mode = None
323323 _quantile_pair = None
324+ _quantile_list = None
324325 if quantiles is not None :
325326 # Validate and auto-detect mode
326327 if quantile_mode == 'auto' :
327328 _resolved_quantile_mode = _detect_quantile_mode (quantiles )
328- elif quantile_mode in ('error_bars' , 'band' ):
329+ elif quantile_mode in ('error_bars' , 'band' , 'discrete' ):
329330 _resolved_quantile_mode = quantile_mode
330331 # Still validate the list
331332 for q in quantiles :
332333 if q <= 0 or q >= 1 :
333334 raise ValueError (f"quantiles must be in (0, 1), got { q } " )
334- elif quantile_mode in ('discrete' , 'nested_band' ):
335- raise NotImplementedError (
336- f"Phase B: { quantile_mode } mode is not yet implemented."
337- )
338335 else :
339336 raise ValueError (
340- f"quantile_mode must be 'auto', 'error_bars', or 'band ', "
337+ f"quantile_mode must be 'auto', 'error_bars', 'band', or 'discrete ', "
341338 f"got { quantile_mode !r} "
342339 )
343340
@@ -350,12 +347,16 @@ def draw_profile(
350347 "quantile_mode='band' which supports central='none'."
351348 )
352349
353- # Extract the quantile pair (lower, upper) for computation
350+ # Extract quantile values for computation
354351 qs = sorted (quantiles )
352+ _quantile_pair = None
353+ _quantile_list = None
355354 if _resolved_quantile_mode == 'error_bars' :
356355 _quantile_pair = (qs [0 ], qs [1 ])
357356 elif _resolved_quantile_mode == 'band' :
358357 _quantile_pair = (qs [0 ], qs [2 ]) # skip 0.5 in the middle
358+ elif _resolved_quantile_mode == 'discrete' :
359+ _quantile_list = qs # all quantiles, one line each
359360
360361 # AD-52 FIX1: rebind error only when user did NOT explicitly set it.
361362 # None (signature default) → rebind to "quantile" for error_bars mode.
@@ -462,13 +463,21 @@ def draw_profile(
462463
463464 # Phase 13.25.DF: Compute per-bin quantiles if requested
464465 _q_lower = _q_upper = None
466+ _q_all = None # dict {q_value: per_bin_array} for discrete mode
465467 if quantiles is not None and _quantile_pair is not None :
466468 _ , _q_lower , _q_upper , _ = _compute_per_bin_quantiles (
467469 x_data , y_data , bins , x_range , _quantile_pair ,
468470 )
469471 # Add to stats dict
470472 stats_dict ['q_lower_per_bin' ] = _q_lower
471473 stats_dict ['q_upper_per_bin' ] = _q_upper
474+ elif quantiles is not None and _quantile_list is not None :
475+ # Discrete mode: compute per-bin value for EACH quantile
476+ _q_all = _compute_per_bin_all_quantiles (
477+ x_data , y_data , bins , x_range , _quantile_list ,
478+ )
479+ # Add to stats dict
480+ stats_dict ['quantiles_per_bin' ] = _q_all
472481
473482 # Phase 13.25.DF: Compute per-bin median if needed
474483 _bin_medians = None
@@ -531,6 +540,27 @@ def draw_profile(
531540 capsize = capsize , linestyle = linestyle , linewidth = linewidth ,
532541 label = label , ** kwargs
533542 )
543+ elif _resolved_quantile_mode == 'discrete' and _q_all is not None :
544+ # Discrete mode: one line per quantile value — the general case.
545+ # Central line first (unless central='none')
546+ if central != 'none' :
547+ ax .errorbar (
548+ bin_centers [plot_mask ], _central_values [plot_mask ],
549+ yerr = bin_errors [plot_mask ],
550+ fmt = marker , color = color , markersize = markersize ,
551+ capsize = capsize , linestyle = linestyle , linewidth = linewidth ,
552+ label = label , ** kwargs
553+ )
554+ # One dashed line per quantile
555+ _ls_cycle = ['--' , '-.' , ':' , (0 , (3 , 1 , 1 , 1 ))]
556+ for j , (q_val , q_per_bin ) in enumerate (_q_all .items ()):
557+ q_ls = _ls_cycle [j % len (_ls_cycle )]
558+ q_label = f'q={ q_val :.0%} ' if q_val != 0.5 else 'median'
559+ ax .plot (
560+ bin_centers [plot_mask ], q_per_bin [plot_mask ],
561+ color = color , linestyle = q_ls , linewidth = linewidth * 0.8 ,
562+ label = q_label ,
563+ )
534564 else :
535565 # No quantiles — standard profile rendering (existing behavior)
536566 ax .errorbar (
@@ -869,25 +899,13 @@ def _detect_quantile_mode(quantiles: list) -> str:
869899 """
870900 Auto-detect quantile rendering mode from the shape of the quantiles list.
871901
872- Phase A supports error_bars and band only; other shapes raise
873- NotImplementedError (Phase B) or ValueError.
874-
875- Parameters
876- ----------
877- quantiles : list of float
878- Quantile fractions in (0, 1).
902+ The GENERAL case is discrete (one line per quantile). Error_bars and band
903+ are OPTIMIZATIONS for specific symmetric shapes.
879904
880905 Returns
881906 -------
882907 str
883- 'error_bars' or 'band' (Phase A).
884-
885- Raises
886- ------
887- ValueError
888- If quantiles is empty, single-valued, or contains out-of-range values.
889- NotImplementedError
890- If quantiles shape requires Phase B modes (discrete-line, nested-band).
908+ 'error_bars', 'band', or 'discrete'.
891909 """
892910 if not quantiles :
893911 raise ValueError ("quantiles must be non-empty list of fractions in (0, 1)" )
@@ -903,49 +921,26 @@ def _detect_quantile_mode(quantiles: list) -> str:
903921 qs = sorted (quantiles )
904922 n = len (qs )
905923
924+ # Single value → discrete (just one quantile line)
906925 if n == 1 :
907- raise ValueError (
908- f"quantiles requires at least a symmetric pair (e.g., [0.16, 0.84]), "
909- f"got single value [{ qs [0 ]} ]"
910- )
926+ return 'discrete'
911927
912- # Check if symmetric pair (no 0.5)
928+ # Check if symmetric pair (no 0.5) → error_bars optimization
913929 if n == 2 :
914930 is_symmetric = abs (qs [0 ] + qs [1 ] - 1.0 ) < 1e-9
915931 has_05 = any (abs (q - 0.5 ) < 1e-9 for q in qs )
916932 if is_symmetric and not has_05 :
917933 return 'error_bars'
918934
919- # Check if symmetric triple with 0.5
935+ # Check if symmetric triple with 0.5 → band optimization
920936 if n == 3 :
921937 has_05 = abs (qs [1 ] - 0.5 ) < 1e-9
922938 is_symmetric = abs (qs [0 ] + qs [2 ] - 1.0 ) < 1e-9
923939 if has_05 and is_symmetric :
924940 return 'band'
925941
926- # Multi-pair symmetric (Phase B: nested-band)
927- # Check if all non-0.5 entries form symmetric pairs
928- non_05 = [q for q in qs if abs (q - 0.5 ) > 1e-9 ]
929- if len (non_05 ) >= 4 :
930- pairs_symmetric = all (
931- abs (non_05 [i ] + non_05 [- (i + 1 )] - 1.0 ) < 1e-9
932- for i in range (len (non_05 ) // 2 )
933- )
934- if pairs_symmetric :
935- raise NotImplementedError (
936- "Phase B: nested-band mode is not yet implemented. "
937- "Use a single symmetric pair (Phase A error_bars, e.g., [0.16, 0.84]) "
938- "or a symmetric triple including 0.5 (Phase A band, e.g., [0.16, 0.5, 0.84]) "
939- "until Phase B ships. See brainstorm §8.1."
940- )
941-
942- # Asymmetric / arbitrary → Phase B discrete-line
943- raise NotImplementedError (
944- "Phase B: discrete-line mode is not yet implemented. "
945- "Use a single symmetric pair (Phase A error_bars, e.g., [0.16, 0.84]) "
946- "or a symmetric triple including 0.5 (Phase A band, e.g., [0.16, 0.5, 0.84]) "
947- "until Phase B ships. See brainstorm §8.1."
948- )
942+ # Everything else → discrete (one line per quantile)
943+ return 'discrete'
949944
950945
951946def _compute_per_bin_quantiles (
@@ -1003,6 +998,43 @@ def _compute_per_bin_quantiles(
1003998 return bin_centers , q_lower , q_upper , bin_counts
1004999
10051000
1001+ def _compute_per_bin_all_quantiles (
1002+ x_data : np .ndarray ,
1003+ y_data : np .ndarray ,
1004+ bins : int ,
1005+ x_range ,
1006+ quantile_list : list ,
1007+ w_data = None ,
1008+ ) -> dict :
1009+ """
1010+ Compute per-bin values for ALL quantiles in the list.
1011+
1012+ The general case: any list of quantile fractions.
1013+ Returns a dict {q_value: per_bin_array} for discrete-mode rendering.
1014+ """
1015+ if w_data is not None :
1016+ raise NotImplementedError ("weighted quantiles deferred to Phase B" )
1017+
1018+ if x_range is None :
1019+ x_range = (np .nanmin (x_data ), np .nanmax (x_data ))
1020+
1021+ bin_edges = np .linspace (x_range [0 ], x_range [1 ], bins + 1 )
1022+ bin_indices = np .clip (np .digitize (x_data , bin_edges ) - 1 , 0 , bins - 1 )
1023+
1024+ result = {}
1025+ for q in quantile_list :
1026+ q_per_bin = np .full (bins , np .nan )
1027+ for i in range (bins ):
1028+ mask = bin_indices == i
1029+ y_bin = y_data [mask ]
1030+ y_bin = y_bin [~ np .isnan (y_bin )]
1031+ if len (y_bin ) > 0 :
1032+ q_per_bin [i ] = float (np .nanpercentile (y_bin , q * 100 ))
1033+ result [q ] = q_per_bin
1034+
1035+ return result
1036+
1037+
10061038def _compute_per_bin_median (
10071039 x_data : np .ndarray ,
10081040 y_data : np .ndarray ,
0 commit comments