Skip to content

Commit df4ea02

Browse files
authored
Add bounded and inequality operand definition (#53)
* Add bounded and inequality operand definition * fix: tests * update notebook 7d * update notebook 7d * simplify Operand : remove bounds, rename min_val, max_val * fix: tests * fix: convert one-element array to float properly * add tests for intercept_lcs operands * simplify delta_ineq() logic * Add comments to _fun() * fix: check that decenter is not a single element array
1 parent d6c0504 commit df4ea02

File tree

11 files changed

+266
-192
lines changed

11 files changed

+266
-192
lines changed

docs/examples/Tutorial_7d_Three_Mirror_Anastigmat.ipynb

Lines changed: 120 additions & 115 deletions
Large diffs are not rendered by default.

optiland/optimization/operand/operand.py

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
1616
Kramer Harrison, 2024
1717
"""
18+
from dataclasses import dataclass
1819
from optiland.optimization.operand.paraxial import ParaxialOperand
1920
from optiland.optimization.operand.aberration import AberrationOperand
2021
from optiland.optimization.operand.ray import RayOperand
@@ -137,47 +138,79 @@ def __repr__(self):
137138
for name, func in METRIC_DICT.items():
138139
operand_registry.register(name, func)
139140

140-
141-
class Operand(object):
141+
@dataclass
142+
class Operand:
142143
"""
143144
Represents an operand used in optimization calculations.
145+
If no target is specified, a default is created at the current value.
144146
145147
Attributes:
146-
type (str): The type of the operand.
147-
target (float): The target value for the operand.
148+
operand_type (str): The type of the operand.
149+
target (float): The target value of the operand (equality operand).
150+
min_val (float): The operand should stay above this value (inequality operand).
151+
max_val (float): The operand should stay below this value (inequality operand).
148152
weight (float): The weight of the operand.
149-
input_data (dict): Additional input data for the operand's metric
150-
function.
153+
input_data (dict): Additional input data for the operand.
151154
152155
Methods:
153156
value(): Get the current value of the operand.
154-
delta(): Calculate the difference between the target and current value.
157+
delta_target(): Calculate the difference between the value and target.
158+
delta_ineq(): Calculate the difference between the value and targets.
155159
fun(): Calculate the objective function value.
156160
"""
157161

158-
def __init__(self, operand_type, target, weight, input_data={}):
159-
self.type = operand_type
160-
self.target = target
161-
self.weight = weight
162-
self.input_data = input_data
162+
operand_type: str = None
163+
target: float = None
164+
min_val: float = None
165+
max_val: float = None
166+
weight: float = None
167+
input_data: dict = None
168+
169+
def __post_init__(self):
170+
if self.min_val is not None and self.max_val is not None and self.min_val > self.max_val:
171+
raise ValueError(f"{self.operand_type} operand: min_val is higher than max_val")
172+
if self.target is not None and (self.min_val is not None or self.max_val is not None):
173+
raise ValueError(f"{self.operand_type} operand cannot accept both equality and inequality targets")
174+
if all(x is None for x in [self.target, self.min_val, self.max_val]):
175+
self.target = self.value # No target has been defined, default it to the current value
163176

164177
@property
165178
def value(self):
166179
"""Get current value of the operand"""
167-
metric_function = operand_registry.get(self.type)
180+
metric_function = operand_registry.get(self.operand_type)
168181
if metric_function:
169182
return metric_function(**self.input_data)
170183
else:
171-
raise ValueError(f'Unknown operand type: {self.type}')
184+
raise ValueError(f'Unknown operand type: {self.operand_type}')
172185

173-
def delta(self):
174-
"""Calculate the difference between the target and current value"""
175-
return (self.value - self.target)
186+
def delta_target(self):
187+
"""Calculate the difference between the value and target"""
188+
return self.value - self.target
189+
190+
def delta_ineq(self):
191+
"""Calculate the difference between the value and targets.
176192
193+
If the value is on the right side of the bound(s),
194+
then this operand simply is zero.
195+
Otherwise, it is the distance to the closest bound.
196+
"""
197+
lower_penalty = max(0, self.min_val - self.value) if self.min_val is not None else 0
198+
upper_penalty = max(0, self.value - self.max_val) if self.max_val is not None else 0
199+
return lower_penalty + upper_penalty
200+
201+
def delta(self):
202+
"""Calculate the difference to target"""
203+
if self.target is not None:
204+
return self.delta_target()
205+
elif self.min_val is not None or self.max_val is not None:
206+
return self.delta_ineq()
207+
else:
208+
raise ValueError(f"{self.operand_type} operand cannot compute delta")
209+
177210
def fun(self):
178211
"""Calculate the objective function value"""
179212
return self.weight * self.delta()
180213

181214
def __str__(self):
182215
"""Return a string representation of the operand"""
183-
return self.type.replace('_', ' ')
216+
return self.operand_type.replace('_', ' ')

optiland/optimization/operand/operand_manager.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,19 @@ class OperandManager:
1717
def __init__(self):
1818
self.operands = []
1919

20-
def add(self, operand_type, target, weight=1, input_data={}):
20+
def add(self, operand_type=None, target=None, min_val=None, max_val=None, weight=1, input_data={}):
2121
"""Add an operand to the merit function
2222
2323
Args:
2424
operand_type (str): The type of the operand.
2525
target (float): The target value of the operand.
26+
min_val (float): The operand should stay above this value (inequality operand).
27+
max_val (float): The operand should stay below this value (inequality operand).
2628
weight (float): The weight of the operand.
2729
input_data (dict): Additional input data for the operand.
30+
2831
"""
29-
self.operands.append(Operand(operand_type, target, weight, input_data))
32+
self.operands.append(Operand(operand_type, target, min_val, max_val, weight, input_data))
3033

3134
def clear(self):
3235
"""Clear all operands from the merit function"""

optiland/optimization/operand/ray.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ def z_intercept_lcs(optic, surface_number, Hx, Hy, Px, Py, wavelength):
163163
optic.trace_generic(Hx, Hy, Px, Py, wavelength)
164164
intercept = optic.surface_group.z[surface_number, 0]
165165
decenter = optic.surface_group.surfaces[surface_number].geometry.cs.z
166+
167+
# For some reason decenter can sometimes be a single-element array.
168+
# In that case, retreive the float inside.
169+
# This is a workaround until a solution is found.
170+
if type(decenter) == np.ndarray:
171+
decenter = decenter.item()
172+
166173
return intercept - decenter
167174

168175
@staticmethod

optiland/optimization/optimization.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ def __init__(self):
4444
self.variables = VariableManager()
4545
self.initial_value = 0.0
4646

47-
def add_operand(self, operand_type, target, weight=1, input_data={}):
47+
def add_operand(self, operand_type=None, target=None, min_val=None, max_val=None, weight=1, input_data={}):
4848
"""Add an operand to the merit function"""
49-
self.operands.add(operand_type, target, weight, input_data)
49+
self.operands.add(operand_type, target, min_val, max_val, weight, input_data)
5050

5151
def add_variable(self, optic, variable_type, **kwargs):
5252
"""Add a variable to the merit function"""
@@ -84,16 +84,18 @@ def update_optics(self):
8484

8585
def operand_info(self):
8686
"""Print information about the operands in the merit function"""
87-
data = {'Operand Type': [op.type.replace('_', ' ')
87+
data = {'Operand Type': [op.operand_type.replace('_', ' ')
8888
for op in self.operands],
89-
'Target': [op.target for op in self.operands],
89+
'Target': [f'{op.target:+.3f}' if op.target is not None else '' for op in self.operands],
90+
'Min Bound': [op.min_val if op.min_val else '' for op in self.operands],
91+
'Max Bound': [op.max_val if op.max_val else '' for op in self.operands],
9092
'Weight': [op.weight for op in self.operands],
91-
'Value': [op.value for op in self.operands],
92-
'Delta': [op.delta() for op in self.operands]}
93+
'Value': [f'{op.value:+.3f}' for op in self.operands],
94+
'Delta': [f'{op.delta():+.3f}' for op in self.operands]}
9395

9496
df = pd.DataFrame(data)
9597
funs = self.fun_array()
96-
df['Contribution (%)'] = funs / np.sum(funs) * 100
98+
df['Contrib. [%]'] = np.round(funs / np.sum(funs) * 100, 2)
9799

98100
print(df.to_markdown(headers='keys', tablefmt='fancy_outline'))
99101

@@ -119,7 +121,7 @@ def merit_info(self):
119121
improve_percent = ((self.initial_value - current_value) /
120122
self.initial_value * 100)
121123

122-
data = {'Merit Function Value': [self.sum_squared()],
124+
data = {'Merit Function Value': [current_value],
123125
'Improvement (%)': improve_percent}
124126
df = pd.DataFrame(data)
125127
print(df.to_markdown(headers='keys', tablefmt='fancy_outline'))
@@ -211,11 +213,16 @@ def _fun(self, x):
211213
Returns:
212214
rss (float): The residual sum of squares.
213215
"""
216+
217+
# Update all variables to their new values
214218
for idvar, var in enumerate(self.problem.variables):
215219
var.update(x[idvar])
216-
self.problem.update_optics() # update all optics (e.g., pickups)
217-
funs = np.array([op.fun() for op in self.problem.operands])
218-
rss = np.sum(funs**2)
220+
221+
# Update optics (e.g., pickups and solves)
222+
self.problem.update_optics()
223+
224+
# Compute merit function value
225+
rss = self.problem.sum_squared()
219226
if np.isnan(rss):
220227
return 1e10
221228
else:

optiland/surfaces/surface_factory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def _configure_cs(self, index, **kwargs):
155155
elif index == 1:
156156
z = 0 # first surface, always at zero
157157
else:
158-
z = float(self._surface_group.positions[index-1]) + \
158+
z = float(self._surface_group.positions[index-1].item()) + \
159159
self.last_thickness
160160

161161
# self.last_thickness = thickness

optiland/tolerancing/core.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,21 +58,20 @@ def __init__(self, optic, method='generic', tol=1e-5):
5858
self.compensator = CompensatorOptimizer(method=method, tol=tol)
5959

6060
def add_operand(self, operand_type: str, input_data: dict = {},
61-
target: float = None, weight: float = 1.0):
61+
target: float = None, weight: float = 1.0,
62+
min_val: float = None, max_val: float = None):
6263
"""
6364
Add an operand to the tolerancing problem.
6465
6566
Args:
66-
operand_type: The type of the operand.
67-
input_data: A dictionary of input data for the operand. Defaults to
68-
an empty dictionary.
69-
target: The target value for the operand. Defaults to None, in
70-
which case the target is set based on the current value of
71-
the operand.
72-
weight: The weight of the operand for optimization during
73-
compensation. Defaults to 1.0.
67+
operand_type (str): The type of the operand.
68+
target (float): The target value of the operand (equality operand).
69+
min_val (float): The operand should stay above this value (inequality operand).
70+
max_val (float): The operand should stay below this value (inequality operand).
71+
weight (float): The weight of the operand.
72+
input_data (dict): Additional input data for the operand.
7473
"""
75-
new_operand = Operand(operand_type, target, weight, input_data)
74+
new_operand = Operand(operand_type, target, min_val, max_val, weight, input_data)
7675
if target is None:
7776
new_operand.target = new_operand.value
7877
self.operands.append(new_operand)

tests/test_compensator.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def test_run_optimizer_generic():
3232
optic = Edmund_49_847()
3333
optimizer = CompensatorOptimizer(method='generic')
3434
optimizer.add_variable(optic, 'radius', surface_number=1)
35-
optimizer.add_operand('f2', 25, 1, {'optic': optic})
35+
optimizer.add_operand(operand_type='f2', target=25, weight=1, input_data={'optic': optic})
3636
result = optimizer.run()
3737
assert result is not None
3838

@@ -41,7 +41,7 @@ def test_run_optimizer_least_squares():
4141
optic = Edmund_49_847()
4242
optimizer = CompensatorOptimizer(method='least_squares')
4343
optimizer.add_variable(optic, 'radius', surface_number=1)
44-
optimizer.add_operand('f2', 25, 1, {'optic': optic})
44+
optimizer.add_operand(operand_type='f2', target=2525, weight=1, input_data={'optic': optic})
4545
result = optimizer.run()
4646
assert result is not None
4747

tests/test_operand.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,24 @@ def test_z_intercept(self, hubble):
158158
assert np.isclose(operand.RayOperand.z_intercept(**data),
159159
6347.146837237045)
160160

161+
def test_x_intercept_lcs(self, hubble):
162+
data = {'optic': hubble, 'surface_number': -1, 'Hx': 1.0, 'Hy': 0.0,
163+
'Px': 0.0, 'Py': 0.0, 'wavelength': 0.55}
164+
assert np.isclose(operand.RayOperand.x_intercept_lcs(**data),
165+
-150.42338010762842)
166+
167+
def test_y_intercept_lcs(self, hubble):
168+
data = {'optic': hubble, 'surface_number': -1, 'Hx': 0.0, 'Hy': 1.0,
169+
'Px': 0.0, 'Py': 0.0, 'wavelength': 0.55}
170+
assert np.isclose(operand.RayOperand.y_intercept_lcs(**data),
171+
150.42338010762842)
172+
173+
def test_z_intercept_lcs(self, hubble):
174+
data = {'optic': hubble, 'surface_number': -1, 'Hx': 0.0, 'Hy': 1.0,
175+
'Px': 0.0, 'Py': 0.0, 'wavelength': 0.55}
176+
assert np.isclose(operand.RayOperand.z_intercept_lcs(**data),
177+
-18.062712762936826) # Because Hubble's image is curved, otherwise it would be 0
178+
161179
def test_L(self, hubble):
162180
data = {'optic': hubble, 'surface_number': -1, 'Hx': 0.0, 'Hy': 1.0,
163181
'Px': 0.0, 'Py': 0.0, 'wavelength': 0.55}
@@ -208,23 +226,23 @@ def test_opd_diff_new_dist(self, hubble):
208226
class TestOperand:
209227
def test_get_value(self, hubble):
210228
input_data = {'optic': hubble}
211-
op = operand.Operand('f2', 1, 1, input_data)
229+
op = operand.Operand(operand_type='f2', target=1, weight=1, input_data=input_data)
212230
assert np.isclose(op.value, 57600.080998403595)
213231

214232
def test_invalid_operand(self, hubble):
215233
input_data = {'optic': hubble}
216-
op = operand.Operand('f3', 1, 1, input_data)
234+
op = operand.Operand(operand_type='f3', target=1, weight=1, input_data=input_data)
217235
with pytest.raises(ValueError):
218236
op.value
219237

220238
def test_delta(self, hubble):
221239
input_data = {'optic': hubble}
222-
op = operand.Operand('f2', 5000, 1, input_data)
240+
op = operand.Operand(operand_type='f2', target=5000, weight=1, input_data=input_data)
223241
assert np.isclose(op.delta(), 52600.080998403595)
224242

225243
def test_fun(self, hubble):
226244
input_data = {'optic': hubble}
227-
op = operand.Operand('f2', 1e5, 1.5, input_data)
245+
op = operand.Operand(operand_type='f2', target=1e5, weight=1.5, input_data=input_data)
228246
assert np.isclose(op.fun(), -63599.87850239461)
229247

230248
def test_reregister_operand(self):
@@ -268,7 +286,7 @@ def test_getitem(self):
268286
def test_setitem(self):
269287
manager = operand.OperandManager()
270288
manager.add('f1', 1)
271-
manager[0] = operand.Operand('f2', 1, 1, {})
289+
manager[0] = operand.Operand(operand_type='f2', target=1, weight=1, input_data={})
272290
assert len(manager) == 1
273291

274292
def test_len(self):

0 commit comments

Comments
 (0)