Skip to content

Commit 0f0b554

Browse files
0.29.4
1 parent 0394ee6 commit 0f0b554

5 files changed

Lines changed: 234 additions & 129 deletions

File tree

notebooks/00_spotPython_tests.ipynb

Lines changed: 25 additions & 86 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,18 @@ build-backend = "setuptools.build_meta"
77

88
[project]
99
name = "spotpython"
10-
version = "0.29.3"
10+
version = "0.29.4"
1111
authors = [
1212
{ name="T. Bartz-Beielstein", email="tbb@bartzundbartz.de" }
1313
]
1414
description = "spotpython - Sequential Parameter Optimization in Python"
1515
readme = "README.md"
16-
license = { text="AGPL-3.0-or-later" }
16+
license = "AGPL-3.0-or-later"
1717
requires-python = ">=3.10"
1818
classifiers = [
1919
"Programming Language :: Python :: 3.10",
2020
"Programming Language :: Python :: 3.11",
2121
"Programming Language :: Python :: 3.12",
22-
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
2322
"Operating System :: OS Independent",
2423
]
2524
# PEP 621 dependencies declaration

src/spotpython/fun/objectivefunctions.py

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -440,8 +440,9 @@ def fun_runge(self, X: np.ndarray, fun_control: Optional[Dict] = None) -> np.nda
440440
y = 1 / (1 + sum_squared_diff)
441441
return self._add_noise(y)
442442

443-
def fun_wingwt(self, X: np.ndarray, fun_control: Optional[Dict] = None) -> np.ndarray:
443+
def fun_wingwt_to_nat(self, X: np.ndarray, fun_control: Optional[Dict] = None) -> np.ndarray:
444444
r"""Wing weight function.
445+
Converts coded values to natural values, before applying the original `fun_wingwt` function (Eq. 1.4 in [Forr08a]).
445446
Calculate the weight of an unpainted light aircraft wing based on design and operational parameters.
446447
This function implements the wing weight model from Forrester et al., which aims to predict
447448
the wing weight \( W \) using the following formula:
@@ -477,8 +478,8 @@ def fun_wingwt(self, X: np.ndarray, fun_control: Optional[Dict] = None) -> np.nd
477478
| \( \lambda \) | Taper ratio | 0.672 | 0.5 | 1 |
478479
| \( R_{tc} \) | Aerofoil thickness to chord ratio | 0.12 | 0.08 | 0.18 |
479480
| \( N_z \) | Ultimate load factor | 3.8 | 2.5 | 6 |
480-
| \( W_{dg} \) | Flight design gross weight (lb) | 2000 | 1700 | 2500 |
481-
| \( W_p \) | Paint weight \((\text{lb/ft}^2)\) | 0.064 | 0.025 | 0.08 |
481+
| \( W_{dg} \) | Flight design gross weight (lb) | 2000 | 1700 | 2500 |
482+
| \( W_p \) | Paint weight \((\text{lb/ft}^2)\) | 0.064 | 0.025 | 0.08 |
482483
483484
Args:
484485
X (np.ndarray):
@@ -511,8 +512,93 @@ def fun_wingwt(self, X: np.ndarray, fun_control: Optional[Dict] = None) -> np.nd
511512
Wdg = X[:, 8] * 800 + 1700 # equivalent to (2500 - 1700) + 1700
512513
Wp = X[:, 9] * 0.055 + 0.025 # equivalent to (0.08 - 0.025) + 0.025
513514
# Calculate W for all rows in a vectorized manner
514-
W = 0.036 * Sw**0.758 * Wfw**0.0035 * (A / np.cos(L) ** 2) ** 0.6 * q**0.006
515-
W *= la**0.04 * (100 * Rtc / np.cos(L)) ** (-0.3) * (Nz * Wdg) ** (0.49)
515+
W = 0.036 * Sw**0.758 * Wfw**0.0035
516+
W *= (A / np.cos(L) ** 2) ** 0.6 * q**0.006
517+
W *= la**0.04
518+
print(f"W: {W}")
519+
print(f"(100 * Rtc / np.cos(L)): {(100 * Rtc / np.cos(L))}")
520+
W *= (100 * Rtc / np.cos(L)) ** (-0.3)
521+
print(f"W: {W}")
522+
W *= (Nz * Wdg) ** (0.49)
523+
W += Sw * Wp
524+
return self._add_noise(y=W)
525+
526+
def fun_wingwt(self, X: np.ndarray, fun_control: Optional[Dict] = None) -> np.ndarray:
527+
r"""Wing weight function. Returns coded, not natural values.
528+
Calculate the weight of an unpainted light aircraft wing based on design and operational parameters.
529+
This function implements the wing weight model from Forrester et al., which aims to predict
530+
the wing weight \( W \) using the following formula:
531+
532+
\[
533+
W = 0.036 \times S_W^{0.758} \times W_{fw}^{0.0035} \times \left( \frac{A}{\cos^2 \Lambda} \right)^{0.6}
534+
\times q^{0.006} \times \lambda^{0.04} \times \left( \frac{100 \times R_{tc}}{\cos \Lambda} \right)^{-0.3}
535+
\times (N_z \times W_{dg})^{0.49} + S_W \times W_p
536+
\]
537+
538+
where:
539+
540+
- \( S_W \): Wing area \((\text{ft}^2)\)
541+
- \( W_{fw} \): Weight of fuel in the wing (lb)
542+
- \( A \): Aspect ratio
543+
- \( \Lambda \): Quarter-chord sweep (degrees)
544+
- \( q \): Dynamic pressure at cruise \((\text{lb/ft}^2)\)
545+
- \( \lambda \): Taper ratio
546+
- \( R_{tc} \): Aerofoil thickness to chord ratio
547+
- \( N_z \): Ultimate load factor
548+
- \( W_{dg} \): Flight design gross weight (lb)
549+
- \( W_p \): Paint weight \((\text{lb/ft}^2)\)
550+
551+
Parameter Overview:
552+
553+
| Symbol | Parameter | Baseline | Minimum | Maximum |
554+
|-----------|----------------------------------------|----------|---------|---------|
555+
| \( S_W \) | Wing area \((\text{ft}^2)\) | 174 | 150 | 200 |
556+
| \( W_{fw} \) | Weight of fuel in wing (lb) | 252 | 220 | 300 |
557+
| \( A \) | Aspect ratio | 7.52 | 6 | 10 |
558+
| \( \Lambda \) | Quarter-chord sweep (deg) | 0 | -10 | 10 |
559+
| \( q \) | Dynamic pressure at cruise \((\text{lb/ft}^2)\) | 34 | 16 | 45 |
560+
| \( \lambda \) | Taper ratio | 0.672 | 0.5 | 1 |
561+
| \( R_{tc} \) | Aerofoil thickness to chord ratio | 0.12 | 0.08 | 0.18 |
562+
| \( N_z \) | Ultimate load factor | 3.8 | 2.5 | 6 |
563+
| \( W_{dg} \) | Flight design gross weight (lb) | 2000 | 1700 | 2500 |
564+
| \( W_p \) | Paint weight \((\text{lb/ft}^2)\) | 0.064 | 0.025 | 0.08 |
565+
566+
Args:
567+
X (np.ndarray):
568+
A 2D numpy array where each row contains 10 parameters for which the wing weight will be calculated.
569+
fun_control (Optional[Dict]):
570+
A dictionary with keys `sigma` (noise level) and `seed` (random seed)
571+
for incorporating randomness if required. Default is `None`.
572+
573+
Returns:
574+
np.ndarray:
575+
A 1D numpy array with shape (n,) containing the calculated wing weight values.
576+
577+
Examples:
578+
>>> from spotpython.fun.objectivefunctions import analytical
579+
>>> import numpy as np
580+
>>> X = np.array([np.zeros(10), np.ones(10)])
581+
>>> fun = analytical()
582+
>>> fun.fun_wingwt(X)
583+
array([158.28245046, 409.33182691])
584+
"""
585+
X = self._prepare_input_data(X, fun_control)
586+
Sw = X[:, 0]
587+
Wfw = X[:, 1]
588+
A = X[:, 2]
589+
L = X[:, 3] * np.pi / 180
590+
q = X[:, 4]
591+
la = X[:, 5]
592+
Rtc = X[:, 6]
593+
Nz = X[:, 7]
594+
Wdg = X[:, 8]
595+
Wp = X[:, 9]
596+
# Calculate W for all rows in a vectorized manner
597+
W = 0.036 * Sw**0.758 * Wfw**0.0035
598+
W *= (A / np.cos(L) ** 2) ** 0.6 * q**0.006
599+
W *= la**0.04
600+
W *= (100 * Rtc / np.cos(L)) ** (-0.3)
601+
W *= (Nz * Wdg) ** (0.49)
516602
W += Sw * Wp
517603
return self._add_noise(y=W)
518604

src/spotpython/utils/effects.py

Lines changed: 43 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def screeningplan(k, p, xi, r):
5252
return X
5353

5454

55-
def screening(X, fun, xi, p, labels, range=None, print=False) -> pd.DataFrame:
55+
def screening(X, fun, xi, p, labels, bounds=None, print=False) -> pd.DataFrame:
5656
"""Generates a DataFrame with elementary effect screening metrics.
5757
5858
This function calculates the mean and standard deviation of the
@@ -68,7 +68,7 @@ def screening(X, fun, xi, p, labels, range=None, print=False) -> pd.DataFrame:
6868
p (int): Number of discrete levels along each dimension.
6969
labels (list of str): A list of variable names corresponding to
7070
the design variables.
71-
range (np.ndarray): A 2xk matrix where the first row contains
71+
bounds (np.ndarray): A 2xk matrix where the first row contains
7272
lower bounds and the second row contains upper bounds for
7373
each variable.
7474
@@ -78,66 +78,74 @@ def screening(X, fun, xi, p, labels, range=None, print=False) -> pd.DataFrame:
7878
- 'mean': The mean of the elementary effects for each variable.
7979
- 'sd': The standard deviation of the elementary effects for
8080
each variable.
81+
or None: If print is set to False, a plot of the results is
82+
generated instead of returning a DataFrame.
8183
8284
Examples:
8385
>>> import numpy as np
84-
>>> from spotpython.fun.objectivefunctions import Analytical
85-
>>> from spotpython.utils.effects import screening
86-
>>>
87-
>>> # Create a small test input with shape (n, 10)
88-
>>> X_test = np.array([
89-
... [0.0]*10,
90-
... [1.0]*10
91-
... ])
92-
>>> fun = Analytical()
93-
>>> labels = ["x1", "x2", "x3", "x4", "x5", "x6", "x7", "x8", "x9", "x10"]
94-
>>> result = screening(X_test, fun.fun_wingwt, np.array([[0]*10, [1]*10]), 0.1, 3, labels)
95-
>>> print
86+
from spotpython.utils.effects import screening, screeningplan
87+
from spotpython.fun.objectivefunctions import Analytical
88+
fun = Analytical()
89+
k = 10
90+
p = 10
91+
xi = 1
92+
r = 25
93+
X = screeningplan(k=k, p=p, xi=xi, r=r) # shape (r x (k+1), k)
94+
# Provide real-world bounds from the wing weight docs (2 x 10).
95+
value_range = np.array([
96+
[150, 220, 6, -10, 16, 0.5, 0.08, 2.5, 1700, 0.025],
97+
[200, 300, 10, 10, 45, 1.0, 0.18, 6.0, 2500, 0.08 ],
98+
])
99+
labels = [
100+
"S_W", "W_fw", "A", "Lambda",
101+
"q", "lambda", "tc", "N_z",
102+
"W_dg", "W_p"
103+
]
104+
screening(
105+
X=X,
106+
fun=fun.fun_wingwt,
107+
bounds=value_range,
108+
xi=xi,
109+
p=p,
110+
labels=labels,
111+
print=False,
112+
)
96113
"""
97-
# Determine the number of design variables (k)
98114
k = X.shape[1]
99-
# Determine the number of repetitions (r)
100115
r = X.shape[0] // (k + 1)
101116

102-
# Scale each design point to the given range and evaluate the objective function
117+
# Scale each design point
103118
t = np.zeros(X.shape[0])
104119
for i in range(X.shape[0]):
105-
if range is not None:
106-
X[i, :] = range[0, :] + X[i, :] * (range[1, :] - range[0, :])
120+
if bounds is not None:
121+
X[i, :] = bounds[0, :] + X[i, :] * (bounds[1, :] - bounds[0, :])
107122
t[i] = fun(X[i, :])
108123

109-
# Calculate the elementary effects
124+
# Elementary effects
110125
F = np.zeros((k, r))
111126
for i in range(r):
112127
for j in range(i * (k + 1), i * (k + 1) + k):
113-
index = np.where(X[j, :] - X[j + 1, :] != 0)[0][0]
114-
F[index, i] = (t[j + 1] - t[j]) / (xi / (p - 1))
128+
idx = np.where(X[j, :] - X[j + 1, :] != 0)[0][0]
129+
F[idx, i] = (t[j + 1] - t[j]) / (xi / (p - 1))
115130

116-
# Compute statistical measures
117-
ssd = np.std(F, axis=1)
118-
sm = np.abs(np.mean(F, axis=1))
131+
# Statistical measures (divide by n)
132+
ssd = np.std(F, axis=1, ddof=0)
133+
sm = np.mean(F, axis=1)
119134

120135
if print:
121-
# sort the variables by decreasing mean
122-
idx = np.argsort(-sm)
123-
labels = [labels[i] for i in idx]
136+
idx = np.argsort(-np.abs(sm))
137+
sorted_labels = [labels[i] for i in idx]
124138
sm = sm[idx]
125139
ssd = ssd[idx]
126-
df = pd.DataFrame({"varname": labels, "mean": sm, "sd": ssd})
127-
140+
df = pd.DataFrame({"varname": sorted_labels, "mean": sm, "sd": ssd})
128141
return df
129142
else:
130-
# Generate plot
131143
plt.figure()
132-
133144
for i in range(k):
134145
plt.text(sm[i], ssd[i], labels[i], fontsize=10)
135-
136146
plt.axis([min(sm), 1.1 * max(sm), min(ssd), 1.1 * max(ssd)])
137147
plt.xlabel("Sample means")
138148
plt.ylabel("Sample standard deviations")
139-
plt.gca().set_xlabel("Sample means")
140-
plt.gca().set_ylabel("Sample standard deviations")
141149
plt.gca().tick_params(labelsize=10)
142150
plt.grid(True)
143151
plt.show()

test/test_effects.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import numpy as np
2+
import pandas as pd
3+
import pytest
4+
from spotpython.utils.effects import screening
5+
6+
def mock_objective_function(x):
7+
"""Mock objective function for testing."""
8+
return np.sum(x**2)
9+
10+
11+
@pytest.fixture
12+
def test_data():
13+
"""Fixture to provide test data."""
14+
X = np.array([
15+
[0.0, 0.0],
16+
[0.1, 0.0],
17+
[0.0, 0.1],
18+
[0.1, 0.1]
19+
])
20+
labels = ["x1", "x2"]
21+
range_ = np.array([[0, 0], [1, 1]])
22+
return X, labels, range_
23+
24+
25+
def test_screening_dataframe_output(test_data):
26+
"""Test if screening returns a DataFrame with correct structure."""
27+
X, labels, range_ = test_data
28+
xi = 0.1
29+
p = 3
30+
31+
result = screening(X, mock_objective_function, xi, p, labels, bounds=range_, print=True)
32+
33+
assert isinstance(result, pd.DataFrame), "Output should be a DataFrame"
34+
assert set(result.columns) == {"varname", "mean", "sd"}, "DataFrame should have columns 'varname', 'mean', and 'sd'"
35+
assert len(result) == len(labels), "DataFrame should have one row per variable"
36+
37+
38+
def test_screening_mean_and_sd_calculation(test_data):
39+
"""Test if the mean and standard deviation are calculated correctly."""
40+
X, labels, range_ = test_data
41+
xi = 0.1
42+
p = 3
43+
44+
result = screening(X, mock_objective_function, xi, p, labels, bounds=range_, print=True)
45+
46+
# Check if mean and standard deviation are non-negative
47+
assert (result["mean"] >= 0).all(), "Mean values should be non-negative"
48+
assert (result["sd"] >= 0).all(), "Standard deviation values should be non-negative"
49+
50+
51+
def test_screening_with_no_range(test_data):
52+
"""Test screening function when no bounds is provided."""
53+
X, labels, _ = test_data
54+
xi = 0.1
55+
p = 3
56+
57+
result = screening(X, mock_objective_function, xi, p, labels, bounds=None, print=True)
58+
59+
assert isinstance(result, pd.DataFrame), "Output should be a DataFrame"
60+
assert len(result) == len(labels), "DataFrame should have one row per variable"
61+
62+
63+
def test_screening_plot_output(test_data):
64+
"""Test if screening generates a plot when print=False."""
65+
X, labels, range_ = test_data
66+
xi = 0.1
67+
p = 3
68+
69+
# Ensure no exceptions are raised during plotting
70+
try:
71+
screening(X, mock_objective_function, xi, p, labels, bounds=range_, print=False)
72+
except Exception as e:
73+
pytest.fail(f"Plotting failed with exception: {e}")

0 commit comments

Comments
 (0)