Skip to content

feat(beams2d): record the optimization trajectory on each OptiStep - #277

Open
mkeeler43 wants to merge 1 commit into
mainfrom
feat/2d-optistep-sensitivities
Open

mkeeler43 wants to merge 1 commit into
mainfrom
feat/2d-optistep-sensitivities

Conversation

@mkeeler43

@mkeeler43 mkeeler43 commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

What

beams2d recorded only the objective value and the design at each optimization
step. The sensitivity at that design, the move the optimizer made from it, and the
objective change that move produced were all computed and thrown away, so
recovering any of them afterwards meant re-running the solve that had already
produced them.

Each step now carries what the optimizer already knew:

field value
x the design the step was evaluated at
x_sensitivities the filtered objective sensitivity there
x_update the move taken from that design
obj_values_update the objective change that move produced

Nothing new is computed — every value already existed in the loop. The step is
recorded after inner_opt rather than before the sensitivity block, because the
move is not known until then, and the design is taken beforehand because the
overhang filter rebinds xPrint.

ExtendedOptiStep and its design field are untouched. x holds the same array,
so callers reading either one keep working.

Three choices worth a reviewer's attention

x_sensitivities is the objective sensitivity alone, shaped like the design.
One value per design variable. thermoelastic2d and beams3d stack theirs as
[objective..., constraint] on a leading axis; photonics2d reports the objective
gradient bare. The bare form is used here because downstream consumers flatten this
field — EngiOpt's collector does np.ravel(...) per step — so an extra channel
doubles the vector's length with nothing recording that it did, and a reader
assuming one sensitivity per design variable would misread it silently. The volume
sensitivity dv is one line to add back if that is wanted.

x_update is in printed-density space. inner_opt returns three fields — raw
density variables, processed density, printed density — and the recorded design is
the printed one. Differencing the raw field would mix the two spaces, so the move
is measured against the printed one, making design + x_update exactly the next
step's design. There is a test for that.

The last step's obj_values_update stays None. thermoelastic2d fills its
own in by running an extra iteration after convergence and then reverting the
design. That buys one scalar for the price of a full solve, and the value is
derivable from the next step for every step that has one. On beams2d the revert
would also put the returned design at risk, since xPrint is rebound three times
per iteration.

Effect on existing behavior

None. Running optimize for 25 iterations on a 20x10 grid, with and without the
overhang constraint, before and after:

main vs branch
step count identical
objective values identical
per-step designs identical
returned design identical

Tests

tests/test_beams2d.py is new, following tests/test_photonics2d.py (module-scoped
fixture, small grid, few iterations; about a second to run). It checks step
numbering, that each recorded field is shaped like the design, that the sensitivity
is nonpositive and finite, that design + x_update lands on the next step's design,
and that obj_values_update matches the next step's objective difference on every
step but the last.

If you add tests here: max_iter must be passed to optimize, not to the
constructor. optimize rebuilds its config as
Config(**{**asdict(self.simulate_config), **config}), and SimulateConfig does not
carry max_iter, so a constructor-supplied value is silently replaced by the default
of 100.

Follow-ups this does not attempt

OptiStep.x_sensitivities is documented only as "the sensitivities of the design
variables". Across the problems that populate it, the layout varies — bare grid
(photonics2d) against channel-first stacks of differing depth (thermoelastic2d 4,
beams3d 2), with the channel order recorded nowhere and no relation to
problem.objectives. A metric written against this field cannot currently be
problem-agnostic. Worth a documented convention in core.py, separately from this PR.

heatconduction2d builds its history by regex-scraping an IPOPT log after a
dolfin-adjoint container run, with no per-iteration callback holding the design; it
needs a derivative_cb_post on the ReducedFunctional and new arrays in the output
npz. mto2d parses solver output columns that contain no gradient.

This changes what optimize() returns at runtime and touches no published dataset.
Dataset generation keeps the returned design and discards the history — see
photonics2d/dataset_slurm_test.py, where it is bound to _obj_trajectory — so
trajectories reach a dataset only if a generation script is changed to keep them.

@mkeeler43
mkeeler43 force-pushed the feat/2d-optistep-sensitivities branch from aa4810d to 556637c Compare September 3, 2026 17:14
@mkeeler43 mkeeler43 changed the title feat(beams2d): record the design and its sensitivity on each OptiStep feat(beams2d): record the sensitivity alongside the design on each OptiStep Sep 3, 2026
@mkeeler43
mkeeler43 force-pushed the feat/2d-optistep-sensitivities branch from 556637c to 6c8d174 Compare September 3, 2026 17:23
@mkeeler43 mkeeler43 changed the title feat(beams2d): record the sensitivity alongside the design on each OptiStep feat(beams2d): record the sensitivity on each OptiStep Sep 3, 2026
@mkeeler43
mkeeler43 force-pushed the feat/2d-optistep-sensitivities branch from 6c8d174 to 755fd0a Compare September 3, 2026 17:41
@mkeeler43 mkeeler43 changed the title feat(beams2d): record the sensitivity on each OptiStep feat(beams2d): record the optimization trajectory on each OptiStep Sep 3, 2026
beams2d recorded only the objective value and the design at each step. The
sensitivity there, the move the optimizer made, and the objective that move
bought were all computed and discarded, so recovering any of them afterwards
meant re-running the solve that had already produced them.

Each step now carries what the optimizer already knew:

  x                 the design the step was evaluated at
  x_sensitivities   the filtered objective sensitivity there
  x_update          the move taken from that design
  obj_values_update the objective change that move produced

Nothing new is computed; every value already existed in the loop. The step is
recorded after `inner_opt` rather than before the sensitivity block, since the
move is not known until then, and the design is taken beforehand because the
overhang filter rebinds `xPrint`.

Three choices worth stating.

`x_sensitivities` holds the objective sensitivity by itself, shaped like the
design it belongs to, so there is one value per design variable. The volume
sensitivity `dv` is not stacked alongside it. thermoelastic2d and beams3d do
stack theirs, but consumers flatten this field, so an extra channel doubles its
length with nothing recording that it did; photonics2d, the other 2D problem
reporting sensitivities, reports the objective gradient alone.

`x_update` is measured in printed density, the space the recorded design is in,
so `design + x_update` is the next step's design. `inner_opt` also returns the
raw density field, and differencing that would mix the two spaces.

The last step's `obj_values_update` stays None. thermoelastic2d fills its own in
by running an extra iteration after convergence and reverting the design, which
buys one scalar for the price of a full solve; the same value is derivable from
the next step for every step that has one, and the revert would put beams2d's
returned design at risk.

`ExtendedOptiStep` and its `design` field are untouched; `x` holds the same
array, so callers reading either keep working.

Optimizer behavior is unchanged: step count, objective values, per-step designs
and the returned design are identical to before, with and without the overhang
constraint.
@mkeeler43
mkeeler43 force-pushed the feat/2d-optistep-sensitivities branch from 755fd0a to cfa906e Compare September 3, 2026 17:45

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant