From 32908aa8f465d199111959c8a48996306a94c6f8 Mon Sep 17 00:00:00 2001
From: clcheng <chi.lung.cheng@cern.ch>
Date: Sun, 13 Apr 2025 17:14:45 -0700
Subject: [PATCH 1/7] Add Ellipse artist

---
 quickstats/plots/artists.py | 26 ++++++++++++++++++++++++++
 1 file changed, 26 insertions(+)

diff --git a/quickstats/plots/artists.py b/quickstats/plots/artists.py
index f263d6b6..80451e11 100644
--- a/quickstats/plots/artists.py
+++ b/quickstats/plots/artists.py
@@ -1,5 +1,7 @@
 from typing import Optional, Union, Any, Dict, Tuple, Callable
 
+from matplotlib.patches import Ellipse as Ellipse_mpl
+
 from quickstats.core import mappings as mp
 from quickstats.core.decorators import dataclass_ex
 from quickstats.core.typing import ArrayLike
@@ -136,6 +138,30 @@ class FillBetween(SingleArtist):
         )
         return handle
 
+@dataclass_ex(kw_only=True)
+class Ellipse(SingleArtist):
+    
+    xy: Tuple[float, float]
+    width: float
+    height: float
+    angle: float
+
+    def draw(
+        self,
+        ax,
+        base_styles_map: Optional[StylesMapType] = None
+    ):
+        styles = self.resolve_styles(base_styles_map, keys='ellipse')
+        handle = Ellipse_mpl(
+            xy=self.xy,
+            width=self.width,
+            height=self.height,
+            angle=self.angle,
+            **styles
+        )
+        ax.add_patch(handle)
+        return handle
+
 @dataclass_ex(kw_only=True)
 class ErrorBand(MixedArtist):
 
-- 
GitLab


From 53fefd2bac7553d2dc9de3566a5f5032ca9fc217 Mon Sep 17 00:00:00 2001
From: clcheng <chi.lung.cheng@cern.ch>
Date: Sun, 13 Apr 2025 17:15:22 -0700
Subject: [PATCH 2/7] Support for drawing ellipse in AbstractPlot; Support for
 getting color by domain

---
 quickstats/plots/abstract_plot.py | 45 +++++++++++++++++++++++++++++--
 1 file changed, 43 insertions(+), 2 deletions(-)

diff --git a/quickstats/plots/abstract_plot.py b/quickstats/plots/abstract_plot.py
index 854d7f1f..d66d136f 100644
--- a/quickstats/plots/abstract_plot.py
+++ b/quickstats/plots/abstract_plot.py
@@ -58,7 +58,8 @@ from .artists import (
     FillBetween,
     ErrorBand,
     Annotation,
-    Text
+    Text,
+    Ellipse
 )
 
 # Type variables for better type hints
@@ -441,6 +442,33 @@ class AbstractPlot(AbstractObject):
             raise ValueError(f'config option not set: {key}')
         return config[key]
 
+    def get_target_color(
+        self,
+        name: str,
+        target: Optional[str] = None,
+        fallback: bool = True
+    ) -> Optional[str]:
+        """
+        Get color for a domain.
+
+        Parameters
+        ----------
+        name : str
+            The name to get the color for
+        ...
+        fallback : bool, default False
+            Whether to fall back to name-only lookup
+
+        Returns
+        -------
+        Optional[str]
+            The target color if found
+        """
+        domain = self.color_map.format(target, name)
+        if domain not in self.color_map and fallback:
+            return self.color_map.get(name)
+        return self.color_map.get(domain)
+
     def get_target_label(
         self,
         name: str,
@@ -524,6 +552,19 @@ class AbstractPlot(AbstractObject):
         artist = FillBetween(x=x, y1=y1, y2=y2, label=label, styles=kwargs)
         self.add_artist(artist, name=name or label)
 
+    def add_ellipse(
+        self,
+        xy: Tuple[float, float],
+        width: float,
+        height: float,
+        angle: float = 0,
+        label: Optional[str] = None,
+        name: Optional[str] = None,
+        **kwargs
+    ) -> None:
+        artist = Ellipse(xy=xy, width=width, height=height, angle=angle, label=label, styles=kwargs)
+        self.add_artist(artist, name=name or label)
+
     def add_errorband(
         self,
         x: ArrayLike,
@@ -772,7 +813,7 @@ class AbstractPlot(AbstractObject):
         handles: List[Artist] = []
         labels: List[str] = []
 
-        targets = targets or self.legend_order
+        targets = targets or self.legend_order or self.get_labelled_legend_domains()
 
         try:
             for name in targets:
-- 
GitLab


From d3fb4321dc173ed5ea9f881f529d51aeabc5c6ba Mon Sep 17 00:00:00 2001
From: clcheng <chi.lung.cheng@cern.ch>
Date: Sun, 13 Apr 2025 17:15:50 -0700
Subject: [PATCH 3/7] Method for obtaining ellipse shape from ellipse artist

---
 quickstats/plots/template.py | 35 ++++++++++++++++++++++++++++++++---
 1 file changed, 32 insertions(+), 3 deletions(-)

diff --git a/quickstats/plots/template.py b/quickstats/plots/template.py
index 14d2731d..795031c0 100644
--- a/quickstats/plots/template.py
+++ b/quickstats/plots/template.py
@@ -17,7 +17,7 @@ import matplotlib.colors as mcolors
 from matplotlib.axes import Axes 
 from matplotlib.axis import Axis
 from matplotlib.artist import Artist
-from matplotlib.patches import Patch, Rectangle, Polygon
+from matplotlib.patches import Patch, Rectangle, Polygon, Ellipse
 from matplotlib.lines import Line2D
 from matplotlib.container import (
     Container,
@@ -1359,7 +1359,6 @@ def remake_handles(
                     **line_styles
                 )
             new_subhandles.append(subhandle)
-            
             if fill_border and isinstance(subhandle, (PolyCollection, BarContainer)):
                 border_style = border_styles or {}
                 border_handle = Rectangle(
@@ -1617,4 +1616,34 @@ def contour_to_shapes(
     for path in contour.get_paths():
         shape = alphashape(path.vertices, alpha)
         shapes.append(shape)
-    return shapes
\ No newline at end of file
+    return shapes
+
+def ellipse_to_shape(
+    ellipse_patch: "Ellipse",
+    num_points: int = 1000
+):
+    from quickstats.core.modules import require_module
+    require_module("shapely")
+    from shapely.geometry import Polygon
+    
+    t = np.linspace(0, 2 * np.pi, num_points)
+    
+    # Ellipse parameters
+    width, height = ellipse_patch.width, ellipse_patch.height
+    angle_rad = np.deg2rad(ellipse_patch.angle)
+    cx, cy = ellipse_patch.center
+    
+    # Parametric equation of ellipse before rotation
+    x = (width / 2) * np.cos(t)
+    y = (height / 2) * np.sin(t)
+    
+    # Rotation matrix
+    R = np.array([
+        [np.cos(angle_rad), -np.sin(angle_rad)],
+        [np.sin(angle_rad),  np.cos(angle_rad)]
+    ])
+    
+    # Apply rotation and translation
+    points = np.column_stack((x, y)) @ R.T + np.array([cx, cy])
+
+    return Polygon(points)
\ No newline at end of file
-- 
GitLab


From 8efbf0b54e1ac6abc64994eb34587995f934a25d Mon Sep 17 00:00:00 2001
From: clcheng <chi.lung.cheng@cern.ch>
Date: Sun, 13 Apr 2025 17:16:18 -0700
Subject: [PATCH 4/7] Template style for ellipse

---
 quickstats/plots/template_styles.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/quickstats/plots/template_styles.py b/quickstats/plots/template_styles.py
index 2c5de4c5..2478121b 100644
--- a/quickstats/plots/template_styles.py
+++ b/quickstats/plots/template_styles.py
@@ -138,6 +138,8 @@ REGISTRY['default'] = {
     'vline': {
     },
     'line_collection': {
+    },
+    'ellipse': {
     }
 }
 
-- 
GitLab


From 178b81f03604781d482b9b431df997da0813c387 Mon Sep 17 00:00:00 2001
From: clcheng <chi.lung.cheng@cern.ch>
Date: Sun, 13 Apr 2025 17:17:01 -0700
Subject: [PATCH 5/7] Fix likelihood level name not properly formatted

---
 quickstats/plots/likelihood_mixin.py | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/quickstats/plots/likelihood_mixin.py b/quickstats/plots/likelihood_mixin.py
index 6aa8cf27..bc4872fb 100644
--- a/quickstats/plots/likelihood_mixin.py
+++ b/quickstats/plots/likelihood_mixin.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from typing import Optional, Dict, Union, Tuple
 
-from quickstats.maths.numerics import str_encode_value
+from quickstats.maths.numerics import str_encode_value, is_integer
 from quickstats.maths.statistics import sigma_to_chi2, confidence_level_to_chi2
 from .abstract_plot import AbstractPlot
 
@@ -42,7 +42,10 @@ class LikelihoodMixin(AbstractPlot):
     def get_level_key(self, level: Union[int, float], use_sigma: bool = False) -> str:
         """Get dictionary key for confidence/sigma level."""
         key = 'sigma_level' if use_sigma else 'confidence_level'
-        level_str = str_encode_value(level)
+        if is_integer(level):
+            level_str = str(int(level))
+        else:
+            level_str = str_encode_value(level)
         return self.config.get('level_key', {}).get(key, '').format(level_str=level_str)
 
     def get_level_specs(
-- 
GitLab


From 621fcff87922b789ad1d3376da043006e4609e5f Mon Sep 17 00:00:00 2001
From: clcheng <chi.lung.cheng@cern.ch>
Date: Sun, 13 Apr 2025 17:17:55 -0700
Subject: [PATCH 6/7] Some fixes to Likelihood2DPlot

---
 quickstats/plots/likelihood_2D_plot.py | 19 ++++++++-----------
 1 file changed, 8 insertions(+), 11 deletions(-)

diff --git a/quickstats/plots/likelihood_2D_plot.py b/quickstats/plots/likelihood_2D_plot.py
index 00068138..88199342 100644
--- a/quickstats/plots/likelihood_2D_plot.py
+++ b/quickstats/plots/likelihood_2D_plot.py
@@ -34,14 +34,15 @@ class Likelihood2DPlot(LikelihoodMixin, General2DPlot):
         'bestfit': {
             'marker': '*',
             'linewidth': 0,
-            'markersize': 15
+            'markersize': 20,
+            'markeredgecolor': 'black'
         },
         'point': {
             'linewidth': 0,
             'marker': '*',
             'markersize': 20,
             'color': '#E9F1DF',
-            'markeredgecolor': 'black'            
+            'markeredgecolor': 'black'
         },
         'contourf': {
             'extend': 'min'
@@ -77,7 +78,8 @@ class Likelihood2DPlot(LikelihoodMixin, General2DPlot):
         },
         'remove_nan_points_within_distance': None,
         'shade_nan_points': False,
-        'alphashape_alpha': 0.1
+        'alphashape_alpha': 0.1,
+        'distinct_colors': False
     }
 
     def __init__(
@@ -128,7 +130,7 @@ class Likelihood2DPlot(LikelihoodMixin, General2DPlot):
         ]:
             if domain in self.color_map:
                 color = self.color_map[domain]
-                if color not in used_colors:
+                if (color not in used_colors) or (not self.config['distinct_colors']):
                     return color, color_index
                     
         if color_index >= len(default_colors):
@@ -147,7 +149,6 @@ class Likelihood2DPlot(LikelihoodMixin, General2DPlot):
             contour_levels = None
             
         target_styles = super().resolve_target_styles(contour_levels=contour_levels, **kwargs)
-        
         default_colors = self.get_colors()
         color_index = 0
         
@@ -328,6 +329,7 @@ class Likelihood2DPlot(LikelihoodMixin, General2DPlot):
     ) -> None:
         """Draw best fit point (minimum likelihood)."""
         styles = mp.concat((self.styles.get('bestfit'), styles), copy=True)
+        styles.setdefault('color', self.get_target_color('bestfit', domain))
         bestfit_x, bestfit_y, bestfit_z = self.get_bestfit(x, y, z)
         
         bestfit_label_fmt = self.get_target_label('bestfit', domain)
@@ -564,13 +566,8 @@ class Likelihood2DPlot(LikelihoodMixin, General2DPlot):
         self.draw_axis_components(ax, xlabel=xlabel, ylabel=ylabel, title=title)
         self.set_axis_range(ax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax)
         self.finalize(ax)
-        
-        if legend_order is not None:
-            self.legend_order = legend_order
-        else:
-            self.legend_order = self.get_labelled_legend_domains()
             
         if self.config['draw_legend']:
-            self.draw_legend(ax)
+            self.draw_legend(ax, targets=legend_order)
             
         return ax
\ No newline at end of file
-- 
GitLab


From 47f381dff139bfd784ae609cb547ac0b05c222e2 Mon Sep 17 00:00:00 2001
From: clcheng <chi.lung.cheng@cern.ch>
Date: Sun, 13 Apr 2025 17:18:14 -0700
Subject: [PATCH 7/7] Update version

---
 quickstats/_version.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/quickstats/_version.py b/quickstats/_version.py
index 54ed38f7..83c2f44e 100644
--- a/quickstats/_version.py
+++ b/quickstats/_version.py
@@ -1 +1 @@
-__version__ = "0.8.3.5.6"
+__version__ = "0.8.3.5.7"
-- 
GitLab