Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added ability to add expressions as constraints #875

Merged
merged 42 commits into from
Dec 12, 2022
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
3f89549
added test problems
kaushikponnapalli Oct 7, 2022
7d2b03d
added option to timeseries options dictionary indicating if an output…
kaushikponnapalli Oct 7, 2022
e4a7781
add new option when adding timeseries outputs
kaushikponnapalli Oct 7, 2022
ac0fb3a
added a method to introspect expressions and make the connections to …
kaushikponnapalli Oct 11, 2022
e21d70b
modified configure method for gl transcription to check if a timeseri…
kaushikponnapalli Oct 11, 2022
ec5e6bc
added timeseries exec comp for solve_ivp and explicit shooting
kaushikponnapalli Oct 12, 2022
92dbfed
Merge branch 'master' of github.com:kaushikponnapalli/dymos into time…
kaushikponnapalli Oct 20, 2022
ec6aba5
changed introspection to determine shape from in-built methods. Also …
kaushikponnapalli Oct 20, 2022
1650b1c
Added the polynomial controls and their rates as timeseries outputs i…
kaushikponnapalli Oct 20, 2022
4bf68df
Merge branch 'shooting_polynomial_controls' of github.com:kaushikponn…
kaushikponnapalli Oct 20, 2022
44508f7
fixed a bug in introspection where units of rate2 was incorrect
kaushikponnapalli Oct 20, 2022
76174e3
Merge branch 'shooting_polynomial_controls' of github.com:kaushikponn…
kaushikponnapalli Oct 20, 2022
44a8dff
changed order of instantiation for timeseries exec comp and timeserie…
kaushikponnapalli Oct 21, 2022
81cbcb6
Added capability for analytic phases
kaushikponnapalli Oct 21, 2022
7d23cc0
deleted a test that checked a capability that had not been implemented
kaushikponnapalli Oct 26, 2022
1a080cd
Added a timeseries group and moved timeseries output comp and the exe…
kaushikponnapalli Oct 26, 2022
9b0c1b0
Changed timeseries plotting to go one level deeper to extract timeser…
kaushikponnapalli Oct 26, 2022
acea66f
Updated name of the timesereies comp subsystem to match new name
kaushikponnapalli Oct 26, 2022
c78f136
Added recordable=False to the option to supress the warnings
kaushikponnapalli Oct 27, 2022
2047178
Modified test problems to match new API
kaushikponnapalli Oct 27, 2022
3e3baad
Added more tests for timeseries expressions and added the ability to …
robfalck Oct 28, 2022
23d2536
Merge branch 'master' of https://github.com/OpenMDAO/dymos into kaush…
robfalck Oct 28, 2022
820988d
Merge branch 'master' of github.com:OpenMDAO/dymos into timeseries_expr
kaushikponnapalli Nov 2, 2022
3ab88ab
Merge pull request #7 from robfalck/kaushik_timeseries_expr
kaushikponnapalli Nov 2, 2022
d0f3af2
added a kwargs dict thaat is redeclared each time in introspection to…
kaushikponnapalli Nov 3, 2022
a992037
Added a cannonball problem test case
kaushikponnapalli Nov 3, 2022
5517178
Added checks to see if a constraint is an expression
kaushikponnapalli Nov 3, 2022
396f2d3
Removed print statements from debugging
kaushikponnapalli Nov 3, 2022
8b39345
Merge branch 'timeseries_expr' of github.com:kaushikponnapalli/dymos …
kaushikponnapalli Nov 3, 2022
b374b6c
Moved introspection of expressions outside of timeseries introspectio…
kaushikponnapalli Nov 29, 2022
5e64e74
Modified add_path_constraint to accept expressions and added a test p…
kaushikponnapalli Nov 29, 2022
775b520
Modified add_path_constraint to accept expressions and added a test p…
kaushikponnapalli Nov 29, 2022
490f8d0
merge from master
kaushikponnapalli Nov 29, 2022
3124f38
Fixed a bug where incorrect name was used for non expression constraints
kaushikponnapalli Nov 30, 2022
236c974
Added a check for the case in solve_ivp where output_nodes_per_seg is…
kaushikponnapalli Nov 30, 2022
4c37c9c
changed cannonball example to use scipy driver instead of pyoptsparse
kaushikponnapalli Nov 30, 2022
e2a7bd8
Renamed tests to make it more clear what they were testing and added …
kaushikponnapalli Dec 1, 2022
b1836e0
Moved regex compilation outside loop
kaushikponnapalli Dec 5, 2022
5f36c14
Added tests to check error messages
kaushikponnapalli Dec 6, 2022
e354af2
Fixed docstrings and formatting
kaushikponnapalli Dec 6, 2022
2fabf73
Changed tests to use scipy driver
kaushikponnapalli Dec 7, 2022
255d6aa
Added documentation
kaushikponnapalli Dec 7, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import os
import unittest

import matplotlib
import openmdao.api as om
import matplotlib.pyplot as plt
import dymos as dm

from openmdao.utils.testing_utils import use_tempdirs
from openmdao.utils.assert_utils import assert_near_equal
from dymos.examples.brachistochrone.brachistochrone_ode import BrachistochroneODE

matplotlib.use('Agg')
plt.style.use('ggplot')


@use_tempdirs
class TestBrachistochroneStaticGravity(unittest.TestCase):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please change this to TestBrachistochroneExprPathConstraint, just to make it a bit more obvious what it's testing.


def _make_problem(self, tx):
p = om.Problem(model=om.Group())

p.driver = om.ScipyOptimizeDriver()
p.driver.declare_coloring()

phase = dm.Phase(ode_class=BrachistochroneODE,
transcription=tx)

p.model.add_subsystem('phase0', phase)

phase.set_time_options(fix_initial=True, duration_bounds=(.5, 10))

phase.set_state_options('x', fix_initial=True)
phase.set_state_options('y', fix_initial=True)
phase.set_state_options('v', fix_initial=True)

phase.add_control('theta', continuity=True, rate_continuity=True, opt=True,
units='deg', lower=0.01, upper=179.9, ref=1, ref0=0)

phase.add_boundary_constraint('x', loc='final', equals=10.0)
phase.add_path_constraint('pc = y-x/2-1', lower=0.0)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the Left Hand Side always required? maybe that info should be presented in the docstring for "name".

phase.add_parameter('g', opt=False, units='m/s**2', val=9.80665, include_timeseries=True)

phase.add_objective('time_phase', loc='final', scaler=10)

p.model.options['assembled_jac_type'] = 'csc'
p.model.linear_solver = om.DirectSolver()
p.setup(check=True)

p['phase0.t_initial'] = 0.0
p['phase0.t_duration'] = 2.0

p['phase0.states:x'] = phase.interp('x', [0, 10])
p['phase0.states:y'] = phase.interp('y', [10, 5])
p['phase0.states:v'] = phase.interp('v', [0, 9.9])
p[f'phase0.controls:theta'] = phase.interp('theta', [5, 100])
p['phase0.parameters:g'] = 9.80665
return p

def tearDown(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably don't need the Teardown since we are using Tempdirs.

for filename in ['total_coloring.pkl', 'SLSQP.out', 'SNOPT_print.out', 'SNOPT_summary.out']:
if os.path.exists(filename):
os.remove(filename)

def test_brachistochrone_path_constraint(self):
robfalck marked this conversation as resolved.
Show resolved Hide resolved
prob = self._make_problem(tx=dm.Radau(num_segments=5, order=3, compressed=True))
prob.run_driver()
yf = prob.get_val('phase0.timeseries.states:y')[-1]

assert_near_equal(yf, 6)


if __name__ == '__main__': # pragma: no cover
unittest.main()
124 changes: 124 additions & 0 deletions dymos/examples/cannonball/test/test_cannonball_expr_constraint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import unittest
import matplotlib.pyplot as plt

from openmdao.utils.testing_utils import use_tempdirs
import openmdao.api as om
from openmdao.utils.assert_utils import assert_near_equal

import dymos as dm
from dymos.examples.cannonball.size_comp import CannonballSizeComp
from dymos.examples.cannonball.cannonball_ode import CannonballODE


plt.switch_backend('Agg')


@use_tempdirs
class TestCannonballBoundaryConstraint(unittest.TestCase):

def test_cannonball_design_boundary_constraint_expression(self):

p = om.Problem(model=om.Group())

p.driver = om.ScipyOptimizeDriver()
p.driver.options['optimizer'] = 'SLSQP'
p.driver.declare_coloring()

p.model.add_subsystem('size_comp', CannonballSizeComp(),
promotes_inputs=['radius', 'dens'])
p.model.set_input_defaults('dens', val=7.87, units='g/cm**3')
p.model.add_design_var('radius', lower=0.01, upper=0.10,
ref0=0.01, ref=0.10, units='m')

traj = p.model.add_subsystem('traj', dm.Trajectory())

transcription = dm.Radau(num_segments=5, order=3, compressed=True)
ascent = dm.Phase(ode_class=CannonballODE, transcription=transcription)

ascent = traj.add_phase('ascent', ascent)

ascent.set_time_options(fix_initial=True, duration_bounds=(1, 100),
duration_ref=100, units='s')

ascent.set_time_options(fix_initial=True, duration_bounds=(1, 100), duration_ref=100, units='s')
ascent.add_state('r', fix_initial=True, fix_final=False, rate_source='r_dot', units='m')
ascent.add_state('h', fix_initial=True, fix_final=False, units='m', rate_source='h_dot')
ascent.add_state('gam', fix_initial=False, fix_final=True, units='rad', rate_source='gam_dot')
ascent.add_state('v', fix_initial=False, fix_final=False, units='m/s', rate_source='v_dot')

ascent.add_parameter('S', units='m**2', dynamic=False)
ascent.add_parameter('m', units='kg', dynamic=False)

# Limit the muzzle energy
ascent.add_boundary_constraint('ke = 0.5 * m * v**2', loc='initial',
upper=400000, lower=0, ref=100000)

# Second Phase (descent)
transcription = dm.GaussLobatto(num_segments=5, order=3, compressed=True)
descent = dm.Phase(ode_class=CannonballODE, transcription=transcription)

traj.add_phase('descent', descent)

descent.set_time_options(initial_bounds=(.5, 100), duration_bounds=(.5, 100),
duration_ref=100, units='s')
descent.add_state('r', units='m', rate_source='r_dot')
descent.add_state('h', units='m', rate_source='h_dot', fix_initial=False, fix_final=True)
descent.add_state('gam', units='rad', rate_source='gam_dot', fix_initial=False, fix_final=False)
descent.add_state('v', units='m/s', rate_source='v_dot', fix_initial=False, fix_final=False)

descent.add_parameter('S', units='m**2', dynamic=False)
descent.add_parameter('m', units='kg', dynamic=False)

descent.add_objective('r', loc='final', scaler=-1.0)

traj.add_parameter('CD',
targets={'ascent': ['CD'], 'descent': ['CD']},
val=0.5, units=None, opt=False, dynamic=False)

traj.add_parameter('m', units='kg', val=1.0,
targets={'ascent': 'm', 'descent': 'm'}, dynamic=False)

traj.add_parameter('S', units='m**2', val=0.005, dynamic=False)

# Link Phases (link time and all state variables)
traj.link_phases(phases=['ascent', 'descent'], vars=['*'])

p.model.connect('size_comp.mass', 'traj.parameters:m')
p.model.connect('size_comp.S', 'traj.parameters:S')

p.model.linear_solver = om.DirectSolver()

# Finish Problem Setup
p.setup()

# Set constants and initial guesses
p.set_val('radius', 0.05, units='m')
p.set_val('dens', 7.87, units='g/cm**3')

p.set_val('traj.parameters:CD', 0.5)

p.set_val('traj.ascent.t_initial', 0.0)
p.set_val('traj.ascent.t_duration', 10.0)

p.set_val('traj.ascent.states:r', ascent.interp(ys=[0, 100], nodes='state_input'))
p.set_val('traj.ascent.states:h', ascent.interp(ys=[0, 100], nodes='state_input'))
p.set_val('traj.ascent.states:v', ascent.interp(ys=[200, 150], nodes='state_input'))
p.set_val('traj.ascent.states:gam', ascent.interp(ys=[25, 0], nodes='state_input'), units='deg')

p.set_val('traj.descent.t_initial', 10.0)
p.set_val('traj.descent.t_duration', 10.0)

p.set_val('traj.descent.states:r', descent.interp(ys=[100, 200], nodes='state_input'))
p.set_val('traj.descent.states:h', descent.interp(ys=[100, 0], nodes='state_input'))
p.set_val('traj.descent.states:v', descent.interp(ys=[150, 200], nodes='state_input'))
p.set_val('traj.descent.states:gam', descent.interp(ys=[0, -45], nodes='state_input'), units='deg')

# Run the optimization and final explicit simulation
dm.run_problem(p)

assert_near_equal(p.get_val('traj.descent.states:r')[-1],
3183.25, tolerance=1.0E-2)


if __name__ == '__main__':
unittest.main()
4 changes: 4 additions & 0 deletions dymos/phase/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,10 @@ def __init__(self, read_only=False):
desc='If True, the given indices will be treated as indices into a C-order flattened array based '
'on the shaped of the constrained variable at a point in time.')

self.declare(name='is_expr', types=bool, default=False,
desc='If True, the given constraint is an expression that must be evaluated rather than a'
' single variable.')


class TimeseriesOutputOptionsDictionary(om.OptionsDictionary):
"""
Expand Down
49 changes: 42 additions & 7 deletions dymos/phase/phase.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from ..utils.indexing import get_constraint_flat_idxs
from ..utils.introspection import configure_time_introspection, _configure_constraint_introspection, \
configure_controls_introspection, configure_parameters_introspection, \
configure_timeseries_output_introspection, classify_var, get_promoted_vars
configure_timeseries_output_introspection, classify_var, configure_timeseries_expr_introspection
from ..utils.misc import _unspecified
from ..utils.lgl import lgl

Expand Down Expand Up @@ -1083,9 +1083,9 @@ def add_boundary_constraint(self, name, loc, constraint_name=None, units=None,
loc : str
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring description for "name" should also be modified to explain that it can be an expression of variables.

The location of the boundary constraint ('initial' or 'final').
constraint_name : str or None
The name of the variable as provided to the boundary constraint comp. By
default this is the last element in `name` when split by dots. The user may
override the constraint name if splitting the path causes name collisions.
The name of the boundary constraint. By default, this is the left-hand side of
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reword: "By default, this is 'var_constraint' if name is a single variable, or the left-hand side of the equation if name is an expression."

the given expression or "var_constraint" if var is a single variable. The user may
override the constraint name if desired.
units : str or None
The units in which the boundary constraint is to be applied. If None, use the
units associated with the constrained output. If provided, must be compatible with
Expand Down Expand Up @@ -1126,7 +1126,19 @@ def add_boundary_constraint(self, name, loc, constraint_name=None, units=None,
raise ValueError(f'Invalid boundary constraint location "{loc}". Must be '
'"initial" or "final".')

if constraint_name is None:
expr_operators = ['(', '+', '-', '/', '*', '&', '%']
robfalck marked this conversation as resolved.
Show resolved Hide resolved
if '=' in name:
is_expr = True
elif '=' not in name and any(opr in name for opr in expr_operators):
raise ValueError(f'The expression provided {name} has invalid format. '
'Expression may be a single variable or an equation'
'of the form "constraint_name = func(vars)"')
else:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For coverage, you should include a test for every error message that you added. They can be simple contrived tests that live in the tests folder in the code rather than something complex in the examples.

is_expr = False

if is_expr:
constraint_name = name.split('=')[0].strip()
elif constraint_name is None:
constraint_name = name.rpartition('.')[-1]

bc_list = self._initial_boundary_constraints if loc == 'initial' else self._final_boundary_constraints
Expand All @@ -1137,6 +1149,13 @@ def add_boundary_constraint(self, name, loc, constraint_name=None, units=None,
raise ValueError(f'Cannot add new {loc} boundary constraint for variable `{name}` and indices {indices}. '
f'One already exists.')

existing_bc = [bc for bc in bc_list if bc['name'] == constraint_name and
bc['indices'] is None and indices is None]

if existing_bc:
raise ValueError(f'Cannot add new {loc} boundary constraint named `{constraint_name}` and indices{indices}.'
f' `{constraint_name}` is already in use as a {loc} boundary constraint')

bc = ConstraintOptionsDictionary()
bc_list.append(bc)

Expand All @@ -1154,6 +1173,7 @@ def add_boundary_constraint(self, name, loc, constraint_name=None, units=None,
bc['linear'] = linear
bc['units'] = units
bc['flat_indices'] = flat_indices
bc['is_expr'] = is_expr

# Automatically add the requested variable to the timeseries outputs if it's an ODE output.
var_type = self.classify_var(name)
Expand Down Expand Up @@ -1211,7 +1231,19 @@ def add_path_constraint(self, name, constraint_name=None, units=None, shape=None
If True, treat indices as flattened C-ordered indices of elements to constrain at each given point in time.
Otherwise, indices should be a tuple or list giving the elements to constrain at each point in time.
"""
if constraint_name is None:
expr_operators = ['(', '+', '-', '/', '*', '&', '%']
if '=' in name:
is_expr = True
elif '=' not in name and any(opr in name for opr in expr_operators):
raise ValueError(f'The expression provided {name} has invalid format. '
'Expression may be a single variable or an equation'
'of the form "constraint_name = func(vars)"')
else:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I question the need for all of this expression checking (here) and the name checking (in introspection). I think OpenMDAO might do all this checking in the execcomp, so these might be redundant. The only reason you might do this here is to deliver a better error message to the user.

is_expr = False

if is_expr:
constraint_name = name.split('=')[0].strip()
elif constraint_name is None:
constraint_name = name.rpartition('.')[-1]

existing_pc = [pc for pc in self._path_constraints
Expand All @@ -1238,6 +1270,7 @@ def add_path_constraint(self, name, constraint_name=None, units=None, shape=None
pc['linear'] = linear
pc['units'] = units
pc['flat_indices'] = flat_indices
pc['is_expr'] = is_expr

# Automatically add the requested variable to the timeseries outputs if it's an ODE output.
var_type = self.classify_var(name)
Expand Down Expand Up @@ -1758,7 +1791,9 @@ def configure(self):

_configure_constraint_introspection(self)

transcription._configure_boundary_constraints(self)
configure_timeseries_expr_introspection(self)

transcription.configure_boundary_constraints(self)

transcription.configure_path_constraints(self)

Expand Down
4 changes: 2 additions & 2 deletions dymos/phase/test/test_timeseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ def test_invalid_expr_var(self):

expected = "phase0: Timeseries expression `k=units` uses the following disallowed variable " \
"name in its definition: 'units'."
self.assertEqual(str(e.exception.__cause__), expected)
self.assertEqual(str(e.exception), expected)

def test_input_units(self):
p = om.Problem(model=om.Group())
Expand Down Expand Up @@ -549,7 +549,7 @@ def test_output_units(self):
phase.add_timeseries_output('sin_theta2=sin(theta)', sin_theta2={'units': 'unitless'})
phase.add_timeseries_output('sin_theta3=sin(theta)', shape=(1,), sin_theta3={'shape': (1,)})

expected = 'phase0: User-provided shape for timeseries expression output `sin_theta3`using ' \
expected = 'phase0: User-provided shape for timeseries expression output `sin_theta3` using ' \
'both the\n`shape` keyword argument and the `sin_theta3` keyword argument dictionary.\n' \
'The latter will take precedence.'

Expand Down
11 changes: 7 additions & 4 deletions dymos/trajectory/trajectory.py
Original file line number Diff line number Diff line change
Expand Up @@ -1112,8 +1112,11 @@ def _print_constraints(phase, outstream):
for loc, d in ds.items():
str_loc = f'[{loc}]'
for options in d:
expr = options['name']
_, shape, units, linear = tx._get_objective_src(expr, loc, phase, ode_outputs=ode_outputs)
if options['is_expr']:
name = options['constraint_name']
else:
name = options['name']
_, shape, units, linear = tx._get_objective_src(name, loc, phase, ode_outputs=ode_outputs)

equals = options['equals']
lower = options['lower']
Expand Down Expand Up @@ -1146,11 +1149,11 @@ def _print_constraints(phase, outstream):
str_upper = ''

if equals is not None:
printer(f'{2 * indent}{str_loc:<10s}{str_equals} == {expr} [{str_units}]',
printer(f'{2 * indent}{str_loc:<10s}{str_equals} == {name} [{str_units}]',
file=outstream)
else:
printer(
f'{2 * indent}{str_loc:<10s}{str_lower} {expr} {str_upper} [{str_units}]',
f'{2 * indent}{str_loc:<10s}{str_lower} {name} {str_upper} [{str_units}]',
file=outstream)

for phase_name, phs in self._phases.items():
Expand Down
5 changes: 5 additions & 0 deletions dymos/transcriptions/pseudospectral/pseudospectral_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,11 @@ def _get_objective_src(self, var, loc, phase, ode_outputs=None):
units = control_rate_units
linear = False
constraint_path = f'polynomial_control_rates:{var}'
elif var_type == 'timeseries_exec_comp_output':
shape = (1,)
units = None
constraint_path = f'timeseries.timeseries_exec_comp.{var}'
linear = False
else:
# Failed to find variable, assume it is in the ODE. This requires introspection.
constraint_path = f'{self._rhs_source}.{var}'
Expand Down
8 changes: 6 additions & 2 deletions dymos/transcriptions/solve_ivp/solve_ivp.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ def configure_path_constraints(self, phase):
"""
pass

def _configure_boundary_constraints(self, phase):
def configure_boundary_constraints(self, phase):
"""
Not used in SolveIVP.

Expand Down Expand Up @@ -686,4 +686,8 @@ def _get_num_timeseries_nodes(self):
int
The number of nodes in the default timeseries for this transcription.
"""
return self.grid_data.num_segments * self.options['output_nodes_per_seg']
if self.options['output_nodes_per_seg'] is None:
output_nodes = self.grid_data.num_segments
else:
output_nodes = self.grid_data.num_segments * self.options['output_nodes_per_seg']
return output_nodes
Loading