Repository: lmmentel/mendeleev

Issue:
[MNT] Move `fetch_attrs_for_group` and `estimate_from_group` from `models.py` to `electronegativity.py`
**Is your feature request related to a problem? Please describe.**


**Describe the solution you'd like**
The two functions are only needed for calculating Sanderson's electronegativity and should live in the electronegativity module.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.


Discussion:

Patch:
diff --git a/mendeleev/electronegativity.py b/mendeleev/electronegativity.py
index 514aac5..608a4a6 100644
--- a/mendeleev/electronegativity.py
+++ b/mendeleev/electronegativity.py
@@ -43,6 +43,38 @@ def n_effective(n: int, source: str = "slater") -> Union[float, None]:
         )
 
 
+def interpolate_property(
+    x: int, x_ref: List[int], y_ref: List[float], poly_deg: int = 1
+) -> float:
+    """
+    Estiate a property for element by interpolation or
+    extrapolation of the data points from x`x_ref` and `y_ref`.
+
+    Args:
+        x: value for which the property will be evaluated
+        x_ref: list of values for the elements
+        y_ref: list of values of the property for the elements
+        deg: degree of the polynomial used in the extrapolation beyond
+            the provided data points, default=1
+    """
+    x_ref = np.array(x_ref)
+    y_ref = np.array(y_ref)
+    if x_ref.min() <= x <= x_ref.max():
+        return np.interp([x], x_ref, y_ref)
+
+    # extrapolation
+    if x < x_ref.min():
+        x_slice = x_ref[:3]
+        y_slice = y_ref[:3]
+    elif x > x_ref.max():
+        x_slice = x_ref[-3:]
+        y_slice = y_ref[-3:]
+
+    fit = np.polyfit(x_slice, y_slice, poly_deg)
+    fn = np.poly1d(fit)
+    return fn(x)
+
+
 def allred_rochow(zeff: float, radius: float) -> float:
     """
     Calculate the electronegativity of an atom according to the definition
diff --git a/mendeleev/models.py b/mendeleev/models.py
index 66856fd..0c4dc5c 100644
--- a/mendeleev/models.py
+++ b/mendeleev/models.py
@@ -25,6 +25,7 @@ from .electronegativity import (
     martynov_batsanov,
     mulliken,
     sanderson,
+    interpolate_property,
 )
 from .db import get_session
 from .econf import ElectronicConfiguration, get_l, ORBITALS
@@ -774,15 +775,21 @@ class Element(Base):
         "Pauling's electronegativity"
         return self.en_pauling
 
-    def electronegativity_sanderson(self, radius="covalent_radius_pyykko") -> float:
+    def electronegativity_sanderson(
+        self, radius: str = "covalent_radius_pyykko"
+    ) -> float:
         """
-        Sanderson electronegativity
+        Sanderson's electronegativity
 
         Args:
             radius : radius to use in the calculation
         """
-        # estimate the radius of a corresponding noble gas
-        noble_gas_radius = estimate_from_group(self.atomic_number, radius)
+        results = fetch_by_group(["atomic_number", radius], group=18)
+        # transpose rows to list of properties
+        atomic_numbers, radii = list(zip(*results))
+        noble_gas_radius = interpolate_property(
+            self.atomic_number, atomic_numbers, radii
+        )
         return sanderson(getattr(self, radius), noble_gas_radius)
 
     def nvalence(self, method: str = None) -> int:
@@ -841,6 +848,36 @@ class Element(Base):
         )
 
 
+def fetch_by_group(properties: List[str], group: int = 18) -> tuple[list[Any]]:
+    """
+    Get a specified properties for all the elements of a given group.
+
+    Args:
+        properties: Attributes of `Element` to retrieve for all group members
+        group: Group number to retrieve data for
+
+    Returns:
+        result: list of tuples with the requested properties for all group members
+    """
+    if isinstance(properties, str):
+        props = [properties]
+    else:
+        props = properties[:]
+
+    if "atomic_number" not in props:
+        props = ["atomic_number"] + props
+
+    session = get_session()
+    results = (
+        session.query(*[getattr(Element, prop) for prop in props])
+        .filter(Element.group_id == group)
+        .order_by("atomic_number")
+        .all()
+    )
+    session.close()
+    return results
+
+
 class ValueOrigin(enum.Enum):
     "Options for the origin of the property value."
 
@@ -889,65 +926,6 @@ class PropertyMetadata(Base):
         )
 
 
-def fetch_attrs_for_group(attrs: List[str], group: int = 18) -> Tuple[List[Any]]:
-    """
-    A convenience function for getting a specified attribute for all
-    the memebers of a given group.
-
-    Args:
-        attr : Attribute of `Element` to retrieve for all group members
-
-    Returns:
-        data (dict): Dictionary with noble gas atomic numbers as keys and values of the
-            `attr` as values
-    """
-    session = get_session()
-    members = (
-        session.query(Element)
-        .filter(Element.group_id == group)
-        .order_by(Element.atomic_number)
-        .all()
-    )
-
-    results = tuple([getattr(member, attr) for member in members] for attr in attrs)
-    session.close()
-    return results
-
-
-def estimate_from_group(
-    atomic_number, attr_name, group: int = 18, deg: int = 1
-) -> float:
-    """
-    Evaluate a value `attribute` for element by interpolation or
-    extrapolation of the data points from elements from `group`.
-
-    Args:
-        atomic_number: value for which the property will be evaluated
-        attr_name: attribute to be estimated
-        group: periodic table group number
-        deg: degree of the polynomial used in the extrapolation beyond
-            the provided data points
-    """
-    xref, yref = fetch_attrs_for_group(["atomic_number", attr_name], group=group)
-
-    x = atomic_number
-    xref = np.array(xref)
-    yref = np.array(yref)
-    if xref.min() <= x <= xref.max():
-        return np.interp([x], xref, yref)
-
-    if x < xref.min():
-        xslice = xref[:3]
-        yslice = yref[:3]
-    elif x > xref.max():
-        xslice = xref[-3:]
-        yslice = yref[-3:]
-
-    fit = np.polyfit(xslice, yslice, deg)
-    fn = np.poly1d(fit)
-    return fn(x)
-
-
 class IonicRadius(Base):
     """
     Effective ionic radii and crystal radii in pm retrieved from [1]_.
@@ -1130,6 +1108,7 @@ class Series(Base):
         return "<Series(name={n:s}, color={c:s})>".format(n=self.name, c=self.color)
 
 
+# TODO: move to utils
 def with_uncertainty(value: float, uncertainty: float, digits: int = 5) -> str:
     """Format a value with uncertainty using scientific notation.
 
@@ -1346,6 +1325,7 @@ class PhaseTransition(Base):
         return str(self)
 
 
+# TODO: some    thing is wrong with the docstring
 class ScatteringFactor(Base):
     """Atomic scattering factors
 


Test patch:
diff --git a/tests/test_electronegativity.py b/tests/test_electronegativity.py
index e821873..d5e590e 100644
--- a/tests/test_electronegativity.py
+++ b/tests/test_electronegativity.py
@@ -2,16 +2,17 @@ import pytest
 
 from mendeleev.models import Element
 from mendeleev.electronegativity import (
-    n_effective,
     allred_rochow,
     cottrell_sutton,
+    generic,
     gordy,
+    interpolate_property,
     li_xue,
     martynov_batsanov,
     mulliken,
+    n_effective,
     nagle,
     sanderson,
-    generic,
 )
 
 
@@ -74,3 +75,43 @@ def test_sanderson():
 def test_generic():
     assert generic(4.0, 2.0, 2, 1) == pytest.approx(1.0)
     assert generic(9.0, 3.0, 2, 0.5) == pytest.approx(1.0)
+
+
+def test_interpolation_within_range():
+    x_ref = [1, 2, 4, 5]
+    y_ref = [10, 20, 40, 50]
+    x = 3
+    result = interpolate_property(x, x_ref, y_ref)
+    assert pytest.approx(result, 0.0001) == 30.0
+
+
+def test_extrapolation_below_range():
+    x_ref = [2, 3, 4, 5]
+    y_ref = [20, 30, 40, 50]
+    x = 1
+    result = interpolate_property(x, x_ref, y_ref)
+    assert pytest.approx(result, 1e-1) == 10.0
+
+
+def test_extrapolation_above_range():
+    x_ref = [1, 2, 3, 4]
+    y_ref = [10, 20, 30, 40]
+    x = 5
+    result = interpolate_property(x, x_ref, y_ref)
+    assert pytest.approx(result, 1e-1) == 50.0
+
+
+def test_linear_interpolation():
+    x_ref = [1, 3, 5]
+    y_ref = [10, 30, 50]
+    x = 4
+    result = interpolate_property(x, x_ref, y_ref)
+    assert pytest.approx(result, 0.0001) == 40.0
+
+
+def test_invalid_inputs():
+    x_ref = [1, 2, 3]
+    y_ref = [10, 20]  # Mismatched lengths
+    x = 2
+    with pytest.raises(ValueError):
+        interpolate_property(x, x_ref, y_ref)