Skip to content

Commit 16f0355

Browse files
authored
Merge pull request #14 from dingraha/shape_by_conn
Add multi-dimensional `shape_by_conn` support
2 parents 8b6772b + be1aad2 commit 16f0355

File tree

15 files changed

+333
-30
lines changed

15 files changed

+333
-30
lines changed

docs/make.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ function main()
77
sitename="OpenMDAO.jl",
88
modules = [OpenMDAOCore, OpenMDAO],
99
format=Documenter.HTML(prettyurls=get(ENV, "CI", nothing) == "true"),
10+
doctest=true,
1011
pages = ["Home"=>"index.md",
1112
"A Simple Example"=>"simple_paraboloid.md",
1213
"A More Complicated Example"=>"nonlinear_circuit.md",
14+
"Variable Shapes at Runtime"=>"shape_by_conn.md",
1315
"A Simple Dymos Example"=>"brachistochrone.md",
1416
"API Reference"=>"reference.md",
1517
"Limitations"=>"limitations.md"])

docs/src/shape_by_conn.md

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
```@meta
2+
CurrentModule = OpenMDAODocs
3+
```
4+
# Variable Shapes at Runtime: `shape_by_conn` and `copy_shape`
5+
6+
## A Simple Example
7+
OpenMDAO is able to [determine variable shapes at runtime](https://openmdao.org/newdocs/versions/latest/features/experimental/dyn_shapes.html).
8+
In "normal" (aka non-Julian) OpenMDAO, this is done via the `shape_by_conn` and `copy_shape` arguments to the venerable `add_input` and/or `add_output` `Component` methods.
9+
In OpenMDAO.jl, we can provide the `shape_by_conn` and/or `copy_shape` arguments to the `VarData` `struct` constructor to get the same behavior.
10+
11+
We'll show how this works using a simple `ExplicitComponent` that computes ``y = 2*x^2 + 1`` element-wise, where ``x`` and ``y`` are two-dimensional arrays of any (identical) size.
12+
13+
We'll need `OpenMDAOCore` of course, and need to declare our `ExplicitComponent` in the usual way:
14+
15+
```@example shape_by_conn1
16+
using OpenMDAOCore: OpenMDAOCore
17+
18+
struct ECompShapeByConn <: OpenMDAOCore.AbstractExplicitComp end
19+
```
20+
21+
Next we need a `setup` method:
22+
23+
```@example shape_by_conn1
24+
function OpenMDAOCore.setup(self::ECompShapeByConn)
25+
input_data = [OpenMDAOCore.VarData("x"; shape_by_conn=true)]
26+
output_data = [OpenMDAOCore.VarData("y"; shape_by_conn=true, copy_shape="x")]
27+
28+
partials_data = []
29+
return input_data, output_data, partials_data
30+
end
31+
```
32+
33+
Notice how we provided the `shape_by_conn` argument to the `VarData` `struct` for `x`, and the `shape_by_conn` and `copy_shape` arguments to `y`'s `VarData` `struct`.
34+
This means that the shape of `x` will be determined at runtime by OpenMDAO, and will be set to the shape of whatever output is connected to `x`.
35+
The shape of `y` will be set to that of `x`, since we provided the `copy_shape="x"` argument.
36+
(Also notice how we returned an empty Vector for the `partials_data` output—OpenMDAO.jl always expects `OpenMDAOCore.setup` to return three Vectors, corresponding to `input_data`, `output_data`, and `partials_data`.
37+
But the `partials_data` Vector can be empty if it's not needed.)
38+
39+
Now, the derivative of `y` with respect to `x` will be sparse—the value of an element `y[i,j]` depends on the element `x[i,j]`, and no others.
40+
We can communicate this fact to OpenMDAO through the `rows` and `cols` arguments to `declare_partials` in Python OpenMDAO, or the `PartialsData` `struct` in OpenMDAO.jl.
41+
But how do we do that here, when we don't know the sizes of `x` and `y` in the `setup` method?
42+
The answer is we implement an `OpenMDAOCore.setup_partials` method, which gives us another chance to create more `PartialsData` `structs` after OpenMDAO has figured out what the sizes of all the inputs and outputs are:
43+
44+
```@example shape_by_conn1
45+
function OpenMDAOCore.setup_partials(self::ECompShapeByConn, input_sizes, output_sizes)
46+
@assert input_sizes["x"] == output_sizes["y"]
47+
m, n = input_sizes["x"]
48+
rows, cols = OpenMDAOCore.get_rows_cols(ss_sizes=Dict(:i=>m, :j=>n), of_ss=[:i, :j], wrt_ss=[:i, :j])
49+
partials_data = [OpenMDAOCore.PartialsData("y", "x"; rows=rows, cols=cols)]
50+
51+
return partials_data
52+
end
53+
```
54+
55+
The `OpenMDAOCore.setup_partials` method will always take an instance of the `OpenMDAOCore.AbstractComp` (called `self` here), and two `Dict`s, both with `String` keys and `NTuple{N, Int}` values.
56+
The keys indicate the name of an input or output variable, and the `NTuple{Int, N}` values are the shapes of each variable.
57+
The first `Dict` holds all the input shapes, and the second `Dict` has all the output shapes.
58+
59+
Now, the job of `setup_partials` is to return a `Vector` of `PartialsData` `structs`.
60+
We'd like to include the `rows` and `cols` arguments to the `PartialsData` `struct` for the derivative of `y` with respect to `x`, but it's a bit tricky, since `x` and `y` are two-dimensional.
61+
Luckily, there is a small utility function provided by OpenMDAOCore.jl called `get_rows_cols` that can help us.
62+
63+
## Sparsity Patterns with `get_rows_cols`
64+
The `get_rows_cols` function uses a symbolic notation to express sparsity patterns in a simple way.
65+
Here's an example that corresponds to our present case.
66+
Let's say `x` and `y` have shape `(2, 3)`.
67+
Then the non-zero index combinations for the derivative of `y` with respect to `x` will be (using zero-based indices, which is what OpenMDAO expects for the `rows` and `cols` arguments):
68+
69+
```
70+
y indices: x indices:
71+
(0, 0) (0, 0)
72+
(0, 1) (0, 1)
73+
(0, 2) (0, 2)
74+
(1, 0) (1, 0)
75+
(1, 1) (1, 1)
76+
(1, 2) (1, 2)
77+
```
78+
79+
So that table says that the value of `y[0, 0]` depends on `x[0, 0]` only, and the value of `y[1, 0]` depends on `x[1, 0]` only, etc..
80+
But OpenMDAO expects flattened indices for the `rows` and `cols` arguments, not multi-dimensional indices.
81+
So we need to convert the multi-dimensional indices in that table to flattened ones.
82+
`get_rows_cols` does that for you, but if you wanted to do that by hand, what I usually do is think of an array having the same shape as each input or output, with each entry in the array corresponding to the entry's flat index.
83+
So for `x` and y, that would be:
84+
85+
```
86+
x_flat_indices =
87+
[0 1 2;
88+
3 4 5]
89+
90+
y_flat_indices =
91+
[0 1 2;
92+
3 4 5]
93+
```
94+
95+
(Remember that Python/NumPy arrays use row-major aka C ordering by default.)
96+
So we can now use those two arrays to translate the `y indices` and `x indices` from multi-dimensional to flat:
97+
98+
```
99+
y indices x indices
100+
multi, flat: multi, flat:
101+
(0, 0) 0 (0, 0) 0
102+
(0, 1) 1 (0, 1) 1
103+
(0, 2) 2 (0, 2) 2
104+
(1, 0) 3 (1, 0) 3
105+
(1, 1) 4 (1, 1) 4
106+
(1, 2) 5 (1, 2) 5
107+
```
108+
109+
So the `rows` and `cols` arguments will be
110+
111+
```
112+
rows = [0, 1, 2, 3, 4, 5]
113+
cols = [0, 1, 2, 3, 4, 5]
114+
```
115+
116+
where `rows` is the flat non-zero indices for `y`, and `cols` is the flat non-zero indices for `x`.
117+
118+
Now, how do we do this with `get_rows_cols`?
119+
First we have to assign labels to each dimension of `y` and `x`.
120+
The labels must be `Symbols`, and can be anything (but I usually use index-y things like `:i`, `:j`, `:k`, etc.).
121+
We express the sparsity pattern through the choice of labels.
122+
If we use a label for an output dimension that is also used for an input dimension, then we are saying that, for a given index `i` in the "shared" dimension, the value of the output at that index `i` depends on the value of the input index `i` along the labeled dimension, and no others.
123+
For example, if we had a one-dimensional `y` that was calculated from a one-dimensional `x` in this way:
124+
125+
```
126+
for i in 1:10
127+
y[i] = sin(x[i])
128+
end
129+
```
130+
131+
then we would use the same label for the (single) output and input dimension.
132+
133+
For the present example, we could assign `i` and `j` (say) to the first and second dimensions, respectively, of both `y` and `x`, since `y[i,j]` only depends on `x[i,j]` for all valid `i` and `j`.
134+
We call these `of_ss` (short for "of subscripts for the output) and `wrt_ss` ("with respect to subscripts").
135+
136+
```@example shape_by_conn1
137+
of_ss = [:i, :j]
138+
wrt_ss = [:i, :j]
139+
```
140+
141+
After deciding on the dimension labels, the only other thing we need to do is create a `Dict` that maps the dimension labels to their sizes:
142+
143+
```@example shape_by_conn1
144+
ss_sizes = Dict(:i=>2, :j=>3)
145+
```
146+
147+
since, in our example, the first dimension of `x` and `y` has size `2`, and the second, `3`.
148+
149+
Then we pass those three things to `get_rows_cols`, which then returns the `rows` and `cols` we want.
150+
151+
```@example shape_by_conn1
152+
rows, cols = OpenMDAOCore.get_rows_cols(; ss_sizes, of_ss, wrt_ss)
153+
```
154+
155+
## Back to the Simple Example
156+
Now, back to the simple example.
157+
Remember, we're trying to compute `y = 2*x^2 + 1` elementwise for a 2D `x` and `y`.
158+
The `compute!` method is pretty straight-forward:
159+
160+
```@example shape_by_conn1
161+
function OpenMDAOCore.compute!(self::ECompShapeByConn, inputs, outputs)
162+
x = inputs["x"]
163+
y = outputs["y"]
164+
y .= 2 .* x.^2 .+ 1
165+
return nothing
166+
end
167+
```
168+
169+
Now, for the `compute_partials!` method, we have to be a bit tricky about the shape of the Jacobian of `y` with respect to `x`.
170+
The `get_rows_cols` function orders the `rows` and `cols` in such a way that the Jacobian gets allocated by OpenMDAO with shape (`i`, `j`), and is then flattened.
171+
Since NumPy arrays are row-major ordered, then, we need to reshape the Jacobian in the opposite order, then switch the dimensions.
172+
This is optional, but makes things easier:
173+
174+
```@example shape_by_conn1
175+
function OpenMDAOCore.compute_partials!(self::ECompShapeByConn, inputs, partials)
176+
x = inputs["x"]
177+
m, n = size(x)
178+
# So, with the way I've declared the partials above, OpenMDAO will have
179+
# created a Numpy array of shape (m, n) and then flattened it. So, to get
180+
# that to work, I'll need to do this:
181+
dydx = PermutedDimsArray(reshape(partials["y", "x"], n, m), (2, 1))
182+
dydx .= 4 .* x
183+
return nothing
184+
end
185+
```
186+
187+
## Checking
188+
Now, let's actually create a `Problem` with the new `Component`, along with an `IndepVarComp` that will actually decide on the size:
189+
190+
```@example shape_by_conn1
191+
using OpenMDAO, PythonCall
192+
193+
m, n = 3, 4
194+
p = om.Problem()
195+
comp = om.IndepVarComp()
196+
comp.add_output("x", shape=(m, n))
197+
p.model.add_subsystem("inputs_comp", comp, promotes_outputs=["x"])
198+
199+
ecomp = ECompShapeByConn()
200+
comp = make_component(ecomp)
201+
p.model.add_subsystem("ecomp", comp, promotes_inputs=["x"], promotes_outputs=["y"])
202+
p.setup(force_alloc_complex=true)
203+
```
204+
205+
Now we should be able to check that the output we get is correct:
206+
207+
```@example shape_by_conn1
208+
p.set_val("x", 1:m*n)
209+
p.run_model()
210+
211+
# Test that the output is what we expect.
212+
expected = 2 .* PyArray(p.get_val("x")).^2 .+ 1
213+
actual = PyArray(p.get_val("y"))
214+
println("expected = $(expected)")
215+
println("actual = $(actual)")
216+
```
217+
218+
And we can check the derivatives:
219+
220+
```@example shape_by_conn1
221+
p.check_partials(method="cs")
222+
nothing
223+
```
224+
225+
Looks good!

julia/OpenMDAO.jl/CondaPkg.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
[deps]
2-
openmdao = ""
2+
openmdao = ">=3.26.0,<4"
33

44
[pip.deps]
55
juliapkg = ""
66
omjlcomps = ""
7+
# omjlcomps = "~=0.2.0"
8+
# omjlcomps = "@./../../python/"

julia/OpenMDAO.jl/Project.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
name = "OpenMDAO"
22
uuid = "2d3f9b48-ba2d-11e9-1a3f-97e029ee3d3c"
33
authors = ["Ingraham, Daniel James (GRC-LTV0) <[email protected]>"]
4-
version = "0.3.2"
4+
version = "0.4.0"
55

66
[deps]
77
CondaPkg = "992eb4ea-22a4-4c89-a5bb-47a3300528ab"
88
OpenMDAOCore = "24d19c10-6eee-420f-95df-4537264b2753"
99
PythonCall = "6099a3de-0909-46bc-b1f4-468b9a2dfc0d"
1010

1111
[compat]
12-
OpenMDAOCore = "0.2.10"
12+
OpenMDAOCore = "0.3.0"

julia/OpenMDAO.jl/test/runtests.jl

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,9 @@ using SafeTestsets: @safetestset
286286

287287
function OpenMDAOCore.setup_partials(self::ECompShapeByConn, input_sizes, output_sizes)
288288
@assert input_sizes["x"] == output_sizes["y"]
289-
n = input_sizes["x"]
290-
partials_data = [OpenMDAOCore.PartialsData("y", "x"; rows=0:n-1, cols=0:n-1)]
289+
m, n = input_sizes["x"]
290+
rows, cols = OpenMDAOCore.get_rows_cols(ss_sizes=Dict(:i=>m, :j=>n), of_ss=[:i, :j], wrt_ss=[:i, :j])
291+
partials_data = [OpenMDAOCore.PartialsData("y", "x"; rows=rows, cols=cols)]
291292

292293
return partials_data
293294
end
@@ -301,22 +302,26 @@ using SafeTestsets: @safetestset
301302

302303
function OpenMDAOCore.compute_partials!(self::ECompShapeByConn, inputs, partials)
303304
x = inputs["x"]
304-
dydx = partials["y", "x"]
305+
m, n = size(x)
306+
# So, with the way I've declared the partials above, OpenMDAO will have
307+
# created a Numpy array of shape (m, n) and then flattened it. So, to get
308+
# that to work, I'll need to do this:
309+
dydx = PermutedDimsArray(reshape(partials["y", "x"], n, m), (2, 1))
305310
dydx .= 4 .* x
306311
return nothing
307312
end
308313

309-
n = 10
314+
m, n = 3, 4
310315
p = om.Problem()
311316
comp = om.IndepVarComp()
312-
comp.add_output("x", shape=n)
317+
comp.add_output("x", shape=(m, n))
313318
p.model.add_subsystem("inputs_comp", comp, promotes_outputs=["x"])
314319

315320
ecomp = ECompShapeByConn()
316321
comp = make_component(ecomp)
317322
p.model.add_subsystem("ecomp", comp, promotes_inputs=["x"], promotes_outputs=["y"])
318323
p.setup(force_alloc_complex=true)
319-
p.set_val("x", 1:n)
324+
p.set_val("x", 1:m*n)
320325
p.run_model()
321326

322327
# Test that the output is what we expect.
@@ -1204,10 +1209,10 @@ end
12041209
end
12051210

12061211
function OpenMDAOCore.setup_partials(self::ImplicitShapeByConn, input_sizes, output_sizes)
1207-
n = input_sizes["x"]
1208-
@assert input_sizes["y"] == n
1209-
@assert output_sizes["z1"] == n
1210-
@assert output_sizes["z2"] == n
1212+
@assert input_sizes["y"] == input_sizes["x"]
1213+
@assert output_sizes["z1"] == input_sizes["x"]
1214+
@assert output_sizes["z2"] == input_sizes["x"]
1215+
n = only(input_sizes["x"])
12111216
rows = 0:n-1
12121217
cols = 0:n-1
12131218
partials = [

julia/OpenMDAOCore.jl/Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
name = "OpenMDAOCore"
22
uuid = "24d19c10-6eee-420f-95df-4537264b2753"
33
authors = ["Daniel Ingraham <[email protected]>"]
4-
version = "0.2.10"
4+
version = "0.3.0"

julia/OpenMDAOCore.jl/src/utils.jl

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,53 @@
1-
function get_rows_cols(ss_sizes, of_ss, wrt_ss)
1+
"""
2+
get_rows_cols(; ss_sizes::Dict{Symbol, Int}, of_ss::AbstractVector{Symbol}, wrt_ss::AbstractVector{Symbol})
3+
4+
Get the non-zero row and column indices for a sparsity pattern defined by output subscripts `of_ss` and input subscripts `wrt_ss`.
5+
6+
`ss_sizes` is a `Dict` mapping the subscript symbols in `of_ss` and `wrt_ss` to the size of each dimension the subscript symbols correspond to.
7+
The returned indices will be zero-based, which is what the OpenMDAO `declare_partials` method expects.
8+
9+
# Examples
10+
Diagonal partials for 1D output and 1D input, both with length `5`:
11+
```jldoctest; setup = :(using OpenMDAOCore: get_rows_cols)
12+
julia> rows, cols = get_rows_cols(; ss_sizes=Dict(:i=>5), of_ss=[:i], wrt_ss=[:i])
13+
([0, 1, 2, 3, 4], [0, 1, 2, 3, 4])
14+
```
15+
16+
1D output with length 2 depending on all elements of 1D input with length 3 (so not actually sparse).
17+
```jldoctest; setup = :(using OpenMDAOCore: get_rows_cols)
18+
julia> rows, cols = get_rows_cols(; ss_sizes=Dict(:i=>2, :j=>3), of_ss=[:i], wrt_ss=[:j])
19+
([0, 0, 0, 1, 1, 1], [0, 1, 2, 0, 1, 2])
20+
```
21+
22+
2D output with size `(2, 3)` and 1D input with size `2`, where each `i` output row only depends on the `i` input element.
23+
```jldoctest; setup = :(using OpenMDAOCore: get_rows_cols)
24+
julia> rows, cols = get_rows_cols(; ss_sizes=Dict(:i=>2, :j=>3), of_ss=[:i, :j], wrt_ss=[:i])
25+
([0, 1, 2, 3, 4, 5], [0, 0, 0, 1, 1, 1])
26+
```
27+
28+
2D output with size `(2, 3)` and 1D input with size `3`, where each `j` output column only depends on the `j` input element.
29+
```jldoctest; setup = :(using OpenMDAOCore: get_rows_cols)
30+
julia> rows, cols = get_rows_cols(; ss_sizes=Dict(:i=>2, :j=>3), of_ss=[:i, :j], wrt_ss=[:j])
31+
([0, 1, 2, 3, 4, 5], [0, 1, 2, 0, 1, 2])
32+
```
33+
34+
2D output with size `(2, 3)` depending on input with size `(3, 2)`, where the output element at index `i, j` only depends on input element `j, i` (like a transpose operation).
35+
```jldoctest; setup = :(using OpenMDAOCore: get_rows_cols)
36+
julia> rows, cols = get_rows_cols(; ss_sizes=Dict(:i=>2, :j=>3), of_ss=[:i, :j], wrt_ss=[:j, :i])
37+
([0, 1, 2, 3, 4, 5], [0, 2, 4, 1, 3, 5])
38+
```
39+
40+
2D output with size `(2, 3)` depending on input with size `(3, 4)`, where output `y[:, j]` for each `j` depends on input `x[j, :]`.
41+
```jldoctest; setup = :(using OpenMDAOCore: get_rows_cols)
42+
julia> rows, cols = get_rows_cols(; ss_sizes=Dict(:i=>2, :j=>3, :k=>4), of_ss=[:i, :j], wrt_ss=[:j, :k]);
43+
44+
julia> @show rows cols; # to prevent abbreviating the array display
45+
rows = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5]
46+
cols = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
47+
48+
```
49+
"""
50+
function get_rows_cols(; ss_sizes, of_ss, wrt_ss)
251
# Get the output subscript, which will start with the of_ss, then the
352
# wrt_ss with the subscripts common to both removed.
453
# deriv_ss = of_ss + "".join(set(wrt_ss) - set(of_ss))
@@ -42,5 +91,3 @@ function get_rows_cols(ss_sizes, of_ss, wrt_ss)
4291
# Return flattened versions of the rows and cols arrays.
4392
return rows[:], cols[:]
4493
end
45-
46-
get_rows_cols(; ss_sizes, of_ss, wrt_ss) = get_rows_cols(ss_sizes, of_ss, wrt_ss)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
[deps]
22
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
3+
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"

0 commit comments

Comments
 (0)