Repository: jrsmith3/ibei

Issue:
Refactor `SQSolarcell` and `DeVosSolarcell` classes
# Overview
Now that most of the code for the `2.0.0` release is complete, I looked at both the `SQSolarcell` and `DeVosSolarcell` and there isn't much to them. I think both can be refactored for the new release, the refactor would mostly be a matter of how I want them structured (are they classes or functions?) and where I want them in the module hierarchy.

My current thoughts are that the hierarchy should look something like the following.

```
ibei
ibei.BEI
ibei.models.DeVosSolarcell
ibei.models.SQSolarcell
```

If I do the module hierarchy in that way, both `SQSolarcell` and `DeVosSolarcell` probably have to be classes. Otherwise, I'm going to have a handful of functions at an even deeper level of nesting. Alternatively, I could pull `SQSolarcell` and `DeVosSolarcell` directly under the `ibei` module like so.

```
ibei
ibei.BEI
ibei.DeVosSolarcell
ibei.SQSolarcell
```

Since this package probably won't grow in functionality beyond roughly what it is now, such a module hierarchy is justifiable. I am not really sure if `SQSolarcell` and `DeVosSolarcell` should be classes or sets of functions -- I will answer that question as I complete this ticket.


# Deliverables
* Update `ibei` module hierarchy as shown in the second option above. This refactor includes moving `BEI` under `ibei` and likely results in a single `src/ibei.py` file for the entire module.
* Refactor `SQSolarcell` and `DeVosSolarcell` so they work.
* Fix the tests for `SQSolarcell` and `DeVosSolarcell` so they work.

Discussion:

Patch:
diff --git a/CHANGELOG b/CHANGELOG
index 2aa46da..31e265b 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,6 @@
 2.0.0
 =====
+* 77: Refactor `SQSolarcell` and `DeVosSolarcell` classes.
 * 68: Refactor `uibei` to be a method on a new `BEI` class.
 * 58: Cosmetic documentation edits.
 * 56: Update Python dependency to 3.10.
diff --git a/src/ibei/__init__.py b/src/ibei/__init__.py
index e637027..f83e243 100644
--- a/src/ibei/__init__.py
+++ b/src/ibei/__init__.py
@@ -1,4 +1,4 @@
-# -*- coding: utf-8 -*-
+# coding=utf-8
 """
 Base Library (:mod:`ibei`)
 ==========================
@@ -6,10 +6,9 @@ Base Library (:mod:`ibei`)
 .. currentmodule:: ibei
 """
 
-from . import models
+from .models import BEI, SQSolarcell, DeVosSolarcell
 
 try:
     from ._version import __version__
 except ModuleNotFoundError:
     __version__ = ""
-
diff --git a/src/ibei/devossolarcell.py b/src/ibei/devossolarcell.py
deleted file mode 100644
index 8b80245..0000000
--- a/src/ibei/devossolarcell.py
+++ /dev/null
@@ -1,47 +0,0 @@
-# -*- coding: utf-8 -*-
-
-import numpy as np
-from astropy import constants, units
-from sympy.mpmath import polylog
-from physicalproperty import PhysicalProperty, find_PhysicalProperty
-from ibei import uibei, SQSolarcell
-
-
-class DeVosSolarcell(SQSolarcell):
-    """
-    DeVos single-junction solar cell
-
-    This class implements a solar cell as described by DeVos :cite:`9780198513926` Ch. 6.
-
-    An DeVosSolarcell is instantiated with a :class:`dict` having keys identical to the class's public data attributes. Each key's value must satisfy the constraints noted with the corresponding public data attribute. Dictionary values can be some kind of numeric type or of type :class:`astropy.units.Quantity` so long as the units are compatible with what's listed.
-    """
-
-    temp_planet = PhysicalProperty(unit = "K", lo_bnd = 0)
-    """
-    Planet temperature > 0 [K]
-    """
-
-    voltage = PhysicalProperty(unit = "V")
-    """
-    Bias voltage [V]
-    """
-
-    def calc_power_density(self):
-        """
-        Solar cell power density
-
-        The output power density is calculated according to DeVos's :cite:`9780198513926` Eq. 6.4. Note that this expression assumes fully concentrated sunlight and is therefore not completely general.
-
-        This method returns values of type :class:`astropy.units.Quantity` with units of [W m^-2].
-        """
-        electron_energy = constants.e.si * self.voltage
-
-        if self.bandgap == 0:
-            solar_flux = units.Quantity(0., "1/(m2*s)")
-            solar_cell_flux = units.Quantity(0., "1/(m2*s)")
-        else:
-            solar_flux = uibei(2, self.bandgap, self.temp_sun, 0)
-            solar_cell_flux = uibei(2, self.bandgap, self.temp_planet, electron_energy)
-        power_density = electron_energy * (solar_flux - solar_cell_flux)
-
-        return power_density.to("W/m^2")
diff --git a/src/ibei/models.py b/src/ibei/models.py
index 71b95ed..c30efad 100644
--- a/src/ibei/models.py
+++ b/src/ibei/models.py
@@ -279,3 +279,139 @@ class BEI():
         """
         return (2 * np.pi * self.kT**(self.order + 1)) / \
             (astropy.constants.h**3 * astropy.constants.c**2)
+
+
+@attrs.frozen
+class SQSolarcell():
+    """
+    Shockley-Queisser single-junction solar cell
+
+    This class implements a solar cell as described by Shockley and
+    Queisser :cite:`10.1063/1.1736034`.
+
+    An :class:`SQSolarcell` is instantiated with a :class:`dict` having
+    keys identical to the class's public data attributes. Each key's
+    value must satisfy the constraints noted with the corresponding
+    public data attribute. Dictionary values can be some kind of numeric
+    type or of type :class:`astropy.units.Quantity` so long as the units
+    are compatible with what's listed.    
+    """
+    bei = attrs.field(init=False)
+    bandgap: float | astropy.units.Quantity[astropy.units.eV] = attrs.field(
+            converter=functools.partial(astropy.units.Quantity, unit=astropy.units.eV),
+            validator=[
+                _validate_is_scalar,
+                attrs.validators.ge(0),
+            ]
+        )
+    solar_temperature: float | astropy.units.Quantity[astropy.units.K] = attrs.field(
+            default=5772.,
+            converter=_temperature_converter,
+            validator=[
+                _validate_is_scalar,
+                attrs.validators.gt(0),
+            ]
+        )
+
+
+    def __attrs_post_init__(self):
+        bei = BEI(order=2, energy_bound=self.bandgap, temperature=self.solar_temperature, chemical_potential=0.)
+        object.__setattr__(self, "bei", bei)
+
+
+    def power_density(self) -> astropy.units.Quantity[astropy.units.Unit("W/m2")]:
+        """
+        Solar cell power density
+
+        The output power density is calculated according to a slight
+        modification of Shockley & Queisser's :cite:`10.1063/1.1736034` Eq.
+        2.4. This method returns values of
+        type :class:`astropy.units.Quantity` with units of [W m^-2].
+        """
+        if self.bandgap == 0:
+            solar_flux = astropy.units.Quantity(0., "1/(m2 s)")
+        else:
+            solar_flux = self.bei.upper()
+
+        power_density = self.bandgap * solar_flux
+
+        return power_density.to("W/m2")
+
+
+    def efficiency(self) -> astropy.units.Quantity[astropy.units.dimensionless_unscaled]:
+        """
+        Solar cell efficiency
+
+        The efficiency is calculated according to Shockley &
+        Queisser's :cite:`10.1063/1.1736034` Eq. 2.8. This method returns
+        a :class:`float`.
+        """
+        power_density = self.power_density()
+        solar_power_density = self.bei.radiant_power_flux()
+        efficiency = power_density/solar_power_density
+
+        return efficiency.decompose()
+
+
+@attrs.frozen
+class DeVosSolarcell(SQSolarcell):
+    """
+    DeVos single-junction solar cell
+
+    This class implements a solar cell as described by
+    DeVos :cite:`9780198513926` Ch. 6.
+
+    An DeVosSolarcell is instantiated with a :class:`dict` having keys
+    identical to the class's public data attributes. Each key's value
+    must satisfy the constraints noted with the corresponding public data
+    attribute. Dictionary values can be some kind of numeric type or of
+    type :class:`astropy.units.Quantity` so long as the units are
+    compatible with what's listed.
+    """
+    planetary_temperature: float | astropy.units.Quantity[astropy.units.K] = attrs.field(
+            default=300.,
+            converter=_temperature_converter,
+            validator=[
+                _validate_is_scalar,
+                attrs.validators.gt(0),
+            ]
+        )
+    voltage: float | astropy.units.Quantity[astropy.units.V] = attrs.field(
+            default=0.,
+            converter=functools.partial(astropy.units.Quantity, unit=astropy.units.V),
+            validator=[
+                _validate_is_scalar,
+            ]
+        )
+
+
+    def power_density(self) -> astropy.units.Quantity[astropy.units.Unit("W/m2")]:
+        """
+        Solar cell power density
+
+        The output power density is calculated according to
+        DeVos's :cite:`9780198513926` Eq. 6.4. Note that this expression
+        assumes fully concentrated sunlight and is therefore not completely
+        general.
+
+        This method returns values of type :class:`astropy.units.Quantity`
+        with units of [W m^-2].
+        """
+        electron_energy = astropy.constants.e.si * self.voltage
+
+        if self.bandgap == 0:
+            solar_flux = astropy.units.Quantity(0., "1/(m2 s)")
+            solar_cell_flux = astropy.units.Quantity(0., "1/(m2 s)")
+        else:
+            bei = BEI(
+                    order=2,
+                    energy_bound=self.bandgap,
+                    temperature=self.planetary_temperature,
+                    chemical_potential=electron_energy
+                )
+            solar_flux = self.bei.upper()
+            solar_cell_flux = bei.upper()
+
+        power_density = electron_energy * (solar_flux - solar_cell_flux)
+
+        return power_density.to("W/m2")
diff --git a/src/ibei/sqsolarcell.py b/src/ibei/sqsolarcell.py
deleted file mode 100644
index 5ca064f..0000000
--- a/src/ibei/sqsolarcell.py
+++ /dev/null
@@ -1,96 +0,0 @@
-# -*- coding: utf-8 -*-
-
-import numpy as np
-from astropy import constants, units
-from sympy.mpmath import polylog
-from physicalproperty import PhysicalProperty, find_PhysicalProperty
-from ibei import uibei
-
-
-class SQSolarcell(object):
-    """
-    Shockley-Queisser single-junction solar cell
-
-    This class implements a solar cell as described by Shockley and Queisser :cite:`10.1063/1.1736034`.
-
-    An :class:`SQSolarcell` is instantiated with a :class:`dict` having keys identical to the class's public data attributes. Each key's value must satisfy the constraints noted with the corresponding public data attribute. Dictionary values can be some kind of numeric type or of type :class:`astropy.units.Quantity` so long as the units are compatible with what's listed.
-    """
-
-    temp_sun = PhysicalProperty(unit = "K", lo_bnd = 0)
-    """
-    Solar temperature > 0 [K]
-    """
-
-    bandgap = PhysicalProperty(unit = "eV", lo_bnd = 0)
-    """
-    Bandgap of single homojunction > 0 [eV]
-    """
-
-    def __init__(self, params):
-        for attr in find_PhysicalProperty(self):
-            setattr(self, attr, params[attr])
-
-    def __repr__(self):
-        return str(self._to_dict())
-
-    def _to_dict(self):
-        """
-        Return a dictionary representation of the current object.
-        """
-        physical_prop_names = find_PhysicalProperty(self)
-        physical_prop_vals = [getattr(self, prop) for prop in physical_prop_names]
-
-        return dict(zip(physical_prop_names, physical_prop_vals))
-
-    def calc_blackbody_radiant_power_density(self):
-        """
-        Stefan-Boltzmann radiant power density from sun
-
-        The Stefan-Boltzmann radiant power density is given by 
-
-        .. math::
-            W = \sigma T^{4}
-
-        where
-
-        .. math::
-            \sigma = \\frac{2 \pi^{5} k^{4}}{15 c^{2} h^{3}}
-
-        and
-
-        * :math:`T`: Solar temperature
-        * :math:`k`: Boltzmann's constant
-        * :math:`h`: Planck's constant
-        * :math:`c`: Speed of light in vacuum
-
-        This method returns values of type :class:`astropy.units.Quantity` with units of [W m^-2].
-        """
-        radiant_power_density = constants.sigma_sb * self.temp_sun**4
-
-        return radiant_power_density.to("W/m2")
-
-    def calc_power_density(self):
-        """
-        Solar cell power density
-
-        The output power density is calculated according to a slight modification of Shockley & Queisser's :cite:`10.1063/1.1736034` Eq. 2.4. This method returns values of type :class:`astropy.units.Quantity` with units of [W m^-2].
-        """
-        if self.bandgap == 0:
-            solar_flux = units.Quantity(0., "1/(m2*s)")
-        else:
-            solar_flux = uibei(2, self.bandgap, self.temp_sun, 0)
-        power_density = self.bandgap * solar_flux
-
-        return power_density.to("W/m^2")
-
-    def calc_efficiency(self):
-        """
-        Solar cell efficiency
-
-        The efficiency is calculated according to Shockley & Queisser's :cite:`10.1063/1.1736034` Eq. 2.8. This method returns a :class:`float`.
-        """
-        cell_power = self.calc_power_density()
-        solar_power = self.calc_blackbody_radiant_power_density()
-        efficiency = cell_power/solar_power
-
-        return efficiency.decompose().value


Test patch:
diff --git a/test/test_BEI.py b/test/test_BEI.py
index 2463390..72b1941 100644
--- a/test/test_BEI.py
+++ b/test/test_BEI.py
@@ -1,10 +1,11 @@
 # coding=utf-8
+
 import astropy.units
-import ibei
 import numpy as np
 import pytest
 
 from contextlib import nullcontext as does_not_raise
+from ibei import BEI
 
 
 class TestBEIConstructorHappyPath():
@@ -18,7 +19,7 @@ class TestBEIConstructorHappyPath():
         valid_constructor_args.pop("chemical_potential")
 
         with does_not_raise():
-            bei = ibei.models.BEI(**valid_constructor_args)
+            bei = BEI(**valid_constructor_args)
 
 
     def test_args_with_default_values(self, valid_constructor_args):
@@ -26,7 +27,7 @@ class TestBEIConstructorHappyPath():
         BEI can be instantiated with valid args incl. ones with defaults
         """
         with does_not_raise():
-            bei = ibei.models.BEI(**valid_constructor_args)
+            bei = BEI(**valid_constructor_args)
 
 
     @pytest.mark.parametrize("argname", [
@@ -41,7 +42,7 @@ class TestBEIConstructorHappyPath():
         valid_constructor_args[argname] = 0
 
         with does_not_raise():
-            bei = ibei.models.BEI(**valid_constructor_args)
+            bei = BEI(**valid_constructor_args)
 
 
     @pytest.mark.parametrize("argname,val", [
@@ -57,7 +58,7 @@ class TestBEIConstructorHappyPath():
         valid_constructor_quantity_args[argname] = val
 
         with does_not_raise():
-            bei = ibei.models.BEI(**valid_constructor_quantity_args)
+            bei = BEI(**valid_constructor_quantity_args)
 
 
     @pytest.mark.parametrize("val", [
@@ -75,7 +76,7 @@ class TestBEIConstructorHappyPath():
         valid_constructor_args["order"] = val
 
         with does_not_raise():
-            bei = ibei.models.BEI(**valid_constructor_args)
+            bei = BEI(**valid_constructor_args)
 
 
 class TestBEIConstructorArgsOutsideConstraints():
@@ -94,7 +95,7 @@ class TestBEIConstructorArgsOutsideConstraints():
         invalid_constructor_args[argname] *= 0
 
         with pytest.raises(ValueError):
-            bei = ibei.models.BEI(**invalid_constructor_args)
+            bei = BEI(**invalid_constructor_args)
 
 
     @pytest.mark.parametrize("argname", [
@@ -111,7 +112,7 @@ class TestBEIConstructorArgsOutsideConstraints():
         invalid_constructor_args[argname] *= -1
 
         with pytest.raises(ValueError):
-            bei = ibei.models.BEI(**invalid_constructor_args)
+            bei = BEI(**invalid_constructor_args)
 
 
 @pytest.mark.parametrize("argname,val", [
@@ -136,7 +137,7 @@ def test_constructor_args_incompatible_units(valid_constructor_quantity_args, ar
     invalid_constructor_args[argname] = astropy.units.Quantity(valid_constructor_arg_value, val)
 
     with pytest.raises(astropy.units.UnitConversionError):
-        bei = ibei.models.BEI(**invalid_constructor_args)
+        bei = BEI(**invalid_constructor_args)
 
 
 @pytest.mark.parametrize("argname", [
@@ -161,7 +162,7 @@ def test_constructor_args_non_scalar(valid_constructor_args, argname):
     invalid_constructor_args[argname] = [val, val]
 
     with pytest.raises(TypeError):
-        bei = ibei.models.BEI(**invalid_constructor_args)
+        bei = BEI(**invalid_constructor_args)
 
 
 @pytest.mark.parametrize("val", [
@@ -177,7 +178,7 @@ def test_order_arg_not_coercible_to_int(valid_constructor_args, val):
     invalid_constructor_args["order"] = val
 
     with pytest.raises(TypeError):
-        bei = ibei.models.BEI(**invalid_constructor_args)
+        bei = BEI(**invalid_constructor_args)
 
 
 class TestIssues():
@@ -189,20 +190,20 @@ class TestIssues():
         Refactor of issue 2 focusing on uibei
         """
         with does_not_raise():
-            ibei.models.BEI(order=2, energy_bound=1.15, temperature=5762., chemical_potential=1.2).upper()
+            BEI(order=2, energy_bound=1.15, temperature=5762., chemical_potential=1.2).upper()
 
     def test_issue_4(self):
         """
         uibei shouldn't fail when energy_lo == chem_potential
         """
         with does_not_raise():
-            ibei.models.BEI(order=2, energy_bound=1., temperature=300., chemical_potential=1.).upper()
+            BEI(order=2, energy_bound=1., temperature=300., chemical_potential=1.).upper()
 
     def test_issue_31(self):
         """
         Passing `energy_lo=0` with `chem_potential=0` should yield nonzero result
         """
-        energy_flux = ibei.models.BEI(order=3, energy_bound=0., temperature=300., chemical_potential=0.).upper()
+        energy_flux = BEI(order=3, energy_bound=0., temperature=300., chemical_potential=0.).upper()
         assert energy_flux > 0
 
 
@@ -307,7 +308,7 @@ def test_methods_regression(args, method_under_test, expected_output):
     -----
     This test tests each of the methods of a `BEI` instance at least once.
     """
-    bei = ibei.models.BEI(**args)
+    bei = BEI(**args)
     method = getattr(bei, method_under_test)
 
     if callable(method):
@@ -343,7 +344,7 @@ def test_methods_units(order, expected_unit, method_under_test, valid_constructo
     order than `test_methods_regression`. I have my reasons.
     """
     valid_constructor_quantity_args["order"] = order
-    bei = ibei.models.BEI(**valid_constructor_quantity_args)
+    bei = BEI(**valid_constructor_quantity_args)
 
     output = getattr(bei, method_under_test)()
 
@@ -356,7 +357,7 @@ def test_consistency_upper_and_full_methods(valid_constructor_quantity_args):
     """
     valid_constructor_quantity_args["energy_bound"] = 0
 
-    bei = ibei.models.BEI(**valid_constructor_quantity_args)
+    bei = BEI(**valid_constructor_quantity_args)
 
     assert astropy.units.allclose(bei.upper(), bei.full())
 
@@ -372,7 +373,7 @@ def test_consistency_lower_and_full_methods(valid_constructor_quantity_args):
     valid_constructor_quantity_args["energy_bound"] = np.inf
     valid_constructor_quantity_args["chemical_potential"] = 0.
 
-    bei = ibei.models.BEI(**valid_constructor_quantity_args)
+    bei = BEI(**valid_constructor_quantity_args)
 
     assert astropy.units.allclose(bei.lower(), bei.full())
 
@@ -392,7 +393,7 @@ def test_consistency_full_and_helper_methods(order, helper_method_name, valid_co
     """
     valid_constructor_quantity_args["order"] = order
     valid_constructor_quantity_args["chemical_potential"] = 0.
-    bei = ibei.models.BEI(**valid_constructor_quantity_args)
+    bei = BEI(**valid_constructor_quantity_args)
     output = getattr(bei, helper_method_name)()
 
     assert astropy.units.allclose(bei.full(), output)
@@ -403,7 +404,7 @@ def test_consistency_full_and_helper_methods(order, helper_method_name, valid_co
 @pytest.fixture
 def valid_constructor_quantity_args():
     """
-    Valid constructor arguments for ibei.models.BEI
+    Valid constructor arguments for BEI
     """
     args = {
         "order": 2,
diff --git a/test/test_DeVosSolarcell.py b/test/test_DeVosSolarcell.py
index a10b2f2..af36048 100644
--- a/test/test_DeVosSolarcell.py
+++ b/test/test_DeVosSolarcell.py
@@ -1,86 +1,270 @@
-# -*- coding: utf-8 -*-
-import ibei
-import numpy as np
-import pytest
-import unittest
-
-from astropy import units
+# coding=utf-8
 
+import astropy.units
+import pytest
 
-temp_sun = 5762.
-temp_earth = 288.
-bandgap = 1.15
-
-input_params = {"temp_sun": temp_sun,
-                "temp_planet": temp_earth,
-                "bandgap": bandgap,
-                "voltage": 0.5,}
+from contextlib import nullcontext as does_not_raise
+from ibei import DeVosSolarcell
 
 
-@pytest.mark.xfail(reason="I broke the `DeVosSolarcell` class in a previous commit")
-class Issues(unittest.TestCase):
+class TestDeVosSolarcellConstructorHappyPath():
     """
-    Tests output types of the calculator methods.
+    Circumstances under which DeVosSolarcell instance can be instantiated
     """
+    def test_args_without_default_values(self, valid_constructor_args):
+        """
+        DeVosSolarcell can be instantiated with valid args that don't have defaults
+        """
+        valid_constructor_args.pop("solar_temperature")
+        valid_constructor_args.pop("planetary_temperature")
+        valid_constructor_args.pop("voltage")
 
-    def test_issue_3_DeVosSolarcell(self):
+        with does_not_raise():
+            solarcell = DeVosSolarcell(**valid_constructor_args)
+
+
+    def test_args_with_default_values(self, valid_constructor_args):
         """
-        Inconsistent units cause exception when chem_potential > energy_lo.
+        DeVosSolarcell can be instantiated with valid args incl. ones with defaults
         """
-        ip = {"temp_sun": 5762,
-              "temp_planet": 288,
-              "bandgap": 0.1,
-              "voltage": 0.5,}
-        sc = ibei.DeVosSolarcell(ip)
-        try:
-            sc.calc_power_density()
-        except:
-            self.fail("Succumbing to issue #3.")
+        with does_not_raise():
+            solarcell = DeVosSolarcell(**valid_constructor_args)
 
 
-@pytest.mark.xfail(reason="I broke the `DeVosSolarcell` class in a previous commit")
-class CalculatorsReturnUnits(unittest.TestCase):
-    """
-    Tests units of the calculator methods returned values.
-    """
-    def setUp(self):
+    @pytest.mark.parametrize("argname", [
+                "bandgap",
+                "voltage"
+            ]
+        )
+    def test_args_that_can_equal_zero(self, valid_constructor_args, argname):
         """
-        Initialize DeVosSolarcell object from input_params
+        DeVosSolarcell can be instantiated with args not constrained to be nonzero
         """
-        self.solarcell = ibei.DeVosSolarcell(input_params)
+        valid_constructor_args[argname] = 0
 
-    def test_calc_power_density(self):
+        with does_not_raise():
+            solarcell = DeVosSolarcell(**valid_constructor_args)
+
+
+    @pytest.mark.parametrize("argname", [
+                "voltage"
+            ]
+        )
+    def test_args_that_can_be_lt_0(self, valid_constructor_args, argname):
         """
-        calc_power_density should return value with unit of W m^-2.
+        DeVosSolarcell can be instantiated with args less than zero
         """
-        tested_unit = self.solarcell.calc_power_density().unit
-        target_unit = units.Unit("W/m2")
-        self.assertEqual(tested_unit, target_unit)
+        valid_constructor_args[argname] *= -1
+
+        with does_not_raise():
+            solarcell = DeVosSolarcell(**valid_constructor_args)
 
-    def test_calc_power_density_zero_bandgap(self):
+
+    @pytest.mark.parametrize("argname,val", [
+                ("solar_temperature", astropy.units.Quantity(5498.85, astropy.units.deg_C)),
+                ("bandgap", astropy.units.Quantity(1e-19, astropy.units.J)),
+                ("planetary_temperature", astropy.units.Quantity(27., astropy.units.deg_C)),
+            ]
+        )
+    def test_quantity_args_compatible_units(self, valid_constructor_quantity_args, argname, val):
         """
-        calc_power_density should return value with unit of W m^-2.
+        DeVosSolarcell can be instantiated with args in compatible units
+
+        Note
+        ----
+        No unit is equivalent to volts.
         """
-        self.solarcell.bandgap = 0
-        tested_unit = self.solarcell.calc_power_density().unit
-        target_unit = units.Unit("W/m2")
-        self.assertEqual(tested_unit, target_unit)
+        valid_constructor_quantity_args[argname] = val
 
+        with does_not_raise():
+            solarcell = DeVosSolarcell(**valid_constructor_quantity_args)
 
-@pytest.mark.xfail(reason="I broke the `DeVosSolarcell` class in a previous commit")
-class CalculatorsReturnValue(unittest.TestCase):
+
+class TestDeVosSolarcellConstructorArgsOutsideConstraints():
     """
-    Tests special values of the calculator methods.
+    DeVosSolarcell should raise exceptions if args are outside their constraints
     """
-    def setUp(self):
+    @pytest.mark.parametrize("argname", [
+                "solar_temperature",
+                "planetary_temperature",
+            ]
+        )
+    def test_arg_eq_0(self, valid_constructor_args, argname):
         """
-        Initialize DeVosSolarcell object from input_params
+        DeVosSolarcell raises ValueError if arg equal to zero
         """
-        self.solarcell = ibei.DeVosSolarcell(input_params)
+        invalid_constructor_args = valid_constructor_args.copy()
+        invalid_constructor_args[argname] *= 0
+
+        with pytest.raises(ValueError):
+            solarcell = DeVosSolarcell(**invalid_constructor_args)
 
-    def test_calc_power_density(self):
+
+    @pytest.mark.parametrize("argname", [
+                "bandgap",
+                "solar_temperature",
+                "planetary_temperature",
+            ]
+        )
+    def test_arg_lt_0(self, valid_constructor_args, argname):
         """
-        calc_power_density should return 0 when bandgap = 0.
+        DeVosSolarcell raises ValueError if arg less than zero
         """
-        self.solarcell.bandgap = 0
-        self.assertEqual(0, self.solarcell.calc_power_density())
+        invalid_constructor_args = valid_constructor_args.copy()
+        invalid_constructor_args[argname] *= -1
+
+        with pytest.raises(ValueError):
+            solarcell = DeVosSolarcell(**invalid_constructor_args)
+
+
+@pytest.mark.parametrize("argname,val", [
+            ("bandgap", astropy.units.s),
+            ("solar_temperature", astropy.units.s),
+            ("planetary_temperature", astropy.units.s),
+            ("voltage", astropy.units.s),
+        ]
+    )
+def test_constructor_args_incompatible_units(valid_constructor_quantity_args, argname, val):
+    """
+    DeVosSolarcell raises astropy.units.UnitConversionError if arg has incompatible unit
+    """
+    valid_constructor_arg_value = valid_constructor_quantity_args[argname].value
+
+    invalid_constructor_args = valid_constructor_quantity_args.copy()
+    invalid_constructor_args[argname] = astropy.units.Quantity(valid_constructor_arg_value, val)
+
+    with pytest.raises(astropy.units.UnitConversionError):
+        solarcell = DeVosSolarcell(**invalid_constructor_args)
+
+
+@pytest.mark.parametrize("argname", [
+            "bandgap",
+            "solar_temperature",
+            "planetary_temperature",
+            "voltage",
+        ]
+    )
+def test_constructor_args_non_scalar(valid_constructor_args, argname):
+    """
+    DeVosSolarcell raises TypeError if arg is non-scalar
+    """
+    invalid_constructor_args = valid_constructor_args.copy()
+    val = valid_constructor_args[argname]
+    invalid_constructor_args[argname] = [val, val]
+
+    with pytest.raises(TypeError):
+        solarcell = DeVosSolarcell(**invalid_constructor_args)
+
+
+@pytest.mark.parametrize("args,method_under_test,expected_output", [
+            # Special case.
+            (
+                {
+                    "solar_temperature": 5762.,
+                    "planetary_temperature": 288.,
+                    "bandgap": 0.,
+                    "voltage": 0.5,
+                },
+                "power_density",
+                astropy.units.Quantity(0., "W/m2"),
+            ),
+            (
+                {
+                    "solar_temperature": 5762.,
+                    "planetary_temperature": 288.,
+                    "bandgap": 1.15,
+                    "voltage": 0.5,
+                },
+                "power_density",
+                astropy.units.Quantity(11880930.46659443, "W/m2"),
+            ),
+            # Special case
+            (
+                {
+                    "solar_temperature": 5762.,
+                    "planetary_temperature": 288.,
+                    "bandgap": 0.,
+                    "voltage": 0.5,
+                },
+                "efficiency",
+                astropy.units.Quantity(0.),
+            ),
+            (
+                {
+                    "solar_temperature": 5762.,
+                    "planetary_temperature": 288.,
+                    "bandgap": 1.15,
+                    "voltage": 0.5,
+                },
+                "efficiency",
+                astropy.units.Quantity(0.19008406),
+            ),
+        ]
+    )
+def test_methods_regression(args, method_under_test, expected_output):
+    """
+    Methods' output values should match expected results
+    """
+    solarcell = DeVosSolarcell(**args)
+    output = getattr(solarcell, method_under_test)()
+
+    assert astropy.units.allclose(expected_output, output)
+
+
+@pytest.mark.parametrize("method_under_test,expected_unit,args_mod", [
+            ("power_density", "W/m2", {}),
+            ("power_density", "W/m2", {"bandgap": 0.}),  # Special case.
+            ("efficiency", astropy.units.dimensionless_unscaled, {}),
+            ("efficiency", astropy.units.dimensionless_unscaled, {"bandgap": 0.}),  # Special case.
+        ]
+    )
+def test_methods_units(method_under_test, expected_unit, valid_constructor_args, args_mod):
+    """
+    Units of returned value should match what's documented.
+    """
+    valid_constructor_args |= args_mod
+    solarcell = DeVosSolarcell(**valid_constructor_args)
+    output = getattr(solarcell, method_under_test)()
+
+    assert output.unit.is_equivalent(expected_unit)
+
+
+class Issues():
+    """
+    Tests corresponding to issues raised due to bugs
+    """
+    def test_issue_3_DeVosSolarcell(self):
+        """
+        Inconsistent units cause exception when chem_potential > energy_lo.
+        """
+        args = {
+                "temp_sun": 5762,
+                "temp_planet": 288,
+                "bandgap": 0.1,
+                "voltage": 0.5,
+            }
+        solarcell = DeVosSolarcell(**args)
+
+        with does_not_raise:
+            solarcell.power_density()
+
+
+# Pytest fixture definitions
+# ==========================
+@pytest.fixture
+def valid_constructor_quantity_args():
+    args = {
+        "solar_temperature": astropy.units.Quantity(5762., astropy.units.K),
+        "planetary_temperature": astropy.units.Quantity(288., astropy.units.K),
+        "bandgap": astropy.units.Quantity(1.15, astropy.units.eV),
+        "voltage": astropy.units.Quantity(0.1, astropy.units.V),
+        }
+
+    return args
+
+
+@pytest.fixture(params=[(lambda x: x), (lambda x: getattr(x, "value", x))])
+def valid_constructor_args(request, valid_constructor_quantity_args):
+    args = {key: request.param(val) for key, val in valid_constructor_quantity_args.items()}
+
+    return args
diff --git a/test/test_SBSolarcell.py b/test/test_SBSolarcell.py
index 0930f46..e36169f 100644
--- a/test/test_SBSolarcell.py
+++ b/test/test_SBSolarcell.py
@@ -1,91 +1,212 @@
-# -*- coding: utf-8 -*-
-import ibei
-import numpy as np
-import pytest
-import unittest
-
-from astropy import units
-
+# coding=utf-8
 
-temp_sun = 5762.
-temp_earth = 288.
-bandgap = 1.15
+import astropy.units
+import pytest
 
-input_params = {"temp_sun": temp_sun,
-                "temp_planet": temp_earth,
-                "bandgap": bandgap,
-                "voltage": 0.5,}
+from contextlib import nullcontext as does_not_raise
+from ibei import SQSolarcell
 
 
-@pytest.mark.xfail(reason="I broke the `SQSolarcell` class in a previous commit")
-class CalculatorsReturnUnits(unittest.TestCase):
+class TestSQSolarcellConstructorHappyPath():
     """
-    Tests units of the calculator methods returned values.
+    Circumstances under which SQSolarcell instance can be instantiated
     """
-    def setUp(self):
+    def test_args_without_default_values(self, valid_constructor_args):
         """
-        Initialize SBSolarcell object from input_params
+        SQSolarcell can be instantiated with valid args that don't have defaults
         """
-        self.solarcell = ibei.SQSolarcell(input_params)
+        valid_constructor_args.pop("solar_temperature")
+
+        with does_not_raise():
+            solarcell = SQSolarcell(**valid_constructor_args)
 
-    def test_calc_blackbody_radiant_power_density(self):
+
+    def test_args_with_default_values(self, valid_constructor_args):
         """
-        calc_blackbody_radiant_power_density should return value with unit of W m^-2.
+        SQSolarcell can be instantiated with valid args incl. ones with defaults
         """
-        tested_unit = self.solarcell.calc_blackbody_radiant_power_density().unit
-        target_unit = units.Unit("W/m2")
-        self.assertEqual(tested_unit, target_unit)
+        with does_not_raise():
+            solarcell = SQSolarcell(**valid_constructor_args)
+
 
-    def test_calc_power_density(self):
+    @pytest.mark.parametrize("argname", [
+                "bandgap",
+            ]
+        )
+    def test_args_that_can_equal_zero(self, valid_constructor_args, argname):
         """
-        calc_power_density should return value with unit of W m^-2.
+        SQSolarcell can be instantiated with args not constrained to be nonzero
         """
-        tested_unit = self.solarcell.calc_power_density().unit
-        target_unit = units.Unit("W/m2")
-        self.assertEqual(tested_unit, target_unit)
+        valid_constructor_args[argname] = 0
+
+        with does_not_raise():
+            solarcell = SQSolarcell(**valid_constructor_args)
 
-    def test_calc_power_density_zero_bandgap(self):
+
+    @pytest.mark.parametrize("argname,val", [
+                ("solar_temperature", astropy.units.Quantity(5498.85, astropy.units.deg_C)),
+                ("bandgap", astropy.units.Quantity(1e-19, astropy.units.J)),
+            ]
+        )
+    def test_quantity_args_compatible_units(self, valid_constructor_quantity_args, argname, val):
         """
-        calc_power_density should return value with unit of W m^-2.
+        SQSolarcell can be instantiated with args in compatible units
         """
-        self.solarcell.bandgap = 0
-        tested_unit = self.solarcell.calc_power_density().unit
-        target_unit = units.Unit("W/m2")
-        self.assertEqual(tested_unit, target_unit)
+        valid_constructor_quantity_args[argname] = val
+
+        with does_not_raise():
+            solarcell = SQSolarcell(**valid_constructor_quantity_args)
 
 
-@pytest.mark.xfail(reason="I broke the `SQSolarcell` class in a previous commit")
-class CalculatorsReturnType(unittest.TestCase):
+class TestSQSolarcellConstructorArgsOutsideConstraints():
     """
-    Tests type of the calculator methods returned values.
+    SQSolarcell should raise exceptions if args are outside their constraints
     """
-    def setUp(self):
+    @pytest.mark.parametrize("argname", [
+                "solar_temperature",
+            ]
+        )
+    def test_arg_eq_0(self, valid_constructor_args, argname):
         """
-        Initialize SBSolarcell object from input_params
+        SQSolarcell raises ValueError if arg equal to zero
         """
-        self.solarcell = ibei.SQSolarcell(input_params)
+        invalid_constructor_args = valid_constructor_args.copy()
+        invalid_constructor_args[argname] *= 0
 
-    def test_calc_efficiency(self):
+        with pytest.raises(ValueError):
+            solarcell = SQSolarcell(**invalid_constructor_args)
+
+
+    @pytest.mark.parametrize("argname", [
+                "bandgap",
+                "solar_temperature",
+            ]
+        )
+    def test_arg_lt_0(self, valid_constructor_args, argname):
         """
-        calc_power_density should return value with unit of W m^-2.
+        SQSolarcell raises ValueError if arg less than zero
         """
-        self.assertIsInstance(self.solarcell.calc_efficiency(), float)
+        invalid_constructor_args = valid_constructor_args.copy()
+        invalid_constructor_args[argname] *= -1
+
+        with pytest.raises(ValueError):
+            solarcell = SQSolarcell(**invalid_constructor_args)
 
 
-@pytest.mark.xfail(reason="I broke the `SQSolarcell` class in a previous commit")
-class CalculatorsReturnValue(unittest.TestCase):
+@pytest.mark.parametrize("argname,val", [
+            ("bandgap", astropy.units.s),
+            ("solar_temperature", astropy.units.s),
+        ]
+    )
+def test_constructor_args_incompatible_units(valid_constructor_quantity_args, argname, val):
     """
-    Tests special values of the calculator methods.
+    SQSolarcell raises astropy.units.UnitConversionError if arg has incompatible unit
     """
-    def setUp(self):
-        """
-        Initialize SBSolarcell object from input_params
-        """
-        self.solarcell = ibei.SQSolarcell(input_params)
+    valid_constructor_arg_value = valid_constructor_quantity_args[argname].value
 
-    def test_calc_power_density(self):
-        """
-        calc_power_density should return 0 when bandgap = 0.
-        """
-        self.solarcell.bandgap = 0
-        self.assertEqual(0, self.solarcell.calc_power_density())
+    invalid_constructor_args = valid_constructor_quantity_args.copy()
+    invalid_constructor_args[argname] = astropy.units.Quantity(valid_constructor_arg_value, val)
+
+    with pytest.raises(astropy.units.UnitConversionError):
+        solarcell = SQSolarcell(**invalid_constructor_args)
+
+
+@pytest.mark.parametrize("argname", [
+            "bandgap",
+            "solar_temperature",
+        ]
+    )
+def test_constructor_args_non_scalar(valid_constructor_args, argname):
+    """
+    SQSolarcell raises TypeError if arg is non-scalar
+    """
+    invalid_constructor_args = valid_constructor_args.copy()
+    val = valid_constructor_args[argname]
+    invalid_constructor_args[argname] = [val, val]
+
+    with pytest.raises(TypeError):
+        solarcell = SQSolarcell(**invalid_constructor_args)
+
+
+@pytest.mark.parametrize("args,method_under_test,expected_output", [
+            # Special case
+            (
+                {
+                    "solar_temperature": 5762,
+                    "bandgap": 0.,
+                },
+                "power_density",
+                astropy.units.Quantity(0., "W/m2"),
+            ),
+            (
+                {
+                    "solar_temperature": 5762,
+                    "bandgap": 1.15,
+                },
+                "power_density",
+                astropy.units.Quantity(27326140.07319352, "W/m2"),
+            ),
+            # Special case
+            (
+                {
+                    "solar_temperature": 5762,
+                    "bandgap": 0.,
+                },
+                "efficiency",
+                astropy.units.Quantity(0.),
+            ),
+            (
+                {
+                    "solar_temperature": 5762,
+                    "bandgap": 1.15,
+                },
+                "efficiency",
+                astropy.units.Quantity(0.43719334),
+            ),
+        ]
+    )
+def test_methods_regression(args, method_under_test, expected_output):
+    """
+    Methods' output values should match expected results
+    """
+    solarcell = SQSolarcell(**args)
+    output = getattr(solarcell, method_under_test)()
+
+    assert astropy.units.allclose(expected_output, output)
+
+
+@pytest.mark.parametrize("method_under_test,expected_unit,args_mod", [
+            ("power_density", "W/m2", {}),
+            ("power_density", "W/m2", {"bandgap": 0.}),  # Special case.
+            ("efficiency", astropy.units.dimensionless_unscaled, {}),
+            ("efficiency", astropy.units.dimensionless_unscaled, {"bandgap": 0.}),  # Special case.
+        ]
+    )
+def test_methods_units(method_under_test, expected_unit, valid_constructor_args, args_mod):
+    """
+    Units of returned value should match what's documented.
+    """
+    valid_constructor_args |= args_mod
+    solarcell = SQSolarcell(**valid_constructor_args)
+    output = getattr(solarcell, method_under_test)()
+
+    assert output.unit.is_equivalent(expected_unit)
+
+
+# Pytest fixture definitions
+# ==========================
+@pytest.fixture
+def valid_constructor_quantity_args():
+    args = {
+        "solar_temperature": astropy.units.Quantity(5762., astropy.units.K),
+        "bandgap": astropy.units.Quantity(1.15, astropy.units.eV),
+        }
+
+    return args
+
+
+@pytest.fixture(params=[(lambda x: x), (lambda x: getattr(x, "value", x))])
+def valid_constructor_args(request, valid_constructor_quantity_args):
+    args = {key: request.param(val) for key, val in valid_constructor_quantity_args.items()}
+
+    return args