Skip to content

[Pyomo.DoE] Add simultaneous design of multiple experiments#3866

Open
smondal13 wants to merge 117 commits intoPyomo:mainfrom
smondal13:add-multiexperiment
Open

[Pyomo.DoE] Add simultaneous design of multiple experiments#3866
smondal13 wants to merge 117 commits intoPyomo:mainfrom
smondal13:add-multiexperiment

Conversation

@smondal13
Copy link
Contributor

@smondal13 smondal13 commented Mar 2, 2026

Fixes # .

Summary/Motivation:

This PR adds a new DesignOfExperiments.optimize_experiments() API in pyomo/contrib/doe/doe.py to support simultaneous optimization of multiple experiments in one workflow. The motivation is to provide a multi-experiment DoE interface with stronger initialization options, clearer mode handling (template vs. user-initialized experiments), and richer diagnostics/results than the existing single-experiment path.

Changes proposed in this PR:

  • Added API optimize_experiments() for multi-experiment DoE optimization.
  • Implemented two operating modes:
    • Template mode: pass one experiment and set n_exp.
    • User-initialized mode: pass a list of experiments; n_exp is inferred/validated.
  • Added optional LHS-based initialization (initialization_method="lhs") with controls for:
    • sample count, seed, candidate evaluation parallelism, combination fim metric scoring parallelism,
    • worker count, chunk size, parallel threshold, and optional wall-clock budget.
  • Added symmetry-breaking constraints for multi-experiment solves:
    • supports user-specified variable through sym_break_cons suffix,
    • falls back to the first experiment input with a diagnostic warning when not provided.
  • Expanded output for this API:
    • per-scenario and per-experiment results (designs, outputs, measurement errors, FIM/sensitivities),
    • aggregated FIM metrics, timings, settings, names, diagnostics, and structured run_info.
  • Added JSON-safe serialization via _DoEResultsJSONEncoder for numpy/Pyomo-enum values when writing results_file.

Note:

  • Added new documentation.md which describes the API. This documentation is to help the reviewers to understand the API and will not be merged into Pyomo:main

Legal Acknowledgement

By contributing to this software project, I have read the contribution guide and agree to the following terms and conditions for my contribution:

  1. I agree my contributions are submitted under the BSD license.
  2. I represent I am authorized to make the contributions and grant the license. If my employer has rights to intellectual property that includes these contributions, I represent that I have received permission to make contributions and grant the required license on behalf of that employer.

…d replace `self.experiment` with `self.experiment_list[0]`. `doe/reactor_example.py` runs successfully.
…sing the `doe/reactor_multi_experiment.py`
…nt for `optimize_experiments()` and `compute_FIM()`
… both of sensitivity and optimize_experiments()
…A-opt gave different result for grid and optimization
def __init__(
self,
experiment=None,
experiment_list=None,
Copy link
Contributor Author

@smondal13 smondal13 Mar 3, 2026

Choose a reason for hiding this comment

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

@adowling2 , @blnicho Should we keep both experiment and experiment_list? The user can pass the experiment_list argument if the user wants to pass the user-initialized experiment object. Another option is to keep the experiment argument and make it able to accept list objects as well, and maybe use a hint, e.g., experiment: list.

Currently, I have added a deprecation warning for the experiment argument.

Copy link
Member

Choose a reason for hiding this comment

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

I like allowing experiment to be either an instance of the Experiment class (or a child) or a list of Experiment objects. That seems cleaner to me.

"variable": None,
"source": None,
}
diagnostics_warnings = []
Copy link
Contributor Author

@smondal13 smondal13 Mar 3, 2026

Choose a reason for hiding this comment

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

I want to propagate the warning to the final result, which may help the user. If this is not a good choice, let me know @adowling2, @blnicho

…ing and improve error handling; add utility methods for enum labels and input fixing.

Run black
@smondal13
Copy link
Contributor Author

@blnicho @adowling2 This PR is ready for an initial review. Please take a look when you have a moment.

@blnicho blnicho self-requested a review March 3, 2026 19:43
lhs = "lhs"


class _DoEResultsJSONEncoder(json.JSONEncoder):
Copy link
Contributor Author

@smondal13 smondal13 Mar 5, 2026

Choose a reason for hiding this comment

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

Numpy floats specifically are shown in a weird format, which is why everything is converted to Python objects.

Copy link
Member

Choose a reason for hiding this comment

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

This looks strange to me. Are you saving DoE results to a JSON?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That is one of the options. The user can choose to save the result.

init_n_workers: int = None,
init_combo_chunk_size: int = 5000,
init_combo_parallel_threshold: int = 20000,
init_max_wall_clock_time: float = None,
Copy link
Contributor Author

@smondal13 smondal13 Mar 5, 2026

Choose a reason for hiding this comment

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

@adowling2, @blnicho, It may be worth adding an init_solver = None where the user can add flexible solver options that will be passed for initialization. It may save time for initialization. If the user does not pass that, then the default solver from DesignOfExperiments will be used. Let me know if this is a good idea.

Copy link
Member

Choose a reason for hiding this comment

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

I have been thinking something similar to this... it would be helpful to allow for different solver options: initialization and the full optimization solve.

diagnostics_warnings = []

# Add experiment(s) for each scenario
# TODO: Add s_prev = 0 to handle parameter scenarios
Copy link
Contributor Author

@smondal13 smondal13 Mar 5, 2026

Choose a reason for hiding this comment

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

This is the extension that I will be working on next. So, I have added this as a reference for myself. This is the reason we are using param_scenarios, although we are not using it this time. It does not interfere with the user interface.

@smondal13
Copy link
Contributor Author

@blnicho One of the tests is failing.

…for `_DoEResultsJSONEncoder` and `optimize_experiments()` API
- update LHS candidate FIM evaluation logging to report the actual\n  execution mode so users can immediately see whether initialization\n  is running serially or in parallel.\n- add an explicit warning when lhs_parallel=True resolves to\n  serial execution because only one worker is available, preventing\n  silent fallback confusion during debugging.\n- keep LHS initialization behavior unchanged while improving\n  observability for multi-experiment startup diagnostics.
Copy link
Member

@adowling2 adowling2 left a comment

Choose a reason for hiding this comment

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

There is a lot of added code in this PR. I think it would be helpful to draft a documentation page explaining the key features of this proposed interface. This would be a simple .md file that eventually becomes a RTD page. I find that drafting documentation helps provide more robust motivation and context to engage with the PR.

@@ -0,0 +1,362 @@
# Review: `_DoEResultsJSONEncoder`, `_enum_label`, and `optimize_experiments()` API
Copy link
Member

Choose a reason for hiding this comment

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

How was this file generated? I am looking for some additional context to help me digest the contents of this file. @smondal13

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was for me to review. I forgot to remove this. I asked AI to review the code for edge cases and best practices.

]
sigma_inv = [1 / v for k, v in model.scenario_blocks[0].measurement_error.items()]
sigma_inv = [
1 / v for k, v in model.fd_scenario_blocks[0].measurement_error.items()
Copy link
Member

Choose a reason for hiding this comment

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

I like this!

objective_option="pseudo_trace",
)

model_direct = pyo.ConcreteModel()
Copy link
Member

Choose a reason for hiding this comment

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

What is this block of code doing? Comments would help.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is used to check the build structure of the new method. This one particularly tests the helper function that returns the experiment_input vars


@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available")
def test_multi_experiment_structure_and_results(self):
solver = self._make_solver()
Copy link
Member

Choose a reason for hiding this comment

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

What is this test doing? I recommend adding a comment block with a few-sentence description of the contents and goals of this test.

Copy link
Member

Choose a reason for hiding this comment

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

This applies to all of the tests in this file.

import json
import logging
import math
import os
import threading
Copy link
Member

Choose a reason for hiding this comment

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

We want to discuss with the Pyomo team if this is the recommended approach for threading.

init_n_workers: int = None,
init_combo_chunk_size: int = 5000,
init_combo_parallel_threshold: int = 20000,
init_max_wall_clock_time: float = None,
Copy link
Member

Choose a reason for hiding this comment

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

I have been thinking something similar to this... it would be helpful to allow for different solver options: initialization and the full optimization solve.

@@ -537,6 +619,1555 @@ def run_doe(self, model=None, results_file=None):
with open(results_file, "w") as file:
json.dump(self.results, file)

def optimize_experiments(
Copy link
Member

Choose a reason for hiding this comment

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

Is this function new? Does it replace an exisiting function?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a new helper function. Instead of writing this code multiple times, I have added a function that just do this.

- Updated the `DesignOfExperiments` class to accept either a single experiment or a list of experiments through the `experiment` parameter, replacing the previous `experiment_list` parameter.
- Modified related test cases to reflect this change, ensuring that both single and multiple experiments are handled correctly.
- Adjusted error messages and validation checks to align with the new parameter naming.
- Ensured backward compatibility by updating all references in the test suite and error handling.
- enhance documentation for init_solver usage in multi-experiment optimization
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Ready for design review

Development

Successfully merging this pull request may close these issues.

4 participants