Repository: synthesizer-project/synthesizer

Issue:
Add useful metrics to stars
Methods that calculate the average age and metallicity, including mass-weighted (by default) and luminosity weighted (with specified band), would be a nice addition.

Discussion:
@WillJRoper: I think the best and most general way to implement this would be to have a method like `get_weighted_attr` which takes a string for an attribute to average and an array of weights. Otherwise, this will get messy quickly with lots of bespoke methods.@stephenmwilkins: I agree having the generic method is useful, but I would also have a couple of standard methods (age, metallicity) that are simply call the generic method. 

Solution patch:
diff --git a/conftest.py b/conftest.py
index 84c738e6..e507e897 100644
--- a/conftest.py
+++ b/conftest.py
@@ -2,7 +2,7 @@
 
 import numpy as np
 import pytest
-from unyt import Hz, Mpc, Msun, Myr, angstrom, erg, km, kpc, s, yr
+from unyt import Hz, Mpc, Msun, Myr, angstrom, cm, erg, km, kpc, s, yr
 
 from synthesizer.emission_models import (
     BimodalPacmanEmission,
@@ -21,6 +21,7 @@ from synthesizer.instruments.filters import UVJ
 from synthesizer.parametric.stars import Stars as ParametricStars
 from synthesizer.particle.gas import Gas
 from synthesizer.particle.stars import Stars
+from synthesizer.photometry import PhotometryCollection
 
 # ================================== GRID =====================================
 
@@ -144,6 +145,49 @@ def particle_stars_B():
     )
 
 
+@pytest.fixture
+def unit_mass_stars():
+    """Return a particle Stars object with unit masses for weighting tests."""
+    return Stars(
+        initial_masses=np.ones(3) * Msun,
+        ages=np.array([1.0, 2.0, 3.0]) * Myr,
+        metallicities=np.array([0.01, 0.02, 0.03]),
+        redshift=1.0,
+        tau_v=np.array([0.1, 0.2, 0.3]),
+        coordinates=np.array(
+            [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [2.0, 2.0, 2.0]]
+        )
+        * kpc,
+        current_masses=np.ones(3) * Msun,
+    )
+
+
+@pytest.fixture
+def unit_emission_stars():
+    """Return a particle Stars object with unit masses for weighting tests."""
+    stars = Stars(
+        initial_masses=np.ones(3) * Msun,
+        ages=np.array([1.0, 2.0, 3.0]) * Myr,
+        metallicities=np.array([0.01, 0.02, 0.03]),
+        redshift=1.0,
+        tau_v=np.array([0.1, 0.2, 0.3]),
+        coordinates=np.array(
+            [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [2.0, 2.0, 2.0]]
+        )
+        * kpc,
+        current_masses=np.ones(3) * Msun,
+    )
+    stars.particle_photo_lnu["FAKE"] = PhotometryCollection(
+        filters=None,
+        fake=np.ones(3) * erg / s / Hz,
+    )
+    stars.particle_photo_fnu["FAKE"] = PhotometryCollection(
+        filters=None,
+        fake=np.ones(3) * erg / s / cm**2 / Hz,
+    )
+    return stars
+
+
 @pytest.fixture
 def particle_gas_A():
     """Return a particle Gas object."""
diff --git a/docs/source/components/stars.ipynb b/docs/source/components/stars.ipynb
index 414362e9..889e7d32 100644
--- a/docs/source/components/stars.ipynb
+++ b/docs/source/components/stars.ipynb
@@ -77,7 +77,7 @@
     "\n",
     "grid = Grid(grid_name, grid_dir=grid_dir)\n",
     "\n",
-    "sfzh = stars.get_sfzh(grid.log10age, grid.metallicity)\n",
+    "sfzh = stars.get_sfzh(grid.log10age, grid.log10metallicity)\n",
     "sfzh.plot_sfzh()"
    ]
   },
@@ -195,7 +195,40 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "We can also calculate characteristic light radii from photometry. See the [photometry](../photometry/galaxy_phot.ipynb) documentation for more details."
+    "We can also calculate characteristic light radii from photometry. See the [photometry](../photometry/galaxy_phot.ipynb) documentation for more details.\n",
+    "\n",
+    "### Getting weighted attributes\n",
+    "\n",
+    "We can get weighted attributes from any particle object using the ``get_weighted_attr`` method. This takes the name of an attribute to weight and either the name of an attribute to use for a weight or an array of weights. \n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(stars.get_weighted_attr(\"metallicities\", \"initial_masses\"))\n",
+    "print(stars.get_weighted_attr(\"metallicities\", stars.masses))\n",
+    "print(stars.get_weighted_attr(\"log10metallicities\", stars.masses))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "In addition to this generic method we also provide some pre-defined methods for common attributes, shown below."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(stars.get_mass_weighted_age())\n",
+    "print(stars.get_mass_weighted_metallicity())\n",
+    "print(stars.get_mass_weighted_optical_depth())"
    ]
   },
   {
diff --git a/docs/source/photometry/galaxy_phot.ipynb b/docs/source/photometry/galaxy_phot.ipynb
index 01b5a41b..eb661e70 100644
--- a/docs/source/photometry/galaxy_phot.ipynb
+++ b/docs/source/photometry/galaxy_phot.ipynb
@@ -74,6 +74,7 @@
     "    redshift=1,\n",
     "    coordinates=np.random.normal(0, 0.01, (n, 3)) * Mpc,\n",
     "    centre=np.zeros(3) * Mpc,\n",
+    "    tau_v=np.random.rand(n),\n",
     ")\n",
     "\n",
     "# Make fake properties\n",
@@ -111,6 +112,7 @@
     "    fesc_ly_alpha=0.9,\n",
     "    label=\"stellar_total\",\n",
     ")\n",
+    "pc_model.save_spectra(\"stellar_total\", \"intrinsic\")\n",
     "\n",
     "# Get the UnifiedAGN model\n",
     "uni_model = UnifiedAGN(\n",
@@ -342,6 +344,90 @@
     ")\n",
     "print(int_r20, int_r80, tot_r20, tot_r80)"
    ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Calculating weighted attributes\n",
+    "\n",
+    "Similarly to the weighted attributes shown in the [stellar component docs](../components/stars.ipynb), we can also calculate luminosity and flux weighted attributes for the galaxy's components. Again these exist in generic and specific attribute forms. First we demonstrate the generic form, this requires the attribute to weight, the spectra type, and the filter code to weight by as arguments."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(\n",
+    "    gal.stars.get_lum_weighted_attr(\n",
+    "        \"log10ages\", \"stellar_total\", \"JWST/NIRCam.F444W\"\n",
+    "    )\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We can also do this for fluxes."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# We haven't computed fluxes yet so lets do that first\n",
+    "gal.stars.get_spectra(pc_model)\n",
+    "gal.get_observed_spectra(cosmo)\n",
+    "gal.stars.get_particle_photo_fnu(filters)\n",
+    "\n",
+    "print(\n",
+    "    gal.stars.get_flux_weighted_attr(\n",
+    "        \"log10ages\", \"stellar_total\", \"JWST/NIRCam.F444W\"\n",
+    "    )\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We also provide some specific methods for commonly weighted attributes. These methods still require the spectra type and filter code as arguments, but the attribute to weight is fixed."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(gal.stars.get_lum_weighted_age(\"stellar_total\", \"JWST/NIRCam.F444W\"))\n",
+    "print(gal.stars.get_flux_weighted_age(\"stellar_total\", \"JWST/NIRCam.F444W\"))\n",
+    "print(\n",
+    "    gal.stars.get_lum_weighted_metallicity(\n",
+    "        \"stellar_total\", \"JWST/NIRCam.F444W\"\n",
+    "    )\n",
+    ")\n",
+    "print(\n",
+    "    gal.stars.get_flux_weighted_metallicity(\n",
+    "        \"stellar_total\", \"JWST/NIRCam.F444W\"\n",
+    "    )\n",
+    ")\n",
+    "print(\n",
+    "    gal.stars.get_lum_weighted_optical_depth(\n",
+    "        \"stellar_total\", \"JWST/NIRCam.F444W\"\n",
+    "    )\n",
+    ")\n",
+    "print(\n",
+    "    gal.stars.get_flux_weighted_optical_depth(\n",
+    "        \"stellar_total\", \"JWST/NIRCam.F444W\"\n",
+    "    )\n",
+    ")"
+   ]
   }
  ],
  "metadata": {},
diff --git a/src/synthesizer/components/component.py b/src/synthesizer/components/component.py
index 59fe5558..a3a77562 100644
--- a/src/synthesizer/components/component.py
+++ b/src/synthesizer/components/component.py
@@ -122,6 +122,11 @@ class Component(ABC):
         """Return a mask based on the attribute and threshold."""
         pass
 
+    @abstractmethod
+    def get_weighted_attr(self, attr, weights, **kwargs):
+        """Return the weighted attribute."""
+        pass
+
     def get_photo_lnu(self, filters, verbose=True, nthreads=1):
         """
         Calculate luminosity photometry using a FilterCollection object.
diff --git a/src/synthesizer/components/stellar.py b/src/synthesizer/components/stellar.py
index 26632c95..092dd186 100644
--- a/src/synthesizer/components/stellar.py
+++ b/src/synthesizer/components/stellar.py
@@ -144,3 +144,200 @@ class StarsComponent(Component):
                 )
 
         return young, old
+
+    def get_mass_weighted_age(self):
+        """
+        Calculate the mass-weighted age of the stellar component.
+
+        Returns:
+            mass_weighted_age (unyt_quantity)
+                The mass-weighted age of the stellar component.
+        """
+        if self._star_type == "particle":
+            return self.get_weighted_attr(
+                "ages",
+                self.initial_masses,
+            )
+        return self.get_weighted_attr("ages")
+
+    def get_mass_weighted_metallicity(self):
+        """
+        Calculate the mass-weighted metallicity of the stellar component.
+
+        Returns:
+            mass_weighted_metallicity (unyt_quantity)
+                The mass-weighted metallicity of the stellar component.
+        """
+        if self._star_type == "particle":
+            return self.get_weighted_attr(
+                "metallicities",
+                self.initial_masses,
+            )
+        return self.get_weighted_attr("metallicities")
+
+    def get_mass_weighted_optical_depth(self):
+        """
+        Calculate the mass-weighted optical depth of the stellar component.
+
+        Returns:
+            mass_weighted_tau_v (unyt_quantity)
+                The mass-weighted optical depth of the stellar component.
+        """
+        if self._star_type == "particle":
+            return self.get_weighted_attr(
+                "tau_v",
+                self.initial_masses,
+            )
+        raise exceptions.UnimplementedFunctionality(
+            "Optical depth cannot be weighted for parametric stars."
+        )
+
+    def get_lum_weighted_age(self, spectra_type, filter_code):
+        """
+        Calculate the luminosity-weighted age of the stars.
+
+        Args:
+            spectra_type (str)
+                The type of spectra to use for weighting.
+            filter_code (str)
+                The filter code to use for weighting.
+
+        Returns:
+            lum_weighted_age (unyt_quantity)
+                The luminosity-weighted age of the stellar component.
+        """
+        if self._star_type == "particle":
+            return self.get_lum_weighted_attr(
+                "ages",
+                spectra_type,
+                filter_code,
+            )
+        raise exceptions.UnimplementedFunctionality(
+            "Luminosities for parametric stars are integrated "
+            "so can't be used for weighting."
+        )
+
+    def get_lum_weighted_metallicity(self, spectra_type, filter_code):
+        """
+        Calculate the luminosity-weighted metallicity of the stars.
+
+        Args:
+            spectra_type (str)
+                The type of spectra to use for weighting.
+            filter_code (str)
+                The filter code to use for weighting.
+
+        Returns:
+            lum_weighted_metallicity (unyt_quantity)
+                The luminosity-weighted metallicity of the stellar component.
+        """
+        if self._star_type == "particle":
+            return self.get_lum_weighted_attr(
+                "metallicities",
+                spectra_type,
+                filter_code,
+            )
+        raise exceptions.UnimplementedFunctionality(
+            "Luminosities for parametric stars are integrated "
+            "so can't be used for weighting."
+        )
+
+    def get_lum_weighted_optical_depth(self, spectra_type, filter_code):
+        """
+        Calculate the luminosity-weighted optical depth of the stars.
+
+        Args:
+            spectra_type (str)
+                The type of spectra to use for weighting.
+            filter_code (str)
+                The filter code to use for weighting.
+
+        Returns:
+            lum_weighted_tau_v (unyt_quantity)
+                The luminosity-weighted optical depth of the stellar component.
+        """
+        if self._star_type == "particle":
+            return self.get_lum_weighted_attr(
+                "tau_v",
+                spectra_type,
+                filter_code,
+            )
+        raise exceptions.UnimplementedFunctionality(
+            "Luminosities for parametric stars are integrated "
+            "so can't be used for weighting."
+        )
+
+    def get_flux_weighted_age(self, spectra_type, filter_code):
+        """
+        Calculate the flux-weighted age of the stars.
+
+        Args:
+            spectra_type (str)
+                The type of spectra to use for weighting.
+            filter_code (str)
+                The filter code to use for weighting.
+
+        Returns:
+            flux_weighted_age (unyt_quantity)
+                The flux-weighted age of the stellar component.
+        """
+        if self._star_type == "particle":
+            return self.get_flux_weighted_attr(
+                "ages",
+                spectra_type,
+                filter_code,
+            )
+        raise exceptions.UnimplementedFunctionality(
+            "Fluxes for parametric stars are integrated "
+            "so can't be used for weighting."
+        )
+
+    def get_flux_weighted_metallicity(self, spectra_type, filter_code):
+        """
+        Calculate the flux-weighted metallicity of the stars.
+
+        Args:
+            spectra_type (str)
+                The type of spectra to use for weighting.
+            filter_code (str)
+                The filter code to use for weighting.
+
+        Returns:
+            flux_weighted_metallicity (unyt_quantity)
+                The flux-weighted metallicity of the stellar component.
+        """
+        if self._star_type == "particle":
+            return self.get_flux_weighted_attr(
+                "metallicities",
+                spectra_type,
+                filter_code,
+            )
+        raise exceptions.UnimplementedFunctionality(
+            "Fluxes for parametric stars are integrated "
+            "so can't be used for weighting."
+        )
+
+    def get_flux_weighted_optical_depth(self, spectra_type, filter_code):
+        """
+        Calculate the flux-weighted optical depth of the stars.
+
+        Args:
+            spectra_type (str)
+                The type of spectra to use for weighting.
+            filter_code (str)
+                The filter code to use for weighting.
+
+        Returns:
+            flux_weighted_tau_v (unyt_quantity)
+                The flux-weighted optical depth of the stellar component.
+        """
+        if self._star_type == "particle":
+            return self.get_flux_weighted_attr(
+                "tau_v",
+                spectra_type,
+                filter_code,
+            )
+        raise exceptions.UnimplementedFunctionality(
+            "Fluxes for parametric stars are integrated "
+            "so can't be used for weighting."
+        )
diff --git a/src/synthesizer/parametric/blackholes.py b/src/synthesizer/parametric/blackholes.py
index cb9018c1..8c53ae51 100644
--- a/src/synthesizer/parametric/blackholes.py
+++ b/src/synthesizer/parametric/blackholes.py
@@ -200,3 +200,17 @@ class BlackHole(BlackholesComponent):
             new_mask = np.logical_and(new_mask, mask)
 
         return new_mask
+
+    def get_weighted_attr(self, *args, **kwargs):
+        """
+        Raise an error, weighted attributes are meaningless for a BlackHole.
+
+        Raises:
+            NotImplementedError
+                Parametric black holes are singular and so weighted attributes
+                make no sense.
+        """
+        raise NotImplementedError(
+            "Parametric black holes are by definition singular "
+            "making weighted attributes non-sensical."
+        )
diff --git a/src/synthesizer/parametric/stars.py b/src/synthesizer/parametric/stars.py
index f19d4a0e..6ff4607a 100644
--- a/src/synthesizer/parametric/stars.py
+++ b/src/synthesizer/parametric/stars.py
@@ -797,7 +797,7 @@ class Stars(StarsComponent):
 
         # Set labels
         ax.set_xlabel(r"$\log_{10}(\mathrm{age}/\mathrm{yr})$")
-        ax.set_ylabel(r"$Z$")
+        ax.set_ylabel(r"$\log_{10}(Z)$")
 
         # Set the limits so all axes line up
         ax.set_ylim(self.log10metallicities[0], self.log10metallicities[-1])
@@ -918,3 +918,33 @@ class Stars(StarsComponent):
             plt.show()
 
         return fig, ax
+
+    def get_weighted_attr(self, attr):
+        """
+        Get a weighted attribute of the stellar population.
+
+        Args:
+            attr (str)
+                The attribute to get.
+            axis (int)
+                The axis to sum over.
+
+        Returns:
+            unyt_quantity:
+                The weighted attribute.
+        """
+        # For now we need to raise an error if this is not an axis of the
+        # SFZH grid
+        if "age" not in attr and "metal" not in attr:
+            raise exceptions.InconsistentArguments(
+                "The attribute must be an axis of the SFZH grid"
+            )
+
+        # Get the attribute and the weights
+        attr = getattr(self, attr)
+        if "age" in attr:
+            weight = self.sf_hist
+        else:
+            weight = self.metal_dist
+
+        return weighted_mean(attr, weight)
diff --git a/src/synthesizer/particle/particles.py b/src/synthesizer/particle/particles.py
index 159154c2..b8a8bede 100644
--- a/src/synthesizer/particle/particles.py
+++ b/src/synthesizer/particle/particles.py
@@ -930,6 +930,99 @@ class Particles:
         # Call the rotate_particles method with the computed angles
         return self.rotate_particles(rot_matrix=rot_matrix, inplace=inplace)
 
+    def get_weighted_attr(self, attr, weights, axis=None):
+        """
+        Get a weighted attribute.
+
+        This will compute the weighted average of an attribute using the
+        provided weights.
+
+        Args:
+            attr (str)
+                The attribute to weight.
+            weights (str/(array-like, float))
+                The weights to apply to the attribute. This can either be a
+                string to get the attribute from the particle object or an
+                array-like of the weights.
+            axis (int)
+                The axis to compute the weighted attribute along.
+
+        Returns:
+            weighted_attr (float)
+                The weighted attribute.
+        """
+        # Get the attribute
+        attr_vals = getattr(self, attr)
+
+        # If the weights are a string get the attribute
+        if isinstance(weights, str):
+            weights_vals = getattr(self, weights)
+        else:
+            weights_vals = weights
+
+        # Strip units off the weights if they have them, this can confuse
+        # things
+        if hasattr(weights_vals, "units"):
+            weights_vals = weights_vals.value
+
+        return np.average(attr_vals, weights=weights_vals, axis=axis)
+
+    def get_lum_weighted_attr(
+        self, attr, spectra_type, filter_code, axis=None
+    ):
+        """
+        Get a luminosity weighted attribute.
+
+        This will compute the luminosity weighted average of an attribute
+        using the provided weights.
+
+        Args:
+            attr (str)
+                The attribute to weight.
+            spectra_type (str)
+                The type of spectra to use to compute the luminosity.
+            filter_code (str)
+                The filter code to compute the luminosity for.
+            axis (int)
+                The axis to compute the weighted attribute along.
+
+        Returns:
+            weighted_attr (float)
+                The luminosity weighted attribute.
+        """
+        # Get the luminosity
+        lum = self.particle_photo_lnu[spectra_type][filter_code]
+
+        return self.get_weighted_attr(attr, lum, axis)
+
+    def get_flux_weighted_attr(
+        self, attr, spectra_type, filter_code, axis=None
+    ):
+        """
+        Get a flux weighted attribute.
+
+        This will compute the flux weighted average of an attribute using the
+        provided weights.
+
+        Args:
+            attr (str)
+                The attribute to weight.
+            spectra_type (str)
+                The type of spectra to use to compute the flux.
+            filter_code (str)
+                The filter code to compute the flux for.
+            axis (int)
+                The axis to compute the weighted attribute along.
+
+        Returns:
+            weighted_attr (float)
+                The flux weighted attribute.
+        """
+        # Get the flux
+        flux = self.particle_photo_fnu[spectra_type][filter_code]
+
+        return self.get_weighted_attr(attr, flux, axis)
+
 
 class CoordinateGenerator:
     """


Test patch:
diff --git a/tests/test_particle_stars.py b/tests/test_particle_stars.py
index 38cbb174..720a2c6b 100644
--- a/tests/test_particle_stars.py
+++ b/tests/test_particle_stars.py
@@ -79,3 +79,201 @@ def test_calculate_radii(particle_stars_A):
 
     assert isinstance(particle_stars_A.get_radii(), np.ndarray)
     assert (particle_stars_A.radii <= 6 * kpc).all()
+
+
+class TestWeightedAttributes:
+    """Tests for checking the weighted attributes work correctly."""
+
+    def test_mass_weighted_age(self, unit_mass_stars):
+        """Test that mass weighted age is calculated correctly."""
+        result = unit_mass_stars.get_mass_weighted_age()
+        expected = 2.0 * Myr
+        assert np.isclose(result, expected), f"Age: {result} != {expected}"
+
+    def test_mass_weighted_metallicity(self, unit_mass_stars):
+        """Test that mass weighted metallicity is calculated correctly."""
+        result = unit_mass_stars.get_mass_weighted_metallicity()
+        expected = 0.02
+        assert np.isclose(result, expected), f"Z: {result} != {expected}"
+
+    def test_mass_weighted_optical_depth(self, unit_mass_stars):
+        """Test that mass weighted optical depth is calculated correctly."""
+        result = unit_mass_stars.get_mass_weighted_optical_depth()
+        expected = 0.2
+        assert np.isclose(result, expected), f"tau_V: {result} != {expected}"
+
+    def test_weighted_attr_coordinates_initial_masses(self, unit_mass_stars):
+        """Test weighted coordinates using 'initial_masses'."""
+        result = unit_mass_stars.get_weighted_attr(
+            "coordinates",
+            "initial_masses",
+            axis=0,
+        )
+        expected = np.array([1.0, 1.0, 1.0]) * kpc
+        assert np.allclose(result, expected)
+
+    def test_weighted_attr_coordinates_current_masses(self, unit_mass_stars):
+        """Test weighted coordinates using current_masses."""
+        result = unit_mass_stars.get_weighted_attr(
+            "coordinates",
+            unit_mass_stars.current_masses,
+            axis=0,
+        )
+        expected = np.array([1.0, 1.0, 1.0]) * kpc
+        assert np.allclose(result, expected)
+
+    def test_weighted_attr_coordinates_invalid_axis(self, unit_mass_stars):
+        """Test that using an invalid axis raises an exception."""
+        with pytest.raises(
+            TypeError,
+            match="Axis must be specified when "
+            "shapes of a and weights differ.",
+        ):
+            unit_mass_stars.get_weighted_attr(
+                "coordinates",
+                "initial_masses",
+            )
+        with pytest.raises(
+            np.exceptions.AxisError,
+            match="axis: axis 3 is out of bounds for array of dimension 2",
+        ):
+            unit_mass_stars.get_weighted_attr(
+                "coordinates",
+                "initial_masses",
+                axis=3,
+            )
+
+    def test_invalid_attr(self, unit_mass_stars):
+        """Test that using a non-existent attribute raises an exception."""
+        with pytest.raises(
+            AttributeError,
+            match="'Stars' object has no attribute 'not_an_attribute'",
+        ):
+            unit_mass_stars.get_weighted_attr(
+                "not_an_attribute", "initial_masses"
+            )
+
+    def test_invalid_mass_weight_attr(self, unit_mass_stars):
+        """Test that using a non-existent weight raises an exception."""
+        with pytest.raises(
+            AttributeError,
+            match="'Stars' object has no attribute 'not_an_attribute'",
+        ):
+            unit_mass_stars.get_weighted_attr(
+                "coordinates", "not_an_attribute"
+            )
+
+    def test_lum_weighted_age(self, unit_emission_stars):
+        """Test that luminosity weighted age is calculated correctly."""
+        result = unit_emission_stars.get_lum_weighted_age("FAKE", "fake")
+        expected = 2.0 * Myr
+        assert np.isclose(result, expected), f"Age: {result} != {expected}"
+
+    def test_flux_weighted_age(self, unit_emission_stars):
+        """Test that flux weighted age is calculated correctly."""
+        result = unit_emission_stars.get_flux_weighted_age("FAKE", "fake")
+        expected = 2.0 * Myr
+        assert np.isclose(result, expected), f"Age: {result} != {expected}"
+
+    def test_lum_weighted_metallicity(self, unit_emission_stars):
+        """Test that luminosity weighted metallicity is  correct."""
+        result = unit_emission_stars.get_lum_weighted_metallicity(
+            "FAKE", "fake"
+        )
+        expected = 0.02
+        assert np.isclose(result, expected), f"Z: {result} != {expected}"
+
+    def test_flux_weighted_metallicity(self, unit_emission_stars):
+        """Test that flux weighted metallicity is calculated correctly."""
+        result = unit_emission_stars.get_flux_weighted_metallicity(
+            "FAKE", "fake"
+        )
+        expected = 0.02
+        assert np.isclose(result, expected), f"Z: {result} != {expected}"
+
+    def test_lum_weighted_optical_depth(self, unit_emission_stars):
+        """Test that luminosity weighted optical depth is  correct."""
+        result = unit_emission_stars.get_lum_weighted_optical_depth(
+            "FAKE", "fake"
+        )
+        expected = 0.2
+        assert np.isclose(result, expected), f"tau_V: {result} != {expected}"
+
+    def test_flux_weighted_optical_depth(self, unit_emission_stars):
+        """Test that flux weighted optical depth is calculated correctly."""
+        result = unit_emission_stars.get_flux_weighted_optical_depth(
+            "FAKE", "fake"
+        )
+        expected = 0.2
+        assert np.isclose(result, expected), f"tau_V: {result} != {expected}"
+
+    def test_weighted_attr_coordinates_luminosity(self, unit_emission_stars):
+        """Test weighted coordinates using luminosity."""
+        result = unit_emission_stars.get_lum_weighted_attr(
+            "coordinates",
+            "FAKE",
+            "fake",
+            axis=0,
+        )
+        expected = np.array([1.0, 1.0, 1.0]) * kpc
+        assert np.allclose(result, expected)
+
+    def test_weighted_attr_coordinates_flux(self, unit_emission_stars):
+        """Test weighted coordinates using flux."""
+        result = unit_emission_stars.get_flux_weighted_attr(
+            "coordinates",
+            "FAKE",
+            "fake",
+            axis=0,
+        )
+        expected = np.array([1.0, 1.0, 1.0]) * kpc
+        assert np.allclose(result, expected)
+
+    def test_invalid_attr_lum_weighted(self, unit_emission_stars):
+        """Test that using a non-existent attribute raises an exception."""
+        with pytest.raises(
+            AttributeError,
+            match="'Stars' object has no attribute 'not_an_attribute'",
+        ):
+            unit_emission_stars.get_lum_weighted_attr(
+                "not_an_attribute",
+                "FAKE",
+                "fake",
+            )
+
+    def test_invalid_attr_flux_weighted(self, unit_emission_stars):
+        """Test that using a non-existent attribute raises an exception."""
+        with pytest.raises(
+            AttributeError,
+            match="'Stars' object has no attribute 'not_an_attribute'",
+        ):
+            unit_emission_stars.get_flux_weighted_attr(
+                "not_an_attribute",
+                "FAKE",
+                "fake",
+            )
+
+    def test_invalid_spec_key(self, unit_emission_stars):
+        """Test that using a non-existent key raises an exception."""
+        with pytest.raises(
+            KeyError,
+            match="'not_a_key'",
+        ):
+            unit_emission_stars.get_lum_weighted_attr(
+                "ages",
+                "not_a_key",
+                "fake",
+            )
+
+    def test_invalid_phot_key(self, unit_emission_stars):
+        """Test that using a non-existent key raises an exception."""
+        with pytest.raises(
+            KeyError,
+            match="'Filter code not_a_key not found"
+            " in photometry collection.'",
+        ):
+            unit_emission_stars.get_lum_weighted_attr(
+                "ages",
+                "FAKE",
+                "not_a_key",
+            )