Repository: damar-wicaksono/uqtestfuns (language: python)

Issue:
Add Five-Dimensional Single-Diode Solar Cell Model from Constantine et al. (2015)
The test function models the maximum power of a single-diode solar cell. It was introduced by Constantine et al. (2015)[^Constantine] to demonstrate the active subspace method for (input) dimension reduction and sensitivity analysis.

The model consists of the following set of equations:

$$
\begin{aligned}
\mathcal{M}(\boldsymbol{x}; \boldsymbol{p}) & = P_{\text{max}}(\boldsymbol{x}) = \max_{I, V} I V \\
I(\boldsymbol{x}) & = I_L - I_S \left( \exp{\left(\frac{V + I R_{S}}{N_s n V_{\text{th}}} \right)} - 1 \right) -  \frac{V + I R_S}{R_P} \\
I_L(\boldsymbol{x}) & = I_{SC} + I_S \left( \exp{\left(\frac{I_{SC} R_{S}}{N_s n V_{\text{th}}} \right)} - 1 \right) -  \frac{I_{SC} R_S}{R_P}, \\
\end{aligned}
$$

where $\boldsymbol{x} = \{ I_{SC}, I_S, n, R_S, R_P \}$ is the vector of input variables modeled as independent random variables whose marginals are specified in the table below.

| Symbol | Bounds | Description | Units |
|:-------:|:-------:| ------- |:-------:|
| $I_{SC}$ | $[0.05989, 0.23958]$ | Short circuit current | $[\text{A}]$ |
| $I_S$ | $[2.2 \times 10^{-11}, 2.2 \times 10^{-7}]$  | Diode reverse saturation current | $[\text{A}]$ |
| $n$ | $[1, 2]$ | Ideality factor | $[-]$ |
| $R_S$ | $[0.16625, 0.66500]$ | Series resistance | $[\Omega]$ |
| $R_P$ | $[93.75,375.00]$ | Parallel (shunt) resistance | $[\Omega]$  |

Furthermore, $\boldsymbol{p} = \{ N_s, V_{\text{th}} \}$ is the vector of parameters, namely, the number of cells connected in series and the thermal voltage. In the paper, the number of cells is set to $1$ while the thermal voltage is set to $T = 25 \, [^\text{o}\text{C}]$ with the following relation:

$$
V_\text{th} = k T
$$

where $k$ is the Boltzmann constant in $[\text{eV K}^{-1}]$.

Note that the function above is implicit in the sense that the variable $I$ appears in both sides of the equation and thus must be solved numerically for a given $V$.

[^Constantine]: P. G. Constantine, B. Zaharatos, and M. Campanelli, “Discovering an active subspace in a single‐diode solar cell model,” *Statistical Analysis*, vol. 8, no. 5–6, pp. 264–273, Oct. 2015, doi: [10.1002/sam.11281](https://doi.org/10.1002/sam.11281).



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2653b38..493f8bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
+- The 5-dimensional single-diode solar cell model from Constantine et al.
+  (2015); this is the first function of which the solution must be computed
+  numerically.
 - Update the README with JOSS badge and instruction how to cite the paper and
   package.
 - A commentary on sensitivity analysis within the UQ framework in the docs.
diff --git a/docs/_toc.yml b/docs/_toc.yml
index deb0311..14c14aa 100644
--- a/docs/_toc.yml
+++ b/docs/_toc.yml
@@ -134,6 +134,8 @@ parts:
             title: Sobol'-G*
           - file: test-functions/sobol-levitan
             title: Sobol'-Levitan
+          - file: test-functions/solar-cell
+            title: Solar Cell
           - file: test-functions/speed-reducer-shaft
             title: Speed Reducer Shaft
           - file: test-functions/sulfur
diff --git a/docs/fundamentals/metamodeling.md b/docs/fundamentals/metamodeling.md
index 50a7b74..9580e7e 100644
--- a/docs/fundamentals/metamodeling.md
+++ b/docs/fundamentals/metamodeling.md
@@ -18,41 +18,42 @@ kernelspec:
 The table below listed the available test functions typically used
 in the comparison of metamodeling approaches.
 
-|                                  Name                                  | Input Dimension |       Constructor       |
-|:----------------------------------------------------------------------:|:---------------:|:-----------------------:|
-|                 {ref}`Ackley <test-functions:ackley>`                  |        M        |       `Ackley()`        |
-|  {ref}`Alemazkoor & Meidani (2018) 2D <test-functions:alemazkoor-2d>`  |        2        |    `Alemazkoor2D()`     |
-| {ref}`Alemazkoor & Meidani (2018) 20D <test-functions:alemazkoor-20d>` |       20        |    `Alemazkoor20D()`    |
-|               {ref}`Borehole <test-functions:borehole>`                |        8        |      `Borehole()`       |
-|       {ref}`Cheng and Sandu (2010) 2D <test-functions:cheng2d>`        |        2        |        `Cheng2D`        |
-|          {ref}`Damped Cosine <test-functions:damped-cosine>`           |        1        |    `DampedCosine()`     |
-|      {ref}`Damped Oscillator <test-functions:damped-oscillator>`       |        7        |  `DampedOscillator()`   |
-|                  {ref}`Flood <test-functions:flood>`                   |        8        |        `Flood()`        |
-|       {ref}`Forrester et al. (2008) <test-functions:forrester>`        |        1        |    `Forrester2008()`    |
-|             {ref}`(1st) Franke <test-functions:franke-1>`              |        2        |       `Franke1()`       |
-|             {ref}`(2nd) Franke <test-functions:franke-2>`              |        2        |       `Franke2()`       |
-|             {ref}`(3rd) Franke <test-functions:franke-3>`              |        2        |       `Franke3()`       |
-|             {ref}`(4th) Franke <test-functions:franke-4>`              |        2        |       `Franke4()`       |
-|             {ref}`(5th) Franke <test-functions:franke-5>`              |        2        |       `Franke5()`       |
-|             {ref}`(6th) Franke <test-functions:franke-6>`              |        2        |       `Franke6()`       |
-|           {ref}`Friedman (6D) <test-functions:friedman-6d>`            |        6        |     `Friedman6D()`      |
-|          {ref}`Friedman (10D) <test-functions:friedman-10d>`           |       10        |     `Friedman10D()`     |
-|     {ref}`Gramacy (2007) 1D Sine <test-functions:gramacy-1d-sine>`     |        1        |    `Gramacy1DSine()`    |
-| {ref}`Lim et al. (2002) Non-Polynomial <test-functions:lim-non-poly>`  |        2        |     `LimNonPoly()`      |
-|     {ref}`Lim et al. (2002) Polynomial <test-functions:lim-poly>`      |        2        |       `LimPoly()`       |
-|              {ref}`McLain S1 <test-functions:mclain-s1>`               |        2        |      `McLainS1()`       |
-|              {ref}`McLain S2 <test-functions:mclain-s2>`               |        2        |      `McLainS2()`       |
-|              {ref}`McLain S3 <test-functions:mclain-s3>`               |        2        |      `McLainS3()`       |
-|              {ref}`McLain S4 <test-functions:mclain-s4>`               |        2        |      `McLainS4()`       |
-|              {ref}`McLain S5 <test-functions:mclain-s5>`               |        2        |      `McLainS5()`       |
-|      {ref}`Oakley & O'Hagan (2002) 1D <test-functions:oakley-1d>`      |        1        |      `Oakley1D()`       |
-|            {ref}`OTL Circuit <test-functions:otl-circuit>`             |     6 / 20      |     `OTLCircuit()`      |
-|            {ref}`Piston Simulation <test-functions:piston>`            |     7 / 20      |       `Piston()`        |
-|      {ref}`Webster et al. (1996) 2D <test-functions:webster-2d>`       |        2        |      `Webster2D()`      |
-|                 {ref}`Sulfur <test-functions:sulfur>`                  |        9        |       `Sulfur()`        |
-|    {ref}`Undamped Oscillator <test-functions:undamped-oscillator>`     |        6        | `UndampedOscillator()`  |
-|         {ref}`Welch et al. (1992) <test-functions:welch1992>`          |       20        |      `Welch1992()`      |
-|            {ref}`Wing Weight <test-functions:wing-weight>`             |       10        |     `WingWeight()`      |
+|                                  Name                                  | Input Dimension |      Constructor       |
+|:----------------------------------------------------------------------:|:---------------:|:----------------------:|
+|                 {ref}`Ackley <test-functions:ackley>`                  |        M        |       `Ackley()`       |
+|  {ref}`Alemazkoor & Meidani (2018) 2D <test-functions:alemazkoor-2d>`  |        2        |    `Alemazkoor2D()`    |
+| {ref}`Alemazkoor & Meidani (2018) 20D <test-functions:alemazkoor-20d>` |       20        |   `Alemazkoor20D()`    |
+|               {ref}`Borehole <test-functions:borehole>`                |        8        |      `Borehole()`      |
+|       {ref}`Cheng and Sandu (2010) 2D <test-functions:cheng2d>`        |        2        |       `Cheng2D`        |
+|          {ref}`Damped Cosine <test-functions:damped-cosine>`           |        1        |    `DampedCosine()`    |
+|      {ref}`Damped Oscillator <test-functions:damped-oscillator>`       |        7        |  `DampedOscillator()`  |
+|                  {ref}`Flood <test-functions:flood>`                   |        8        |       `Flood()`        |
+|       {ref}`Forrester et al. (2008) <test-functions:forrester>`        |        1        |   `Forrester2008()`    |
+|             {ref}`(1st) Franke <test-functions:franke-1>`              |        2        |      `Franke1()`       |
+|             {ref}`(2nd) Franke <test-functions:franke-2>`              |        2        |      `Franke2()`       |
+|             {ref}`(3rd) Franke <test-functions:franke-3>`              |        2        |      `Franke3()`       |
+|             {ref}`(4th) Franke <test-functions:franke-4>`              |        2        |      `Franke4()`       |
+|             {ref}`(5th) Franke <test-functions:franke-5>`              |        2        |      `Franke5()`       |
+|             {ref}`(6th) Franke <test-functions:franke-6>`              |        2        |      `Franke6()`       |
+|           {ref}`Friedman (6D) <test-functions:friedman-6d>`            |        6        |     `Friedman6D()`     |
+|          {ref}`Friedman (10D) <test-functions:friedman-10d>`           |       10        |    `Friedman10D()`     |
+|     {ref}`Gramacy (2007) 1D Sine <test-functions:gramacy-1d-sine>`     |        1        |   `Gramacy1DSine()`    |
+| {ref}`Lim et al. (2002) Non-Polynomial <test-functions:lim-non-poly>`  |        2        |     `LimNonPoly()`     |
+|     {ref}`Lim et al. (2002) Polynomial <test-functions:lim-poly>`      |        2        |      `LimPoly()`       |
+|              {ref}`McLain S1 <test-functions:mclain-s1>`               |        2        |      `McLainS1()`      |
+|              {ref}`McLain S2 <test-functions:mclain-s2>`               |        2        |      `McLainS2()`      |
+|              {ref}`McLain S3 <test-functions:mclain-s3>`               |        2        |      `McLainS3()`      |
+|              {ref}`McLain S4 <test-functions:mclain-s4>`               |        2        |      `McLainS4()`      |
+|              {ref}`McLain S5 <test-functions:mclain-s5>`               |        2        |      `McLainS5()`      |
+|      {ref}`Oakley & O'Hagan (2002) 1D <test-functions:oakley-1d>`      |        1        |      `Oakley1D()`      |
+|            {ref}`OTL Circuit <test-functions:otl-circuit>`             |     6 / 20      |     `OTLCircuit()`     |
+|            {ref}`Piston Simulation <test-functions:piston>`            |     7 / 20      |       `Piston()`       |
+|          {ref}`Solar Cell Model <test-functions:solar-cell>`           |        5        |     `SolarCell()`      |
+|                 {ref}`Sulfur <test-functions:sulfur>`                  |        9        |       `Sulfur()`       |
+|    {ref}`Undamped Oscillator <test-functions:undamped-oscillator>`     |        6        | `UndampedOscillator()` |
+|      {ref}`Webster et al. (1996) 2D <test-functions:webster-2d>`       |        2        |     `Webster2D()`      |
+|         {ref}`Welch et al. (1992) <test-functions:welch1992>`          |       20        |     `Welch1992()`      |
+|            {ref}`Wing Weight <test-functions:wing-weight>`             |       10        |     `WingWeight()`     |
 
 In a Python terminal, you can list all the available functions relevant
 for metamodeling applications using ``list_functions()``
diff --git a/docs/fundamentals/sensitivity.md b/docs/fundamentals/sensitivity.md
index 6d9ab50..26ee368 100644
--- a/docs/fundamentals/sensitivity.md
+++ b/docs/fundamentals/sensitivity.md
@@ -38,6 +38,7 @@ in the comparison of sensitivity analysis methods.
 |           {ref}`Sobol'-G <test-functions:sobol-g>`           |        M        |      `SobolG()`      |
 |        {ref}`Sobol'-G* <test-functions:sobol-g-star>`        |        M        |    `SobolGStar()`    |
 |     {ref}`Sobol'-Levitan <test-functions:sobol-levitan>`     |        M        |   `SobolLevitan()`   |
+|     {ref}`Solar Cell Model <test-functions:solar-cell>`      |        5        |    `SolarCell()`     |
 |            {ref}`Sulfur <test-functions:sulfur>`             |        9        |      `Sulfur()`      |
 |    {ref}`Welch et al. (1992) <test-functions:welch1992>`     |       20        |    `Welch1992()`     |
 |       {ref}`Wing Weight <test-functions:wing-weight>`        |       10        |    `WingWeight()`    |
diff --git a/docs/references.bib b/docs/references.bib
index b82cff6..d6ad70e 100644
--- a/docs/references.bib
+++ b/docs/references.bib
@@ -930,4 +930,16 @@ @Book{Saltelli2007
   doi       = {10.1002/9780470725184},
 }
 
+@Article{Constantine2015,
+  author  = {Constantine, Paul G. and Zaharatos, Brian and Campanelli, Mark},
+  journal = {Statistical Analysis and Data Mining: The ASA Data Science Journal},
+  title   = {Discovering an active subspace in a single‐diode solar cell model},
+  year    = {2015},
+  month   = {]},
+  number  = {5–6},
+  pages   = {264--273},
+  volume  = {8},
+  doi     = {10.1002/sam.11281},
+}
+
 @Comment{jabref-meta: databaseType:bibtex;}


Test patch:
diff --git a/docs/test-functions/available.md b/docs/test-functions/available.md
index fe7c2e4..ed4d56f 100644
--- a/docs/test-functions/available.md
+++ b/docs/test-functions/available.md
@@ -70,6 +70,7 @@ regardless of their typical applications.
 |                      {ref}`Sobol'-G <test-functions:sobol-g>`                       |        M        |           `SobolG()`            |
 |                   {ref}`Sobol'-G* <test-functions:sobol-g-star>`                    |        M        |         `SobolGStar()`          |
 |                {ref}`Sobol'-Levitan <test-functions:sobol-levitan>`                 |        M        |        `SobolLevitan()`         |
+|                 {ref}`Solar Cell Model <test-functions:solar-cell>`                 |        5        |          `SolarCell()`          |
 |           {ref}`Speed Reducer Shaft <test-functions:speed-reducer-shaft>`           |        5        |      `SpeedReducerShaft()`      |
 |                        {ref}`Sulfur <test-functions:sulfur>`                        |        9        |           `Sulfur()`            |
 |           {ref}`Undamped Oscillator <test-functions:undamped-oscillator>`           |        6        |     `UndampedOscillator()`      |
diff --git a/docs/test-functions/solar-cell.md b/docs/test-functions/solar-cell.md
new file mode 100644
index 0000000..98b912c
--- /dev/null
+++ b/docs/test-functions/solar-cell.md
@@ -0,0 +1,236 @@
+---
+jupytext:
+  formats: ipynb,md:myst
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.14.1
+kernelspec:
+  display_name: Python 3 (ipykernel)
+  language: python
+  name: python3
+---
+
+(test-functions:solar-cell)=
+# Single-Diode Solar Cell Model
+
+```{code-cell} ipython3
+import numpy as np
+import matplotlib.pyplot as plt
+import uqtestfuns as uqtf
+```
+
+The test function is a five-dimensional, scalar-valued function that models
+the maximum power of a single-diode solar cell. The function was used in
+{cite}`Constantine2015` to demonstrate the active subspace method for
+input dimension reduction and sensitivity analysis.
+
+For a given voltage, the corresponding current is defined implicitly.
+The plot below (left) displays 15 current-voltage curves from 15 different
+input variable values.
+The dots on the curves indicate the current and voltage values that yield
+the maximum cell power; the power curves are shown in the right plot.
+
+```{code-cell} ipython3
+:tags: [remove-input]
+
+my_fun = uqtf.SolarCell()
+my_fun.prob_input.reset_rng(237324)
+num_sample = 15
+xx = my_fun.prob_input.get_sample(num_sample)
+
+n_s = my_fun.parameters["n_s"]
+v_th = my_fun.parameters["v_th"]
+def compute_ii(vv, xx, ns, v_th):
+    ii = np.empty((len(vv), len(xx)))
+    for idx_1 in range(len(xx)):
+        x = xx[idx_1]
+        for idx_2 in range(len(vv)):
+            v = vv[idx_2]
+            ii[idx_2, idx_1] = uqtf.test_functions.solar_cell.compute_current(
+                v,
+                x,
+                n_s,
+                v_th,
+            ).squeeze()
+
+    return ii
+
+vv = np.linspace(0, 1.4, 1000)
+ii = compute_ii(vv, xx, n_s, v_th)
+
+pp_max, vv_max = uqtf.test_functions.solar_cell.compute_power_max(
+    xx,
+    n_s,
+    v_th,
+)
+ii_max = np.array(
+    [
+        uqtf.test_functions.solar_cell.compute_current(
+            vv_max[k],
+            xx[k],
+            n_s,
+            v_th,
+        ).squeeze()
+        for k in range(num_sample)
+    ]
+)
+
+# --- Create a series of plots
+fig = plt.figure(figsize=(10, 5))
+
+# Current-voltage curves
+axs_1 = plt.subplot(121)
+for i in range(len(xx)):
+    axs_1.plot(vv, ii[:, i], color="#8da0cb")
+axs_1.set_ylim([0.0, 0.3])
+axs_1.set_xlim([0.0, 1.5])
+axs_1.scatter(vv_max, ii_max, color="#8da0cb")
+axs_1.grid()
+axs_1.set_xlabel("Voltage (V)", fontsize=14)
+axs_1.set_ylabel("Current (A)", fontsize=14)
+axs_1.set_title("Current-voltage curves")
+
+# Power-voltage curves
+axs_2 = plt.subplot(122)
+for i in range(len(xx)):
+    axs_2.plot(vv, ii[:, i] * vv, color="#8da0cb")
+axs_2.scatter(vv_max, pp_max, color="#8da0cb")
+axs_2.set_ylim([0.0, 0.18])
+axs_2.set_xlim([0.0, 1.5])
+axs_2.grid()
+axs_2.set_xlabel("Voltage (V)", fontsize=14)
+axs_2.set_ylabel("Power (W)", fontsize=14)
+axs_2.set_title("Power-voltage curves")
+
+plt.gcf().tight_layout(pad=4.0)
+plt.gcf().set_dpi(150);
+```
+
+## Test function instance
+
+To create a default instance of the test function:
+
+```{code-cell} ipython3
+my_testfun = uqtf.SolarCell()
+```
+
+Check if it has been correctly instantiated:
+
+```{code-cell} ipython3
+print(my_testfun)
+```
+
+## Description
+
+The model predicts the maximum power of a single-diode solar cell defined
+in the following formula:
+
+$$
+\mathcal{M}(\boldsymbol{x}; \boldsymbol{p}) = \max_{I, V}  I(V; \boldsymbol{x}, \boldsymbol{p}) V,
+$$
+
+where the current ($I$) is defined implicitly as a function of the voltage ($V$),
+input variables $\boldsymbol{x}$ and parameters $\boldsymbol{p}$.
+The implicit relationshow is described by the following equation:
+
+$$
+I(V; \boldsymbol{x}, \boldsymbol{p}) = I_L - I_S \left( \exp{\left( \frac{V + I R_S}{n_S \, n \, V_{\text{th}}} \right) } - 1 \right) - \frac{V + I R_S}{R_P},
+$$
+
+where $I_L$, the photocurrent, is defined by the auxiliary equation:
+
+$$
+I(\boldsymbol{x}, \boldsymbol{p}) = I_{SC} + I_S \left( \exp{ \left( \frac{I_{SC} R_S}{n_S \, n \, V_{\text{th}}} \right) } - 1 \right) + \frac{I_{SC} R_S}{R_P}.
+$$
+
+Here, $\boldsymbol{x} = \left( I_{SC}, I_S, n, R_S, R_P \right)$ represents
+the five-dimensional the vector of input variables probabilistically defined below.
+The vector $\boldsymbol{p} = \left( n_s, V_{\text{th}} \right)$ contains fixed parameters,
+which are also further detailed below.
+
+## Probabilistic input
+
+The probabilistic input model for the test function is shown below.
+
+```{code-cell} ipython3
+:tags: [hide-input]
+
+print(my_testfun.prob_input)
+```
+
+## Parameters
+
+The default values of the parameters are shown below.
+
+```{code-cell} ipython3
+:tags: [hide-input]
+
+print(my_testfun.parameters)
+```
+
+## Reference results
+
+This section provides several reference results of typical UQ analyses involving
+the test function.
+
+### Sample histogram
+
+Shown below is the histogram of the output based on $1000$ random points:
+
+```{code-cell} ipython3
+:tags: [hide-input]
+
+xx_test = my_testfun.prob_input.get_sample(1000)
+yy_test = my_testfun(xx_test)
+
+plt.hist(yy_test, bins="auto", color="#8da0cb");
+plt.grid();
+plt.ylabel("Counts [-]");
+plt.xlabel("$\mathcal{M}(\mathbf{X})$");
+plt.gcf().set_dpi(150);
+```
+
+## Notes on numerical algorithms
+
+The maximum power of the solar cell model is computed numerically
+using the following methods:
+
+- [`root()`](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.root.html)
+  from SciPy, with its default method and parameter values,
+  to solve the implicit current as a function of voltage.
+- [`minimize()`](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html)
+  from SciPy, with its default method (i.e., `BFGS`) and parameter values,
+  to find the maximum power by optimizing over the voltage (the current is
+  is computed on-the-fly during the iteration).
+
+The default parameter values for these methods can be overridden by providing
+a dictionary with new parameter values. For example:
+
+To override the tolerance value for `root()`:
+
+```python
+fun.parameters.add("root", {"tol": 1e-12})  # 'root' as the parameter keyword
+```
+
+To override the tolerance value for `minimize()`:
+
+```python
+fun.parameters.add("minimize", {"tol": 1e-12})  # 'minimize' as the parameter keyword
+```
+
+```{note}
+In this example, `tol` is acceptable keyword-named argument for both `root()`
+and `minimize()`; indeed, the key-value pairs specified for the parameters
+`root` and `minimize` must be recognized by the respective methods.
+```
+
+## References
+
+```{bibliography}
+:style: unsrtalpha
+:filter: docname in docnames
+```
+
+[^location]: See Eqs. (9-12), Section 3.1 in {cite}`Constantine2015`.
diff --git a/src/uqtestfuns/test_functions/__init__.py b/src/uqtestfuns/test_functions/__init__.py
index 5df474b..33588a2 100644
--- a/src/uqtestfuns/test_functions/__init__.py
+++ b/src/uqtestfuns/test_functions/__init__.py
@@ -35,6 +35,7 @@
 from .sobol_g import SobolG
 from .sobol_g_star import SobolGStar
 from .sobol_levitan import SobolLevitan
+from .solar_cell import SolarCell
 from .speed_reducer_shaft import SpeedReducerShaft
 from .sulfur import Sulfur
 from .undamped_oscillator import UndampedOscillator
@@ -95,6 +96,7 @@
     "SobolG",
     "SobolGStar",
     "SobolLevitan",
+    "SolarCell",
     "SpeedReducerShaft",
     "Sulfur",
     "UndampedOscillator",
diff --git a/src/uqtestfuns/test_functions/cheng2010.py b/src/uqtestfuns/test_functions/cheng2010.py
index 1efbda0..936400e 100644
--- a/src/uqtestfuns/test_functions/cheng2010.py
+++ b/src/uqtestfuns/test_functions/cheng2010.py
@@ -9,7 +9,7 @@
 1. H. Cheng and A. Sandu, “Collocation least-squares polynomial chaos method,”
    in Proceedings of the 2010 Spring Simulation Multiconference, Orlando,
    Florida: Society for Computer Simulation International, 2010, pp. 1–6.
-   DOI: 10.1145/1878537.1878621].
+   DOI: 10.1145/1878537.1878621.
 """
 
 import numpy as np
diff --git a/src/uqtestfuns/test_functions/flood.py b/src/uqtestfuns/test_functions/flood.py
index 7d7ef99..6d43863 100644
--- a/src/uqtestfuns/test_functions/flood.py
+++ b/src/uqtestfuns/test_functions/flood.py
@@ -95,7 +95,7 @@
         "function_id": "Flood",
         "description": (
             "Probabilistic input model for the Flood model "
-            "from Iooss and Lemaître (2015)."
+            "from Iooss and Lemaître (2015)"
         ),
         "marginals": MARGINALS_IOOSS2015,
         "copulas": None,
diff --git a/src/uqtestfuns/test_functions/solar_cell.py b/src/uqtestfuns/test_functions/solar_cell.py
new file mode 100644
index 0000000..b8bb403
--- /dev/null
+++ b/src/uqtestfuns/test_functions/solar_cell.py
@@ -0,0 +1,271 @@
+"""
+This module implements the solar cell model from Constantine et al. (2015).
+
+The test function is a five-dimensional, scalar-valued function that models
+the maximum power of a single-diode solar cell. The function was used in
+Constantine et al. (2015) to demonstrate the active subspace method for
+input dimension reduction and sensitivity analysis.
+
+References
+----------
+
+1. P. G. Constantine, B. Zaharatos, and M. Campanelli, “Discovering an active
+   subspace in a single‐diode solar cell model,” Statistical Analysis, vol. 8,
+   no. 5–6, pp. 264–273, 2015.
+   DOI: 10.1002/sam.11281
+"""
+
+import numpy as np
+from scipy.optimize import root, minimize
+from typing import Tuple
+
+from uqtestfuns.core.custom_typing import (
+    MarginalSpecs,
+    FunParamSpecs,
+    ProbInputSpecs,
+)
+from uqtestfuns.core.uqtestfun_abc import UQTestFunFixDimABC
+
+__all__ = ["SolarCell"]
+
+
+MARGINALS_CONSTANTINE2015: MarginalSpecs = [
+    {
+        "name": "Isc",
+        "distribution": "uniform",
+        "parameters": [0.05989, 0.23958],
+        "description": "Short-circuit current [A]",
+    },
+    {
+        "name": "log_Is",
+        "distribution": "uniform",
+        "parameters": [np.log(2.2e-11), np.log(2.2e-7)],
+        "description": "(log) Diode reverse saturation current [A]",
+    },
+    {
+        "name": "n",
+        "distribution": "uniform",
+        "parameters": [1.0, 2.0],
+        "description": "Ideality factor [-]",
+    },
+    {
+        "name": "Rs",
+        "distribution": "uniform",
+        "parameters": [0.16625, 0.66500],
+        "description": "Series resistance [Ohm]",
+    },
+    {
+        "name": "Rp",
+        "distribution": "uniform",
+        "parameters": [93.75, 375.00],
+        "description": "Parallel (shunt) resistance [Ohm]",
+    },
+]
+
+AVAILABLE_INPUTS: ProbInputSpecs = {
+    "Constantine2015": {
+        "function_id": "SolarCell",
+        "description": (
+            "Probabilistic input model for the single-diode solar cell model "
+            "from Constantine et al. (2015)"
+        ),
+        "marginals": MARGINALS_CONSTANTINE2015,
+        "copulas": None,
+    },
+}
+
+
+AVAILABLE_PARAMETERS: FunParamSpecs = {
+    "Constantine2015": {
+        "function_id": "SolarCell",
+        "description": (
+            "Parameter set for the single-diode solar cell model from "
+            "Constantine et al. (2015)"
+        ),
+        "declared_parameters": [
+            {
+                "keyword": "n_s",
+                "value": 1,
+                "type": int,
+                "description": "# of cells connected in series [-]",
+            },
+            {
+                "keyword": "v_th",
+                "value": 0.02585,
+                "type": float,
+                "description": "Thermal voltage at 25degC [V]",
+            },
+        ],
+    },
+}
+
+
+def obj_fun_root(
+    i: float,
+    v: float,
+    x: np.ndarray,
+    n_s: int,
+    v_th: float,
+) -> float:
+    """Compute the equation to get the root.
+
+    Parameters
+    ----------
+    i : float
+        The current [A]
+    v : float
+        The voltage [V]
+    x : np.ndarray
+        The input variables of the solar cell model: I_SC, I_S, n, R_S, R_P.
+    n_s : int
+        The number of cells connected in series.
+    v_th : float
+        The thermal voltage value at 25degC [V]
+
+    Returns
+    -------
+    float
+        The root objective function for implicit current; the function returns
+        0 if the current corresponds to the given voltage.
+    """
+    # Get the input variable values
+    i_sc = x[0]
+    i_s = np.exp(x[1])
+    n = x[2]
+    r_s = x[3]
+    r_p = x[4]
+
+    # Compute the photo current
+    exp_term_1 = np.exp(i_sc * r_s / n_s / n / v_th) - 1
+    i_l = i_sc + i_s * exp_term_1 + i_sc * r_s / r_p
+
+    # Compute the objective function
+    exp_term_2 = np.exp((v + i * r_s) / n_s / n / v_th) - 1
+    y = i - i_l + i_s * exp_term_2 + (v + i * r_s) / r_p
+
+    return y
+
+
+def compute_current(
+    v: float,
+    x: np.ndarray,
+    n_s: int,
+    v_th: float,
+    **kwargs,
+) -> float:
+    """Compute the current of the solar cell given a voltage value.
+
+    Parameters
+    ----------
+    v : float
+        The voltage [V]
+    x : np.ndarray
+        The input variables of the solar cell model: I_SC, I_S, n, R_S, R_P.
+    n_s : int
+        The number of cells connected in series.
+    v_th : float
+        The thermal voltage value at 25degC [V]
+
+    Returns
+    -------
+    float
+        The current that corresponds to the given voltage.
+    """
+    i0 = np.array([0.0])
+    # Find the corresponding current as the root
+    sol = root(obj_fun_root, i0, args=(v, x, n_s, v_th), **kwargs)
+    i = sol.x
+
+    return i
+
+
+def compute_power_max(
+    xx: np.ndarray,
+    n_s: int,
+    v_th: float,
+    **kwargs,
+) -> Tuple[np.ndarray, np.ndarray]:
+    """Compute the maximum power a solar cell given its design parameters.
+
+    Parameters
+    ----------
+    xx : np.ndarray
+        A five-dimensional input values of the solar cell model given by
+        an N-by-5 array where N is the number of input values.
+    n_s : int
+        The number of cells connected in series.
+    v_th : float
+        The thermal voltage value at 25degC [V]
+    kwargs : dict
+        The additional parameters to be passed either to `root()` or
+        `minimize()`. A dictionary value of key `root` will be passed to
+        `root()`, while a dictionary value of key `minimize` will be passed to
+        `minimize()`
+
+    Returns
+    -------
+    Tuple[np.ndarray, np.ndarray]
+        A pair of arrays containing the maximum power a solar cell and the
+        corresponding voltage.
+    """
+    # Initialize output array
+    pp_max = np.empty(len(xx))
+    vv_max = np.empty(len(xx))
+
+    # Get additional arguments for root and minimize
+    root_kwargs = kwargs.get("root", {})
+    minimize_kwargs = kwargs.get("minimize", {})
+
+    for idx in range(len(xx)):
+        # Set up the objective function (negated for minimization)
+        x = xx[idx]
+
+        def _obj_fun(v, x, n_s, v_th, root_kwargs):
+            return -1 * v * compute_current(v, x, n_s, v_th, **root_kwargs)
+
+        # Minimize the objective function
+        v0 = np.array([0.0])
+        res = minimize(
+            _obj_fun,
+            v0,
+            args=(x, n_s, v_th, root_kwargs),
+            **minimize_kwargs,
+        )
+
+        pp_max[idx] = -1 * res.fun  # Negated back for the maximum value
+        vv_max[idx] = res.x
+
+    return pp_max, vv_max
+
+
+def evaluate(xx: np.ndarray, n_s: int, v_th: float, **kwargs) -> np.ndarray:
+    """Evaluate the solar cell model on a set of input values.
+
+    Parameters
+    ----------
+    xx : np.ndarray
+        A five-dimensional input values given by an N-by-5 array
+        where N is the number of input values.
+
+    Returns
+    -------
+    np.ndarray
+        The output of the test function evaluated on the input values.
+        The output is a 1-dimensional array of length N.
+    """
+    yy, _ = compute_power_max(xx, n_s, v_th, **kwargs)
+
+    return yy
+
+
+class SolarCell(UQTestFunFixDimABC):
+    """Concrete implementation of the single-diode solar cell model."""
+
+    _tags = ["metamodeling", "sensitivity"]
+    _description = (
+        "Single-diode solar-cell model from Constantine et al. (2015)"
+    )
+    _available_inputs = AVAILABLE_INPUTS
+    _available_parameters = AVAILABLE_PARAMETERS
+
+    evaluate = staticmethod(evaluate)  # type: ignore